0027. Private registry auth through a Secret reference on TalosCluster

Status: Accepted, amended once · Date: 2026-10-25 · Design section: "Typed configuration", "Monitoring and registry mirrors"

Decided: Private registry auth through TalosCluster.spec.registries[].authSecretRef, resolved and injected as the system layer by pkg/render; the auth-less Render/BuildConfig entry points were merged into one that always takes the resolved auth, after no production caller had ever been moved onto the auth-aware pair

Context

ADR 0003's secret-fields follow-up dropped RegistryAuthConfig.{password,auth,identityToken} from the mirror types: a TalosConfigPatch is a CR that lives in Git, and letting it carry a registry password directly would put that password in Git -- a plain violation of design principle 2, "secrets are referenced, never embedded". That ADR named the fix it did not build: "a reference form -- a field shaped like TalosCluster.spec.secretsRef already is, pointing at a Kubernetes Secret the controller reads and injects, never a value the patch document carries directly". Phase 10 (air gap) needs exactly this: "registry mirrors pushed to every node through the system layer" (architecture.md, "Monitoring and registry mirrors") only works end to end for a private registry if its pull credentials can reach a node at all, and today they can't -- there is no field anywhere in the API that can carry them without reintroducing the Git-embedding problem ADR 0003 just closed.

Two things had to be settled: where the declaration lives, and what shape the reference takes.

Where it lives. The design's own CRD table defines TalosCluster as "Cluster-wide settings", and a registry mirror is exactly that -- it applies to every node, not one machine or one subset selected by label. TalosMachine (one node) and TalosConfigPatch (a patch with a layer) are both the wrong shape for a cluster-wide, always-on declaration.

Patch layers. "### Patch layers" lists the system layer as "generated by Ichor: CNI settings, kube-proxy, KubePrism, volume configs derived from disk roles, kernel modules and sysctls for storage presets, metrics exposure, control-plane taints" -- registry mirrors are not named there, but "### Monitoring and registry mirrors" is explicit that they are "pushed to every node through the system layer". The two sections describe the same stack from different angles (one lists what's already built, the other names a not-yet-built feature by its layer), not two different answers; rendering registries as the system layer, below every TalosConfigPatch layer, is consistent with both and was the one thing this change had to re-check before proceeding, per the task that produced this ADR. It did not surface a contradiction, so the work went ahead.

Decision

TalosClusterRegistry on TalosCluster.spec.registries[]. One entry per mirror: host (the RegistriesConfig map key machinery itself uses), endpoints (mirror URLs, not secret), username (the one RegistryAuthConfig field ADR 0003 left mirrorable, not secret), and authSecretRef, optional -- unset is an anonymous mirror, not an error.

authSecretRef is a corev1.LocalObjectReference, not a corev1.SecretKeySelector. Machinery's RegistryAuthConfig carries three credential shapes beyond username -- password, auth, identityToken, mirroring docker config.json's own auth forms -- and a given registry's credentials use at most one of them. A SecretKeySelector names exactly one key, so expressing "any of three keys, from one Secret" would need either three separate selector fields (three ways to misconfigure one reference, none of which the CRD can rule out which is set) or a selector plus an implicit convention for which other keys ride along with it, which defeats the point of a selector. A LocalObjectReference plus a fixed, documented set of well-known keys (password, auth, identityToken) is the same shape secretsRef already uses for bundleSecretKey, and it says the same thing: "this Secret holds the thing by convention", with exactly as many fields on the CRD as there are registries, not as many as there are credential shapes.

Resolution and injection live in pkg/render, not internal/controller. The existing precedent, loadBundle, lives in internal/controller and is called before render.Render, passing an already-decoded secrets.Bundle in. This change keeps that same shape -- resolve-then-pass-in, nothing new invented -- but places the resolving function itself (render.ResolveRegistryAuth) in pkg/render so the whole registry-auth surface (resolve, inject, and their leak tests) is one package's responsibility. pkg/render's own doc comment already claimed "touches no cluster"; that claim now has one named exception, documented at the top of the package, the same way the rest of the package's guarantees are documented.

Injection is additive, not a breaking signature change. render.Render and render.BuildConfig are called from internal/controller, internal/cli and internal/uiapi -- none of which this unit of work may touch (a different agent owns internal/controller for TalosOperation concurrently; internal/cli and internal/uiapi are out of scope entirely). Changing either function's signature would break all three without a way to fix them here. The fix: BuildConfig/Render are now one-line wrappers around a private buildConfig that takes an extra map[string]RegistryAuth parameter (nil from the existing wrappers, so their behavior and output are byte-for-byte unchanged -- TestRenderWithRegistryAuthNoRegistries checks this directly); BuildConfigWithRegistryAuth/RenderWithRegistryAuth are the new, fully additive entry points that pass a real map through. Wiring either of ResolveRegistryAuth/RenderWithRegistryAuth into TalosMachineReconciler's actual reconcile loop is internal/controller's call to make and is left for whoever next touches that package; this ADR's scope is the capability, built and tested against its public API, not the call site.

Two entry points for one render, closed with a refusal rather than left as a silent trap. Keeping Render/BuildConfig working unchanged means there are now two ways to render a machine's config, and nothing stopped a caller with cluster.Spec.Registries set from calling the one that never resolves or injects auth: the render would still succeed, the config would still apply, and the resulting cluster simply could not pull from its private registry -- no error, no log, the worst kind of failure to track down because nothing says it happened. Caught in review and closed the same day: BuildConfig (and so Render, which calls it) now refuses outright when cluster.Spec.Registries is non-empty, naming both BuildConfigWithRegistryAuth and RenderWithRegistryAuth in the error so the caller is told which one to use instead of just that it chose wrong. TestRenderRefusesRegistriesWithoutAuthPath covers both BuildConfig and Render. This turns "silently wrong" into "loudly wrong," which is the entire point -- it does not turn "two entry points" into "one"; see Consequences below for why that reduction is internal/controller's to make, not this ADR's.

The injected document is built directly from machinery's types, not through a TalosConfigPatch. The password-shaped fields have no mirror-type representation at all -- that is the entire reason authSecretRef exists -- so there is no typedconfig document kind this could go through even if it wanted to. injectRegistries constructs a *v1alpha1.Config (machinery's own type) directly and merges it via the same configpatcher.Apply strategic merge every TalosConfigPatch already goes through, placed in the pipeline immediately after baseConfig and before SelectPatches/applyPatches -- the system layer, lowest in the stack, exactly where "### Monitoring and registry mirrors" puts it.

