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=hostfor host uniqueness, not a CELx-kubernetes-validationsrule. Aself.exists_oneuniqueness rule was tried first and rejected by envtest's real apiserver: withMaxItems=32its estimated cost exceeded the per-field budget by 32x (theexists_onemacro 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 nativelistType: mapstructural-schema mechanism -- the same onestatus.conditionsalready uses onmetav1.Condition'stypekey -- enforces the same uniqueness at zero estimated CEL cost, confirmed by the same envtest apiserver accepting the CRD and rejecting a duplicate-hostCreateinapi/v1alpha1/validation_test.go.- Resolved credentials are held only in a short-lived
map[string]RegistryAuthin memory, handed fromResolveRegistryAuthtoRender/BuildConfigand never written anywhere else -- not a CR's spec or status, not a log line, not an error string, notichor gitops export's output (which only ever marshals theTalosClusterobject itself, and that object never carries a resolved value, onlyauthSecretRef's name). Covered byTestRegistryAuthLeak(every forced error path) followingTestRenderBundleLeak'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'sHashis a one-waysha256); 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'sChangedPathsreports only field-path strings (it walks a decoded YAML tree and appends theprefixon a mismatch, never the leaf values themselves), read but not modified for this change.TestRegistryAuthDriftPathsNeverLeakValue(pkg/render/registry_drift_test.go, an externalrender_testpackage so it can importinternal/controllerwithout an import cycle throughpkg/render) renders the same machine twice with two different resolved passwords and confirmsChangedPathsreports a path namingmachine.registries.config.<host>.auth.passwordand never either password's value -- confirmingTalosMachine.status(which only ever carriesChangedPaths's output, never the rendered config) cannot carry a registry credential either.- Debt (closed, see the amendment below):
BuildConfig/Renderand the*WithRegistryAuthpair were two entry points. - Debt: this feature is built on a field machinery itself marks deprecated.
injectRegistriesnests its output under the legacyv1alpha1.Config.MachineConfig.MachineRegistries(//nolint:staticchecksuppresses theSA1019warning at the call site), not under the modern standaloneRegistryMirrorConfig/RegistryAuthConfigdocuments (packageconfig/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. MigratinginjectRegistries(and, if the allow-list grows to include them,TalosConfigPatchauthors 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.privateKeystay dropped with no replacement -- nothing in any current phase uses them.ichor images listand 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 namespaceloadBundleuses. - UI backend:
Server.ControllerNamespace, the namespace itsloadBundleuses. ichor adopt:ichorControllerNamespace(ichor-system), where the install manifest puts the controller and so where it readsauthSecretRefSecrets, rather than adopt's--namespace(which is where the TalosCluster lives).ichor init: builds the TalosCluster itself, has no flag forspec.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.