# 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`, `$KUBECONFIG` and the default rules behave as for every other command); `certificate-authority-data` holds
  the new Kubernetes CA followed by the old one; the user is a client certificate with CN `admin`, O `system: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; `ca` is the base64 PEM bundle of the new and the old Talos API CA;
  `crt`/`key` an `os:admin` certificate generated with `Bundle.GenerateTalosAPIClientCertificate` from 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.stage` enum gains `AwaitingCredentials`; the chart copy is regenerated. `OperationService` and the proto are
  unchanged apart from the comment on `ca_rotation_stage`; the UI's `caStageProgress` counts 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-rotation` plan.
- 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/AwaitingCredentials` is the visible state.
- `rotate-ca` now needs the operator's kubeconfig to read the Secret and the operation directly; `bundle-export` already 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`.
