From f0c95e5661cfc4487812146b0e627c2feeb45bbb Mon Sep 17 00:00:00 2001 From: Carles Figuerola Date: Mon, 2 Nov 2026 13:04:59 -0600 Subject: [PATCH] atelet: pull actor images with mounted dockerconfigjson Signed-off-by: Carles Figuerola --- charts/substrate/README.md | 1 + charts/substrate/templates/atelet.yaml | 15 ++ .../tests/actor_image_pull_secrets_test.yaml | 62 +++++++ charts/substrate/values.yaml | 6 + .../internal/dockerconfig/dockerconfig.go | 158 ++++++++++++++++ .../dockerconfig/dockerconfig_test.go | 169 ++++++++++++++++++ cmd/atelet/main.go | 25 ++- go.mod | 2 +- internal/imagecache/chain.go | 100 +++++++++++ internal/imagecache/chain_test.go | 111 ++++++++++++ internal/imagecache/imagecache.go | 13 +- 11 files changed, 644 insertions(+), 18 deletions(-) create mode 100644 charts/substrate/tests/actor_image_pull_secrets_test.yaml create mode 100644 cmd/atelet/internal/dockerconfig/dockerconfig.go create mode 100644 cmd/atelet/internal/dockerconfig/dockerconfig_test.go create mode 100644 internal/imagecache/chain.go create mode 100644 internal/imagecache/chain_test.go diff --git a/charts/substrate/README.md b/charts/substrate/README.md index f80beb3809..9728a026a9 100644 --- a/charts/substrate/README.md +++ b/charts/substrate/README.md @@ -66,6 +66,7 @@ See `values.yaml` for the full set; the important keys: | `atelet.imageCredentialProviderConfig` | `""` | Host path to the kubelet credential provider config; set together with the bin directory | | `atelet.imageCredentialProviderConfigYAML` | `""` | Credential provider config as text, rendered into a ConfigMap, for nodes where that file cannot be authored or must differ from the kubelet's; mutually exclusive with the host path | | `atelet.imageCredentialProviderBinDir` | `""` | Host path to the kubelet credential provider binaries; both paths are mounted read-only | +| `atelet.actorImagePullSecrets` | `[]` | `kubernetes.io/dockerconfigjson` Secrets in the release namespace that atelet reads to pull actor images, since Pod `imagePullSecrets` do not reach actors; re-read on every pull | | `credentialProvider.namespacePolicies` | `[]` | Default-deny atespace-to-namespace grants; the chart includes get-only Secret RBAC for the provider | | `ateApi.actorJWTIssuer` | `https://..svc` | Issuer placed in actor JWTs; set a stable URL reachable by relying parties | | `objectStorePlugin.extraEnv` | `[]` | Object storage credentials/settings shared by both object-store plugins | diff --git a/charts/substrate/templates/atelet.yaml b/charts/substrate/templates/atelet.yaml index 5abad9d1c5..8bea4a5f62 100644 --- a/charts/substrate/templates/atelet.yaml +++ b/charts/substrate/templates/atelet.yaml @@ -199,6 +199,9 @@ spec: {{- if $credentialProviderConfig }} - --image-credential-provider-config=/run/image-credential-provider/config.yaml - --image-credential-provider-bin-dir=/run/image-credential-provider/bin +{{- end }} +{{- range $i, $name := .Values.atelet.actorImagePullSecrets }} + - --image-pull-docker-config=/run/actor-image-pull-secrets/{{ $name }}/.dockerconfigjson {{- end }} - --grpc-server-cred-bundle=/run/podidentity.podcert.ate.dev/credential-bundle.pem - --client-ca-certs=/run/podidentity.podcert.ate.dev/trust-bundle.pem @@ -297,6 +300,11 @@ spec: subPath: config.yaml {{- end }} readOnly: true +{{- end }} +{{- range $i, $name := .Values.atelet.actorImagePullSecrets }} + - name: actor-image-pull-secret-{{ $i }} + mountPath: /run/actor-image-pull-secrets/{{ $name }} + readOnly: true {{- end }} volumes: - name: objectstore-plugin @@ -358,3 +366,10 @@ spec: matchLabels: podcert.ate.dev/canarying: live path: trust-bundle.pem +{{- range $i, $name := .Values.atelet.actorImagePullSecrets }} + # Volume names are indexed: Secret names may hold characters a volume + # name cannot (dots, say); the mount path keeps the Secret's name. + - name: actor-image-pull-secret-{{ $i }} + secret: + secretName: {{ $name | quote }} +{{- end }} diff --git a/charts/substrate/tests/actor_image_pull_secrets_test.yaml b/charts/substrate/tests/actor_image_pull_secrets_test.yaml new file mode 100644 index 0000000000..219039db9c --- /dev/null +++ b/charts/substrate/tests/actor_image_pull_secrets_test.yaml @@ -0,0 +1,62 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +suite: atelet actor image pull Secrets +templates: +- atelet.yaml +release: + name: custom + namespace: custom-system +tests: +- it: mounts nothing and passes no flag by default + documentSelector: + path: kind + value: DaemonSet + asserts: + - notExists: + path: spec.template.spec.volumes[?(@.name == "actor-image-pull-secret-0")] + - notExists: + path: spec.template.spec.containers[0].volumeMounts[?(@.name == "actor-image-pull-secret-0")] + - notContains: + path: spec.template.spec.containers[0].args + content: --image-pull-docker-config=/run/actor-image-pull-secrets/registry-pull/.dockerconfigjson + +- it: mounts each Secret read-only and names its .dockerconfigjson to atelet + documentSelector: + path: kind + value: DaemonSet + set: + atelet: + actorImagePullSecrets: + - registry-pull + - mirror.example.com + asserts: + - contains: + path: spec.template.spec.containers[0].args + content: --image-pull-docker-config=/run/actor-image-pull-secrets/registry-pull/.dockerconfigjson + - contains: + path: spec.template.spec.containers[0].args + content: --image-pull-docker-config=/run/actor-image-pull-secrets/mirror.example.com/.dockerconfigjson + - equal: + path: spec.template.spec.volumes[?(@.name == "actor-image-pull-secret-0")].secret.secretName + value: registry-pull + - equal: + path: spec.template.spec.volumes[?(@.name == "actor-image-pull-secret-1")].secret.secretName + value: mirror.example.com + - equal: + path: spec.template.spec.containers[0].volumeMounts[?(@.name == "actor-image-pull-secret-1")] + value: + name: actor-image-pull-secret-1 + mountPath: /run/actor-image-pull-secrets/mirror.example.com + readOnly: true diff --git a/charts/substrate/values.yaml b/charts/substrate/values.yaml index 6cd8426931..be4b51eb72 100644 --- a/charts/substrate/values.yaml +++ b/charts/substrate/values.yaml @@ -83,6 +83,12 @@ atelet: # either one still needs the bin directory. imageCredentialProviderConfigYAML: "" imageCredentialProviderBinDir: "" + # Names of kubernetes.io/dockerconfigjson Secrets in the release namespace + # whose credentials atelet uses to pull actor images. Actors are not Pods, so + # a Pod's imagePullSecrets never reach them; list the same Secrets here. Each + # is mounted read-only and re-read on every pull, so a refreshed Secret needs + # no restart. Tried after the credential provider, when both are set. + actorImagePullSecrets: [] storageBackend: s3 extraArgs: [] extraEnv: [] diff --git a/cmd/atelet/internal/dockerconfig/dockerconfig.go b/cmd/atelet/internal/dockerconfig/dockerconfig.go new file mode 100644 index 0000000000..c22f940c71 --- /dev/null +++ b/cmd/atelet/internal/dockerconfig/dockerconfig.go @@ -0,0 +1,158 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package dockerconfig resolves registry credentials from Docker config files: +// a config.json, or the .dockerconfigjson key of a kubernetes.io/dockerconfigjson +// Secret, which is what a mounted image pull Secret presents. +// +// Actors are not Pods, so a Pod's imagePullSecrets never reach the images atelet +// pulls for them. Mounting the same Secrets into atelet and naming the files here +// gives actors the credentials a Pod in the namespace would have, with no plugin +// binary and no cloud SDK. Files are read on every resolution, so a Secret the +// kubelet refreshes in place takes effect without restarting atelet. +package dockerconfig + +import ( + "context" + "fmt" + "log/slog" + "os" + + "github.com/docker/cli/cli/config" + "github.com/docker/cli/cli/config/configfile" + "github.com/docker/cli/cli/config/types" + "github.com/google/go-containerregistry/pkg/authn" + "github.com/google/go-containerregistry/pkg/name" +) + +// Keychain is a go-containerregistry keychain backed by Docker config files. +// It is safe for concurrent use. +type Keychain struct { + paths []string +} + +var ( + _ authn.Keychain = (*Keychain)(nil) + _ authn.ContextKeychain = (*Keychain)(nil) +) + +// New returns a Keychain over the given files, consulted in order. Each file +// must load once now: a path that is wrong is a deployment mistake better +// reported at startup than as a 401 on the first pull. +func New(paths ...string) (*Keychain, error) { + if len(paths) == 0 { + return nil, fmt.Errorf("at least one Docker config file is required") + } + for _, p := range paths { + if _, err := load(p); err != nil { + return nil, err + } + } + slog.Info("Loaded image pull Docker configs", slog.Any("paths", paths)) + return &Keychain{paths: paths}, nil +} + +// Resolve implements authn.Keychain. +func (k *Keychain) Resolve(target authn.Resource) (authn.Authenticator, error) { + return k.ResolveContext(context.Background(), target) +} + +// ResolveContext implements authn.ContextKeychain. It returns the first +// candidate, or authn.Anonymous if there are none. +func (k *Keychain) ResolveContext(ctx context.Context, target authn.Resource) (authn.Authenticator, error) { + candidates, err := k.Candidates(ctx, target) + if err != nil { + return nil, err + } + if len(candidates) == 0 { + return authn.Anonymous, nil + } + return candidates[0], nil +} + +// Candidates returns one credential per file that has an entry for target, in +// file order, without duplicates. The lookup is the Docker CLI's: the exact +// repository string first, then the registry host, with Docker Hub mapped to its +// legacy auth key. A file that fails to load is logged and skipped so that one +// bad Secret does not take the others down with it. +func (k *Keychain) Candidates(ctx context.Context, target authn.Resource) ([]authn.Authenticator, error) { + var candidates []authn.Authenticator + seen := make(map[authn.AuthConfig]struct{}) + for _, p := range k.paths { + cf, err := load(p) + if err != nil { + slog.WarnContext(ctx, "Skipping unreadable image pull Docker config", + slog.String("path", p), slog.Any("err", err)) + continue + } + cfg, err := lookup(cf, target) + if err != nil { + slog.WarnContext(ctx, "Skipping image pull Docker config that failed to resolve", + slog.String("path", p), slog.String("image", target.String()), slog.Any("err", err)) + continue + } + if cfg == nil { + continue + } + if _, dup := seen[*cfg]; dup { + continue + } + seen[*cfg] = struct{}{} + candidates = append(candidates, authn.FromConfig(*cfg)) + } + return candidates, nil +} + +// load parses one Docker config file. +func load(path string) (*configfile.ConfigFile, error) { + f, err := os.Open(path) + if err != nil { + return nil, fmt.Errorf("while opening Docker config: %w", err) + } + defer f.Close() + cf, err := config.LoadFromReader(f) + if err != nil { + return nil, fmt.Errorf("while parsing Docker config %q: %w", path, err) + } + return cf, nil +} + +// lookup finds the entry for target in cf, or nil. It mirrors +// authn.DefaultKeychain so a config.json behaves exactly as it does for the +// Docker CLI and for ko. +func lookup(cf *configfile.ConfigFile, target authn.Resource) (*authn.AuthConfig, error) { + var empty types.AuthConfig + for _, key := range []string{target.String(), target.RegistryStr()} { + if key == name.DefaultRegistry { + key = authn.DefaultAuthKey + } + cfg, err := cf.GetAuthConfig(key) + if err != nil { + return nil, err + } + // GetAuthConfig fills ServerAddress; drop it for the emptiness test. + cfg.ServerAddress = "" + if cfg == empty { + continue + } + return &authn.AuthConfig{ + Username: cfg.Username, + Password: cfg.Password, + Auth: cfg.Auth, + IdentityToken: cfg.IdentityToken, + RegistryToken: cfg.RegistryToken, + }, nil + } + return nil, nil +} diff --git a/cmd/atelet/internal/dockerconfig/dockerconfig_test.go b/cmd/atelet/internal/dockerconfig/dockerconfig_test.go new file mode 100644 index 0000000000..384fb1d2b4 --- /dev/null +++ b/cmd/atelet/internal/dockerconfig/dockerconfig_test.go @@ -0,0 +1,169 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package dockerconfig + +import ( + "context" + "encoding/base64" + "os" + "path/filepath" + "testing" + + "github.com/google/go-containerregistry/pkg/authn" + "github.com/google/go-containerregistry/pkg/name" +) + +func writeConfig(t *testing.T, dir, file, body string) string { + t.Helper() + p := filepath.Join(dir, file) + if err := os.WriteFile(p, []byte(body), 0o600); err != nil { + t.Fatal(err) + } + return p +} + +func repo(t *testing.T, ref string) name.Repository { + t.Helper() + r, err := name.NewRepository(ref) + if err != nil { + t.Fatal(err) + } + return r +} + +func authorization(t *testing.T, a authn.Authenticator) authn.AuthConfig { + t.Helper() + cfg, err := a.Authorization() + if err != nil { + t.Fatal(err) + } + return *cfg +} + +func TestNewRejectsMissingOrInvalidFiles(t *testing.T) { + if _, err := New(); err == nil { + t.Error("New() with no paths succeeded") + } + if _, err := New(filepath.Join(t.TempDir(), "missing.json")); err == nil { + t.Error("New() with a missing file succeeded") + } + bad := writeConfig(t, t.TempDir(), "bad.json", `{"auths": `) + if _, err := New(bad); err == nil { + t.Error("New() with an unparseable file succeeded") + } +} + +func TestCandidatesResolvesLikeTheDockerCLI(t *testing.T) { + dir := t.TempDir() + auth := base64.StdEncoding.EncodeToString([]byte("svc:tok")) + path := writeConfig(t, dir, ".dockerconfigjson", `{"auths":{ + "https://registry.example.com":{"auth":"`+auth+`","email":"","username":"svc"}, + "exact.example.com/team/app":{"username":"exact","password":"p"}, + "https://index.docker.io/v1/":{"username":"hub","password":"h"} + }}`) + kc, err := New(path) + if err != nil { + t.Fatal(err) + } + for _, tc := range []struct { + image string + want *authn.AuthConfig // nil: no credentials + }{ + {"registry.example.com/team/app", &authn.AuthConfig{Username: "svc", Password: "tok"}}, + {"registry.example.com/other", &authn.AuthConfig{Username: "svc", Password: "tok"}}, + {"exact.example.com/team/app", &authn.AuthConfig{Username: "exact", Password: "p"}}, + // A key with a path still matches by host, as it does for the Docker CLI. + {"exact.example.com/team/other", &authn.AuthConfig{Username: "exact", Password: "p"}}, + {"busybox", &authn.AuthConfig{Username: "hub", Password: "h"}}, + {"index.docker.io/library/busybox", &authn.AuthConfig{Username: "hub", Password: "h"}}, + {"ghcr.io/org/image", nil}, + } { + got, err := kc.Candidates(context.Background(), repo(t, tc.image)) + if err != nil { + t.Fatalf("%s: %v", tc.image, err) + } + if tc.want == nil { + if len(got) != 0 { + t.Errorf("%s: got %d candidates, want none", tc.image, len(got)) + } + a, err := kc.Resolve(repo(t, tc.image)) + if err != nil || a != authn.Anonymous { + t.Errorf("%s: Resolve = %v, %v; want Anonymous", tc.image, a, err) + } + continue + } + if len(got) != 1 { + t.Fatalf("%s: got %d candidates, want 1", tc.image, len(got)) + } + if cfg := authorization(t, got[0]); cfg.Username != tc.want.Username || cfg.Password != tc.want.Password { + t.Errorf("%s: got %s/%q, want %s/%q", tc.image, cfg.Username, cfg.Password, tc.want.Username, tc.want.Password) + } + } +} + +func TestCandidatesAcrossFilesKeepsOrderAndDedupes(t *testing.T) { + dir := t.TempDir() + first := writeConfig(t, dir, "first.json", `{"auths":{"registry.example.com":{"username":"a","password":"1"}}}`) + second := writeConfig(t, dir, "second.json", `{"auths":{"registry.example.com":{"username":"b","password":"2"}}}`) + third := writeConfig(t, dir, "third.json", `{"auths":{"registry.example.com":{"username":"a","password":"1"}}}`) + kc, err := New(first, second, third) + if err != nil { + t.Fatal(err) + } + got, err := kc.Candidates(context.Background(), repo(t, "registry.example.com/app")) + if err != nil { + t.Fatal(err) + } + var users []string + for _, a := range got { + users = append(users, authorization(t, a).Username) + } + if len(users) != 2 || users[0] != "a" || users[1] != "b" { + t.Errorf("candidates = %v, want [a b]", users) + } +} + +func TestCandidatesRereadsFilesAndSkipsBrokenOnes(t *testing.T) { + dir := t.TempDir() + rotating := writeConfig(t, dir, "rotating.json", `{"auths":{"registry.example.com":{"username":"old","password":"1"}}}`) + stable := writeConfig(t, dir, "stable.json", `{"auths":{"registry.example.com":{"username":"stable","password":"s"}}}`) + kc, err := New(rotating, stable) + if err != nil { + t.Fatal(err) + } + target := repo(t, "registry.example.com/app") + + // A rotated Secret is picked up on the next pull, no restart. + writeConfig(t, dir, "rotating.json", `{"auths":{"registry.example.com":{"username":"new","password":"2"}}}`) + got, err := kc.Candidates(context.Background(), target) + if err != nil { + t.Fatal(err) + } + if len(got) != 2 || authorization(t, got[0]).Username != "new" { + t.Errorf("after rotation: got %d candidates, first %q; want 2 with first \"new\"", len(got), authorization(t, got[0]).Username) + } + + // A file that disappears or breaks is skipped; the others still answer. + if err := os.Remove(rotating); err != nil { + t.Fatal(err) + } + got, err = kc.Candidates(context.Background(), target) + if err != nil { + t.Fatal(err) + } + if len(got) != 1 || authorization(t, got[0]).Username != "stable" { + t.Errorf("after removal: got %d candidates; want only \"stable\"", len(got)) + } +} diff --git a/cmd/atelet/main.go b/cmd/atelet/main.go index 0f822093e5..78f9d37947 100644 --- a/cmd/atelet/main.go +++ b/cmd/atelet/main.go @@ -41,6 +41,7 @@ import ( "go.ate.dev/substrate/cmd/atelet/internal/apivalidation" "go.ate.dev/substrate/cmd/atelet/internal/ateletpath" "go.ate.dev/substrate/cmd/atelet/internal/credentialprovider" + "go.ate.dev/substrate/cmd/atelet/internal/dockerconfig" "go.ate.dev/substrate/cmd/atelet/internal/filecache" "go.ate.dev/substrate/cmd/atelet/internal/filecachegc" "go.ate.dev/substrate/cmd/atelet/internal/snapshottracker" @@ -117,6 +118,7 @@ var ( // no cloud SDK compiled in, on any cloud whose nodes configure a provider. imageCredentialProviderConfig = pflag.String("image-credential-provider-config", "", "Path to a kubelet CredentialProviderConfig. Its exec plugins supply image pull credentials; without it, pulls are anonymous.") imageCredentialProviderBinDir = pflag.String("image-credential-provider-bin-dir", "", "Directory holding the credential provider executables named by --image-credential-provider-config. Required when that flag is set.") + imagePullDockerConfigs = pflag.StringSlice("image-pull-docker-config", nil, "Docker config files (a config.json, or the .dockerconfigjson of a mounted kubernetes.io/dockerconfigjson Secret) whose auths supply image pull credentials, tried after the credential providers. Repeatable; re-read on every pull.") localhostRegistryReplacement = pflag.String("localhost-registry-replacement", "", "The replacement registry endpoint for localhost and/or loopback IP addresses, useful for local development. for example kind-registry:5000") imageCacheDir = pflag.String("image-cache-dir", ateletpath.ImageCacheDir, "Directory for the node-local OCI image layer cache. Must be on the volume shared with the worker pods (the cached layers are their overlay lowerdirs), and on a disk sized for both capacity and IOPS: unpack throughput is gated by the volume's IOPS.") @@ -143,7 +145,10 @@ var ( snapshotReportGiveUpAfter = pflag.Duration("snapshot-report-give-up-after", snapshottracker.DefaultReportGiveUpAfter, "How long after a background upload or detach atelet keeps retrying a failed snapshot state report before giving up on it.") ) -var _ imagecache.CandidateKeychain = (*credentialprovider.Keychain)(nil) +var ( + _ imagecache.CandidateKeychain = (*credentialprovider.Keychain)(nil) + _ imagecache.CandidateKeychain = (*dockerconfig.Keychain)(nil) +) func main() { pflag.Parse() @@ -243,9 +248,11 @@ func main() { workerDialer := newWorkerDialer(256) - // Without a credential provider config, pulls are anonymous: all an - // unconfigured node (kind, say) can do and all a public registry needs. - var imageCredsKeychain authn.Keychain + // Pull credentials come from the node's credential provider plugins, from + // mounted Docker configs (image pull Secrets), or both, tried in that order. + // With neither, pulls are anonymous: all an unconfigured node (kind, say) + // can do and all a public registry needs. + var imageKeychains []authn.Keychain if *imageCredentialProviderConfig != "" { if *imageCredentialProviderBinDir == "" { serverboot.Fatal(ctx, "Failed to configure image pull credentials", @@ -255,8 +262,16 @@ func main() { if err != nil { serverboot.Fatal(ctx, "Failed to configure image pull credentials", err) } - imageCredsKeychain = kc + imageKeychains = append(imageKeychains, kc) + } + if len(*imagePullDockerConfigs) > 0 { + kc, err := dockerconfig.New(*imagePullDockerConfigs...) + if err != nil { + serverboot.Fatal(ctx, "Failed to configure image pull credentials", err) + } + imageKeychains = append(imageKeychains, kc) } + imageCredsKeychain := imagecache.Chain(imageKeychains...) // Validated before the caches are cleared, so a directory the flags // reject is never emptied. diff --git a/go.mod b/go.mod index 31106cd7b8..e7d15467c8 100644 --- a/go.mod +++ b/go.mod @@ -22,6 +22,7 @@ require ( github.com/container-storage-interface/spec v1.12.0 github.com/containerd/ttrpc v1.2.8 github.com/distribution/reference v0.6.0 + github.com/docker/cli v29.5.3+incompatible github.com/envoyproxy/go-control-plane v0.14.0 github.com/envoyproxy/go-control-plane/envoy v1.37.1-0.20260812071801-353463cc7248 github.com/fsnotify/fsnotify v1.9.0 @@ -130,7 +131,6 @@ require ( github.com/containerd/platforms v0.2.1 // indirect github.com/cpuguy83/dockercfg v0.3.2 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect - github.com/docker/cli v29.5.3+incompatible // indirect github.com/docker/docker-credential-helpers v0.9.3 // indirect github.com/docker/go-connections v0.7.0 // indirect github.com/docker/go-units v0.5.0 // indirect diff --git a/internal/imagecache/chain.go b/internal/imagecache/chain.go new file mode 100644 index 0000000000..7233e2b0ef --- /dev/null +++ b/internal/imagecache/chain.go @@ -0,0 +1,100 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package imagecache + +import ( + "context" + + "github.com/google/go-containerregistry/pkg/authn" +) + +// Chain consults keychains in order and offers every credential they return, +// so a pull can try, say, the node's credential provider plugin and then the +// mounted pull Secrets. Nil keychains are dropped; with none left the result is +// nil (anonymous pulls), and a single keychain is returned as is. +func Chain(keychains ...authn.Keychain) authn.Keychain { + var kcs []authn.Keychain + for _, kc := range keychains { + if kc != nil { + kcs = append(kcs, kc) + } + } + switch len(kcs) { + case 0: + return nil + case 1: + return kcs[0] + } + return &chain{keychains: kcs} +} + +type chain struct { + keychains []authn.Keychain +} + +var _ CandidateKeychain = (*chain)(nil) + +// Resolve implements authn.Keychain. +func (c *chain) Resolve(target authn.Resource) (authn.Authenticator, error) { + return c.ResolveContext(context.Background(), target) +} + +// ResolveContext implements authn.ContextKeychain: the first candidate, or +// authn.Anonymous. +func (c *chain) ResolveContext(ctx context.Context, target authn.Resource) (authn.Authenticator, error) { + candidates, err := c.Candidates(ctx, target) + if err != nil { + return nil, err + } + if len(candidates) == 0 { + return authn.Anonymous, nil + } + return candidates[0], nil +} + +// Candidates implements CandidateKeychain. A keychain's error ends the +// resolution: the pull fails with that error rather than with a misleading 401. +func (c *chain) Candidates(ctx context.Context, target authn.Resource) ([]authn.Authenticator, error) { + var all []authn.Authenticator + for _, kc := range c.keychains { + candidates, err := candidatesOf(ctx, kc, target) + if err != nil { + return nil, err + } + all = append(all, candidates...) + } + return all, nil +} + +// candidatesOf returns every credential kc offers for target: all of them for +// a CandidateKeychain, the single resolution otherwise. authn.Anonymous is not +// a candidate; the pull tries it on its own once the credentials run out. +func candidatesOf(ctx context.Context, kc authn.Keychain, target authn.Resource) ([]authn.Authenticator, error) { + switch kc := kc.(type) { + case nil: + return nil, nil + case CandidateKeychain: + return kc.Candidates(ctx, target) + default: + auth, err := authn.Resolve(ctx, kc, target) + if err != nil { + return nil, err + } + if auth == authn.Anonymous { + return nil, nil + } + return []authn.Authenticator{auth}, nil + } +} diff --git a/internal/imagecache/chain_test.go b/internal/imagecache/chain_test.go new file mode 100644 index 0000000000..2a6eb5c2a6 --- /dev/null +++ b/internal/imagecache/chain_test.go @@ -0,0 +1,111 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package imagecache + +import ( + "context" + "errors" + "testing" + + "github.com/google/go-containerregistry/pkg/authn" + "github.com/google/go-containerregistry/pkg/name" +) + +// resolveOnly is a plain authn.Keychain (no Candidates) returning one auth. +type resolveOnly struct { + auth authn.Authenticator + err error +} + +func (r resolveOnly) Resolve(authn.Resource) (authn.Authenticator, error) { return r.auth, r.err } + +// candidatesOnly is a CandidateKeychain returning several auths. +type candidatesOnly struct{ auths []authn.Authenticator } + +func (c candidatesOnly) Resolve(authn.Resource) (authn.Authenticator, error) { + return c.auths[0], nil +} + +func (c candidatesOnly) Candidates(context.Context, authn.Resource) ([]authn.Authenticator, error) { + return c.auths, nil +} + +func basic(user string) authn.Authenticator { + return authn.FromConfig(authn.AuthConfig{Username: user, Password: "p"}) +} + +func usernames(t *testing.T, auths []authn.Authenticator) []string { + t.Helper() + var out []string + for _, a := range auths { + cfg, err := a.Authorization() + if err != nil { + t.Fatal(err) + } + out = append(out, cfg.Username) + } + return out +} + +func TestChain(t *testing.T) { + target, err := name.NewRepository("registry.example.com/app") + if err != nil { + t.Fatal(err) + } + ctx := context.Background() + + if Chain() != nil || Chain(nil, nil) != nil { + t.Error("Chain of nothing should be nil (anonymous pulls)") + } + single := resolveOnly{auth: basic("only")} + if got := Chain(nil, single); got != single { + t.Errorf("Chain of one keychain should return it unchanged, got %T", got) + } + + kc := Chain( + candidatesOnly{auths: []authn.Authenticator{basic("plugin-a"), basic("plugin-b")}}, + resolveOnly{auth: authn.Anonymous}, // contributes nothing + resolveOnly{auth: basic("secret")}, + ) + ck, ok := kc.(CandidateKeychain) + if !ok { + t.Fatalf("Chain of several keychains is a %T, want CandidateKeychain", kc) + } + got, err := ck.Candidates(ctx, target) + if err != nil { + t.Fatal(err) + } + if u := usernames(t, got); len(u) != 3 || u[0] != "plugin-a" || u[1] != "plugin-b" || u[2] != "secret" { + t.Errorf("candidates = %v, want [plugin-a plugin-b secret]", u) + } + first, err := kc.Resolve(target) + if err != nil { + t.Fatal(err) + } + if u := usernames(t, []authn.Authenticator{first}); u[0] != "plugin-a" { + t.Errorf("Resolve = %v, want the first candidate", u) + } + + empty := Chain(resolveOnly{auth: authn.Anonymous}, resolveOnly{auth: authn.Anonymous}) + if a, err := empty.Resolve(target); err != nil || a != authn.Anonymous { + t.Errorf("Resolve with no credentials = %v, %v; want Anonymous", a, err) + } + + boom := errors.New("boom") + failing := Chain(resolveOnly{auth: basic("x")}, resolveOnly{err: boom}) + if _, err := failing.(CandidateKeychain).Candidates(ctx, target); !errors.Is(err, boom) { + t.Errorf("a failing keychain should fail the chain, got %v", err) + } +} diff --git a/internal/imagecache/imagecache.go b/internal/imagecache/imagecache.go index b73e91c02a..e791dbc87a 100644 --- a/internal/imagecache/imagecache.go +++ b/internal/imagecache/imagecache.go @@ -902,18 +902,7 @@ func (s *Store) withCredentials(ctx context.Context, parsedRef name.Reference, f } func (s *Store) credentials(ctx context.Context, repo name.Repository) ([]authn.Authenticator, error) { - switch kc := s.keychain.(type) { - case nil: - return nil, nil - case CandidateKeychain: - return kc.Candidates(ctx, repo) - default: - auth, err := authn.Resolve(ctx, kc, repo) - if err != nil { - return nil, err - } - return []authn.Authenticator{auth}, nil - } + return candidatesOf(ctx, s.keychain, repo) } // parseRef applies the localhost-registry rewrite (kind local registries) and