0091. A CA rotation waits for the operator's new credentials before anything they hold stops working
Status: Accepted · Date: 2026-10-27 · Design section: Backup, recovery, certificates; Trust · Supersedes in part: 0063 (stage sequence, "How Ichor stays connected" covers Ichor only), 0068 Decision 7 (the CLI start) · Amended by: 0093 (the UI download)
Decided: the ca-rotation stages are Accepting, AwaitingCredentials, Issuing, Dropping, Finalizing, Done; AwaitingCredentials changes nothing on any node and the controller leaves it only when the operation carries the annotation ichor.maesh.dev/credentials-handoff with the value talos=<new talos CA fingerprint>,kubernetes=<new kubernetes CA fingerprint> (ichorv1alpha1.CARotationHandoffToken); the CLI sets it, the controller only reads it and never writes spec; ichor cluster rotate-ca waits for the gate through the Kubernetes API, then writes a kubeconfig and a talosconfig that trust both the old and the new CAs and are signed by the new ones, checks that the new kubeconfig authenticates to the cluster, and only then sets the annotation; ichor cluster credentials NAME writes the same files from the cluster (--acknowledge also sets the annotation) or, with --bundle FILE, offline from a bundle-export file; rotate-ca --follow follows through the Kubernetes API with the new kubeconfig, not through the ichor-ui tunnel; the UI offers no download here, which ADR 0093 reverses: it adds an admin-gated download and a continue button
Context
The first real rotation (3 control planes, 1 worker) locked the operator out. Within seconds of Issuing the kube-apiserver
served the new CA and the operator's kubeconfig, which trusts the old one only, failed with x509: certificate signed by unknown authority. The nodes served certificates from the new Talos API CA from Issuing on, so the operator's talosconfig,
which trusts the old Talos API CA only, was refused on every node from that pass on, not only after the old CA was dropped
(about two and a half minutes later, at Dropping). Both files therefore stop working from the second pass (Issuing). Nothing the operator held reached the cluster afterwards, bundle-export needs a working kubeconfig,
and nothing in Ichor handed the operator new credentials. talosctl rotate-ca does: it writes a new talosconfig and builds
a new kubeconfig from the nodes during the rotation (Talos v1.14.3, pkg/rotate/pki/kubernetes fetches a client after the
swap, cmd/talosctl/cmd/talos/rotate-ca.go saves the new talosconfig). ADR 0063 ran the rotation in the controller and
left that part out. Its --follow also printed API unreachable, retrying (dial tcp [::1]:<port>: connection refused)
every 15 s from the start: the port-forward to ichor-ui goes through the kube-apiserver, which restarted when it was given the
new CA list, the tunnel died, and Reconnect (which does exist, follow_retry.go) failed every time without saying so
(the old kubeconfig was refused by the same API server) so the failure looked like a hang.
Decision 1: the gate
After Accepting is verified on every machine, every node accepts both CAs for the Talos API and the Kubernetes API and the
old CAs still issue, so every credential the operator holds still works and a credential signed by the new CAs works too.
That is the only moment in the rotation where both are true, so it is where the operator gets the new ones. The stage
AwaitingCredentials follows Accepting; the operation is Paused with the CARotation condition False/AwaitingCredentials
(its message names ichor cluster credentials NAME --acknowledge), requeued every operationRequeueInterval like every other
unverifiable step, and it applies nothing. There is no way to skip it: no flag, no annotation value that matches by accident
(the token is the new CAs' fingerprints, so an acknowledgement left from another rotation does not release this one). A
--skip-credential-handoff was considered and not built: an operator who does not want credentials still has to say so by running
the acknowledge command, and nothing in the product needs the unattended path (no e2e covers rotation yet; a harness can set the
annotation itself). If one is wanted later it must be an explicit field on the operation that the plan states; it must not be a default.
The wait is not a timeout and not a failure: it holds the cluster lock and the finalizer like any stage, the machine controller
stands aside, and nothing is half-done, because a cluster in AwaitingCredentials is in the state Accepting left it.
Decision 2: the dual-CA files
ichor cluster credentials and rotate-ca write two files, mode 0600 (directory 0700 if created), through the atomic writer of
bundlefile.go, never printing their contents:
- kubeconfig. The server, cluster, context, user and namespace names are copied from the current kubeconfig (so
--kubeconfig,$KUBECONFIGand the default rules behave as for every other command);certificate-authority-dataholds the new Kubernetes CA followed by the old one; the user is a client certificate with CNadmin, Osystem:masters, signed by the new Kubernetes CA (key type follows the CA, one year). Only the current context is kept. - talosconfig. One context named after the cluster;
cais the base64 PEM bundle of the new and the old Talos API CA;crt/keyanos:admincertificate generated withBundle.GenerateTalosAPIClientCertificatefrom a copy of the bundle whose OS CA is the new one. Endpoints are--talos-endpoints, else the control-plane machines'DialEndpoint(), else the current talosconfig's.
Default paths: the current kubeconfig's and talosconfig's path plus .ichor-new; --kubeconfig-out and --talosconfig-out
choose others. The command refuses an output that is the current file, so a failure never costs the operator the credentials that
work. Outside a rotation (no rotation.yaml) the same command writes files that trust the one current CA, which is what to run
after the rotation completes.
Why this works with the Talos trust model. A kubeconfig may carry several CAs in certificate-authority-data; the client
accepts a server certificate chaining to any of them, so it works against the old serving CA (before Issuing) and the new one
(after). The Kubernetes API accepts client certificates signed by the CAs in cluster.acceptedCAs, which Accepting added; the Talos API
does the same for machine.acceptedCAs. That is the claim the gate rests on, and Ichor does not take it on trust: before
acknowledging, the CLI asks the API server who the new kubeconfig is (a SelfSubjectReview with the new files), and does not set the
annotation if that fails, so a Talos build where an accepted CA does not authenticate clients leaves the rotation waiting, with the
old credentials intact, rather than stranding the operator. The same check against a node's Talos API with the new talosconfig is
made too, but only warns when it fails: the operator's machine often cannot reach the nodes except through the Kubernetes API, and
the Kubernetes check is the one that guards the file the rotation cannot continue without. (ponytail: neither has run against
real hardware yet; the first run on a disposable cluster should read the check's result.)
Decision 3: what sets the acknowledgement, and where the CLI reads the CAs
The CAs' keys are in the bundle Secret (rotation.yaml, written before Accepting, ADR 0063). Making credentials needs those keys,
so the command reads the Secret with the operator's own kubeconfig, as bundle-export does, and the keys never travel through the
ichor-ui API or the browser. (ADR 0093 reverses this for the UI: an admin-gated download and a continue button.) For the same reason the UI had no download (ADR 0084's downloads are diagnostics; these are
cluster-admin private keys): the start dialog and the operation page say that the credentials stop working, that the operation waits,
and which command to run. Choosing the CLI over a gated download is the simpler choice and the safer one: nothing new crosses the
network, the admin gate and audit of ADR 0084 are not extended to key material, and the only party that can release the gate is one
who holds a working kubeconfig at that moment. The acknowledgement is an annotation on the TalosOperation (metadata, not spec),
which needs patch on talosoperations that the operator already has to start the rotation.
rotate-ca always performs the hand-off, with or without --follow: a rotation started without it would otherwise wait forever.
It waits for the gate by reading the operation through the Kubernetes API with the old kubeconfig (retrying the transient errors of an
API server restarting for the accepted-CA change), writes and checks the files, acknowledges, prints where the files are and how to
use them (KUBECONFIG=… TALOSCONFIG=…), and returns; with --follow it then watches the operation to its end with a client built from the
new kubeconfig. If it is interrupted while the operation waits, ichor cluster credentials NAME --acknowledge does the rest.
Decision 4: --follow and the tunnel
The tunnel to ichor-ui is a port-forward through the kube-apiserver. It dies when the apiserver restarts, and it cannot be rebuilt
with a kubeconfig the apiserver no longer accepts, which after Issuing is the operator's old one. retryTransient called Reconnect
and dropped its error, so the failure repeated silently. Two changes: the error is now printed
(ichor: could not re-open the tunnel to ichor-ui (...)) for every --follow/--watch, and rotate-ca --follow does not use the
tunnel after the hand-off: it polls the TalosOperation through the Kubernetes API with the new kubeconfig (watchCARotation),
which is independent of ichor-ui's pod and of its endpoint, and retries transient errors with the shared helper. The plain
operation get --watch keeps the tunnel and the generic reconnect, now with its reason shown.
Decision 5: offline credentials
ichor cluster credentials NAME --bundle FILE reads a bundle-export file and needs no cluster: it writes the files for the single CA in
the file. It exists for the operator who holds a current bundle (exported after the last rotation) and has lost every kubeconfig and
talosconfig; the Kubernetes server comes from --kubeconfig (any kubeconfig naming the server) and the Talos endpoints from
--talos-endpoints. A bundle saved before a rotation names CAs no node accepts any more: that is the reason BundleBackupStale
exists, and the command does not pretend otherwise.
Consequences
- The CRD's
status.caRotation.stageenum gainsAwaitingCredentials; the chart copy is regenerated.OperationServiceand the proto are unchanged apart from the comment onca_rotation_stage; the UI'scaStageProgresscounts six stages. - The plan text (CLI and UI) states that the operator's kubeconfig and talosconfig stop working, that the operation waits for the
hand-off, and how it is done. The destructive alert in the dialog is shown for every
ca-rotationplan. - A rotation started from the UI waits at the gate until someone runs the CLI. It cannot stall the cluster: the lock is held, the
machine controller stands aside, as in any stage;
Paused/AwaitingCredentialsis the visible state. rotate-canow needs the operator's kubeconfig to read the Secret and the operation directly;bundle-exportalready did. Where the operator has only ichor-ui access, the rotation waits.- Not built (the UI download was added by ADR 0093):
--skip-credential-handoff, writing the new files over the current ones, refreshing a kubeconfig that names several clusters (only the current context is written), and credentials for the etcd and aggregator CAs (not rotated, ADR 0063). - ADR 0063's statement that "Ichor's dial" is the only client it keeps connected stands: the controller's bundle-based dial is unaffected; this ADR is about every other client the operator owns.
Amendment 2026-11-06: ichor cluster rotate-ca without --follow still stays until the hand-off is done (Decision 3 stands: the files and the acknowledgement need the operator's own kubeconfig, and the CLI is the one party that has it); it returns once the operation has gone on, in seconds when the nodes already trust the new CAs. Its help text and the --follow usage now say so. To start a rotation without the CLI, create the operation with kubectl or the UI and hand it off later with ichor cluster credentials NAME --acknowledge.