0024. CNI hand-over: no registry, render-time removal, a separate controller

Status: Accepted (amended 2026-10-26); superseded in part by ADR 0099 · Date: 2026-10-25 · Design section: GitOps and Argo CD

Decided: No CNI hand-over registry (one real member); removal happens at render time off a status condition; a separate controller; the generated Application syncs automatically (prune, selfHeal), amended 2026-10-26

Context

ADR 0019 built ichor init through step 5 and deliberately excluded step 6 (hand-over): "it is a continuous reconciliation loop against Argo CD's own status (watch, poll, retry) ... It belongs to the controller, as a later unit's work." This is that unit.

The design document's "GitOps and Argo CD" section originally described hand-over as "one mechanism with a component registry, not three special cases," implying three components (CNI, Ichor, Argo CD) each go through inject-into-cluster.inlineManifests → adopted by Argo CD → removed. Building ichor init (ADR 0019) showed that isn't what actually happens:

  • Ichor is applied directly through the API server (applyIchorManifest), never inlined. It never needs hand-over.
  • Argo CD is never installed by Ichor at all.
  • The CNI is the only one that genuinely has to be inlined (a node isn't Ready and nothing can be scheduled before a CNI exists), and the only one Argo CD later adopts.

So the registry would have exactly one member.

Decision: no registry

Ponytail's own rule applies directly here: don't build an abstraction over a single implementation. pkg/cni.Preset already declares everything a CNI hand-over needs (BootstrapManifest, ArgoCDApplication, HandoverWorkloads) as part of the one interface every CNI implements. A "component registry" would be a second interface wrapping the first for no caller that exists. The hand-over reconciler calls preset.ArgoCDApplication directly to get the Name/Namespace it needs to look up; there is nothing to register.

Decision: removal happens at render time, not by rewriting a TalosConfigPatch

Why removal has to actually happen, not just "Argo CD adopting it": Talos's ManifestApplyController (checked against Talos v1.14.3's source) only ever server-side-applies cluster.inlineManifests and re-applies them on every Kubernetes upgrade -- it has no delete path. If the CNI's inlined entry were left in place, an upgrade would silently roll the CNI back to whatever Ichor originally inlined, overwriting anything Argo CD has since changed. The same controller refuses to apply over an object carrying another controller's inventory annotation, which sounds like protection -- but Argo CD's own tracking marker (configured by argocd-cm's application.resourceTrackingMethod, read rather than assumed, per the design doc) is not that annotation, so adoption alone does not stop the re-apply.

Given that, removing the entry has to be a real, visible state change, not an implicit side effect of Argo CD's status. It is implemented in pkg/render.BuildConfig: once TalosCluster.status carries the CNIHandover condition as True, every patch's ClusterConfig.ClusterInlineManifests has the entry named cni.InlineManifestName filtered out before merging (dropHandedOverInlineManifest/withoutInlineManifest, pkg/render/render.go). This follows the design's existing precedent (control-plane taint folding already happens in render, not as a stored patch) and principle 5 (the controller renders from ingredients; it does not rewrite them): no TalosConfigPatch object is ever mutated -- a patch carrying the entry is deep-copied only for the filtered copy that gets merged, and every other patch passes through as the original pointer (verified in pkg/render/render_test.go's TestCNIHandoverDropsInlineManifest, including a pointer-identity check).

Decision: automatic / manual / disabled, as a CR field only

TalosClusterCNI.Handover (api/v1alpha1/taloscluster_types.go) is one of automatic (default), manual, or disabled, matching the design's "Hand-over can be automatic, manual (ichor gitops handoff cni), or disabled when Argo CD isn't used." This unit only builds the field and the reconciler's branching on it: disabled and manual both set the CNIHandover condition False (with a reason naming which) and never requeue. The ichor gitops handoff cni CLI command that would flip a manual cluster to adopted is explicitly another agent's work and is not built here.

Decision: a separate controller, not a bump to TalosClusterReconciler

CNIHandoverReconciler (internal/controller/cnihandover_controller.go) is its own controller watching TalosCluster, not folded into TalosClusterReconciler. TalosClusterReconciler's own doc comment already warns DiscoverAll can block a single TalosCluster's reconcile for up to ~68s at spec.discovery's caps. Raising that reconciler's MaxConcurrentReconciles would not have helped: controller-runtime's workqueue deduplicates and serializes by object key within one controller, so a single TalosCluster only ever has one in-flight reconcile through one controller's queue regardless of concurrency settings -- a slow discovery pass would still block a hand-over check sharing the same queue. Two controllers watching the same Kind each get an independent work queue, so a slow DiscoverAll reconcile in one never delays the other.

