0011. Discovery does not choose its own candidates, and never auto-registers a weak identity
Status: Accepted · Date: 2026-10-25 · Design section: "Nodes" (discovery and approval)
Decided: Made TalosMachineSpec.Role optional; discovery never picks a role or auto-registers a weak identity
Context
Phase 3 asked for the half of "Nodes" that runs before approval: read a
maintenance-mode node's hardware identity over the insecure connection
maintenance mode offers, and turn a strong identity into a pending
TalosMachine that approval later acts on. Two things had to be settled
before any of that could be written.
Where does a candidate endpoint come from? "Nodes" lists two options --
"registered by IP or found by scanning a subnet" -- but nothing in the API
types or the controller package picks one, and scanning a subnet is
explicitly out of this phase's scope (it is its own piece of design: which
interfaces, which CIDRs, how a result is deduplicated against a machine
that already has a TalosMachine). Deciding it here would be choosing a
design the task handing this phase down had already flagged as undecided.
DiscoveryReconciler.Discover therefore takes the endpoint as a plain
string argument. Whatever supplies it -- an operator registering one by
IP, a future subnet scanner, a CLI command -- calls Discover the same way;
nothing about this phase's shape commits to one of them. This is handed
back to the design, not decided here.
(Resolved below, in "Discovery probes only declared ranges": the answer is
neither free-form registration nor a scanner, but a declared range on
TalosCluster.spec.discovery that EnumerateCandidates expands and
DiscoverAll drives through Discover unchanged.)
Is TalosMachineIdentity sufficient? Yes, as given: uuid (required),
serial and macAddresses (both optional) already hold exactly the tuple
"Nodes" defines, and optional fields are the right shape for a key that can
be weak. Approved on TalosMachineSpec already carries the approval gate
TalosMachineReconciler.Reconcile already enforces (its first line: an
unapproved machine is left untouched, no status write, nothing). Nothing
needed adding to either.
role, on the other hand, did not fit. It was +required with an enum
of controlplane/worker, but discovery creates the TalosMachine before
approval assigns a role ("Nodes": approval "sets the role, labels, disk
roles and basic networking"). A required field with no value yet is a type
that cannot represent the state this phase needs to create. role is now
+optional; nothing else on the identity or approval types changed.
Decision
TalosMachineSpec.Rolebecomes optional. A discovered, unapproved machine has no role until approval sets one;pkg/renderalready fails closed on an empty role (viamachine.ParseType), so an operator who approves without setting a role gets a clearRenderFailedcondition rather than a silently wrong config.DiscoveryReconciler.Discover(ctx, cluster, endpoint)reads identity over aMaintenanceClient(a new, three-line interface mirroringTalosClient: one method,Identity, dialed throughclient.WithMaintenanceMode-- the same insecure, unauthenticated connection "Nodes" specifies) and classifies what it finds:- Weak (
strongIdentityis false: UUID, serial or every permanent MAC missing) -- never auto-registered.Discoverrecords aWeakIdentityWarning Event on theTalosCluster(address, whatever of UUID/serial/MACs/manufacturer were read) and creates nothing. - Strong, UUID unseen -- creates a new
TalosMachinenamed after the UUID,Approved: false,Roleunset. - Strong, UUID already named by an existing
TalosMachinewith the same serial and MAC set -- the same machine returning.Discoverreturns the existing object; nothing is created or modified. - Strong, UUID already named by an existing
TalosMachinewith a different serial or MAC set -- a collision "Nodes" says Ichor cannot resolve by itself (the UUID "may repeat").Discoverrecords anIdentityCollisionWarning Event and leaves the existing machine untouched; nothing new is created.
- Weak (
- The object's name is the UUID itself. SMBIOS UUIDs are already
lowercase, hyphenated hex -- a valid Kubernetes name -- and using it
turns "does a
TalosMachinealready exist for this identity" into a singleGet, which is also what makes the collision case detectable at all: aList-and-filter approach would have no single place to apply "same name, different hardware." - Neither outcome ever sets
Approved. The configuration reconciler's existing first line -- untouched by this change -- is therefore the only place that decides whether a machine gets configuration, and it already refuses anything unapproved.
Consequences
- No new CRD, no new abstraction beyond the two small interface types
(
MaintenanceClient,HardwareIdentity)talosclient.goalready has a direct precedent for. - A weak or colliding identity leaves no
TalosMachinebehind at all. The only record is the Event on theTalosCluster. An operator resolving it has to go look at cluster Events, not a dedicated list -- an acceptable gap for this phase, since the approval UI that would normally surface this is explicitly later work ("Nodes", "Roadmap" phase 5). - Because discovery never sets
Approvedand never loads the secrets bundle, it cannot be the path through which an unintended node gets configuration: the only writer that applies anything isTalosMachineReconciler, gated onApproved, which this phase does not touch. - Discovery's candidate-selection question stays open. Whoever answers it
next (registration CLI, subnet scanner, or both) implements it as a
caller of
Discover, not a change toDiscoveritself.
A second finding in the same file: the bundle Secret's namespace
While working in internal/controller/talosmachine_controller.go, a
cluster-wide ClusterRole rule granting unrestricted get;list;watch on
every Secret in every namespace was found reintroduced by commit
645f5be ("feat(controller): reconcile TalosMachine configuration"),
which added loadBundle's read of TalosCluster.spec.secretsRef. Commit
823b04f had already removed an identical rule once, for a different
Secret (ichor-webhook-server-cert), by moving it into a namespace-scoped
Role. 645f5be's marker --
// +kubebuilder:rbac:groups="",resources=secrets,verbs=get;list;watch
-- regenerates the same cluster-wide rule, confirmed by inspecting
config/rbac/role.yaml after make manifests.
Why this one could not be narrowed the same way
823b04f's fix works because ichor-webhook-server-cert always lives in
the controller's own namespace. The bundle Secret did not: loadBundle
resolved it in cluster.Namespace -- whichever namespace that particular
TalosCluster happens to live in -- and test/e2e/e2e_test.go creates its
TalosCluster in ichor-e2e, not the controller's namespace, proving this
is not hypothetical. A Role scoped to one fixed namespace can never grant
access to a Secret whose namespace varies per TalosCluster; Kubernetes RBAC
has no way to express "the Secret this field names, wherever its owner
lives" below cluster scope.
Decision: resolve the bundle Secret in the controller's own namespace, not the TalosCluster's
### Deployment already describes the default placement:
The default is self-managed: Ichor runs inside the cluster it manages. ... A hub mode, where Ichor manages clusters from outside, is left open by the API but not built first.
One controller, one namespace, by default. Resolving secretsRef against
that namespace rather than the TalosCluster's turns the open question
above into a non-issue: the Secret's location is now fixed and known at
manifest-generation time, so the RBAC marker can carry
namespace=ichor-system (the same literal the webhook certificate markers
already use) and make manifests emits a namespace-scoped Role, not a
ClusterRole.
This is a change in meaning for a public field, not just an
implementation detail: TalosClusterSpec.SecretsRef used to mean "the
Secret in this TalosCluster's namespace" and now means "the Secret in the
controller's own namespace." The TalosCluster itself is unaffected -- it
may still live in any namespace the controller watches; only the bundle
Secret's location changed. The field's doc comment in
api/v1alpha1/taloscluster_types.go says so.
loadBundle takes the controller's namespace as an explicit parameter
(TalosMachineReconciler.ControllerNamespace, set from POD_NAMESPACE in
cmd/main.go, the same downward-API variable the webhook certificate
rotator already uses) and fails outright if it is empty, rather than
letting an empty namespace resolve against every namespace by accident.
dist/chart/chart_test.go gained a standing guard,
checkNoClusterWideSecretsRead, asserting that no rendered ClusterRole
grants get, list or watch on secrets without resourceNames. Run
against the regression this ADR describes (the marker without
namespace=), it fails with:
ClusterRole "ichor-manager-role" grants [get list watch] on secrets across every namespace with no
resourceNames; the bundle Secret is resolved in the controller's own namespace (ADR 0011), so this
permission must live in a namespace-scoped Role instead
rbac.namespaced's default is unchanged (false): that toggle scopes
every rule in manager-role, not only the secrets one, to the release
namespace, and nothing about this decision needs the rest of the
controller's RBAC narrowed.
Consequence for hub mode
This decision is deliberately tied to the self-managed default. If hub
mode (TalosCluster objects managed from outside the clusters they
describe, "Deployment") is ever built, "the controller's own namespace"
stops being a safe place to assume every managed cluster's bundle lives --
a hub managing clusters across teams or tenants is exactly the case a
single shared namespace of Secrets was meant to avoid. Hub mode's design
has to revisit this RBAC shape explicitly: a namespace-scoped Role per
managed namespace (one per tenant), or an explicit opt-in ClusterRole
for operators who accept the wider blast radius -- not a silent
cluster-wide grant by default. This is called out here so that whoever
designs hub mode does not have to rediscover it.
A third finding: role must not be silently defaulted
TalosMachineSpec.Role becoming optional (this ADR's main decision) raised
the question of what happens when an approved machine still has no role
-- discovery never sets one, and approval is not built yet. The assumption
when Role was loosened was that pkg/render's existing
machine.ParseType call would fail closed on an empty string.
It does not. Talos machinery's machine.ParseType("") returns
TypeWorker, nil -- not an error -- because it exists to parse config
documents, where an absent machineType field has always defaulted to
worker. Used on TalosMachineSpec.Role directly, that default is wrong: an
approved machine with no role would have silently rendered (and applied) a
worker configuration instead of failing visibly.
pkg/render.Render now checks m.Spec.Role == "" explicitly, ahead of
ParseType, and fails with "render: machine role is not set". The
reconciler's existing RenderFailed condition path (unchanged) is what
surfaces this to an operator, the same way any other render failure does.
pkg/render/render_test.go's TestRenderBundleLeak gained an "empty role" case alongside its existing "unparseable role" one, confirming the
explicit failure and that no bundle material leaks into its message.
Enforcing this earlier -- at admission, when a machine is approved without
a role -- was considered and not done: TalosMachineCustomValidator is
registered for verbs=delete only (ADR 0006, guarded by
chart_test.go's checkWebhookInvariants), and widening it to
create/update is a bigger, deliberate scope change that ADR made on
purpose. The render-time check is sufficient for this phase's guarantee --
nothing ever applies a worker configuration to a machine that was not
explicitly given that role -- without reopening ADR 0006.
Discovery probes only declared ranges
This ADR's opening section left one question unanswered on purpose: where
a candidate endpoint for Discover comes from. It is answered now.
Decision: TalosCluster.spec.discovery declares addresses and CIDRs; empty means off
spec.discovery (TalosClusterDiscovery) holds two optional lists,
addresses and cidrs. Both empty -- the default, on every TalosCluster
that doesn't set the field at all -- means discovery is disabled: nothing
in EnumerateCandidates or DiscoverAll ever opens a connection. Ichor
never discovers a range by itself. No subnet scan, no listening for a
beacon, no default range: every candidate traces back to something an
operator wrote into the CR. EnumerateCandidates pairs each declared
address with Talos's fixed apid port (constants.ApidPort, 50000) --
the only port this ever dials; DiscoverAll then calls the existing
Discover once per candidate, unchanged from this ADR's first section.
Three reasons, the third the deciding one:
- A CR per machine would make creating the CR the approval. "Nodes" describes a pending-approval list a human acts on; a machine has to be able to appear on it without a human operation. A declared range (one CR, written once, well before any specific machine exists) keeps discovery and approval two separate steps; a CR-per-candidate scheme collapses them into one.
- An unbounded scan opens connections on networks nobody told the tooling about, and is unreliable besides (ARP timeouts, firewalls, mixed-MTU segments all behave differently across a scan Ichor didn't choose the shape of).
### Trustalready assumes a separate provisioning network exists: "A separate provisioning network is assumed; the docs say so." A declared CIDR or address list is that network, named explicitly. This decision adds no new assumption to the design -- it gives the API a place to write down an assumption the design already made.
Validation lives in the CRD, not the controller
Both fields are CEL-validated (k8s.io/apiserver's IP/CIDR CEL library,
available to CRD x-kubernetes-validations by default -- confirmed against
envtest's Kubernetes 1.36): addresses must each satisfy isIP(a) && ip(a).family() == 4; cidrs must each satisfy isCIDR(c) && cidr(c).ip().family() == 4 && cidr(c).prefixLength() >= 24. IPv6 is
rejected on both -- consistent with the design's own non-goals ("IPv6 ...
Later, maybe") and avoiding a /24-sized numeric threshold that would mean
something absurd for IPv6 (a /24 of IPv6 is still 2^104 addresses).
family() and prefixLength() return CEL int, not string, which is not
obvious from the function names and cost an iteration to find (envtest
rejected == '4' with a type error before accepting == 4).
A naive self.all(...) rule on an unbounded list is itself rejected by the
API server's own CEL cost estimator ("estimated rule cost exceeds budget"),
because the estimator assumes worst-case string length when none is
declared. +kubebuilder:validation:items:MaxLength (not plain MaxLength,
which controller-gen refuses on a slice field: "must apply maxlength to a
textual value, found type array") on each list brings the estimate back
under budget.
Why a /24 cap on cidrs, and why a cap at all
A cap exists. cidrs accepts at most four entries (MaxItems=4), each
/24 or smaller (prefixLength() >= 24, so at most 256 addresses);
addresses accepts at most 64 entries directly. A /16 is 65536
addresses and a /8 is 16777216 -- both large enough to turn one
TalosCluster edit into a connection-attempt budget no fixed concurrency
and timeout combination keeps politely bounded, and both far bigger than
any single provisioning network a rack or two of hardware actually needs.
Capped and rejected at admission (not accepted and left to the controller
to cope with) because the CRD is the layer that can refuse before anything
runs, the same reasoning MaxItems already uses elsewhere in this schema.
Worst case with the caps: 64 + 4×256 = 1088 candidates.
Rate limiting: fixed values, not settings
discoveryConcurrency = 32 and discoveryDialTimeout = 2 * time.Second,
both in internal/controller/discovery.go, neither exposed as a field
anywhere -- nobody asked for that knob, and a fixed worst case is the
property that matters, not tunability. The per-candidate deadline covers
the whole of one Discover call: the insecure maintenance-mode dial and
identity read, and the Kubernetes API Get/Create that follow for a
strong identity. That is deliberate, not an oversight: an earlier version
bounded only the dial, at 750ms, and go test's own parallel package run
(every package's envtest instance competing for the same CPU) occasionally
made the Kubernetes API calls alone take longer than that, which
DiscoverAll then silently treated as "unreachable" -- turning ordinary
API latency into a lost discovery. 2s absorbs that without meaningfully
changing the worst case: 1088 candidates / 32 workers × 2s ≈ 68 seconds,
still well short of "hangs."
Silence is the point, not a gap
DiscoverAll does not log or event per unreachable candidate, and neither
does Discover's own connect/read error paths (checked again here: they
return a plain error, nothing is logged on that branch). A declared
provisioning network answers on a handful of addresses and says nothing on
the rest, by construction -- that is what "separate provisioning network"
means in practice, not every address having a machine behind it. Reporting
every miss would make the one candidate worth an operator's attention
indistinguishable from a thousand that were never going to answer.
Tests never reach a real socket
Every test in internal/controller/discovery_test.go supplies its own
MaintenanceClient factory; none ever constructs
NewMachineryMaintenanceClient, the only thing in this codebase that
calls client.WithMaintenanceMode and actually dials. Addresses used in
tests are RFC 5737 TEST-NET-1 (192.0.2.0/24) and TEST-NET-2
(198.51.100.0/24) -- documentation-only ranges that are never real
networks -- on top of the factory-level guarantee, not instead of it. The
concurrency-bound and timeout tests simulate latency with time.Sleep and
<-ctx.Done(), never a dial.
What is still open
DiscoverAll is not wired into cmd/main.go or any ctrl.Manager trigger.
Which candidates to probe is now decided; when DiscoverAll runs for a
given TalosCluster -- on a timer, on a watch event, from a CLI command --
is a separate question this phase still does not answer, for the same
reason the original endpoint question was deferred: it is its own design
decision, not implied by anything settled here.
- Addendum (ADR 0086): the same identity answering in maintenance mode at a new address is recorded in
status.observedEndpoint, never inspec.endpoint. - Addendum (ADR 0108): a
TalosMachine'sspec.endpointmust be an address ofspec.discovery(or the user needsupdateontalosmachines/endpoint), and approving needsapproveontalosmachines/approval. Discovery-created machines are unaffected.