0033. strongIdentity and loadBundle: two call sites, one rule each
Status: Accepted · Date: 2026-10-26 · Design section: Nodes, Trust
Decided: moved the identity-tuple rule to TalosMachineIdentity.IsStrongIdentity in api/v1alpha1, and the secrets-bundle Secret read/decode to a new internal/secretsbundle package, so internal/controller and internal/uiapi each still call it from their own, separately-justified call site, but neither keeps its own copy of the rule.
Context
Both internal/controller and internal/uiapi independently implemented
two of the same checks:
-
strongIdentity: ADR 0011's identity-tuple rule (SMBIOS UUID, serial, at least one permanent MAC address), checked once by discovery (internal/controller/discovery.go, against a freshly discoveredHardwareIdentity) and once by approval (internal/uiapi/identity.go, against aTalosMachine's already-recordedspec.identity). Having two call sites is correct and deliberate: approval must not depend on discovery having already enforced the rule -- aTalosMachinecreated any other way (a future registration path, a hand-written fixture, a test) is not guaranteed to have gone through discovery, so approval is the backstop that has to re-check. The problem was that each call site carried its own copy of the three-line rule itself. A rule that guards an approval gate drifting between two copies is not a cosmetic problem: if one copy gains a condition the other doesn't, the UI can approve a machine the controller would have refused as weak, and nothing detects it -- there is no test that compares the two rules against each other, only tests that each rule matches its own copy's intent. -
loadBundle: reads the SecretTalosCluster.spec.secretsRefnames and decodes it as asecrets.Bundle, once ininternal/controller/bundle.go(to apply configuration) and once ininternal/uiapi/bundle.go(to render an effective-config preview and dial a read-only Talos client).internal/uiapi's own doc comment argued the duplication was safe because a drift's symptom would be "loud" -- a decode failure. That argument covers a decoding drift, but not a leak-safety drift:internal/controller/bundle.go's own doc comment states the actual invariant that matters -- "every error path below is structural ... and never includes the Secret's data in its message" -- and a copy that quietly stopped upholding that would fail silently: the Secret's own key material would start appearing in an error message, logged or shown somewhere, with no test anywhere positioned to catch it, becauseinternal/uiapihad no leak test of its own for this function at all.
Decision
IsStrongIdentity, on the CRD type
func (m TalosMachineIdentity) IsStrongIdentity() bool {
return m.UUID != "" && m.Serial != "" && len(m.MACAddresses) > 0
}
api/v1alpha1 is the natural home: TalosMachineIdentity already lives
there, and both existing call sites work with that type one way or
another.
internal/uiapi/machine_service.go's call site already has aTalosMachineIdentityin hand (m.Spec.Identity), so it calls the method directly:m.Spec.Identity.IsStrongIdentity().internal/uiapi/identity.goand its ownTestStrongIdentityare deleted outright -- there is nothing left for that file to do, and the test (five cases covering every missing-element combination) moved toapi/v1alpha1/talosmachine_identity_test.go, the one place the rule's truth table needs covering now.internal/controller/discovery.go's call site has aHardwareIdentity(its own, pre-CRD representation of whatDiscoverread off the wire), not yet aTalosMachineIdentity-- that conversion only happens a few lines later, once this very check has passed.strongIdentity(hw HardwareIdentity) boolstays as a thin wrapper that builds the three-field struct and calls the method, so the existing call site (discovery.go:126) and its own test (discovery_test.go) needed no changes beyond the function body.
Two call sites, as before; one rule, where there were two.
internal/secretsbundle, a new package under internal/
package secretsbundle
const SecretKey = "secrets.yaml"
func Load(ctx context.Context, c client.Client, namespace, name string) (*secrets.Bundle, error)
Both callers are already under internal/, so this costs nothing in
public API surface -- it is exactly as internal as the two copies it
replaces. Load takes the resolved namespace and name directly rather
than a *TalosCluster, because the two callers resolve the namespace
differently and that choice is each caller's own to keep:
internal/controller/bundle.go'sloadBundlealways reads in the controller's own fixed namespace (POD_NAMESPACE), regardless of which namespace theTalosClusteritself lives in -- the RBAC tradeoff ADR 0011 already made (Kubernetes RBAC cannot express "the Secret thisTalosClusternames, wherever it lives" any narrower than a cluster-wide Secret read, so the controller fixes its own namespace instead and keeps the RBAC marker scoped to it).internal/uiapi/bundle.go'sloadBundlereads in theTalosCluster's own namespace.
This asymmetry already existed before this change and is left exactly as it was: this ADR centralizes the read-and-decode-without-leaking logic, not the namespace-resolution policy, which is a separate question neither caller's own code or tests raised as broken.
SecretKey is the one literal value now. internal/controller and
internal/uiapi each keep a package-local bundleSecretKey = secretsbundle.SecretKey
alias, purely so their own existing test fixtures (which build a
corev1.Secret keyed on the package-level name) did not need editing --
an alias cannot drift from the constant it points at, so this is not a
second definition.
A leak test internal/secretsbundle needed, and internal/uiapi never had
internal/secretsbundle/secretsbundle_test.go adds Load's own direct
tests, independent of either caller: a round trip, "Secret not found",
"key missing", and -- the one that actually matters --
"malformed YAML", which encodes a real, freshly-generated bundle, corrupts
the bytes just enough to fail decoding, and asserts none of the bundle's
secret material (bootstrap token, trustd token, cluster ID/secret, every
cert/key pair) appears in the resulting error. This is the same technique
internal/controller/leak_test.go's seven routes and pkg/render's nine
already use for the same property.
internal/controller's existing leak test continues to exercise this
exact same code through loadBundle's new one-line delegation, unchanged.
internal/uiapi gains the guarantee structurally -- its loadBundle now
runs the one decoder that is directly tested -- without needing a leak
test of its own, which it never had before this.
Consequences
internal/uiapi/identity.goandinternal/uiapi/identity_test.goare deleted;api/v1alpha1/talosmachine_identity_test.gois new.internal/controller/bundle.goandinternal/uiapi/bundle.goboth shrink to a namespace/name resolution plus a call intointernal/secretsbundle.Load; their ownshortcut:comments justifying the duplication are removed, since the duplication they excused no longer exists.internal/cli/bundlefile.gokeeps its own, thirdbundleSecretKeyconstant (= "secrets.yaml"), unexamined and unchanged here: it reads and writes the local~/.ichor/clusters/<name>/secrets.yamlbackup file, not a Kubernetes Secret, andinternal/cliis out of scope for this change. Its own comment, which currently points at "internal/controller/bundle.go'sbundleSecretKey," is now slightly stale (that constant is an alias tointernal/secretsbundle.SecretKey, not the definition) -- left as a known, minor staleness for whoever next touches that file, not fixed here.- The frontend's own
isStrongIdentity()(docs/adr/0021, TypeScript, client-side display only -- a warning badge on the approval dialog, not an authorization decision) is a third reimplementation of the same rule, in a different language and runtime this change cannot share code with. Left as-is, for the same out-of-scope reason.