0053. ichor restore --recover-cluster: full-loss etcd recovery from the bundle and a snapshot

Status: Accepted · Date: 2026-10-26 · Design section: Backup, recovery, certificates

Decided: ichor restore --recover-cluster runs standalone (no Kubernetes, no Ichor): it reads the local secrets bundle, takes S3 credentials from environment variables, picks the newest snapshot that decrypts and passes the etcd checksum (or the one named by --snapshot, which is refused if broken), asks for confirmation, then uploads it with EtcdRecover to one freshly installed control plane and calls Bootstrap{RecoverEtcd: true}; restoring into a live cluster is a separate controller-run operation (ADR 0089)

Context

Goal 4: restore from the secrets bundle and a snapshot alone. ADR 0047 fixed the stored format and left restore to this unit. After a total loss the controller is gone too, so the command cannot lean on any CR, Secret or the Connect API (ADR 0049); it is one of the CLI operations that predate the cluster, like init and adopt.

Decisions

  1. Talos side. The sequence is talosctl bootstrap --recover-from (cmd/talosctl/cmd/talos/bootstrap.go, Talos 1.14.3): Client.EtcdRecover streams the snapshot, then Client.Bootstrap with RecoverEtcd: true. In the server (v1alpha1_server.go), EtcdRecover writes /var/lib/etcd.snapshot atomically and needs a control plane (CheckControlplane); Bootstrap needs IsBootstrapAllowed (the cri service healthy), a machine config with an etcd CA, time in sync, and an empty /var/lib/etcd (AlreadyExists otherwise); the etcd service then runs recoverFromSnapshot (services/etcd.go), which restores with manager.Status and the etcd hash check (we do not pass RecoverSkipHashCheck: our snapshots always carry the trailer). So the target is one control plane that is installed and configured (not in maintenance mode) with a machine config made from the same bundle, with empty etcd data; the CLI dials it with the bundle's admin identity (the dialAdmin recipe) and exactly that one node. Other control planes join by themselves afterwards. Applying the machine config to the replacement node is not done here (the CRs that render it lived in the lost etcd); the operator installs the node first, for example with talosctl apply-config --insecure from the config they kept. Deferred: the design's "controller re-reconciles every node the way adopt does and waits for confirmation before applying anything" is the restored controller's job and is not built.
  2. Bundle. Same file ichor init writes (internal/cli/bundlefile.go): ~/.ichor/clusters/<cluster>/secrets.yaml, or --secrets-bundle for a copy kept elsewhere (the machine doing the recovery is often not the one that ran init). Decoding errors carry the path only, never content. The derived passphrase and the bundle are never printed; restore_test.go asserts none of the bundle's scalars, the passphrase or the S3 keys appear in stdout, stderr or any error, including when an S3 or Talos error string contains the credentials (every such error passes through a scrubber holding them).
  3. S3 credentials. Non-secret addressing is flags (--s3-endpoint, --s3-bucket, --s3-region, --s3-prefix, --s3-ca-file). Secrets are only environment variables, ICHOR_S3_ACCESS_KEY_ID, ICHOR_S3_SECRET_ACCESS_KEY, ICHOR_S3_SESSION_TOKEN, with the standard AWS_* ones as fallback; no flag exists for them, so they never appear in ps. (Environment is readable by the same user via /proc, which is the same exposure as the bundle file; a credentials-file option is deferred.)
  4. Which snapshot. Default newest. Each candidate, newest first, is downloaded, decrypted and run through etcdbackup.Verifier (size and sha256 trailer) before anything is sent or even confirmed; one that fails is skipped, reported on stderr, and never sent. Falling back to an older snapshot loses data, so the confirmation names the snapshot actually chosen and its time. --snapshot <name> is exclusive: broken means an error, never a substitute. A bundle that is not the cluster's fails age's authentication on every object, and the final error says nothing was sent. The plaintext is kept in memory, not on disk (it holds every Secret); ponytail: a multi-GB etcd would need a protected spool file instead.
  5. Confirmation. The shared confirm and the global --yes; the prompt states the node, the snapshot and its time, that state reverts, and that it cannot be undone. Declining contacts nothing.

Locking

The cluster lock (acquireDisruptiveLock, clusterlock.Acquire) is a Kubernetes object, and in full loss there is no Kubernetes. The command is the only actor by construction, and it is refused by Talos unless etcd data is empty. acquire_guard_test.go is unaffected: this code never calls Acquire. The live-cluster restore, which does have a cluster, must take the lock.

Not done

  • Restore into a live cluster (UI and CLI) is built, in ADR 0089: ichor restore --live and the UI create an etcd-restore operation; the controller takes the lock, wipes etcd on every control plane, recovers the snapshot on one with the calls above, lets the others rejoin and reconciles every machine again; it reuses fetchVerified (now etcdbackup.FetchVerified). --recover-cluster is still the way back when that operation cannot finish, for example when the controller ran on the only control plane that was wiped.

  • A restore round trip against a real Talos (e2e) is the design's regular proof and is not part of this unit.

  • Tests use an in-memory etcdbackup.Store plus a fake Talos client; the real S3 client is covered by internal/etcdbackup's TLS fake S3, which lives in a _test.go file and is not importable.

  • Amendment 2026-10-27: loadBundleFile now decodes through secretsbundle.Decode, which fills Bundle.Clock; before, --recover-cluster panicked in dialRecover on a nil Clock.

Search the docs

Search pages and headings