A missing Secret or a Secret with none of the three recognized keys fails the render. Silently producing an anonymous mirror for a registry that named a Secret would look like a working configuration until the first pull fails on the node, with nothing in Ichor's own output pointing at why -- the same "fail loudly, not silently" requirement loadBundle already follows for the secrets bundle.

Consequences

  • TalosCluster.spec.registries[] uses +listType=map / +listMapKey=host for host uniqueness, not a CEL x-kubernetes-validations rule. A self.exists_one uniqueness rule was tried first and rejected by envtest's real apiserver: with MaxItems=32 its estimated cost exceeded the per-field budget by 32x (the exists_one macro is quadratic in list length) and still contributed enough to blow the whole-schema budget (this CRD already carries CEL rules elsewhere) by 3.3x. The native listType: map structural-schema mechanism -- the same one status.conditions already uses on metav1.Condition's type key -- enforces the same uniqueness at zero estimated CEL cost, confirmed by the same envtest apiserver accepting the CRD and rejecting a duplicate-host Create in api/v1alpha1/validation_test.go.
  • Resolved credentials are held only in a short-lived map[string]RegistryAuth in memory, handed from ResolveRegistryAuth to Render/BuildConfig and never written anywhere else -- not a CR's spec or status, not a log line, not an error string, not ichor gitops export's output (which only ever marshals the TalosCluster object itself, and that object never carries a resolved value, only authSecretRef's name). Covered by TestRegistryAuthLeak (every forced error path) following TestRenderBundleLeak's existing discipline for the secrets bundle.
  • The rendered config's hash changes when a resolved credential changes, same as any other input -- correct, since the node needs the new config re-applied. The hash cannot be reversed to recover the credential (pkg/render's Hash is a one-way sha256); this was flagged as not needing a test, since it follows from using a cryptographic hash and not from anything this change wrote.
  • internal/controller/diff.go's ChangedPaths reports only field-path strings (it walks a decoded YAML tree and appends the prefix on a mismatch, never the leaf values themselves), read but not modified for this change. TestRegistryAuthDriftPathsNeverLeakValue (pkg/render/registry_drift_test.go, an external render_test package so it can import internal/controller without an import cycle through pkg/render) renders the same machine twice with two different resolved passwords and confirms ChangedPaths reports a path naming machine.registries.config.<host>.auth.password and never either password's value -- confirming TalosMachine.status (which only ever carries ChangedPaths's output, never the rendered config) cannot carry a registry credential either.
  • Debt (closed, see the amendment below): BuildConfig/Render and the *WithRegistryAuth pair were two entry points.
  • Debt: this feature is built on a field machinery itself marks deprecated. injectRegistries nests its output under the legacy v1alpha1.Config.MachineConfig.MachineRegistries (//nolint:staticcheck suppresses the SA1019 warning at the call site), not under the modern standalone RegistryMirrorConfig/ RegistryAuthConfig documents (package config/types/cri) machinery's own doc comment points to instead. That was the right call for this change -- the modern documents are not in Ichor's mirror-type allow-list, and adding them is a mirrorgen-root-sized change (ADR 0012's shape), not a render-time-injection-sized one -- but it means this feature depends on a deprecated field today, not merely declines to use a nicer one. Migrating injectRegistries (and, if the allow-list grows to include them, TalosConfigPatch authors generally) onto the standalone documents is follow-up work this ADR creates, not a closed question; until it happens, a future machinery release that actually removes the deprecated field breaks this feature, not just produces a louder lint warning.
  • Out of scope, same as the removal that produced this follow-up: VIPEquinixMetalConfig.apiToken, VIPHCloudConfig.apiToken, WireguardDevice.privateKey stay dropped with no replacement -- nothing in any current phase uses them. ichor images list and mirror-reachability checks (also air-gap, but not this ADR's slice of it) are untouched.

Alternatives considered

A validating admission webhook rejecting a credential-bearing patch. Not applicable here -- the whole point is that the mirror type cannot carry the credential at all, so there is nothing for a patch to smuggle through in the first place. (This is the same reasoning ADR 0003 already gave for not adding a webhook over its nine dropped secret fields.)

Three SecretKeySelector fields (passwordSecretRef, authSecretRef, identityTokenSecretRef) instead of one LocalObjectReference. More flexible -- each credential could live in a different Secret -- but no real use case needs that, it triples the CRD surface for this one struct, and it breaks the parallel with secretsRef's existing one-reference-fixed-keys shape for no benefit this phase needs.

A CEL rule for registries[].host uniqueness. Tried, measured, rejected -- see Consequences above.

Amendment (2026-10-26): one entry point; every production caller was still on the refusing one

The Consequences bullet above tracked the Render/*WithRegistryAuth split as debt to be paid "once TalosMachineReconciler is wired". It never was: all five production call sites (TalosMachineReconciler.Reconcile, internal/uiapi's effective-config view, ichor init, and ichor adopt's two renders) still called the plain pair, and the refusal guard added so a forgotten call would be loud meant that any TalosCluster with spec.registries set, even an anonymous mirror, could not be reconciled at all. ResolveRegistryAuth and the *WithRegistryAuth functions had no non-test caller. A new envtest spec ("reconciles a TalosCluster that declares registries...") failed first with RenderFailed / cluster.Spec.Registries is set; BuildConfig never resolves or injects registry auth, then passed after the fix.

The cause was having two entry points, so the fix removes one. Render and BuildConfig now take auth map[string]RegistryAuth as a required last argument; RenderWithRegistryAuth, BuildConfigWithRegistryAuth and the refusal guard are deleted. A caller that forgets to resolve auth is now a compile error rather than a runtime refusal. ResolveRegistryAuth returns a nil map without reading anything (and without requiring a namespace) when the cluster declares no registries, so the common path costs nothing and needs no extra RBAC.

Each caller resolves the Secret in a fixed namespace, never cluster.Namespace (ADR 0011; the mistake this repository has made twice, see ADR 0018's amendment):

  • Controller: r.ControllerNamespace, the namespace loadBundle uses.
  • UI backend: Server.ControllerNamespace, the namespace its loadBundle uses.
  • ichor adopt: ichorControllerNamespace (ichor-system), where the install manifest puts the controller and so where it reads authSecretRef Secrets, rather than adopt's --namespace (which is where the TalosCluster lives).
  • ichor init: builds the TalosCluster itself, has no flag for spec.registries, and has no management-cluster client yet. It passes nil and refuses (rather than rendering an unauthenticated mirror) if the plan ever carries registries.

RBAC needed no change: the controller's Role in ichor-system already has get;list;watch on Secrets and the UI backend's has get (both namespaced, the same grants loadBundle relies on); no cluster-wide Secret read was added.

Search the docs

Search pages and headings