diff --git a/CLAUDE.md b/CLAUDE.md index 776bfe6..29d254f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -606,11 +606,49 @@ There is no code path that upserts an archive's documents over existing ones: merging two control planes' data reconciles nothing about which SSH keys are still valid or which users still exist, and an upsert would resurrect a revoked key or a deleted member from the older side. `--force` drops each -collection in the archive first, and is gated behind a typed confirmation -(the target database's name, typed back) on a terminal, or `--confirm-db NAME` -matching the target exactly with none. Naming the target in the command itself -means a copied command carries its intended target with it and cannot destroy -a different one by accident. +collection in the archive first, and is gated behind a second assurance: +`--confirm-db NAME` matching the target exactly, which works everywhere, or — +on a terminal only, and only when `--confirm-db` was not given — the target +database's name typed back at a prompt. `--confirm-db` is accepted on a +terminal too: it is the stronger of the two, because naming the target in the +command itself means a copied command carries its intended target with it and +cannot destroy a different one by accident. Without a terminal and without +`--confirm-db`, `--force` is refused. + +**`--force` drops only what the archive names.** Collections already in the +target that the archive does not carry are left untouched and **named in a +warning** — an archive taken with `--exclude workflow_log_lines` restored over +a live database leaves the old lines joined to restored runs, which the +operator must be told. Dropping them instead would delete data nobody asked to +delete, and there is no way back from that. + +**Index specifications are replayed verbatim, never reconstructed.** +`dumpIndexes` stores each spec as extended JSON over the raw BSON the server +reported, and `replayIndexes` hands it back to `createIndexes` through +`RunCommand` with only `v` and `ns` stripped and `_id_` skipped. Rebuilding a +`mongo.IndexModel` from a hand-picked set of options dropped +`partialFilterExpression` — which this codebase relies on in +`services/workflows.go` and `services/settings.go` — so a partial unique index +came back as a full one, failed on duplicate keys, and aborted the restore +mid-write. Reconstructing the key document from JSON also lost compound key +order, which is significant. + +**`backup.ciphertextFields` mirrors `server/internal/models` by hand.** +`shared/` is a separate module and `models` is under `server/internal`, so +`shared/backup` cannot import it; the map naming each collection's `*_enc` +fields (`keys`, `secrets`, `auth_providers`, `console_sessions`) must change in +the same commit as any of those bson tags, the same hazard as +`web/lib/targets.ts` and `services.MaxWorkloadLogLines`. Wrong field names are +silent: `verify`'s live probe simply finds no ciphertext and reports "this +database stores no ciphertext yet", so the one gate that catches what a +fingerprint cannot no-ops. `settings` is deliberately in neither that map nor +`CiphertextCollections()` — its ESO read token is a SHA-256 hash, not +ciphertext. + +**A file-backed `backup` writes to `.tar.gz.partial` and renames on +success**, the same discipline the agent uses for `authorized_keys`. A failed +dump must not leave a partial file named exactly like a good archive; `--out -` +is untouched, since a broken pipe has no file to mislead anyone. **`vantagectl/Dockerfile`'s runtime stage is `scratch`, and needs the same explicit `/tmp` as `server/Dockerfile`.** `restore` extracts an archive to a diff --git a/docsite/docs/operations/backup-and-restore.md b/docsite/docs/operations/backup-and-restore.md index 6db3026..57f60e8 100644 --- a/docsite/docs/operations/backup-and-restore.md +++ b/docsite/docs/operations/backup-and-restore.md @@ -130,6 +130,25 @@ Each line of output answers a different question: it worth putting on a schedule — a backup job that "succeeded" last night is not the same claim as a backup that will actually restore. +## Looking inside an archive + +`inspect` prints an archive's manifest and touches no database at all — no +`--mongo-uri`, no key. It is what to run against an archive of unknown origin, +before deciding whether it is the one you want: + +```bash +vantagectl inspect /backups/vantage-backup-vantage-20260907T020000Z.tar.gz +``` + +It reports when the archive was taken and on which host, the Vantage and +MongoDB versions behind it, the database it came from, the key fingerprint (or +that it carries none), every collection with its document count and size, and +anything `--exclude` left out. Opening the archive verifies every member's +checksum on the way, so a corrupt archive fails here too. + +Reach for `verify` instead when the question is whether the key you hold opens +it; reach for `inspect` when the question is what it is. + ## Restoring `restore` expects the target database to be empty. Pointed at one that already @@ -143,13 +162,25 @@ vantagectl restore /backups/vantage-backup-vantage-20260907T020000Z.tar.gz \ ``` To overwrite a database that is not empty, add `--force`, which drops each -collection named in the archive before loading it. On a terminal, `--force` -alone prompts you to type the target database's name back — a deliberate pause -before something destructive. With no terminal — a Kubernetes Job, a CI step, a -cron entry — that prompt cannot happen, so `--force` instead requires -`--confirm-db NAME` naming the target exactly; a mismatch is refused. Naming -the database in the command itself means a copy-pasted invocation carries its -intended target with it and cannot destroy a different one by accident. +collection named in the archive before loading it. `--force` always needs a +second assurance, in one of two forms: + +- `--confirm-db NAME`, naming the target exactly. A mismatch is refused. This + works everywhere — on a terminal and in a Kubernetes Job, a CI step or a cron + entry alike — and is the form to script. +- Nothing, on a terminal: `--force` alone prompts you to type the target + database's name back, a deliberate pause before something destructive. + +Without a terminal and without `--confirm-db`, `--force` is refused: there is +nobody there to prompt. Naming the database in the command itself means a +copy-pasted invocation carries its intended target with it and cannot destroy a +different one by accident. + +`--force` drops only the collections the archive carries. Anything else already +in the target is left alone and named in a warning, so an archive taken with +`--exclude workflow_log_lines` restored over a live database tells you the old +log lines are still there, joined to freshly restored runs. Dropping them +instead would delete data you never asked to delete. `restore` also refuses when the archive's key fingerprint does not match the `KEY_ENCRYPTION_KEY` in your environment — see "When the key is wrong" below. @@ -187,7 +218,6 @@ will come back with ciphertext nobody can read: - `secrets` — the vault - `auth_providers` — OIDC/SSO client secrets - `console_sessions` — RDP/VNC credentials -- `settings` — anything encrypted at the instance level There is no way to recover that ciphertext afterwards. If you have reached this point, the right key was lost along with the chance to read those rows —