0120. The CA rotation also replaces the etcd CA and the Kubernetes aggregator CA
Status: Accepted; built; amended after hardware 2026-11-05 (aggregator rotation verified; the etcd roll by reboot awaits its hardware re-run) · Date: 2026-11-04 · Design section: Backup, recovery, certificates · Touches: 0063, 0091, 0093, 0108
Decided: the ca-rotation operation can also replace the etcd CA and the Kubernetes aggregator (front-proxy) CA, chosen with the new, optional spec.caRotation.targets (talos, kubernetes, etcd, kubernetes-aggregator; absent means talos and kubernetes, as before). It is the same state machine, lock, pause/continue and finalizer, with the same three passes. The aggregator CA uses Talos's own accepted-CA list (KubeAggregatorCAConfig, acceptedCAs). Talos has no accepted-CA list for etcd, so Ichor puts both etcd CA certificates into cluster.etcd.ca.crt (a PEM bundle, the new key signing from Issuing on) and restarts the etcd members one at a time after each pass; the credential hand-off (AwaitingCredentials) happens only when the Talos or Kubernetes CA is among the targets. The etcd backup key does not change. Before the first hardware run the etcd bundle is a reading of Talos v1.14.5 source, not of its documentation; ADR section "Hardware verification" says what must be seen before it is trusted.
Context
ADR 0108 (S6) left the etcd CA and the aggregator CA out of the rotation: "the etcd CA rotation needs every member restarted with both CAs trusted in a defined order". The user decided on 2026-11-04 that both ship in v0.1.0. The service-account signing key and the secretbox key are a different mechanism (ADR 0121).
What Talos v1.14 offers (version in go.mod: siderolabs/talos/pkg/machinery v1.14.5)
talosctl rotate-carotates the Talos API CA (--talos) and the Kubernetes API CA (--kubernetes) only. Talos CA rotation lists the general flow (generate, accept, issue, refresh, drop) and covers those two;cmd/talosctl/cmd/talos/rotate-ca.goandpkg/rotate/pki/{talos,kubernetes}contain nothing for etcd or the aggregator. The same page says: "The aggregator CA is configured separately throughKubeAggregatorCAConfig" and gives no procedure for it.- Aggregator CA. The typed document
KubeAggregatorCAConfig(pkg/machinery/config/types/k8s/aggregator_ca.go) hasissuingCAandacceptedCAs; its documentation says "The issuing CA certificate is automatically added to the list of accepted CAs". Talos rendersacceptedCAsintoaggregator-ca.crt, which is kube-apiserver's--requestheader-client-ca-file(controllers/k8s/render_secrets_static_pod.go,control_plane_final.go), and signs the front-proxy client certificate (--proxy-client-cert-file) withissuingCA(controllers/secrets/kubernetes_dynamic_certs.go). kube-apiserver publishes the accepted bundle in the ConfigMapkube-system/extension-apiserver-authenticationfor extension API servers (Configure the Aggregation Layer). The document exists for control planes only and only with a version contract that has the multi-document Kubernetes configuration (MultidocKubernetesConfigSupported, v1.14); earlier contracts usecluster.aggregatorCA, a single CA with no accepted list, and the two forms conflict (V1Alpha1ConflictValidate). - etcd CA. The only field is
cluster.etcd.ca(config reference: "Thecais the root certificate authority of the PKI"). There is no accepted-CA list. In the source:EtcdConfig.CA()feedssecrets.EtcdRoot(controllers/secrets/root.go);etcd/pki.gowritesEtcdCA.Crtverbatim to/system/secrets/etcd/ca.crt; etcd runs withtrusted-ca-fileandpeer-trusted-ca-filepointing at that file (system/services/etcd.go);secrets/etcd.gomakes the server, peer, admin and kube-apiserver client certificates from the CA withx509.NewCertificateAuthorityFromCertificateAndKey, which parses only the first PEM block ofCrt(pem.Decode) and the key; kube-apiserver gets the same file asetcd-client-ca.crt(--etcd-cafile). Nothing restarts etcd when the files change, and nothing validates the CA's content beyond "present". Consequence: with a plain replacement ofcluster.etcd.caa member on the new CA and a member on the old one cannot talk to each other, so rotating the CA member by member loses quorum at the middle restart.
Decision
1. Targets (API)
Append-only: TalosOperation.spec.caRotation (TalosOperationCARotationSpec, optional) with targets []string (enum talos, kubernetes, etcd, kubernetes-aggregator; unique; at least one). Absent or empty means talos and kubernetes, so every existing operation, script and UI call is unchanged. The names are the ones status.certificates of the cluster already uses for expiry (ADR 0063). status.caRotation.certificates gets entries named like the targets, and two new status fields: etcdRestarted and etcdRestartedAt (below). The stage enum is unchanged. The wire gets repeated string ca_targets on CreateOperationRequest and PlanOperationRequest (the choice), PlanOperationResponse (the effective list, the default pair spelled out) and Operation (for the UI).
A separate operation kind was rejected: it would need its own lock handling, finalizer, machine-controller quiescing, pause/continue, rotation.yaml and bundle replacement, which are exactly what the existing machine provides, and a second kind could run beside a first one. One operation per cluster at a time stays true.
The targets are decided once, when the operation starts: the new CAs are generated for the targets and written to rotation.yaml (optional keys talos, kubernetes, etcd, aggregator; the set of keys present is the set of targets). A restarted controller, or a rotation started by an older Ichor, reads the targets from rotation.yaml, not from the spec.
2. Stages and rendered configuration
The stages and their meaning stay: Accepting (the new CA is trusted, the old one issues), Issuing (the new one issues, the old is trusted), Dropping (only the new one), Finalizing. Per target, in the typed documents:
| target | Accepting | Issuing | Dropping, Finalizing |
|---|---|---|---|
| talos | machine.acceptedCAs + new | machine.ca new, accepted old | machine.ca new |
| kubernetes | KubeAPIServerCAConfig accepted + new | issuing new, accepted old | issuing new |
| kubernetes-aggregator (control planes) | KubeAggregatorCAConfig.acceptedCAs + new | issuingCA new, acceptedCAs old | issuingCA new |
| etcd (control planes) | cluster.etcd.ca: certificate = old then new, key = old key | certificate = new then old, key = new key | certificate = new, key = new key |
The etcd rows rely on two facts read in Talos's source (above): the certificate field is written verbatim into the trust file, which etcd and kube-apiserver read as a PEM bundle, and the signing CA is the first block with the key. The first row therefore keeps the old CA signing while both are trusted; the second changes the signer while both are still trusted; the third drops the old one. Dropping and Finalizing render byte for byte what a bundle holding the new CAs renders (a test pins it), so the machine controller sees no drift afterwards. A target that is not rotated renders exactly as before.
Refused at the start (plan blocked and executor preflight): kubernetes-aggregator on a cluster whose version contract is older than v1.14 (no accepted list exists there). A two-member etcd, which would lose its quorum while one member restarts, needs no check of its own: the cluster lock already refuses every disruptive operation on an even number of approved control planes (ClusterTransitioning). An etcd rotation also refuses to start unless etcd is whole: every control plane reports etcd Running and healthy, one member per control plane, no alarm, kube-apiserver ready.
3. etcd: restart the members, one at a time, after each pass
Applying the configuration changes the files and the leaf certificates, but the running etcd keeps what it loaded, so after each of the three passes the controller restarts the etcd service of every control plane, one at a time, in the order the control planes are visited:
- The restart starts only when every machine already runs the stage's configuration (the apply and the restart are separate reconciles) and only when the cluster is healthy: every control plane's
etcdservice is Running and healthy, its member list has one member per approved control plane, no alarm is set, its kube-apiserver is ready, and the node's etcd PKI is the stage's: the root CA the node reports (COSIsecrets.EtcdRoot) holds the expected certificates in the expected order and its server certificate (secrets.Etcd) is signed by the expected CA. Otherwise the operation pauses (Blocked) and tries again every 30 s; nothing is restarted. - If the member leads, it forfeits leadership first (
EtcdForfeitLeadership), thenServiceRestart("etcd"). - The name goes into
status.caRotation.etcdRestartedand the time intoetcdRestartedAt; the next member is restarted only afteretcdRestartSettle(15 s) and a new health check.etcdRestartedis emptied when the stage changes. A controller killed after the restart but before the status write restarts that member once more, which is harmless. - The pass is verified (as before: Ichor reaches each node, every Node is Ready) only after the last member has restarted and the final health check passes.
Because both CAs are trusted by every member from the first pass on, and every member presents a certificate either CA vouches for, a member on an earlier or later step of the roll can always talk to the others: during each roll at most one member is down, which is an ordinary rolling restart, quorum holds with three or more members, and a single control plane merely has its API unavailable while its only member restarts (the plan says so: kubernetes_api_unavailable). Three rolls are needed, not one: after Accepting every member trusts both CAs; after Issuing every member presents a certificate of the new CA (kube-apiserver then trusts both); after Dropping only the new CA is trusted. kube-apiserver reads its etcd client CA and certificate from the same Talos secrets, and Talos restarts its static pod on every change, so each pass also restarts kube-apiserver once per control plane; the existing Node-Ready verification covers it.
4. Aggregator: wait for the extension API servers, never restart them
kube-apiserver restarts on its own (Talos re-renders the static pod). Before Issuing, the controller reads extension-apiserver-authentication through the admin kubeconfig a Talos node issues (as the kube-root-ca.crt check does) and waits until its requestheader-client-ca-file holds the new CA, then waits ClientSettleDelay (2 minutes; the clock is a condition transition time, so it survives a restart) so that extension API servers that reload the ConfigMap have the new CA before the front-proxy certificate is signed by it. Ichor restarts none of them (they are workloads such as metrics-server; ADR 0101): the plan and the completion message say that an extension API server that read the CA only once must be restarted by the owner. The wait is Verifying-stage of Accepting, not a new stage.
5. Credentials hand-off, secrets bundle, backups
- AwaitingCredentials exists for the operator's talosconfig and kubeconfig (ADR 0091). When neither
talosnorkubernetesis a target nothing the operator holds changes, so the stage is skipped (Accepting goes to Issuing) and the CLI/UI do not ask for a hand-off.CARotationHandoffTokenis unchanged in form; it also names etcd and aggregator entries when present. - Bundle.
Certs.EtcdandCerts.K8sAggregatorare replaced in the one Secret update that replaces the others (Rotation.Applyreplaces whatrotation.yamlholds and nothing else).rotation.yamlholds the keys of the new CAs from before any node is told anything, as in ADR 0063. A key that is not inrotation.yamlis never touched. - BundleBackupStale. Raised as before.
BundleFingerprint(Talos and Kubernetes CAs) is unchanged and is still what the credentials code compares; a newExportFingerprintadds the etcd and aggregator CAs, andbundle-exportand the cert-expiry reconciler use it, so an export made before an etcd-only rotation does not count as current. Cost: an export annotation written by an earlier Ichor no longer matches, so a cluster that has BundleBackupStale up at the moment of the upgrade needs one morebundle-export. - etcd backups. The backup key derives from
Cluster.SecretandCluster.ID(ADR 0063, ADR 0108 section 4), which a CA rotation does not touch; snapshots contain no PKI. Snapshots taken before the rotation stay readable and restorable (a restore renders the configuration with the then-current CAs).TestPassphraseSurvivesCARotationstill pins it, and the controller test asserts the cluster ID and secret are unchanged. The secretbox key stays out of scope (ADR 0121). - A fresh snapshot first. The plan warning for an etcd rotation tells the user to take an etcd snapshot before starting (
ichor etcd backup now NAME). Ichor does not require one: a missing backup is the user's decision for their cluster, not something Ichor forces (the "warn, do not force" rule for the managed cluster).
6. Failure and rollback
Every step is idempotent and re-derived from the nodes and from status (ADR 0063): a failed apply, a node that does not answer, a Node not Ready, an etcd member that does not come back, a refused dry run: the operation is Paused with CARotation=False/Blocked and a message, the next stage is never started, and it retries every 30 s. No member is restarted while any is unhealthy, so at most one member is down at any time. A rotation is never rolled back by Ichor automatically; it only moves forward. Until Dropping has started, every member and kube-apiserver still trust the old CA, so a rotation that cannot proceed can be abandoned by hand: remove rotation.yaml from the bundle Secret and the operation's finalizer (ADR 0063), after which the machine controller renders the old CAs again; the members pick that up at their next restart (expected, not exercised on hardware). After Dropping started, the old etcd CA is gone from the nodes that were already rolled: continue, do not revert.
7. UI and CLI
- CLI:
ichor cluster rotate-ca NAME [--ca talos,kubernetes,etcd,kubernetes-aggregator] [--all]; default unchanged.--yes,--followas before. The plan lists the targets, the stages, the etcd restarts and the warnings; the credential hand-off runs only whentalosorkubernetesis a target. - UI: the start dialog for a CA rotation shows four checkboxes (Talos API and Kubernetes API checked, etcd and aggregator unchecked) and sends
ca_targets; the plan view names the CAs, the etcd restarts and the warnings and shows the credentials alert only whentalosorkubernetesis a target; the operation page lists the CAs and the etcd members restarted in the stage (Operation.ca_targetsand the details), and the credentials panel (CARotationCredentials) appears only for rotations that includetalosorkubernetes. - The cert-expiry message no longer says the two CAs "are not covered".
Rejected
- Replacing
cluster.etcd.cain one pass (all members at once, or one by one). At once is an outage of the whole control plane; one by one, with a single CA per member, splits the cluster by CA at the middle restart. - Issuing etcd leaf certificates from the new CA and keeping the old trust through a separate file. Talos has no field for it and Ichor may not write raw files (machine config is typed only, no raw escape hatch).
- Treating the etcd CA as unrotatable and documenting it. The user decided it ships in v0.1.0.
- A restart of every member at the same time per pass, or no restart in Accepting. The certificates are loaded at start; a member that never restarted after Accepting would not trust the new CA when Issuing makes the others present it.
- A hard requirement for a recent etcd snapshot. Warned about, not enforced (above).
- Restarting extension API servers. Not Ichor's workloads (ADR 0101).
Risks
- The etcd bundle depends on Talos behaviour that is not in its documentation (first PEM block signs, whole field is the trust file, etcd and kube-apiserver read bundles). If a Talos release changes it, the etcd target would split the cluster; Ichor's own check (the node's
secrets.Etcdis signed by the expected CA, the trusted list is the expected one) pauses the rotation before any restart if the node does not behave as read. The target is therefore also the one to run first on a disposable cluster. - Three etcd rolls and three kube-apiserver restarts per control plane; a one-control-plane cluster has its Kubernetes API down during each member restart.
- Extension API servers that read the aggregator CA once need a restart by their owner.
ClientSettleDelayadds two minutes to a rotation that includes the aggregator CA.
Test
envtest (internal/controller, a fake node per machine as in ADR 0063): the stage sequence, rendered documents per stage and role for etcd and aggregator (typed: cluster.etcd.ca certificate order and key, KubeAggregatorCAConfig), Dropping equal to the replaced bundle's render, rotation of only etcd skipping AwaitingCredentials, etcd members restarted in order with the health gate and leader forfeit, each member restarted exactly once per stage with a new reconciler at every step, an unhealthy member holding the roll, a node that has not yet loaded the stage's etcd PKI holding it, a missing member and two control planes refusing the start, a legacy contract refused (render), the aggregator wait, rotation.yaml keys and the replaced bundle, backup key unchanged; pkg/render tests per stage and role; CLI flags and plan output; UI selection and card.
Hardware verification
Not run. Steps (3 control planes and 1 worker, contract v1.14, a throwaway cluster; kubectl --context admin@talos-cluster01 for the outer cluster only):
ichor etcd backup now NAMEand wait for the snapshot. Notetalosctl etcd membersand a ConfigMap written before the rotation.ichor cluster rotate-ca NAME --ca etcd --yes --follow. Expect: no AwaitingCredentials; after Accepting,talosctl -n <cp> read /system/secrets/etcd/ca.crtholds two certificates (old first); after Issuing, new first; after Dropping, one (the new). Each roll: only oneetcdservice restarts at a time (talosctl service etcdon each),talosctl etcd membersnever loses a member,kubectl get --raw /readyzrecovers within a minute per control plane.- After Done:
talosctl etcd statuson every control plane, checktalosctl read /system/secrets/etcd/etcd.crt | openssl x509 -noout -issuer -fingerprintagainst the new CA andtalosctl get etcdrootsecret -o yaml(public part). The ConfigMap from step 1 is still readable; an etcd backup taken after the rotation succeeds; an older snapshot is listed and its key still decrypts it. ichor cluster rotate-ca NAME --ca kubernetes-aggregator --yes --followon a cluster with metrics-server:kubectl top nodesworks before, after Accepting and the settle wait, after Issuing and after Done;kubectl get cm -n kube-system extension-apiserver-authentication -o jsonpath='{.data.requestheader-client-ca-file}'holds two certificates, then one.ichor cluster rotate-ca NAME --all --yes --followonce on a fresh cluster, thenichor cluster bundle-export NAMEclears BundleBackupStale.- Negative: kill the controller pod during an etcd roll; the roll resumes, no member is restarted twice in a row without a health check. Power off one control plane before starting; the operation refuses to start.
- If step 2 shows a member that does not trust a certificate signed by the other CA, stop: the etcd bundle assumption is wrong for that Talos build; do not use the
etcdtarget and record the Talos version.
Amendment (hardware, 2026-11-05)
Lane A ran the rotation on a compact HA cluster (3 control planes and 1 worker, Talos v1.14.5, Kubernetes v1.36.6, etcd 3.7.3, Ichor main ecb01c33). The aggregator rotation worked; the etcd rotation did not, and three smaller defects showed. This amendment replaces what it names and leaves the rest of the ADR as written.
1. etcd members load the CA with a reboot, not a service restart (replaces section 3)
Observed. --ca etcd paused in Accepting: restarting etcd on "<machine>" failed: rpc error: code = Unknown desc = service "etcd" doesn't support restart operation via API. talosctl service etcd restart and stop fail the same way on v1.14.5. The Accepting configuration itself applied without a reboot (/system/secrets/etcd/ca.crt went from one to two certificates on all three control planes and etcd kept running), and afterwards Talos reported RebootRequired for cluster.etcd.ca.crt.
Documentation (Talos v1.14). Edit machine configuration, "Changes which might need a reboot": ".cluster.etcd: the etcd service reads its settings ... when it starts. Talos does not restart etcd on a configuration change, since restarting a control plane member is disruptive and is not something Talos does implicitly. Reboot the node to pick up the new settings." The same page says machine configuration "is always applied to the running node without a reboot" in v1.14, that --mode=reboot was removed in Talos 1.14, and that a change which needs a reboot is applied first and the node then rebooted explicitly "so it can be sequenced with cordoning and draining". etcd maintenance repeats it for the etcd image: "Talos does not restart it on configuration change, so reboot the node." So a reboot is the only supported way for a running member to load a new cluster.etcd.ca, and there is no apply mode that applies and reboots in one call: the existing NO_REBOOT apply of the pass followed by an explicit, gated reboot is the design the documentation describes.
Decision. After each of the three passes, once every machine runs the stage's configuration, the controller reboots the control planes one at a time. The roll is the node action's reboot (ADR 0085) run inside the rotation, which holds the cluster lock (a child TalosOperation could not take it):
- Before any reboot, and again before each next one: every control plane's etcd Running and healthy, a leader seen, one member per approved control plane, no alarm, kube-apiserver ready, the node's etcd PKI the stage's (the same checks as before), and every Kubernetes Node Ready. Otherwise the operation pauses (
Blocked) and nothing is rebooted. - For the next control plane in order: the storage evacuation hook (nil when none is wired), then cordon and drain with the helper the config rollout uses (
KubeClient, skipped when none is wired), a leader forfeits leadership (best effort), the boot ID it leaves is written tostatus.caRotation.etcdRebootBootIDand its name toetcdRebooting, and only thenRebootis called. A controller killed between the status write and the call finds the marker and waits; if the node still reports that boot afteretcdRebootRetry(5 minutes) the marker is cleared and the request made again. A failedRebootcall clears the marker, uncordons and retries. - While
etcdRebootingis set the pass does nothing else (it does not dial the node to read its configuration) and waits,InProgresswith reasonRebootingEtcd, for: the node to answer on a different boot ID, every Node Ready, and the same health and PKI gate as in 1 on all control planes. PastnodeActionTimeout(20 minutes) the operation pauses. Then the node is uncordoned (storage evacuation ended), its name is appended toetcdRestarted(the name of the field is kept: it is append-only; it now lists the control planes rebooted in the stage) andetcdRebootingis cleared.etcdRestartedAtrecords when the reboot was requested. etcdRestartSettleis gone: the gate after a reboot replaces a fixed wait.
New status fields, append-only: status.caRotation.etcdRebooting and status.caRotation.etcdRebootBootID. The reason RestartingEtcd became RebootingEtcd.
Cost and risk (replaces the corresponding lines of Risks). Three reboots of every control plane per etcd rotation (one per pass), each with a drain, one node at a time; a rotation of etcd alone on three control planes is nine reboots. The plan (CLI and UI) and ichor cluster rotate-ca --help say so. A cluster with one control plane has no Kubernetes API during each of its three reboots, and the drain of its only control plane evicts what runs there. A reboot is longer than the restart this replaced, and the node comes back with the stage's configuration loaded from disk (the pass applied it with NO_REBOOT, which writes it to the STATE partition).
Still unverified. The assumption that both CA certificates in one cluster.etcd.ca.crt are trusted, with the first signing, was not reached (no Issuing stage ran). The check that stops before any reboot unless the node reports the stage's PKI (secrets.EtcdRoot and secrets.Etcd) stays, and the hardware run below must still end the question. A member that does not trust a certificate signed by the other CA now shows as a rebooted node whose etcd does not become healthy: the roll stops at that node, pauses, and reboots no further one.
2. Deleting a rotation before its point of no return abandons it
Observed. ichor operation delete on a Paused ca-rotation set deletionTimestamp, but the operation stayed Paused with its finalizer, the machines stayed Ready=Unknown (CARotationInProgress) and the cluster lock stayed held; the agent removed rotation.yaml and the finalizer by hand. The finalizer step only released an operation that was no longer under way, and an operation paused mid-rotation is under way for ever.
Decision (ca-rotation and key-rotation, which share the pattern). A delete of a rotation that is under way is decided by RotationAbandonable (api/v1alpha1):
| rotation | abandonable in | not in | why |
|---|---|---|---|
| ca-rotation | Accepting, AwaitingCredentials; Issuing when neither the Kubernetes nor the etcd CA is a target | Issuing with the Kubernetes or etcd CA; Dropping; Finalizing | After Issuing the pods have been given the new Kubernetes CA alone (kube-root-ca.crt), and an etcd member that went back to the old CA would not trust its neighbours' new certificates; Dropping and Finalizing remove the old CA. Talos and aggregator CAs survive Issuing: the old one is still accepted. |
| key-rotation | Accepting | Issuing and later | From Issuing the new secretbox key encrypts and the new service-account key signs: going back leaves Secrets and tokens nobody can read. |
For an abandonable rotation the finalizer step, instead of waiting for a rotation that will never settle: removes rotation.yaml (keyrotation.yaml) from the cluster's bundle Secret (the one object a rotation writes; secrets.yaml was not touched before Finalizing), uncordons the control plane that was being rebooted (named by spec.hostname or status.nodeName, as the node may be down), releases the cluster lock if the operation holds it (Release is a no-op for another holder), records an Event on the cluster, and removes the finalizer. Every step is idempotent and retried as a whole. The machine controller then renders the old material again: the nodes still carry the extra accepted CAs (and for etcd the two-certificate field), which Talos classifies as reboot-requiring, so the machines report Ready=False/RebootRequired and ichor config apply finishes the revert (3 minutes for three control planes on hardware, as the hand-made abandon did). Ichor does not apply it by itself: it never reboots a node on its own.
For a rotation past the point: the controller sets the operation condition DeletionHeld=True (reason PastPointOfNoReturn, the stage in the message) once and goes on; the finalizer is released when the rotation is Done. DeleteOperation in the UI API (CLI and UI) refuses it up front with FailedPrecondition and the stage. A webhook on DELETE was rejected: it would put every TalosOperation delete behind a failurePolicy: Fail admission call, and the condition plus the API refusal reach the same people (a raw kubectl delete still ends in a held, explained delete).
3. The old aggregator CA leaves extension-apiserver-authentication
Observed. After Done the ConfigMap's requestheader-client-ca-file still held both CAs although aggregator-ca.crt on every control plane held one: kube-apiserver merges and never removes. Deleting the ConfigMap made it come back with one certificate and kubectl top kept working.
Decision. In Finalizing, after every machine is verified against the replaced bundle, AggregatorAuthReset reads the ConfigMap through the admin kubeconfig a Talos node issues for the call (the credential the other CA checks use; there is no standing credential and no RBAC of Ichor's own for the managed cluster, so no ClusterRole rule is added and the chart is unchanged). If it holds exactly the new CA the step is settled; if not, it deletes the ConfigMap by name with a UID precondition (a ConfigMap kube-apiserver has recreated meanwhile is not deleted again) and reports not settled; a missing ConfigMap is not settled either. The operation stays InProgress (SettlingAggregator) and the check repeats every 30 s; it completes only when the ConfigMap holds the new CA alone. Extension API servers that read the ConfigMap once still need a restart by their owner (unchanged).
4. The credential hand-off waits for the Kubernetes API
Observed. With a single control plane endpoint the hand-off failed once with connection refused while kube-apiserver restarted; the operation kept waiting and ichor cluster credentials --acknowledge fixed it. Only the final annotation patch retried transient errors.
Decision. Every API call of the hand-off (reading the secrets bundle, listing the operations, verifying the new kubeconfig) goes through retryHandoff, the CLI's existing transient-error retry (retryTransient: backoff from 1 s to 15 s for up to 10 minutes), and prints once that it is waiting for the Kubernetes API. A non-transient error (a certificate that does not authenticate) still stops at once and the operation is not acknowledged.
Tests
envtest: the roll by reboot (order, one reboot per control plane per stage, no ServiceRestart ever, unhealthy member holds the roll, a node that does not answer is waited for, a boot ID that does not change is waited for and re-requested after etcdRebootRetry, the 20-minute pause, leader forfeits before its reboot, a Node not Ready holds the roll, cordon and uncordon around the reboot); abandon (ca-rotation paused in AwaitingCredentials, Issuing of the Talos CA, mid-reboot uncordon, held past Dropping with DeletionHeld and released at Done; key-rotation in Accepting abandoned and in Issuing held); the aggregator reset in Finalizing and the reset step itself against a fake ConfigMap client (by name, UID precondition, missing, settled). api/v1alpha1: RotationAbandonable. uiapi: the delete refusal. cli: the hand-off retries and gives up after the window.
Hardware verification (replaces steps 2 and 6 above; the rest stands)
ichor cluster rotate-ca NAME --ca etcd --yes --followon three control planes: no AwaitingCredentials; each pass reboots the control planes one after the other (kubectl get nodesshows one cordoned node at a time),talosctl etcd membersnever loses a member,kubectl get --raw /readyzrecovers per node; after Acceptingca.crtholds two certificates (old first), after Issuing two (new first), after Dropping one. The first Issuing reboot answers the open question (a member that trusts a certificate signed by the other CA): if a rebooted node's etcd does not become healthy the roll stops there; record the Talos version and do not use theetcdtarget.- Kill the controller pod between the status write and the reboot, and during a reboot: the roll resumes without rebooting a second control plane meanwhile.
ichor operation deleteon a rotation Paused in Accepting: the operation disappears,rotation.yamlis gone from the Secret, the lock lease is gone, the machines showRebootRequired,ichor config applyfinishes. The same in Dropping is refused by the CLI. - After an aggregator rotation:
kubectl get cm -n kube-system extension-apiserver-authentication -o jsonpath='{.data.requestheader-client-ca-file}'holds one certificate when the operation reports Completed. - With one control plane endpoint, start
rotate-ca(default CAs) from the terminal: a refused connection during the hand-off prints the waiting line and the hand-off completes by itself.
Amendment (lane E re-verification, 2026-11-05): webhook failures in the hand-off, the etcd plan wording, and the aggregator restart
- The hand-off retry (point 4) treated a refused webhook connection as transient but ended on
failed calling webhook ...: context deadline exceeded, which arrives as a 500 InternalError whose text does not mention a refused connection. Anyfailed calling webhookerror that is not a Forbidden or Invalid refusal (a webhook that answered "no" stays an answer) is now retried for the whole 10-minute window. - The CLI plan of an etcd rotation no longer says "without a reboot": it says the control planes reboot in each pass (the workers do not).
- A rotation of the aggregator CA turned Completed about 13 s before the last kube-apiserver restart on the endpoint control plane ended: a pass applied the stage to every machine and verified only the Talos API and the Nodes, but Talos restarts kube-apiserver some seconds after the apply. A pass now records, per control plane it applied to, the kube-apiserver container id read just before the apply (
status.caRotation.apiServerInstances, withappliedAt; both additive and emptied on every stage change) and does not verify the stage until each of them runs in another container and answers/readyz. A container that did not change within a minute of the apply (the stage changed nothing kube-apiserver loads) is taken as not restarted; without a recorded id (the controller died between the apply and the status write) only/readyzis asked./readyzis not asked when the Kubernetes CA is rotated too, because the probe verifies against the bundle's old Kubernetes CA. Rotations that do not target the aggregator CA are unchanged.
Amendment (lane F, 2026-11-05): discovery failures in the hand-off retry
On hardware the hand-off retry still ended after 11 s with unable to retrieve the complete list of server APIs: ichor.maesh.dev/v1alpha1: no matches for ichor.maesh.dev/v1alpha1, Resource= while the endpoint control plane's kube-apiserver came back and did not yet serve the CRD group: the controller-runtime client's REST mapper failed discovery, an error isTransient did not know. isTransient now matches by type meta.IsNoMatchError, *apiutil.ErrResourceDiscoveryFailed (what the lazy REST mapper returns) and discovery.IsGroupDiscoveryFailedError, so these are retried for the whole 10-minute window; Forbidden, NotFound, Invalid and webhook refusals stay final. The same client may be used again: the lazy REST mapper keeps no negative cache (a failed lookup leaves the group and version out of its cache, and the next call asks the server again), so the client is not rebuilt per attempt; the existing redial after a rotated kubeconfig is unchanged.
Amendment (2026-11-05): an early hand-off acknowledgement does not skip the gate
CredentialsHandoffAnnotation holds CARotationHandoffToken(status), the fingerprints of the new CAs, which status.caRotation.certificates shows from stage Accepting on. The controller read the annotation only in AwaitingCredentials, but an annotation set earlier (by hand, or by a client that computed the token from Accepting) already matched when the operation got there, so the gate was passed in the same reconcile and the operator never had a chance to hold working credentials. When the controller enters AwaitingCredentials (the pass that verifies Accepting) it now removes whatever annotation is on the operation, with a merge patch through a copy and the new resource version carried over, so a change made after the drop fails the status update and is dropped on the next pass. An annotation set while the operation waits is honoured as before; the CLI and the UI set it only after they see the stage. The remaining trust is unchanged (ADR 0129): the annotation is an attestation, and the controller checks only the token. Test: TalosOperation ca-rotation ignores an acknowledgement given before the stage AwaitingCredentials.