Decision: Argo CD absent is "not here yet," never a hang or a hard error

automatic mode reads the CNI's Argo CD Application as unstructured.Unstructured (the standard controller-runtime pattern for a foreign CRD this project neither owns nor vendors Go types for) at the exact Name/Namespace preset.ArgoCDApplication itself returns -- which is also how "adoption produces no diff" is satisfied: the same template that was injected is the only thing ever looked up, so there's no second, independently-guessed shape to drift from (pkg/cni/cilium's TestArgoCDApplication already covers the template's own content; this reconciler's test (internal/controller/cnihandover_controller_test.go) covers that the lookup uses that template's Name/Namespace).

Both apierrors.IsNotFound (Argo CD exists but hasn't adopted the Application yet) and apimeta.IsNoMatchError (Argo CD's CRDs aren't installed at all -- the realistic "this cluster doesn't run Argo CD" case) are treated identically: the CNIHandover condition is set False with reason WaitingForArgoCD, and the reconcile returns a bounded RequeueAfter (handoverRequeueInterval, 30s), never a blocking wait or an error. A cluster that genuinely never runs Argo CD should set spec.cni.handover: disabled to stop this polling; left at the automatic default it polls forever and says so plainly, which satisfies "does not break" (nothing blocks, nothing retries in a tight loop) without guessing that the operator meant disabled.

Update (ADR 0055): the handed-over Application now also carries ignoreDifferences on CiliumIdentity; release name, namespace and values are unchanged and a test compares them with the bootstrap values.

Consequences

  • pkg/cni.InlineManifestName is a new exported constant duplicating internal/cli's own unexported cniKey: both pkg/render and internal/controller need the exact string Ichor inlined the manifest under, and internal/cli is off-limits to this unit, so there is no shared home below it. pkg/cni is safe for both to import (its own subpackages cilium/flannel import it, not the reverse).
  • CNIHandoverReconciler.cniPresetFor duplicates internal/cli's own preset switch ("cilium"/"flannel", "custom" deliberately absent, same as ADR 0013) for the same reason: a controller cannot import the CLI package.
  • Not built here: the ichor gitops handoff cni CLI command; any change to internal/cli, internal/uiapi, pkg/upgrade, or the e2e suite.

Update (ADR 0055 addendum, 2026-10-26): the generated Cilium Application now carries syncPolicy.automated {prune: true, selfHeal: true} and PruneLast=true. Without it Argo CD never synced the Application, so it never became Synced + Healthy and the hand-over never completed (found on real hardware). Ichor still never changes the sync policy of an existing Application; it only chooses one when it creates its own.

Update (2026-10-28): cluster.inlineManifests, cluster.extraManifests and cluster.extraManifestHeaders are safe in pkg/fieldmeta. Found on real hardware: after the hand-over Ichor stops rendering the CNI entry, and with the default (reboot-required) class the single control plane sat Ready=False/RebootRequired for that pending removal (the machine controller never reboots), a later CNI operation paused on "unrelated pending configuration change at cluster.inlineManifests", and only ichor config apply cleared it, by rebooting the node (~3 min API outage). Talos v1.14.3 needs no reboot: controllers/k8s/control_plane.go:510-552 rebuilds ExtraManifestsConfig live from the active MachineConfig on control planes only (controlplaneMapFunc, :44-66); extra_manifest.go:131-141 destroys the no-longer-listed k8s.Manifest resource; manifest_apply.go:230-393 only server-side-applies (skips inventoried objects, :335-338) and has no delete path, so removing an entry deletes nothing in the cluster. The machine controller therefore applies the removal live right after the hand-over and nothing reboot-required is left behind.

Amendment (2026-10-28): the hand-over status is a render input, so its removal is a change, not drift

The machine controller classes a node/render mismatch as drift when desiredInputsHash (the generations of the cluster, machine, patches, storage, plus the registry credentials) equals the hash recorded at the last convergence. The hand-over writes status.conditions[CNIHandover], which moves no generation, so the hash stayed put while the render dropped the CNI's cluster.inlineManifests entry. The removal read as drift and, under the default warnOnly, was never applied (NodeDrifted). desiredInputsHash now also folds in whether CNIHandover is True and status.cni.kubeProxy (ADR 0066), the two status values the render reads. The removal is then an intended change and is applied live under any driftPolicy, since cluster.inlineManifests is safe and needs no reboot. Hashes of clusters that have neither are unchanged.

Search the docs

Search pages and headings