diff --git a/charts/substrate/README.md b/charts/substrate/README.md index c17d0b125d..ab474148da 100644 --- a/charts/substrate/README.md +++ b/charts/substrate/README.md @@ -53,6 +53,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.imagePullSecrets` | `[]` | `kubernetes.io/dockerconfigjson` Secrets (`[{name: ...}]`) in the release namespace whose credentials atelet pulls actor images with, before the credential provider's; mounted read-only, rotation needs no restart | | `atelet.imageCache.{gcPeriod,highPercent,lowPercent,minAge,maxBytes}` | `null` (atelet's defaults: `5m`, `85`, `80`, `2m`, no cap) | atelet's image-cache eviction pass: `--image-cache-gc-period`, `--image-cache-high-percent`, `--image-cache-low-percent`, `--image-cache-min-age`, `--image-cache-max-bytes` | | `atelet.imageCache.pinnedImages` | `[]` | Image references every pass pulls and never evicts (the ConfigMap `atelet-pinned-images`, mounted for `--image-cache-pinned-images-file`): the pool's ActorTemplate images on nodes whose cache volume stays above the watermark. atelet reads the list at every pass, so a change restarts no atelet pod | | `credentialProvider.namespacePolicies` | `[]` | Default-deny atespace-to-namespace grants; the chart includes get-only Secret RBAC for the provider. A policy with an `ownNamespaceSelector` (a label selector) in place of `atespace` and `allowedNamespaces` lets each namespace it matches be read by the atespace of the same name only, matched by the namespace's current labels; an `allowedNamespaceSelector` beside `atespace` grants that atespace every namespace it matches (an explicit opt-in, the only way `ate-golden` reads a namespace by label); with either, the chart adds list/watch RBAC on namespaces | diff --git a/charts/substrate/templates/atelet.yaml b/charts/substrate/templates/atelet.yaml index 9f65f3029f..3f87f3303f 100644 --- a/charts/substrate/templates/atelet.yaml +++ b/charts/substrate/templates/atelet.yaml @@ -193,6 +193,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 .Values.atelet.imagePullSecrets }} + - --image-pull-secret-file=/run/image-pull-secrets/{{ required "atelet.imagePullSecrets entries need a name" .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 @@ -310,6 +313,11 @@ spec: subPath: config.yaml {{- end }} readOnly: true +{{- end }} +{{- range $i, $secret := .Values.atelet.imagePullSecrets }} + - name: image-pull-secret-{{ $i }} + mountPath: /run/image-pull-secrets/{{ $secret.name }} + readOnly: true {{- end }} # Mounted whole, never through subPath: kubelet then swaps in a # changed list in place. @@ -347,6 +355,14 @@ spec: path: {{ .Values.atelet.imageCredentialProviderConfig | quote }} type: File {{- end }} +{{- end }} +{{- range $i, $secret := .Values.atelet.imagePullSecrets }} + - name: image-pull-secret-{{ $i }} + secret: + secretName: {{ $secret.name | quote }} + items: + - key: .dockerconfigjson + path: .dockerconfigjson {{- end }} - name: pinned-images configMap: diff --git a/charts/substrate/values.yaml b/charts/substrate/values.yaml index 38d7293498..3dbabb421e 100644 --- a/charts/substrate/values.yaml +++ b/charts/substrate/values.yaml @@ -97,6 +97,12 @@ atelet: # either one still needs the bin directory. imageCredentialProviderConfigYAML: "" imageCredentialProviderBinDir: "" + # kubernetes.io/dockerconfigjson Secrets in the release namespace whose + # credentials atelet uses to pull actor images, tried before the credential + # provider's: [{name: my-registry}]. Each is mounted read-only, so a rotated + # Secret takes effect without a restart. Unlike imagePullSecrets, which + # pull the chart's own pods, these pull the images the actors run. + imagePullSecrets: [] storageBackend: s3 extraArgs: [] extraEnv: [] diff --git a/cmd/atelet/helm_test.go b/cmd/atelet/helm_test.go index 063ebe295d..5ebe2473c6 100644 --- a/cmd/atelet/helm_test.go +++ b/cmd/atelet/helm_test.go @@ -16,6 +16,7 @@ package main import ( "os/exec" + "reflect" "slices" "strings" "testing" @@ -136,3 +137,61 @@ providers: []` }) } } + +func TestHelmImagePullSecrets(t *testing.T) { + if _, err := exec.LookPath("helm"); err != nil { + t.Skip("helm is not installed") + } + out, err := exec.CommandContext(t.Context(), "helm", "template", "substrate", "../../charts/substrate", + "--show-only", "templates/atelet.yaml", + "--set", "atelet.imagePullSecrets[0].name=registry-a", + "--set", "atelet.imagePullSecrets[1].name=registry-b").CombinedOutput() + if err != nil { + t.Fatalf("render: %v\n%s", err, out) + } + var pod *corev1.PodSpec + for _, doc := range strings.Split(string(out), "\n---\n") { + var ds appsv1.DaemonSet + if err := yaml.Unmarshal([]byte(doc), &ds); err != nil { + t.Fatal(err) + } + if ds.Kind == "DaemonSet" { + pod = &ds.Spec.Template.Spec + } + } + if pod == nil || len(pod.Containers) != 1 { + t.Fatal("expected atelet DaemonSet with one container") + } + container := pod.Containers[0] + var flagged []string + for _, arg := range container.Args { + if file, ok := strings.CutPrefix(arg, "--image-pull-secret-file="); ok { + flagged = append(flagged, file) + } + } + if want := []string{"/run/image-pull-secrets/registry-a/.dockerconfigjson", "/run/image-pull-secrets/registry-b/.dockerconfigjson"}; !slices.Equal(flagged, want) { + t.Fatalf("--image-pull-secret-file flags = %v, want %v in order", flagged, want) + } + for i, secret := range []string{"registry-a", "registry-b"} { + mountIndex := slices.IndexFunc(container.VolumeMounts, func(m corev1.VolumeMount) bool { + return m.MountPath == "/run/image-pull-secrets/"+secret + }) + if mountIndex < 0 || !container.VolumeMounts[mountIndex].ReadOnly || container.VolumeMounts[mountIndex].SubPath != "" { + t.Fatalf("Secret %s must mount read-only without subPath, so rotation reaches atelet", secret) + } + volumeIndex := slices.IndexFunc(pod.Volumes, func(v corev1.Volume) bool { return v.Name == container.VolumeMounts[mountIndex].Name }) + if volumeIndex < 0 || pod.Volumes[volumeIndex].Secret == nil { + t.Fatalf("mount %d has no Secret volume", i) + } + src := pod.Volumes[volumeIndex].Secret + if src.SecretName != secret || !reflect.DeepEqual(src.Items, []corev1.KeyToPath{{Key: ".dockerconfigjson", Path: ".dockerconfigjson"}}) { + t.Errorf("volume for %s projects %+v", secret, src) + } + } + + out, err = exec.CommandContext(t.Context(), "helm", "template", "substrate", "../../charts/substrate", + "--show-only", "templates/atelet.yaml", "--set", "atelet.imagePullSecrets[0].other=x").CombinedOutput() + if err == nil || !strings.Contains(string(out), "need a name") { + t.Errorf("render accepted a pull secret without a name: %v\n%s", err, out) + } +} diff --git a/cmd/atelet/internal/credentialprovider/credentialprovider.go b/cmd/atelet/internal/credentialprovider/credentialprovider.go index 0a832993ae..e42d57d711 100644 --- a/cmd/atelet/internal/credentialprovider/credentialprovider.go +++ b/cmd/atelet/internal/credentialprovider/credentialprovider.go @@ -72,14 +72,7 @@ func (k *Keychain) Resolve(target authn.Resource) (authn.Authenticator, error) { // 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 + return firstCandidate(k.Candidates(ctx, target)) } // Candidates returns the credentials of all providers matching target, in the diff --git a/cmd/atelet/internal/credentialprovider/dockerconfig.go b/cmd/atelet/internal/credentialprovider/dockerconfig.go new file mode 100644 index 0000000000..1e8389ff09 --- /dev/null +++ b/cmd/atelet/internal/credentialprovider/dockerconfig.go @@ -0,0 +1,199 @@ +// 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 credentialprovider + +import ( + "context" + "encoding/json" + "fmt" + "log/slog" + "maps" + "os" + "slices" + "strings" + + "github.com/google/go-containerregistry/pkg/authn" +) + +// DockerConfigKeychain resolves registry credentials from .dockerconfigjson +// files, the content of a kubernetes.io/dockerconfigjson Secret, matched to an +// image like the kubelet matches a Pod's imagePullSecrets. Every lookup reads +// the files again, so a rotated Secret mounted into the pod takes effect +// without a restart. It is safe for concurrent use. +type DockerConfigKeychain struct { + paths []string +} + +var ( + _ authn.Keychain = (*DockerConfigKeychain)(nil) + _ authn.ContextKeychain = (*DockerConfigKeychain)(nil) +) + +// NewDockerConfig returns a keychain over the .dockerconfigjson files at +// paths, tried in order. Each file must parse now, so a misconfigured mount +// fails at start-up instead of at the first private pull. +func NewDockerConfig(paths []string) (*DockerConfigKeychain, error) { + for _, path := range paths { + if _, err := readDockerConfig(path); err != nil { + return nil, err + } + } + slog.Info("Loaded image pull secrets", slog.Any("files", paths)) + return &DockerConfigKeychain{paths: paths}, nil +} + +// Resolve implements authn.Keychain. +func (k *DockerConfigKeychain) 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 *DockerConfigKeychain) ResolveContext(ctx context.Context, target authn.Resource) (authn.Authenticator, error) { + return firstCandidate(k.Candidates(ctx, target)) +} + +// Candidates returns the credentials of every file entry matching target, in +// the order to try them: most specific registry key first, then file order. +// A file that cannot be read any more is logged and skipped. +func (k *DockerConfigKeychain) Candidates(ctx context.Context, target authn.Resource) ([]authn.Authenticator, error) { + image := target.String() + byKey := make(map[string][]authn.AuthConfig) + for _, path := range k.paths { + auths, err := readDockerConfig(path) + if err != nil { + slog.WarnContext(ctx, "Image pull secret unreadable; skipping it", + slog.String("file", path), slog.Any("err", err)) + continue + } + keys, err := matchingAuthKeys(auths, image) + if err != nil { + slog.WarnContext(ctx, "Image pull secret has an invalid registry key; skipping it", + slog.String("file", path), slog.String("image", image), slog.Any("err", err)) + continue + } + if len(keys) > 0 { + slog.InfoContext(ctx, "Resolved image credentials from image pull secret", + slog.String("file", path), slog.String("image", image)) + } + for _, key := range keys { + byKey[key] = append(byKey[key], auths[key]...) + } + } + + keys := make([]string, 0, len(byKey)) + for key := range byKey { + keys = append(keys, key) + } + sortAuthKeys(keys) + var candidates []authn.Authenticator + seen := make(map[authn.AuthConfig]struct{}) + for _, key := range keys { + for _, auth := range byKey[key] { + if _, dup := seen[auth]; dup { + continue + } + seen[auth] = struct{}{} + candidates = append(candidates, authn.FromConfig(auth)) + } + } + return candidates, nil +} + +// readDockerConfig returns the auths of the .dockerconfigjson file at path, +// keyed by registry in the scheme-less form matchesImage compares. Keys that +// differ only in scheme or trailing slash share an entry, in key order. +func readDockerConfig(path string) (map[string][]authn.AuthConfig, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("while reading image pull secret: %w", err) + } + var config struct { + Auths map[string]authn.AuthConfig `json:"auths"` + } + if err := json.Unmarshal(data, &config); err != nil { + return nil, fmt.Errorf("while parsing image pull secret %s: %w", path, err) + } + auths := make(map[string][]authn.AuthConfig, len(config.Auths)) + for _, key := range slices.Sorted(maps.Keys(config.Auths)) { + auth := config.Auths[key] + // The encoded form is derived from Username and Password; dropping it + // keeps equal credentials equal for deduplication. + auth.Auth = "" + normalized := normalizeRegistryKey(key) + auths[normalized] = append(auths[normalized], auth) + } + return auths, nil +} + +// normalizeRegistryKey strips a registry key's scheme and trailing slash and +// maps Docker Hub's aliases (the legacy "https://index.docker.io/v1/" that +// docker login writes among them) onto the name go-containerregistry gives +// its repositories, "index.docker.io". +func normalizeRegistryKey(key string) string { + key = strings.TrimPrefix(strings.TrimPrefix(key, "https://"), "http://") + key = strings.TrimSuffix(key, "/") + switch key { + case "docker.io", "index.docker.io/v1", "registry-1.docker.io": + return "index.docker.io" + } + return key +} + +// CandidateSource is a keychain that returns several credentials to try. +type CandidateSource interface { + Candidates(ctx context.Context, target authn.Resource) ([]authn.Authenticator, error) +} + +// Chain is a keychain that tries the candidates of its keychains in order. +type Chain []CandidateSource + +var ( + _ authn.Keychain = Chain(nil) + _ authn.ContextKeychain = 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. +func (c Chain) ResolveContext(ctx context.Context, target authn.Resource) (authn.Authenticator, error) { + return firstCandidate(c.Candidates(ctx, target)) +} + +// Candidates returns every keychain's candidates, the first keychain's first. +func (c Chain) Candidates(ctx context.Context, target authn.Resource) ([]authn.Authenticator, error) { + var candidates []authn.Authenticator + for _, kc := range c { + got, err := kc.Candidates(ctx, target) + if err != nil { + return nil, err + } + candidates = append(candidates, got...) + } + return candidates, nil +} + +func firstCandidate(candidates []authn.Authenticator, err error) (authn.Authenticator, error) { + if err != nil { + return nil, err + } + if len(candidates) == 0 { + return authn.Anonymous, nil + } + return candidates[0], nil +} diff --git a/cmd/atelet/internal/credentialprovider/dockerconfig_test.go b/cmd/atelet/internal/credentialprovider/dockerconfig_test.go new file mode 100644 index 0000000000..f24a6245e4 --- /dev/null +++ b/cmd/atelet/internal/credentialprovider/dockerconfig_test.go @@ -0,0 +1,200 @@ +// 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 credentialprovider + +import ( + "context" + "encoding/base64" + "os" + "path/filepath" + "slices" + "testing" + + "github.com/google/go-containerregistry/pkg/authn" +) + +// writeDockerConfig writes a .dockerconfigjson file and returns its path. +func writeDockerConfig(t *testing.T, contents string) string { + t.Helper() + path := filepath.Join(t.TempDir(), ".dockerconfigjson") + if err := os.WriteFile(path, []byte(contents), 0o600); err != nil { + t.Fatal(err) + } + return path +} + +func basicAuth(user, password string) string { + return base64.StdEncoding.EncodeToString([]byte(user + ":" + password)) +} + +// candidateUsers returns the usernames of kc's candidates for ref, in order. +func candidateUsers(t *testing.T, kc CandidateSource, ref string) []string { + t.Helper() + candidates, err := kc.Candidates(context.Background(), repo(t, ref)) + if err != nil { + t.Fatalf("Candidates(%q) returned unexpected error: %v", ref, err) + } + var users []string + for _, c := range candidates { + cfg, err := c.Authorization() + if err != nil { + t.Fatal(err) + } + users = append(users, cfg.Username) + } + return users +} + +func TestDockerConfigKeychainMatchesLikeTheKubelet(t *testing.T) { + kc, err := NewDockerConfig([]string{writeDockerConfig(t, `{"auths": { + "registry.example.com": {"auth": "`+basicAuth("registry", "p1")+`"}, + "https://registry.example.com/team/": {"username": "team", "password": "p2"}, + "*.mirror.example.com": {"username": "mirror", "password": "p3"}, + "https://index.docker.io/v1/": {"username": "hub", "password": "p4"}, + "other.example.com:5000": {"username": "port", "password": "p5"} + }}`)}) + if err != nil { + t.Fatal(err) + } + for _, tc := range []struct { + ref string + want []string + }{ + {"registry.example.com/app:1", []string{"registry"}}, + {"registry.example.com/team/app:1", []string{"team", "registry"}}, + {"eu.mirror.example.com/app:1", []string{"mirror"}}, + {"a.b.mirror.example.com/app:1", nil}, + {"busybox:1", []string{"hub"}}, + {"docker.io/library/busybox:1", []string{"hub"}}, + {"other.example.com:5000/app:1", []string{"port"}}, + {"other.example.com/app:1", nil}, + {"unknown.example.com/app:1", nil}, + } { + t.Run(tc.ref, func(t *testing.T) { + if got := candidateUsers(t, kc, tc.ref); !slices.Equal(got, tc.want) { + t.Errorf("candidates = %v, want %v", got, tc.want) + } + }) + } +} + +func TestDockerConfigKeychainDecodesAuthField(t *testing.T) { + kc, err := NewDockerConfig([]string{writeDockerConfig(t, + `{"auths": {"registry.example.com": {"auth": "`+basicAuth("user", "secret:with:colons")+`"}}}`)}) + if err != nil { + t.Fatal(err) + } + auth, err := kc.Resolve(repo(t, "registry.example.com/app:1")) + if err != nil { + t.Fatal(err) + } + cfg, err := auth.Authorization() + if err != nil { + t.Fatal(err) + } + if cfg.Username != "user" || cfg.Password != "secret:with:colons" { + t.Errorf("Authorization() = %q/%q, want the decoded auth field", cfg.Username, cfg.Password) + } +} + +func TestDockerConfigKeychainTriesFilesInOrderWithoutDuplicates(t *testing.T) { + first := writeDockerConfig(t, `{"auths": {"registry.example.com": {"username": "first", "password": "p"}}}`) + second := writeDockerConfig(t, `{"auths": { + "registry.example.com": {"username": "second", "password": "p"}, + "*.example.com": {"auth": "`+basicAuth("first", "p")+`"} + }}`) + kc, err := NewDockerConfig([]string{first, second}) + if err != nil { + t.Fatal(err) + } + if got, want := candidateUsers(t, kc, "registry.example.com/app:1"), []string{"first", "second"}; !slices.Equal(got, want) { + t.Errorf("candidates = %v, want %v", got, want) + } +} + +func TestDockerConfigKeychainKeepsKeysThatNormalizeAlike(t *testing.T) { + kc, err := NewDockerConfig([]string{writeDockerConfig(t, `{"auths": { + "registry.example.com": {"username": "plain", "password": "p"}, + "https://registry.example.com/": {"username": "scheme", "password": "p"} + }}`)}) + if err != nil { + t.Fatal(err) + } + if got, want := candidateUsers(t, kc, "registry.example.com/app:1"), []string{"scheme", "plain"}; !slices.Equal(got, want) { + t.Errorf("candidates = %v, want %v", got, want) + } +} + +func TestDockerConfigKeychainRereadsRotatedFile(t *testing.T) { + path := writeDockerConfig(t, `{"auths": {"registry.example.com": {"username": "old", "password": "p"}}}`) + kc, err := NewDockerConfig([]string{path}) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(`{"auths": {"registry.example.com": {"username": "new", "password": "p"}}}`), 0o600); err != nil { + t.Fatal(err) + } + if got, want := candidateUsers(t, kc, "registry.example.com/app:1"), []string{"new"}; !slices.Equal(got, want) { + t.Errorf("candidates = %v, want %v", got, want) + } + + // A file that goes bad after start-up is skipped, not fatal. + if err := os.WriteFile(path, []byte("not json"), 0o600); err != nil { + t.Fatal(err) + } + if got := candidateUsers(t, kc, "registry.example.com/app:1"); len(got) != 0 { + t.Errorf("candidates = %v, want none from an unparsable file", got) + } +} + +func TestNewDockerConfigRejectsBadFiles(t *testing.T) { + for name, path := range map[string]string{ + "missing": filepath.Join(t.TempDir(), "absent"), + "not json": writeDockerConfig(t, "{"), + "bad base64": writeDockerConfig(t, `{"auths": {"registry.example.com": {"auth": "!!"}}}`), + "no password": writeDockerConfig(t, `{"auths": {"registry.example.com": {"auth": "`+base64.StdEncoding.EncodeToString([]byte("user"))+`"}}}`), + } { + t.Run(name, func(t *testing.T) { + if _, err := NewDockerConfig([]string{path}); err == nil { + t.Error("NewDockerConfig succeeded, want an error") + } + }) + } +} + +// staticSource returns fixed candidates. +type staticSource []string + +func (s staticSource) Candidates(context.Context, authn.Resource) ([]authn.Authenticator, error) { + var candidates []authn.Authenticator + for _, user := range s { + candidates = append(candidates, authn.FromConfig(authn.AuthConfig{Username: user, Password: "p"})) + } + return candidates, nil +} + +func TestChainTriesKeychainsInOrder(t *testing.T) { + chain := Chain{staticSource{"pull-secret"}, staticSource{"provider-a", "provider-b"}} + if got, want := candidateUsers(t, chain, "registry.example.com/app:1"), []string{"pull-secret", "provider-a", "provider-b"}; !slices.Equal(got, want) { + t.Errorf("candidates = %v, want %v", got, want) + } + auth, err := Chain{staticSource{}}.Resolve(repo(t, "registry.example.com/app:1")) + if err != nil { + t.Fatal(err) + } + if auth != authn.Anonymous { + t.Errorf("Resolve() = %v, want anonymous without candidates", auth) + } +} diff --git a/cmd/atelet/main.go b/cmd/atelet/main.go index a5e60f5d0c..3bb49119ec 100644 --- a/cmd/atelet/main.go +++ b/cmd/atelet/main.go @@ -104,6 +104,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.") + imagePullSecretFiles = pflag.StringArray("image-pull-secret-file", nil, "Path to a .dockerconfigjson file, the content of a kubernetes.io/dockerconfigjson Secret, whose registry credentials are tried before the credential providers'. Repeatable; read again 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 ateom 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.") @@ -119,7 +120,7 @@ var ( drainTimeout = pflag.Duration("drain-timeout", 5*time.Minute, "Deadline for the graceful gRPC drain on shutdown. In-flight RPCs still running past it are forcefully cancelled.") ) -var _ imagecache.CandidateKeychain = (*credentialprovider.Keychain)(nil) +var _ imagecache.CandidateKeychain = credentialprovider.Chain(nil) func main() { pflag.Parse() @@ -204,9 +205,18 @@ func main() { ateomDialer := newAteomDialer(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 + // Image pull secrets come first, as the kubelet tries a Pod's + // imagePullSecrets before the node's credentials. Without either, pulls + // are anonymous: all an unconfigured node (kind, say) can do and all a + // public registry needs. + var imageCredsKeychains credentialprovider.Chain + if len(*imagePullSecretFiles) > 0 { + kc, err := credentialprovider.NewDockerConfig(*imagePullSecretFiles) + if err != nil { + serverboot.Fatal(ctx, "Failed to configure image pull secrets", err) + } + imageCredsKeychains = append(imageCredsKeychains, kc) + } if *imageCredentialProviderConfig != "" { if *imageCredentialProviderBinDir == "" { serverboot.Fatal(ctx, "Failed to configure image pull credentials", @@ -216,7 +226,11 @@ func main() { if err != nil { serverboot.Fatal(ctx, "Failed to configure image pull credentials", err) } - imageCredsKeychain = kc + imageCredsKeychains = append(imageCredsKeychains, kc) + } + var imageCredsKeychain authn.Keychain + if len(imageCredsKeychains) > 0 { + imageCredsKeychain = imageCredsKeychains } if err := validateImageCacheGCFlags(); err != nil { diff --git a/docs/upgrade.md b/docs/upgrade.md index 9c928ef9ae..e8084c5b3a 100644 --- a/docs/upgrade.md +++ b/docs/upgrade.md @@ -1386,6 +1386,32 @@ it. retake under the roll does not fail its template. - **Reset:** none. **Conversations lost:** none. +### The 1.17 to 1.18 step + +1.18.0 stays on upstream v0.5.0-alpha2 and lets atelet pull actor images +with `kubernetes.io/dockerconfigjson` Secrets. Step to it from the latest +1.17 patch with the numbered steps above; the step needs no migration, no +snapshot reset and no manual step, and conversations and durable volumes +survive it. + +- **Persisted format:** unchanged. Nothing new is stored; no CRD, no + Postgres schema and no snapshot layout differs from 1.17. +- **Chart:** `atelet.imagePullSecrets` (`[{name: }]`, Secrets of + the release namespace) mounts each Secret read-only into atelet and + passes it as `--image-pull-secret-file`. Empty by default, which renders + the 1.17 manifests unchanged. Not to be confused with `imagePullSecrets`, + which pull the chart's own pods. +- **Behavior, image pulls:** a pull secret's credentials are matched to an + image as the kubelet matches a Pod's `imagePullSecrets` and tried before + the credential providers'; a rotated Secret is read at the next pull, no + restart. An atelet whose pull-secret file does not parse at start-up + exits with the reason. +- **During the roll:** an actor image only a pull secret can read is pulled + once its node's atelet runs 1.18; a 1.17 atelet still pulls it + anonymously or through the providers, as before. +- **Goldens:** kept. +- **Reset:** none. **Conversations lost:** none. + ### After the roll, on GKE > [!WARNING] diff --git a/fork/atelet-dockerconfig-keychain.md b/fork/atelet-dockerconfig-keychain.md new file mode 100644 index 0000000000..9173eb2b67 --- /dev/null +++ b/fork/atelet-dockerconfig-keychain.md @@ -0,0 +1,30 @@ +--- +id: atelet-dockerconfig-keychain +fork: substrate +theme: chart-knobs +category: feature +status: carried +commits: + - 'feat(atelet): pull actor images with dockerconfigjson Secrets' +upstream: + outcome: to-file + refs: [] + exit: upstream PR merged or declined +depends_on: [] +verified: 2026-10-23 +--- +# atelet pulls actor images with dockerconfigjson Secrets + +## Change + +atelet reads `.dockerconfigjson` files, the content of a `kubernetes.io/dockerconfigjson` Secret, through the repeatable `--image-pull-secret-file` flag, next to the kubelet credential providers. Their credentials are matched to an image as the kubelet matches a Pod's `imagePullSecrets` (URL globbing, most specific key first, Docker Hub's aliases folded onto `index.docker.io`) and tried before the providers'; every lookup re-reads the files, so a rotated Secret needs no restart. The chart's `atelet.imagePullSecrets` (`[{name: ...}]`) mounts each Secret read-only and passes its file; the default render is unchanged. Unit tests for the keychain, the chain and the chart wiring. + +## Reason + +atelet pulls a session's tool images itself and had no pull-secret path: a registry with static credentials needed a credential provider binary on every node, and a cluster whose node image cannot change could not pull private tool images at all. No persisted format changes. + +## History + +to file — prepared in the fork: branch `upstream/atelet-dockerconfig-keychain` + +[#540](https://github.com/giantswarm/substrate/pull/540): the upstream-ready commit, rebase-merged