diff --git a/docs/backups.md b/docs/backups.md
index beea2c2..8893f98 100644
--- a/docs/backups.md
+++ b/docs/backups.md
@@ -22,21 +22,117 @@ the tar ([ADR-0019](decisions/0019-pause-quiesce-and-parallel-stops.md), see
## Backing up Watchtower itself
-This whole document is about **your stacks**. Watchtower's own records — stacks, routes, realms,
-accounts, credentials, audit trail, metrics history — live in the PostgreSQL it is configured against
-([ADR-0024](decisions/0024-postgresql-only-and-state-in-the-database.md)), and nothing here touches
-that. Back it up the way you back up any database:
+Everything Watchtower knows — the stacks and their environment variables, templates, products and
+releases, routes, realms, accounts, credentials, certificates and keys, the audit trail and the
+metrics history — lives in the PostgreSQL it is configured against
+([ADR-0024](decisions/0024-postgresql-only-and-state-in-the-database.md)). Backing up your stacks
+without it restores their data but nothing that deploys them.
+
+Watchtower backs that database up itself
+([ADR-0027](decisions/0027-full-instance-backup-and-restore.md)). Under **Settings → Watchtower's own
+database**, "Include in the backup schedule" (on by default) adds a `pg_dumpall` of it to the same
+schedule your stacks run on, written to `{instance}/_watchtower/` on the same storage — so one folder
+per instance holds the whole picture. "Back up Watchtower now" runs one immediately.
+
+Two things to know:
+
+- **An encryption passphrase is required.** The dump carries every database role's password hash, the
+ data-protection key ring, the identity signing key and every certificate's private key. Without a
+ passphrase set (see [Encryption](#encryption)) the schedule skips it and the button is disabled.
+- **Nothing is stopped.** The dump is taken while Watchtower keeps serving — it has to be, since
+ Watchtower is what runs it.
+
+This needs your PostgreSQL to be **a container on the same Docker daemon**, which it is in the shipped
+compose file. Watchtower finds it from its own connection string; if you run several database
+containers and it picks wrong (or cannot choose), name the right one in **Database container** or
+`WATCHTOWER__BACKUP__SELFPOSTGRESCONTAINER`. A managed PostgreSQL (RDS, Neon, a host-installed
+server) cannot be dumped this way — back it up with whatever your provider offers.
+
+### The full backup bundle
+
+"Build bundle" (same card, admins only) produces **one file** holding a fresh dump of this database, the
+newest archive of every stack, and the secrets that live outside the database — everything a new
+Watchtower needs to become this one. Take one before migrating to a new host, and keep it wherever you
+keep passwords.
+
+```
+bundle-manifest.json ← which Watchtower wrote it, against which schema, and every archive's SHA-256
+secrets.json ← key-protection secret, backup passphrase, storage credentials
+watchtower/watchtower_20260826T033000Z.tar.gz.enc
+stacks/prod/blog/blog_20260826T033000Z.tar.gz.enc
+stacks/prod/shop/globex/shop-globex_20260826T033100Z.tar.gz.enc
+```
+
+It is a plain (uncompressed) tar — its members are already compressed and encrypted — so `tar -tf` lists
+it and `tar -xf` unpacks it anywhere. Each stack archive keeps the path it had on the backup storage, so
+a restore can put it back exactly where the restored database expects to find it.
+
+> **The bundle is the instance.** `secrets.json` holds the key-protection secret, the backup passphrase
+> and your storage credentials in plain text — deliberately, because a bundle that restores into an
+> instance whose certificates and keys are unreadable is not a backup. Treat the file as a credential.
+
+A stack that has never been backed up appears in the manifest with no archive: its *definition* comes
+back with the database, its *data* does not. The card says how many, so you can back those up and build
+again.
+
+The bundle is kept in Watchtower's own container and one is staged at a time, so it is lost on restart —
+download it when it is ready, or build a fresh one later.
+
+### Restoring a whole instance
+
+**Settings → Watchtower's own database → Restore this Watchtower…** takes a bundle and makes this
+Watchtower into the one it came from. Before touching anything it checks the bundle and refuses on:
+
+- a bundle written by a **newer Watchtower** than this one — a database only ever migrates forward, so
+ update this instance first;
+- a **key-protection secret** that does not match. The certificates, ACME account key and signing key in
+ the bundle are encrypted under the source instance's
+ `WATCHTOWER__AUTH__KEYPROTECTIONSECRET`; set that variable to the value in the bundle's
+ `secrets.json` and restart Watchtower before restoring. It cannot be changed while running;
+- an archive that is missing, that does not match its checksum, or that the bundle's own passphrase
+ cannot open.
+
+It warns — but does not stop — when this Watchtower already manages stacks. Restoring replaces its whole
+database; the containers it deployed keep running, unmanaged, until the checklist redeploys them.
+
+The restore itself takes a few seconds: a helper container stops Watchtower, replays the dump, and
+starts it again. It takes a safety dump of the current database first and replays that back if the
+restore's own replay fails, so a failed restore leaves the instance as it was. The page waits for
+Watchtower to come back and sends you to the sign-in form — **sign in with an account from the instance
+the bundle came from**; the accounts this Watchtower had are gone with its database.
+
+Watchtower must be running as a container on the same Docker daemon for this: it is stopped and started
+around the replay. If it is not, restore the dump by hand (below).
+
+#### Bringing the stacks back
+
+After a restore, Settings shows a checklist of every stack in the restored database. Each one is
+**deployed from git and then restored from its newest archive**, in that order — only the deploy creates
+the volumes the restore needs, and a deploy on its own leaves the stack running on empty ones. Do them
+one at a time or press **Revive all**; a stack you are handling yourself can be skipped, and the whole
+checklist dismissed when you are done. What happened stays in the audit trail.
+
+### Restoring it by hand
+
+The archive is an ordinary Watchtower archive: decrypt it with stock OpenSSL as under
+[Restoring a backup](#restoring-a-backup), and `backup/_dumps/watchtower.sql` inside it is a
+`pg_dumpall` script.
```bash
-docker compose exec postgres pg_dump -U watchtower -Fc watchtower > watchtower-$(date +%F).dump
+openssl enc -d -aes-256-cbc -pbkdf2 -iter 600000 -md sha256 \
+ -in watchtower_20260826T033000Z.tar.gz.enc -pass pass:'YOUR PASSPHRASE' \
+ | tar -xzO backup/_dumps/watchtower.sql > watchtower.sql
+docker compose exec -T postgres psql -U watchtower -d postgres < watchtower.sql
```
-Restore with `pg_restore -U watchtower -d watchtower --clean` into an empty database, then start
-Watchtower — it migrates on startup, so a dump from an older version comes forward on its own.
+Then start Watchtower: it migrates on startup, so a dump from an older version comes forward on its
+own. **Carry `WATCHTOWER__AUTH__KEYPROTECTIONSECRET` across with it.** The certificates, the ACME
+account key and the signing key are encrypted in the database under that secret, and an instance
+restored without it throws on every one of them. It is an environment variable, never stored in the
+database, and it cannot be changed at runtime.
-Take the `watchtower-data` volume with it: the certificates, the ACME account key and the
-data-protection key ring still live there, and a database restored without them signs everyone out and
-reissues every certificate. Neither half is much use alone.
+(The `watchtower-data` volume is not part of this. It held the certificates and key ring before
+ADR-0024; it holds nothing Watchtower needs now.)
## Setting it up
@@ -57,6 +153,8 @@ variables — env vars pin their setting read-only in the UI, see
| Helper image | `WATCHTOWER__BACKUP__HELPERIMAGE` | Image for the never-started helper container (default `busybox:stable`); any pullable image works. |
| Stop grace | `WATCHTOWER__BACKUP__STOPTIMEOUTSECONDS` | How long a container *stopped* for the snapshot gets to exit on SIGTERM before SIGKILL (`docker stop -t`). Default `5` (the daemon's own default is 10); clamped to 1 … 300. Not in the UI. A service that needs longer belongs on a dump or on `pause`, not on a longer window. |
| Provider | `WATCHTOWER__BACKUP__PROVIDER` | `sftp` (default) or `local`. |
+| Include Watchtower's own database | `WATCHTOWER__BACKUP__INCLUDESELF` | Adds a dump of Watchtower's own PostgreSQL to the schedule (default on). Needs an encryption passphrase; see [Backing up Watchtower itself](#backing-up-watchtower-itself). |
+| Database container | `WATCHTOWER__BACKUP__SELFPOSTGRESCONTAINER` | Names the container holding Watchtower's own database, when detection cannot pick one. Blank = detect it. |
Then opt each stack in on its **Backups tab**: include it in the schedule, optionally give it a
**schedule override** (its own cron expression instead of the instance one), and choose whether its
diff --git a/docs/decisions/0027-full-instance-backup-and-restore.md b/docs/decisions/0027-full-instance-backup-and-restore.md
new file mode 100644
index 0000000..7e7b755
--- /dev/null
+++ b/docs/decisions/0027-full-instance-backup-and-restore.md
@@ -0,0 +1,173 @@
+# ADR-0027: Watchtower backs itself up, and a bundle restores it somewhere else
+
+- Status: Accepted (implemented)
+- Date: 2026-08-26
+- Related: [ADR-0016](0016-stack-backups.md) (the stack backup machinery this reuses whole),
+ [ADR-0017](0017-database-aware-dumps.md) (the `pg_dumpall` path it points at Watchtower's own
+ database), [ADR-0018](0018-cron-backup-schedule.md) (the schedule it joins),
+ [ADR-0024](0024-postgresql-only-and-state-in-the-database.md) (why the database *is* the instance),
+ [docs/backups.md](../backups.md) (the operator-facing description).
+
+## Context
+
+Since ADR-0024 every fact Watchtower owns lives in one PostgreSQL database: the stacks and their
+environment variables, the templates, products and releases, the routes and their access grants, the
+accounts and sessions, and — after decision 4 of that ADR — the certificates, the ACME account key,
+the identity signing key and the data-protection key ring. The container's `/data` volume holds
+nothing Watchtower needs any more.
+
+Against that, the backup feature covered **only stacks**. An operator with a nightly schedule and a
+year of archives could restore every stack's volumes onto a new box and still have nothing that knew
+how to deploy them: no repository URLs, no environment variables, no routes, no accounts. ADR-0024
+noted in passing that backing up Watchtower's own state "becomes a PostgreSQL concern"; nothing was
+built, and `docs/backups.md` told operators to run `pg_dump` by hand — next to a stale paragraph
+telling them to also keep the `watchtower-data` volume, which by then held nothing.
+
+Three things made this awkward rather than obvious:
+
+1. **Watchtower cannot back itself up through its own stack machinery.** `SelfProjectNameProvider`
+ reserves Watchtower's own compose project against stack use (so no stack can read its containers
+ through the App API), which also means the database cannot be registered as a stack and dumped
+ like any other.
+2. **A dump of this database is not an ordinary archive.** `pg_dumpall` carries every role's password
+ hash, and the tables carry the data-protection key ring, the signing key and every certificate's
+ private key. It is the instance.
+3. **Restoring it cannot be done by the process that holds it open.** `pg_dumpall --clean` drops and
+ recreates every database after terminating every other session — including Watchtower's own EF
+ connection pool, which would immediately reconnect into the middle of the replay.
+
+## Decision
+
+### 1. A scheduled, always-encrypted dump of Watchtower's own database, beside the stack archives
+
+`InstanceBackupService` takes a `pg_dumpall` of Watchtower's database over the Docker exec API,
+wraps it in the same archive format, gzip and OpenSSL-compatible encryption a stack backup uses, and
+uploads it through the same `IBackupStorage` to `{instance}/_watchtower/watchtower_{ts}.tar.gz.enc`
+— a sibling of the stack directories under the same instance root. **One storage folder per instance
+therefore holds everything a rebuild needs.** The same retention applies, through the same
+`BackupRetentionRunner` the stack runs now use.
+
+Consequences of the "same everything" choice, each deliberate:
+
+- **The archive carries no volumes.** Since ADR-0024 there is no file state to snapshot, and
+ `BackupArchiveService` already supported a dumps-only archive (a stack whose only state is a dumped
+ database produces one).
+- **Nothing is stopped or paused.** The dump is consistent by construction, which is what lets
+ Watchtower keep serving through its own backup — as it must, being the thing running it.
+- **Encryption is mandatory, not optional as it is for a stack.** A run without a passphrase is
+ refused rather than silently downgraded, for the reason in context §2.
+- **It shares the single-flight backup queue.** An instance dump waits behind a large stack backup,
+ which is the right way round: a queued dump is a delayed dump, whereas two runs racing for the
+ spool disk is a failed one.
+
+The schedule is the instance-wide cron (there is one instance, so there is nothing to override it
+with), governed by `Watchtower:Backup:IncludeSelf` (default on). Its cursor is a settings row rather
+than a column, since there is no instance table to put one on; it is read through the settings
+manager rather than the options snapshot, so a value written last tick is certainly seen this tick.
+A window that opens with no passphrase configured is **skipped and logged, and the cursor still
+moves** — the alternative is a run that fails every night, and a window that re-fires every minute.
+
+### 2. `BackupEvent.StackId` becomes nullable rather than growing a parallel table
+
+An instance run has no stack. The history views, the single-flight queue, the retention pass and the
+startup sweep all already speak `BackupEvent`, and a second table would have duplicated every one of
+them. The wire DTO gains a `kind` (`stack` | `instance`) derived from the null, so the UI branches on
+a word rather than on an absence, and `backups.events` gains an optional `kind` filter. Unfiltered
+history is unchanged and still returns both — an instance run is part of "what has this Watchtower
+been backing up".
+
+The stack relationship still cascades, so a deleted stack takes its own history with it; only the
+stackless rows outlive every stack.
+
+`_watchtower` is refused as a stack name (`BackupNaming.IsReserved`, checked where the compose
+project name already is), because a stack sanitizing onto it would write its archives into the
+instance directory, and retention prunes a *directory*.
+
+### 3. Finding the database is a detection with a loud failure, not a configuration
+
+`SelfPostgresLocator` parses Watchtower's own connection string and looks for a running PostgreSQL
+container that answers to its `Host` — by compose service, container name, or the
+`{project}-{service}-{replica}` name Compose generates — among the containers of Watchtower's own
+compose project, or among all running containers when it is not under Compose. One unmatched
+candidate still wins (a service aliased differently from the host is ordinary); several do not, and
+the run fails naming them, because the loser would be dumped and the dump would look healthy.
+`Watchtower:Backup:SelfPostgresContainer` is the override.
+
+A managed or host-installed PostgreSQL has no container to exec into. That fails **loudly**, with a
+message that says so and also admits it is what an unreachable daemon looks like from here: a
+self-backup that quietly does nothing is invisible until the day it is needed.
+
+### 4. An exportable bundle carries an instance to another machine
+
+An admin can export one plain (uncompressed) tar containing the fresh instance archive, the newest
+archive of every stack, a `bundle-manifest.json` and a `secrets.json`. Plain tar because its members
+are already compressed and encrypted, and because the point of the artifact is to be handed to the
+import on the other side.
+
+`bundle-manifest.json` records `bundleFormatVersion`, the instance name, `appVersion`,
+`lastMigrationId`, and per-archive sizes and SHA-256s. **`lastMigrationId` is what an import decides
+on**, not the version string: migrations only roll forward, so "this binary knows that migration" is
+exact where comparing versions guesses. The version string is for the operator's error message.
+
+### 5. Restore runs from a sibling coordinator container
+
+The running Watchtower pre-stages everything it still can — re-uploading the bundle's stack archives
+to storage at their recorded paths, extracting the SQL into the database container, writing a nonce
+into the database it is about to lose — then spawns a `--restore-self` coordinator from its own
+image, modelled on the `--self-update` coordinator: Docker socket, no network, group ids from
+`/proc/self/status`. The coordinator takes a safety dump, stops Watchtower, replays, and restarts it
+in a `finally` whatever happened. On the way back up, the nonce's absence is what proves the replay
+committed.
+
+Validation happens **before** any of that, and refuses rather than warns on: a bundle whose
+`lastMigrationId` this binary does not know, and a `KeyProtectionSecret` that differs from the one
+this instance runs with. The second is the sharpest edge in the whole feature — the DB's protected
+rows are AES-GCM under an env-only secret, so restoring without it yields an instance that throws on
+every certificate and key it touches. The message names the variable and says it needs a restart.
+
+### 6. Restore is offered after login, never anonymously
+
+A fresh instance already creates a bootstrap admin (`AuthBootstrapService`) whose password is set by
+env or printed once to the log. The restore wizard lives behind that login, and behind
+`[RequireRole(Admin)]`, rather than on an anonymous "is this instance empty" endpoint: an unauthenticated
+restore endpoint is an unauthenticated way to replace an instance, and "the instance looked empty" is
+not an authorization decision. The wizard is offered on a fresh-looking instance and is also always
+reachable from Settings.
+
+After the restart, a recovery checklist walks the stacks: redeploy from git (the definitions are in
+the restored database), then restore each stack's newest volume archive.
+
+## Consequences
+
+- **The bundle is radioactive, by design.** `secrets.json` carries the key-protection secret, the
+ backup passphrase and the storage credentials in plain text, so that one artifact plus its
+ passphrase is a complete instance. Export is admin-only and audited, and the UI says what the file
+ is. This is a deliberate trade against the alternative — an operator who restores into a new box
+ and discovers their certificates are unreadable because a secret they never knew about stayed
+ behind.
+- **The pg password reaches the restore coordinator as an env var on its create body**, visible to
+ anyone who can `docker inspect` it. Accepted: that is anyone who already owns the Docker socket,
+ and therefore the host.
+- **Anyone who can place an instance archive on the backup storage can walk away with the
+ instance.** `backups.runInstance` is admin-only for that reason, where the stack runs are not.
+- **Restoring an older dump into a newer binary is the supported direction** and works by itself:
+ migrations run on startup, so the restored schema rolls forward. The reverse is refused.
+- **A restored instance carries stale coordination rows** — scheduler claims, role leases — and a
+ rolled-back backup cursor. The completion pass clamps the cursors and bumps the routes version;
+ the leases expire on their own.
+- **`docs/backups.md`'s "Backing up Watchtower itself" section is replaced**, including the stale
+ instruction to keep the `watchtower-data` volume for certificates and keys, which have been rows
+ since ADR-0024.
+
+## Alternatives considered
+
+- **A second `instance_backup_events` table.** Rejected: it duplicates the queue, the sweep, the
+ retention pass and both history views to avoid one nullable column.
+- **Restoring from storage only, with no bundle.** Simpler, and still the right path for an instance
+ restoring itself in place — but it requires the new box to already have the storage credentials and
+ the passphrase, which is exactly what an operator rebuilding after a loss does not have to hand.
+ The bundle exists to be the one thing they need. (Restore-from-storage remains a natural
+ complement, not built.)
+- **An anonymous first-run restore endpoint.** Rejected — see decision 6.
+- **Registering Watchtower's own compose project as a stack.** Rejected: the project reservation
+ exists to stop exactly that, and undoing it would hand any stack Watchtower's own containers.
diff --git a/rpc-schema.json b/rpc-schema.json
index fb4ee04..c982dce 100644
--- a/rpc-schema.json
+++ b/rpc-schema.json
@@ -148,6 +148,22 @@
]
}
},
+ "backups.dismissRecovery": {
+ "params": {
+ "type": "object"
+ },
+ "result": {
+ "type": "object",
+ "properties": {
+ "dismissed": {
+ "type": "boolean"
+ }
+ },
+ "required": [
+ "dismissed"
+ ]
+ }
+ },
"backups.events": {
"params": {
"type": "object",
@@ -169,6 +185,13 @@
"null"
],
"default": null
+ },
+ "kind": {
+ "type": [
+ "string",
+ "null"
+ ],
+ "default": null
}
}
},
@@ -184,10 +207,16 @@
"type": "integer"
},
"stackId": {
- "type": "integer"
+ "type": [
+ "integer",
+ "null"
+ ]
},
"stackName": {
- "type": "string"
+ "type": [
+ "string",
+ "null"
+ ]
},
"triggeredBy": {
"type": "string"
@@ -223,6 +252,9 @@
"null"
],
"format": "date-time"
+ },
+ "kind": {
+ "type": "string"
}
},
"required": [
@@ -235,7 +267,8 @@
"sizeBytes",
"output",
"startedAt",
- "finishedAt"
+ "finishedAt",
+ "kind"
]
}
}
@@ -245,6 +278,78 @@
]
}
},
+ "backups.exportBundle": {
+ "params": {
+ "type": "object"
+ },
+ "result": {
+ "type": "object",
+ "properties": {
+ "export": {
+ "type": "object",
+ "properties": {
+ "backupEventId": {
+ "type": "integer"
+ },
+ "status": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "backupEventId",
+ "status"
+ ]
+ }
+ },
+ "required": [
+ "export"
+ ]
+ }
+ },
+ "backups.getBundleStatus": {
+ "params": {
+ "type": "object"
+ },
+ "result": {
+ "type": "object",
+ "properties": {
+ "bundle": {
+ "type": [
+ "object",
+ "null"
+ ],
+ "properties": {
+ "fileName": {
+ "type": "string"
+ },
+ "sizeBytes": {
+ "type": "integer"
+ },
+ "createdAtUtc": {
+ "type": "string",
+ "format": "date-time"
+ },
+ "stackCount": {
+ "type": "integer"
+ },
+ "missingStackCount": {
+ "type": "integer"
+ }
+ },
+ "required": [
+ "fileName",
+ "sizeBytes",
+ "createdAtUtc",
+ "stackCount",
+ "missingStackCount"
+ ]
+ }
+ },
+ "required": [
+ "bundle"
+ ]
+ }
+ },
"backups.getConfig": {
"params": {
"type": "object"
@@ -330,6 +435,18 @@
"items": {
"type": "string"
}
+ },
+ "includeSelf": {
+ "type": "boolean"
+ },
+ "selfPostgresContainer": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "instanceDirectory": {
+ "type": "string"
}
},
"required": [
@@ -344,7 +461,10 @@
"provider",
"sftp",
"localBasePath",
- "pinnedPaths"
+ "pinnedPaths",
+ "includeSelf",
+ "selfPostgresContainer",
+ "instanceDirectory"
]
}
},
@@ -513,6 +633,186 @@
]
}
},
+ "backups.getRecoveryChecklist": {
+ "params": {
+ "type": "object"
+ },
+ "result": {
+ "type": "object",
+ "properties": {
+ "checklist": {
+ "type": [
+ "object",
+ "null"
+ ],
+ "properties": {
+ "restoredAtUtc": {
+ "type": "string",
+ "format": "date-time"
+ },
+ "sourceInstance": {
+ "type": "string"
+ },
+ "dismissed": {
+ "type": "boolean"
+ },
+ "stacks": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "properties": {
+ "stackId": {
+ "type": "integer"
+ },
+ "name": {
+ "type": "string"
+ },
+ "status": {
+ "type": "string"
+ },
+ "detail": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "deployEventId": {
+ "type": [
+ "integer",
+ "null"
+ ]
+ },
+ "backupEventId": {
+ "type": [
+ "integer",
+ "null"
+ ]
+ }
+ },
+ "required": [
+ "stackId",
+ "name",
+ "status",
+ "detail",
+ "deployEventId",
+ "backupEventId"
+ ]
+ }
+ }
+ },
+ "required": [
+ "restoredAtUtc",
+ "sourceInstance",
+ "dismissed",
+ "stacks"
+ ]
+ }
+ },
+ "required": [
+ "checklist"
+ ]
+ }
+ },
+ "backups.getRestoreStatus": {
+ "params": {
+ "type": "object"
+ },
+ "result": {
+ "type": "object",
+ "properties": {
+ "freshInstance": {
+ "type": "boolean"
+ },
+ "staged": {
+ "type": [
+ "object",
+ "null"
+ ],
+ "properties": {
+ "canRestore": {
+ "type": "boolean"
+ },
+ "blocking": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "properties": {
+ "code": {
+ "type": "string"
+ },
+ "message": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "code",
+ "message"
+ ]
+ }
+ },
+ "warnings": {
+ "type": "array",
+ "items": {
+ "$ref": "#/properties/staged/properties/blocking/items"
+ }
+ },
+ "instanceName": {
+ "type": "string"
+ },
+ "appVersion": {
+ "type": "string"
+ },
+ "createdAtUtc": {
+ "type": "string",
+ "format": "date-time"
+ },
+ "stackCount": {
+ "type": "integer"
+ },
+ "missingStackCount": {
+ "type": "integer"
+ },
+ "stackNames": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ }
+ },
+ "required": [
+ "canRestore",
+ "blocking",
+ "warnings",
+ "instanceName",
+ "appVersion",
+ "createdAtUtc",
+ "stackCount",
+ "missingStackCount",
+ "stackNames"
+ ]
+ },
+ "lastOutcome": {
+ "type": "string"
+ },
+ "lastError": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "recoveryPending": {
+ "type": "boolean"
+ }
+ },
+ "required": [
+ "freshInstance",
+ "staged",
+ "lastOutcome",
+ "lastError",
+ "recoveryPending"
+ ]
+ }
+ },
"backups.getStackConfig": {
"params": {
"type": "object",
@@ -618,7 +918,51 @@
}
},
"required": [
- "config"
+ "config"
+ ]
+ }
+ },
+ "backups.listInstance": {
+ "params": {
+ "type": "object"
+ },
+ "result": {
+ "type": "object",
+ "properties": {
+ "files": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "properties": {
+ "name": {
+ "type": "string"
+ },
+ "sizeBytes": {
+ "type": "integer"
+ },
+ "takenAt": {
+ "type": "string",
+ "format": "date-time"
+ },
+ "encrypted": {
+ "type": "boolean"
+ }
+ },
+ "required": [
+ "name",
+ "sizeBytes",
+ "takenAt",
+ "encrypted"
+ ]
+ }
+ },
+ "directory": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "files",
+ "directory"
]
}
},
@@ -889,6 +1233,151 @@
]
}
},
+ "backups.reviveAll": {
+ "params": {
+ "type": "object"
+ },
+ "result": {
+ "type": "object",
+ "properties": {
+ "revived": {
+ "type": "integer"
+ },
+ "checklist": {
+ "type": [
+ "object",
+ "null"
+ ],
+ "properties": {
+ "restoredAtUtc": {
+ "type": "string",
+ "format": "date-time"
+ },
+ "sourceInstance": {
+ "type": "string"
+ },
+ "dismissed": {
+ "type": "boolean"
+ },
+ "stacks": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "properties": {
+ "stackId": {
+ "type": "integer"
+ },
+ "name": {
+ "type": "string"
+ },
+ "status": {
+ "type": "string"
+ },
+ "detail": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "deployEventId": {
+ "type": [
+ "integer",
+ "null"
+ ]
+ },
+ "backupEventId": {
+ "type": [
+ "integer",
+ "null"
+ ]
+ }
+ },
+ "required": [
+ "stackId",
+ "name",
+ "status",
+ "detail",
+ "deployEventId",
+ "backupEventId"
+ ]
+ }
+ }
+ },
+ "required": [
+ "restoredAtUtc",
+ "sourceInstance",
+ "dismissed",
+ "stacks"
+ ]
+ }
+ },
+ "required": [
+ "revived",
+ "checklist"
+ ]
+ }
+ },
+ "backups.reviveStack": {
+ "params": {
+ "type": "object",
+ "properties": {
+ "stackId": {
+ "type": "integer"
+ }
+ },
+ "required": [
+ "stackId"
+ ]
+ },
+ "result": {
+ "type": "object",
+ "properties": {
+ "stack": {
+ "type": "object",
+ "properties": {
+ "stackId": {
+ "type": "integer"
+ },
+ "name": {
+ "type": "string"
+ },
+ "status": {
+ "type": "string"
+ },
+ "detail": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "deployEventId": {
+ "type": [
+ "integer",
+ "null"
+ ]
+ },
+ "backupEventId": {
+ "type": [
+ "integer",
+ "null"
+ ]
+ }
+ },
+ "required": [
+ "stackId",
+ "name",
+ "status",
+ "detail",
+ "deployEventId",
+ "backupEventId"
+ ]
+ }
+ },
+ "required": [
+ "stack"
+ ]
+ }
+ },
"backups.run": {
"params": {
"type": "object",
@@ -925,6 +1414,34 @@
]
}
},
+ "backups.runInstance": {
+ "params": {
+ "type": "object"
+ },
+ "result": {
+ "type": "object",
+ "properties": {
+ "backup": {
+ "type": "object",
+ "properties": {
+ "backupEventId": {
+ "type": "integer"
+ },
+ "status": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "backupEventId",
+ "status"
+ ]
+ }
+ },
+ "required": [
+ "backup"
+ ]
+ }
+ },
"backups.setServiceOverride": {
"params": {
"type": "object",
@@ -1357,6 +1874,83 @@
]
}
},
+ "backups.skipRecoveryStack": {
+ "params": {
+ "type": "object",
+ "properties": {
+ "stackId": {
+ "type": "integer"
+ }
+ },
+ "required": [
+ "stackId"
+ ]
+ },
+ "result": {
+ "type": "object",
+ "properties": {
+ "stack": {
+ "type": "object",
+ "properties": {
+ "stackId": {
+ "type": "integer"
+ },
+ "name": {
+ "type": "string"
+ },
+ "status": {
+ "type": "string"
+ },
+ "detail": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "deployEventId": {
+ "type": [
+ "integer",
+ "null"
+ ]
+ },
+ "backupEventId": {
+ "type": [
+ "integer",
+ "null"
+ ]
+ }
+ },
+ "required": [
+ "stackId",
+ "name",
+ "status",
+ "detail",
+ "deployEventId",
+ "backupEventId"
+ ]
+ }
+ },
+ "required": [
+ "stack"
+ ]
+ }
+ },
+ "backups.startInstanceRestore": {
+ "params": {
+ "type": "object"
+ },
+ "result": {
+ "type": "object",
+ "properties": {
+ "sourceInstance": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "sourceInstance"
+ ]
+ }
+ },
"backups.testStorage": {
"params": {
"type": "object"
@@ -1463,6 +2057,20 @@
"null"
],
"default": null
+ },
+ "includeSelf": {
+ "type": [
+ "boolean",
+ "null"
+ ],
+ "default": null
+ },
+ "selfPostgresContainer": {
+ "type": [
+ "string",
+ "null"
+ ],
+ "default": null
}
},
"required": [
@@ -1556,6 +2164,18 @@
"items": {
"type": "string"
}
+ },
+ "includeSelf": {
+ "type": "boolean"
+ },
+ "selfPostgresContainer": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "instanceDirectory": {
+ "type": "string"
}
},
"required": [
@@ -1570,7 +2190,10 @@
"provider",
"sftp",
"localBasePath",
- "pinnedPaths"
+ "pinnedPaths",
+ "includeSelf",
+ "selfPostgresContainer",
+ "instanceDirectory"
]
}
},
diff --git a/src/Watchtower.Api.Tests/InstanceBundleEndpointTests.cs b/src/Watchtower.Api.Tests/InstanceBundleEndpointTests.cs
new file mode 100644
index 0000000..419cf2a
--- /dev/null
+++ b/src/Watchtower.Api.Tests/InstanceBundleEndpointTests.cs
@@ -0,0 +1,131 @@
+using System.Net;
+using System.Net.Http.Json;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.DependencyInjection;
+using Watchtower.Application.Entities;
+using Watchtower.Application.Persistence;
+using Watchtower.Application.Services;
+using Xunit;
+
+// The non-admin account is created through the same helper the access tests use, so it is made the way
+// the login endpoint makes one rather than by hand.
+
+namespace Watchtower.Api.Tests;
+
+///
+/// Who may download a full backup bundle (ADR-0027 §4). This is the sharpest authorization edge in the
+/// feature: the tar carries the key-protection secret, the backup passphrase and the storage credentials
+/// in plain text, so anyone who can fetch it can stand the instance up somewhere else. It is therefore
+/// gated on admin of the operator realm, not merely on holding a valid session.
+///
+public sealed class InstanceBundleEndpointTests {
+ private const string BundleUrl = "/api/instance/bundle";
+
+ private static CancellationToken Ct => TestContext.Current.CancellationToken;
+
+ private static WatchtowerApiFactory AuthEnabled() => new(("Watchtower:Auth:Enabled", "true"));
+
+ /// Stages a bundle file so the endpoint has something to serve.
+ private static string StageBundle(WatchtowerApiFactory factory, string content = "bundle-bytes") {
+ var directory = Directory.CreateTempSubdirectory("wt-bundle-endpoint").FullName;
+ var path = Path.Combine(directory, "watchtower-bundle_test.tar");
+ File.WriteAllText(path, content);
+ factory.Services.GetRequiredService().Replace(new StagedBundle(
+ path, "watchtower-bundle_test.tar", content.Length, DateTimeOffset.UtcNow,
+ StackCount: 1, MissingStackCount: 0));
+ return path;
+ }
+
+ /// Signs in and returns the __wt_sso cookie pair.
+ private static async Task SignInAsync(HttpClient client, string userName, string password) {
+ var response = await client.PostAsJsonAsync(
+ "/api/auth/login", new { userName, password }, Ct);
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+ return Assert.Single(response.Headers.GetValues("Set-Cookie")).Split(';')[0];
+ }
+
+ private static async Task GetAsync(HttpClient client, string? cookie) {
+ var request = new HttpRequestMessage(HttpMethod.Get, BundleUrl);
+ if (cookie is not null) request.Headers.Add("Cookie", cookie);
+ return await client.SendAsync(request, Ct);
+ }
+
+ /// The password gives a new account.
+ private const string OperatorPassword = "correct-horse-battery";
+
+ [Fact]
+ public async Task AnAnonymousCallerIsChallenged() {
+ using var factory = AuthEnabled();
+ using var client = factory.CreateApiClient();
+ StageBundle(factory);
+
+ Assert.Equal(HttpStatusCode.Unauthorized, (await GetAsync(client, cookie: null)).StatusCode);
+ }
+
+ [Fact]
+ public async Task AnOperatorWithoutTheAdminRoleIsRefused() {
+ // A signed-in operator can see the backup history and run stack backups. Taking the whole
+ // instance off the box is a different privilege, and this is where the two part company.
+ using var factory = AuthEnabled();
+ using var client = factory.CreateApiClient();
+ StageBundle(factory);
+ await factory.AddUserAsync("olive", password: OperatorPassword);
+
+ var cookie = await SignInAsync(client, "olive", OperatorPassword);
+
+ Assert.Equal(HttpStatusCode.Forbidden, (await GetAsync(client, cookie)).StatusCode);
+ }
+
+ [Fact]
+ public async Task AnAdminGetsTheTarWithItsFileNameAndLength() {
+ using var factory = AuthEnabled();
+ using var client = factory.CreateApiClient();
+ StageBundle(factory);
+ var cookie = await SignInAsync(client, "admin", WatchtowerApiFactory.AdminPassword);
+
+ var response = await GetAsync(client, cookie);
+
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+ Assert.Equal("application/x-tar", response.Content.Headers.ContentType?.MediaType);
+ Assert.Equal("watchtower-bundle_test.tar", response.Content.Headers.ContentDisposition?.FileName?.Trim('"'));
+ // Declared up front so the browser can show real progress on a file that is typically large.
+ Assert.Equal("bundle-bytes".Length, response.Content.Headers.ContentLength);
+ Assert.Equal("bundle-bytes", await response.Content.ReadAsStringAsync(Ct));
+ }
+
+ [Fact]
+ public async Task TheDownloadIsAudited() {
+ // The bundle leaving the box is the event worth being able to point at afterwards.
+ using var factory = AuthEnabled();
+ using var client = factory.CreateApiClient();
+ StageBundle(factory);
+ var cookie = await SignInAsync(client, "admin", WatchtowerApiFactory.AdminPassword);
+
+ Assert.Equal(HttpStatusCode.OK, (await GetAsync(client, cookie)).StatusCode);
+
+ await using var scope = factory.Services.CreateAsyncScope();
+ var db = scope.ServiceProvider.GetRequiredService();
+ // The audit writer is best-effort and out-of-band, so give it a moment to land its row.
+ AuditEvent? row = null;
+ for (var attempt = 0; attempt < 50 && row is null; attempt++) {
+ row = await db.AuditEvents.AsNoTracking()
+ .FirstOrDefaultAsync(a => a.Action == "bundle.download", Ct);
+ if (row is null) await Task.Delay(100, Ct);
+ }
+
+ Assert.NotNull(row);
+ Assert.Equal("backups", row.Category);
+ Assert.Equal("admin", row.Actor);
+ Assert.Contains("watchtower-bundle_test.tar", row.Detail);
+ }
+
+ [Fact]
+ public async Task WithNothingStagedAnAdminGetsA404() {
+ // Not an empty 200: "there is no bundle" and "here is an empty bundle" are different answers.
+ using var factory = AuthEnabled();
+ using var client = factory.CreateApiClient();
+ var cookie = await SignInAsync(client, "admin", WatchtowerApiFactory.AdminPassword);
+
+ Assert.Equal(HttpStatusCode.NotFound, (await GetAsync(client, cookie)).StatusCode);
+ }
+}
diff --git a/src/Watchtower.Api/Authentication/WatchtowerSessionAuthenticationHandler.cs b/src/Watchtower.Api/Authentication/WatchtowerSessionAuthenticationHandler.cs
index b99fce3..be5d3b6 100644
--- a/src/Watchtower.Api/Authentication/WatchtowerSessionAuthenticationHandler.cs
+++ b/src/Watchtower.Api/Authentication/WatchtowerSessionAuthenticationHandler.cs
@@ -27,6 +27,15 @@ public static class WatchtowerSessionDefaults {
/// so there is one rule and not two.
///
public const string SystemRealmPolicy = "WatchtowerSystemRealm";
+
+ ///
+ /// plus the admin role — the middleware-side equivalent of a handler's
+ /// [RequireRole(WatchtowerClaims.AdminRole)], for the endpoints that hand out an entire
+ /// instance (ADR-0027): the full backup bundle carries the key-protection secret, the backup
+ /// passphrase and the storage credentials in plain text, so downloading one is not something an
+ /// ordinary operator session should be able to do.
+ ///
+ public const string SystemAdminPolicy = "WatchtowerSystemAdmin";
}
///
diff --git a/src/Watchtower.Api/Endpoints/InstanceBackupEndpoints.cs b/src/Watchtower.Api/Endpoints/InstanceBackupEndpoints.cs
new file mode 100644
index 0000000..f7bd146
--- /dev/null
+++ b/src/Watchtower.Api/Endpoints/InstanceBackupEndpoints.cs
@@ -0,0 +1,91 @@
+using Elarion.Abstractions.Identity;
+using Microsoft.AspNetCore.Mvc;
+using Watchtower.Api.Authentication;
+using Watchtower.Application.Modules.Backups;
+using Watchtower.Application.Services;
+
+namespace Watchtower.Api.Endpoints;
+
+///
+/// The instance backup surfaces that move whole files rather than JSON, and so cannot be JSON-RPC
+/// handlers (ADR-0027): downloading the full backup bundle, and — from stage 3 — uploading one back.
+///
+///
+/// Admin-only through , not merely
+/// operator-only: the bundle carries the key-protection secret, the backup passphrase and the storage
+/// credentials in plain text, so downloading one is downloading the instance. With authentication
+/// disabled nothing gates it, which is the same posture as the rest of the management plane — an
+/// unauthenticated Watchtower is a Watchtower on a trusted network by the operator's choice.
+///
+public static class InstanceBackupEndpoints {
+ /// Maps the instance backup file endpoints.
+ /// The application to map onto.
+ /// Whether the session scheme is registered; policies exist only then.
+ public static WebApplication MapInstanceBackupEndpoints(this WebApplication app, bool authEnabled) {
+ foreach (var route in new[] { MapBundleDownload(app), MapBundleUpload(app) })
+ if (authEnabled) route.RequireAuthorization(WatchtowerSessionDefaults.SystemAdminPolicy);
+ return app;
+ }
+
+ ///
+ /// Accepts a full backup bundle, unpacks it and answers with this instance's verdict on restoring it
+ /// (ADR-0027 §5). Nothing is replaced here — the upload is staged, and a separate confirmed call
+ /// (backups.startInstanceRestore) is what acts on it.
+ ///
+ ///
+ /// The body is the tar itself rather than a multipart form: there is exactly one file, and streaming
+ /// it straight to disk keeps a multi-gigabyte upload out of memory. The request size limit is lifted
+ /// for this route alone, since a bundle is as large as the estate it carries.
+ ///
+ private static RouteHandlerBuilder MapBundleUpload(WebApplication app) =>
+ app.MapPost("/api/instance/restore/bundle", async (
+ HttpRequest request, InstanceRestoreService restore, AuditLog audit, ICurrentUser currentUser,
+ CancellationToken ct) => {
+ try {
+ var validation = await restore.StageAsync(request.Body, ct);
+ await audit.RecordAsync(
+ BackupService.AuditCategory, "instance.restore.upload",
+ InstanceRestoreService.AuditTarget,
+ $"bundle from '{validation.InstanceName}' ({validation.AppVersion}) uploaded — "
+ + (validation.CanRestore
+ ? $"restorable, {validation.StackCount} stack archive(s)"
+ : $"refused: {string.Join(" ", validation.Blocking.Select(b => b.Code))}"),
+ actor: await audit.ActorAsync(currentUser, ct), ct: ct);
+ return Results.Ok(RestoreValidationDto.From(validation));
+ } catch (Exception ex) when (ex is not OperationCanceledException) {
+ // The upload was not a bundle at all, or could not be unpacked. A 400 with the reason,
+ // not a 500: the file is the caller's, and so is the fix.
+ return Results.Problem(
+ title: "The upload is not a usable backup bundle", detail: ex.Message,
+ statusCode: StatusCodes.Status400BadRequest);
+ }
+ })
+ // Kestrel's 30 MB default would reject any real bundle. The body is streamed straight to disk,
+ // so the ceiling is the container's temp space rather than memory.
+ .WithMetadata(new RequestSizeLimitAttribute(long.MaxValue));
+
+ ///
+ /// Streams the staged full backup bundle. The tar is built by backups.exportBundle onto the
+ /// container's own filesystem and only ever read from here — its path is never in an API response,
+ /// so there is no name to traverse with.
+ ///
+ private static RouteHandlerBuilder MapBundleDownload(WebApplication app) =>
+ app.MapGet("/api/instance/bundle", async (
+ HttpResponse response, BundleExportState state, AuditLog audit, ICurrentUser currentUser,
+ CancellationToken ct) => {
+ if (state.Current is not { } staged) return Results.NotFound();
+
+ await audit.RecordAsync(
+ BackupService.AuditCategory, "bundle.download", BackupBundleService.AuditTarget,
+ $"{staged.FileName} · {staged.SizeBytes} bytes",
+ actor: await audit.ActorAsync(currentUser, ct), ct: ct);
+
+ response.ContentType = "application/x-tar";
+ response.Headers.ContentDisposition = $"attachment; filename=\"{staged.FileName}\"";
+ // Known up front, so the browser can show real progress on what is typically a large file.
+ response.ContentLength = staged.SizeBytes;
+ await using var file = File.OpenRead(staged.Path);
+ await file.CopyToAsync(response.Body, ct);
+ return Results.Empty;
+ });
+}
diff --git a/src/Watchtower.Api/Program.cs b/src/Watchtower.Api/Program.cs
index 522dd02..32bf30f 100644
--- a/src/Watchtower.Api/Program.cs
+++ b/src/Watchtower.Api/Program.cs
@@ -25,6 +25,13 @@
if (CoordinatorMode.IsApplicable(args))
await CoordinatorMode.RunAndExitAsync(args);
+// ── Restore-coordinator mode ──────────────────────────────────────────────────
+// The same trick for the instance restore (ADR-0027): Watchtower cannot replay a dump over the database
+// its own connection pool is holding open, so a sibling stops it, replays, and starts it again. The web
+// host is NOT started in this mode either.
+if (RestoreCoordinatorMode.IsApplicable(args))
+ await RestoreCoordinatorMode.RunAndExitAsync(args);
+
// ── Schema export mode ──────────────────────────────────────────────────────────
// Generates rpc-schema.json (consumed by the frontend client generator) and exits without
// starting the web server or touching the database.
@@ -156,14 +163,23 @@
.AddAuthentication(WatchtowerSessionDefaults.AuthenticationScheme)
.AddScheme(
WatchtowerSessionDefaults.AuthenticationScheme, configureOptions: null);
- builder.Services.AddAuthorization(o => o.AddPolicy(
- WatchtowerSessionDefaults.SystemRealmPolicy,
- p => p
- // Authenticated first, so an anonymous caller still gets the 401 challenge it always did
- // rather than a 403 that tells it a session would not have helped.
- .RequireAuthenticatedUser()
- // …and then the same operator-realm rule the handler pipeline applies, read off the principal.
- .RequireAssertion(context => WatchtowerClaims.IsSystemRealm(context.User))));
+ builder.Services.AddAuthorization(o => {
+ o.AddPolicy(
+ WatchtowerSessionDefaults.SystemRealmPolicy,
+ p => p
+ // Authenticated first, so an anonymous caller still gets the 401 challenge it always did
+ // rather than a 403 that tells it a session would not have helped.
+ .RequireAuthenticatedUser()
+ // …and then the same operator-realm rule the handler pipeline applies, read off the principal.
+ .RequireAssertion(context => WatchtowerClaims.IsSystemRealm(context.User)));
+ // The same, plus the admin role — for the endpoints that hand out a whole instance (ADR-0027).
+ o.AddPolicy(
+ WatchtowerSessionDefaults.SystemAdminPolicy,
+ p => p
+ .RequireAuthenticatedUser()
+ .RequireAssertion(context => WatchtowerClaims.IsSystemRealm(context.User))
+ .RequireRole(WatchtowerClaims.AdminRole));
+ });
// Per-IP throttle on the login endpoint (design.md §9). Registered only in this mode because the
// route it protects is only mapped here; the policy is attached to that one route, not global.
builder.Services.AddWatchtowerLoginRateLimiter();
@@ -251,6 +267,8 @@
app.MapElarionEndpoints(app.Configuration);
// Webhook, SSE streams, and health.
app.MapWatchtowerHttpEndpoints(authEnabled);
+// Full backup bundle download (ADR-0027) — a file, so not a JSON-RPC handler. Admin-only.
+app.MapInstanceBackupEndpoints(authEnabled);
// SPA fallback: any unmatched route returns index.html so the client router handles it.
app.MapFallbackToFile("index.html");
@@ -280,6 +298,10 @@ await db.BackupEvents
.SetProperty(e => e.Output,
e => (e.Output ?? "") + "\n[Reset: process restarted while backup was in progress]"));
+ // A bundle staged by a previous process (ADR-0027 §4) is unreachable — the state that knew about it
+ // went with the process — so it would only occupy the disk until the container is recreated.
+ scope.ServiceProvider.GetRequiredService().CleanStagingDirectory();
+
// ADR-0022's upgrade guard: an instance that was serving routes under the old implicit `caddy`
// default is pinned to caddy once, so the default flip cannot switch a working proxy silently.
// Here rather than in a hosted service so the ordering is stated rather than inherited: after the
diff --git a/src/Watchtower.Api/RestoreCoordinatorMode.cs b/src/Watchtower.Api/RestoreCoordinatorMode.cs
new file mode 100644
index 0000000..211aa08
--- /dev/null
+++ b/src/Watchtower.Api/RestoreCoordinatorMode.cs
@@ -0,0 +1,137 @@
+using Microsoft.Extensions.Logging.Abstractions;
+using Microsoft.Extensions.Options;
+using Watchtower.Application.Config;
+using Watchtower.Application.Services;
+
+namespace Watchtower.Api;
+
+///
+/// Entry point for restore-coordinator mode (--restore-self, ADR-0027 §5).
+///
+///
+///
+/// Watchtower cannot replay a dump over its own database: pg_dumpall --clean terminates every
+/// session and drops every database, and Watchtower's connection pool would reconnect into the middle of
+/// that. So it spawns a sibling container (same image, same Docker socket) running in this mode, which
+/// stops Watchtower, replays, and starts it again.
+///
+///
+/// It stops and starts the container rather than recreating it, unlike the self-update
+/// coordinator. That is deliberate: the container's filesystem survives, and with it the marker file the
+/// restarted Watchtower reads to find out what happened here.
+///
+///
+/// The dump is already inside the database container when this starts — placed there by the process
+/// that spawned this one, which had the archive and its passphrase. This mode only replays.
+///
+///
+internal static class RestoreCoordinatorMode {
+ ///
+ /// What the database is called in this mode's output. The dump/replay code names a compose service;
+ /// here there is only ever one database, and it is Watchtower's own.
+ ///
+ private const string Service = "watchtower";
+
+ /// Returns true when the process was launched in restore-coordinator mode.
+ internal static bool IsApplicable(string[] args) =>
+ args.Contains(RestoreCoordinatorEnvironment.Flag);
+
+ /// Runs the restore and exits the process. Never returns.
+ internal static async Task RunAndExitAsync(string[] args) {
+ var watchtowerId = Required(args, "--container-id");
+ var postgresId = Required(args, "--postgres-id");
+ var sqlPath = Required(args, "--sql");
+ var user = Required(args, "--db-user");
+ var execUser = GetArg(args, "--db-exec-user");
+ var expected = GetAll(args, "--expect-db");
+ var password = Environment.GetEnvironmentVariable(
+ RestoreCoordinatorEnvironment.PostgresPassword);
+
+ var apiVersion = Environment.GetEnvironmentVariable("WATCHTOWER__DOCKERAPIVERSION") ?? "1.43";
+ using var docker = new DockerEngineClient(
+ Options.Create(new WatchtowerOptions { DockerApiVersion = apiVersion }));
+ var ct = CancellationToken.None;
+ var connection = new PostgresConnection(user, password, execUser) { Databases = expected };
+ // The shipped dump/replay implementation, not a second one: this mode has no DI and no logging
+ // sink, which is all the null logger costs it.
+ var postgres = new PostgresDumpService(docker, NullLogger.Instance);
+ void Log(string line) => Console.WriteLine(line);
+
+ // Let the request that started this return before its container is stopped.
+ await Task.Delay(TimeSpan.FromSeconds(3), ct);
+
+ // A dump of what is there now, taken before anything is dropped. It turns the worst failure
+ // mode — a half-replayed --clean script — into something recoverable.
+ Console.WriteLine("Taking a safety dump of the current database…");
+ try {
+ await postgres.DumpToContainerFileAsync(
+ postgresId, connection, RestoreCoordinatorEnvironment.SafetyDumpPath, ct);
+ } catch (Exception ex) {
+ // Nothing has been touched yet, so the safe thing is to stop here rather than replay
+ // without a way back.
+ Console.WriteLine($"Could not take a safety dump: {ex.Message}");
+ Console.WriteLine("Nothing was changed — Watchtower is still running on its own database.");
+ Environment.Exit(1);
+ }
+
+ Console.WriteLine($"Stopping Watchtower ({Short(watchtowerId)}) for the replay…");
+ await docker.StopContainerAsync(watchtowerId, ct);
+
+ var restored = false;
+ try {
+ await postgres.WaitReadyAsync(postgresId, connection, Service, Log, ct);
+ Console.WriteLine("Replaying the dump…");
+ await postgres.ReplayRemoteAsync(postgresId, connection, Service, sqlPath, expected, Log, ct);
+ restored = true;
+ Console.WriteLine("Replay complete.");
+ } catch (Exception ex) {
+ Console.WriteLine($"Replay failed: {ex.Message}");
+ Console.WriteLine("Rolling back to the safety dump.");
+ try {
+ // No expected-database check on the way back: the safety dump is whatever was there, and
+ // the point is to restore it rather than to assert what it held.
+ await postgres.ReplayRemoteAsync(
+ postgresId, connection, Service, RestoreCoordinatorEnvironment.SafetyDumpPath,
+ expectedDatabases: [], Log, ct);
+ Console.WriteLine("Rollback complete — the database is as it was before the restore.");
+ } catch (Exception rollbackEx) {
+ Console.WriteLine(
+ $"Rollback failed too: {rollbackEx.Message}. The database may be in a partial state; "
+ + $"the pre-restore dump is at {RestoreCoordinatorEnvironment.SafetyDumpPath} inside "
+ + "the database container.");
+ }
+ } finally {
+ // Always, whatever happened: an instance that is down is worse than one that is unchanged.
+ Console.WriteLine($"Starting Watchtower ({Short(watchtowerId)}) again…");
+ try {
+ await docker.StartContainerAsync(watchtowerId, ct);
+ } catch (Exception ex) {
+ Console.WriteLine(
+ $"Could not start Watchtower again: {ex.Message}. Start container {watchtowerId} by hand.");
+ }
+ }
+
+ Console.WriteLine(restored
+ ? "Instance restore complete — Watchtower is coming up on the restored database."
+ : "Instance restore failed — Watchtower is coming up on the database it had.");
+ Environment.Exit(restored ? 0 : 1);
+ }
+
+ private static string Short(string id) => id.Length >= 12 ? id[..12] : id;
+
+ private static string Required(string[] args, string name) =>
+ GetArg(args, name)
+ ?? throw new InvalidOperationException($"{name} is required in restore-coordinator mode");
+
+ private static string? GetArg(string[] args, string name) {
+ var index = Array.IndexOf(args, name);
+ return index >= 0 && index + 1 < args.Length ? args[index + 1] : null;
+ }
+
+ /// Every value of a repeatable flag, in order.
+ private static string[] GetAll(string[] args, string name) => [
+ .. args.Index()
+ .Where(x => x.Item == name && x.Index + 1 < args.Length)
+ .Select(x => args[x.Index + 1]),
+ ];
+}
diff --git a/src/Watchtower.Application.Tests/BackupBundleTests.cs b/src/Watchtower.Application.Tests/BackupBundleTests.cs
new file mode 100644
index 0000000..7981aff
--- /dev/null
+++ b/src/Watchtower.Application.Tests/BackupBundleTests.cs
@@ -0,0 +1,270 @@
+using System.Formats.Tar;
+using System.Security.Cryptography;
+using System.Text;
+using System.Text.Json;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.DependencyInjection;
+using Watchtower.Application.Entities;
+using Watchtower.Application.Persistence;
+using Watchtower.Application.Services;
+using Xunit;
+
+namespace Watchtower.Application.Tests;
+
+///
+/// The exportable full backup bundle (ADR-0027 §4). What it has to get right is what an import on the
+/// other side depends on: every archive present under the storage-relative path the restored database
+/// will look for it at, a manifest that says which Watchtower wrote it and against which schema, and
+/// the out-of-database secrets without which the restored instance cannot read its own keys.
+///
+public sealed class BackupBundleTests : IDisposable {
+ private static CancellationToken Ct => TestContext.Current.CancellationToken;
+
+ private readonly string _storageRoot = Directory.CreateTempSubdirectory("wt-bundle-tests").FullName;
+
+ public void Dispose() {
+ try {
+ Directory.Delete(_storageRoot, recursive: true);
+ } catch (IOException) {
+ // A temp directory the OS will reclaim; never worth failing a passing test over.
+ }
+ }
+
+ private AuthTestHost Start(params (string, string?)[] more) =>
+ AuthTestHost.Start(FakeInstanceBackup.Register, [
+ ("Watchtower:Backup:Provider", "local"),
+ ("Watchtower:Backup:Local:BasePath", _storageRoot),
+ ("Watchtower:Backup:InstanceName", "prod"),
+ ("Watchtower:Backup:EncryptionPassphrase", "s3cret"),
+ .. more,
+ ]);
+
+ private async Task AddStackAsync(AuthTestHost host, string name, string? backupDirectory = null) {
+ await using var scope = host.Services.CreateAsyncScope();
+ var db = scope.ServiceProvider.GetRequiredService();
+ var stack = new Stack {
+ Name = name,
+ ComposeProjectName = name,
+ Product = TestProducts.New(name),
+ BackupDirectory = backupDirectory,
+ };
+ db.Stacks.Add(stack);
+ await db.SaveChangesAsync(Ct);
+ return stack.Id;
+ }
+
+ /// Puts a stand-in stack archive on the storage, the way a real run would have left one.
+ private async Task SeedArchiveAsync(
+ string directory, string stem, DateTimeOffset takenAt, string content) {
+ var name = BackupNaming.FileName(stem, takenAt, encrypted: true);
+ var path = Path.Combine(_storageRoot, directory.Replace('/', Path.DirectorySeparatorChar));
+ Directory.CreateDirectory(path);
+ await File.WriteAllTextAsync(Path.Combine(path, name), content, Ct);
+ return name;
+ }
+
+ /// Runs an export the way the queue would, and returns the staged bundle.
+ private async Task ExportAsync(AuthTestHost host) {
+ int eventId;
+ await using (var scope = host.Services.CreateAsyncScope()) {
+ var db = scope.ServiceProvider.GetRequiredService();
+ var evt = new BackupEvent {
+ StackId = null,
+ TriggeredBy = BackupTriggers.BundleExport,
+ Status = BackupStatuses.Queued,
+ StartedAt = DateTimeOffset.UtcNow,
+ };
+ db.BackupEvents.Add(evt);
+ await db.SaveChangesAsync(Ct);
+ eventId = evt.Id;
+ }
+
+ await host.Services.GetRequiredService().ExecuteExportAsync(eventId, Ct);
+
+ await using (var scope = host.Services.CreateAsyncScope()) {
+ var db = scope.ServiceProvider.GetRequiredService();
+ var evt = await db.BackupEvents.AsNoTracking().SingleAsync(e => e.Id == eventId, Ct);
+ Assert.True(
+ evt.Status == BackupStatuses.Success,
+ $"The export failed. Run log:\n{evt.Output}");
+ }
+
+ var staged = host.Services.GetRequiredService().Current;
+ return Assert.IsType(staged);
+ }
+
+ /// Every entry in the tar, by name, with its bytes.
+ private static async Task> ReadTarAsync(string path) {
+ var entries = new Dictionary(StringComparer.Ordinal);
+ await using var file = File.OpenRead(path);
+ await using var reader = new TarReader(file);
+ while (await reader.GetNextEntryAsync() is { } entry) {
+ using var content = new MemoryStream();
+ if (entry.DataStream is { } data) await data.CopyToAsync(content);
+ entries[entry.Name] = content.ToArray();
+ }
+ return entries;
+ }
+
+ private static T Json(Dictionary entries, string name) =>
+ JsonSerializer.Deserialize(entries[name], BackupBundle.JsonOptions)!;
+
+ [Fact]
+ public async Task CarriesTheInstanceArchiveAndEveryStacksNewestOne() {
+ using var host = Start();
+ await AddStackAsync(host, "blog");
+ await AddStackAsync(host, "shop");
+ var stale = await SeedArchiveAsync(
+ "prod/blog", "blog", new DateTimeOffset(2026, 8, 20, 3, 30, 0, TimeSpan.Zero), "old-blog");
+ var newest = await SeedArchiveAsync(
+ "prod/blog", "blog", new DateTimeOffset(2026, 8, 25, 3, 30, 0, TimeSpan.Zero), "new-blog");
+ await SeedArchiveAsync(
+ "prod/shop", "shop", new DateTimeOffset(2026, 8, 25, 3, 31, 0, TimeSpan.Zero), "shop-bytes");
+
+ var staged = await ExportAsync(host);
+ var entries = await ReadTarAsync(staged.Path);
+
+ Assert.Equal(2, staged.StackCount);
+ Assert.Equal(0, staged.MissingStackCount);
+ Assert.Contains(BackupBundle.ManifestEntry, entries.Keys);
+ Assert.Contains(BackupBundle.SecretsEntry, entries.Keys);
+
+ // The storage-relative path is preserved under stacks/, so an import can put each archive back
+ // exactly where the restored database's BackupDirectory already points.
+ Assert.Equal("new-blog", Encoding.UTF8.GetString(entries[$"stacks/prod/blog/{newest}"]));
+ Assert.DoesNotContain($"stacks/prod/blog/{stale}", entries.Keys);
+ Assert.Equal(
+ FakeInstanceBackup.Content,
+ Encoding.UTF8.GetString(entries.Single(e => e.Key.StartsWith("watchtower/", StringComparison.Ordinal)).Value));
+ }
+
+ [Fact]
+ public async Task TheManifestNamesTheBuildTheSchemaAndEveryArchivesDigest() {
+ using var host = Start();
+ await AddStackAsync(host, "blog");
+ await SeedArchiveAsync(
+ "prod/blog", "blog", new DateTimeOffset(2026, 8, 25, 3, 30, 0, TimeSpan.Zero), "blog-bytes");
+
+ var entries = await ReadTarAsync((await ExportAsync(host)).Path);
+ var manifest = Json(entries, BackupBundle.ManifestEntry);
+
+ Assert.Equal(BackupBundle.FormatVersion, manifest.BundleFormatVersion);
+ Assert.Equal("watchtower", manifest.Tool);
+ Assert.Equal("prod", manifest.InstanceName);
+ Assert.False(string.IsNullOrWhiteSpace(manifest.AppVersion));
+ // The migration id is what an import decides on, so it has to be the *applied* one, not a guess.
+ Assert.False(string.IsNullOrWhiteSpace(manifest.LastMigrationId));
+
+ var stack = Assert.Single(manifest.Stacks);
+ Assert.Equal("blog", stack.Name);
+ var archive = Assert.IsType(stack.Archive);
+ Assert.True(archive.Encrypted);
+ Assert.Equal(
+ Convert.ToHexStringLower(SHA256.HashData(entries[archive.Entry])),
+ archive.Sha256);
+ Assert.Equal(entries[archive.Entry].Length, archive.SizeBytes);
+ Assert.Equal(
+ Convert.ToHexStringLower(SHA256.HashData(entries[manifest.Instance.Entry])),
+ manifest.Instance.Sha256);
+ }
+
+ [Fact]
+ public async Task AStackWithNoArchiveIsRecordedRatherThanOmitted() {
+ // Its definition still comes back with the database, so the operator has to be told that the
+ // data did not — silently listing one stack out of two would read as "there was only one".
+ using var host = Start();
+ await AddStackAsync(host, "blog");
+ await AddStackAsync(host, "never-backed-up");
+ await SeedArchiveAsync(
+ "prod/blog", "blog", new DateTimeOffset(2026, 8, 25, 3, 30, 0, TimeSpan.Zero), "blog-bytes");
+
+ var staged = await ExportAsync(host);
+ var manifest = Json(await ReadTarAsync(staged.Path), BackupBundle.ManifestEntry);
+
+ Assert.Equal(1, staged.StackCount);
+ Assert.Equal(1, staged.MissingStackCount);
+ var missing = Assert.Single(manifest.Stacks, s => s.Name == "never-backed-up");
+ Assert.Null(missing.Archive);
+ Assert.Equal("no archive on the backup storage", missing.Reason);
+ }
+
+ [Fact]
+ public async Task ATenantsStampedDirectoryIsHonoured() {
+ // BackupDirectory is stamped once and never recomputed, so the bundle has to read it rather than
+ // derive a path from the stack's current name — the two differ for every tenant.
+ using var host = Start();
+ await AddStackAsync(host, "shop-globex", backupDirectory: "prod/shop/globex");
+ var name = await SeedArchiveAsync(
+ "prod/shop/globex", "shop-globex",
+ new DateTimeOffset(2026, 8, 25, 3, 30, 0, TimeSpan.Zero), "globex-bytes");
+
+ var entries = await ReadTarAsync((await ExportAsync(host)).Path);
+
+ Assert.Equal("globex-bytes", Encoding.UTF8.GetString(entries[$"stacks/prod/shop/globex/{name}"]));
+ }
+
+ [Fact]
+ public async Task TheSecretsFileCarriesWhatTheDatabaseCannot() {
+ using var host = Start(("Watchtower:Auth:KeyProtectionSecret", "key-protection-secret"));
+
+ var entries = await ReadTarAsync((await ExportAsync(host)).Path);
+ var secrets = Json(entries, BackupBundle.SecretsEntry);
+
+ // Without this one the restored instance throws on every certificate and key it touches.
+ Assert.Equal("key-protection-secret", secrets.KeyProtectionSecret);
+ Assert.Equal("s3cret", secrets.BackupEncryptionPassphrase);
+ Assert.Equal("prod", secrets.BackupInstanceName);
+ Assert.Equal("local", secrets.Storage.Provider);
+ Assert.Equal(_storageRoot, secrets.Storage.LocalBasePath);
+
+ var manifest = Json(entries, BackupBundle.ManifestEntry);
+ Assert.True(manifest.KeyProtectionSecretConfigured);
+ }
+
+ [Fact]
+ public async Task AnInstanceWithNoKeyProtectionSecretSaysSoRatherThanLookingLikeALostOne() {
+ using var host = Start();
+
+ var entries = await ReadTarAsync((await ExportAsync(host)).Path);
+
+ Assert.Null(Json(entries, BackupBundle.SecretsEntry).KeyProtectionSecret);
+ Assert.False(Json(entries, BackupBundle.ManifestEntry).KeyProtectionSecretConfigured);
+ }
+
+ [Fact]
+ public async Task ASecondExportReplacesTheFirstAndDeletesIt() {
+ // One bundle is staged at a time: they are large, and an operator downloading "the" bundle
+ // should never be handed a stale one.
+ using var host = Start();
+ var first = await ExportAsync(host);
+ var second = await ExportAsync(host);
+
+ Assert.NotEqual(first.Path, second.Path);
+ Assert.False(File.Exists(first.Path));
+ Assert.True(File.Exists(second.Path));
+ }
+
+ [Fact]
+ public async Task TheEventRecordsTheBundleAndTheAuditTrailNamesIt() {
+ using var host = Start();
+ await AddStackAsync(host, "blog");
+ await SeedArchiveAsync(
+ "prod/blog", "blog", new DateTimeOffset(2026, 8, 25, 3, 30, 0, TimeSpan.Zero), "blog-bytes");
+
+ var staged = await ExportAsync(host);
+
+ await using var scope = host.Services.CreateAsyncScope();
+ var db = scope.ServiceProvider.GetRequiredService();
+ var evt = await db.BackupEvents.AsNoTracking().SingleAsync(Ct);
+ Assert.Null(evt.StackId);
+ Assert.Equal(staged.SizeBytes, evt.SizeBytes);
+ Assert.Equal(staged.FileName, evt.RemotePath);
+
+ var row = await db.AuditEvents.AsNoTracking()
+ .SingleAsync(a => a.Action == "bundle.export", Ct);
+ Assert.True(row.Success);
+ Assert.Contains("1 stack archive(s)", row.Detail);
+ // Never the passphrase or the key-protection secret, whatever else the row says.
+ Assert.DoesNotContain("s3cret", row.Detail);
+ }
+}
diff --git a/src/Watchtower.Application.Tests/BackupNamingAndRetentionTests.cs b/src/Watchtower.Application.Tests/BackupNamingAndRetentionTests.cs
index a2e3f84..9bfa442 100644
--- a/src/Watchtower.Application.Tests/BackupNamingAndRetentionTests.cs
+++ b/src/Watchtower.Application.Tests/BackupNamingAndRetentionTests.cs
@@ -54,6 +54,43 @@ public void ForeignFileNamesDoNotParse(string name) =>
public void SanitizeYieldsASafeSingleSegment(string input, string expected) =>
Assert.Equal(expected, BackupNaming.Sanitize(input));
+ // ── The instance's own directory (ADR-0027) ──────────────────────────────
+
+ [Fact]
+ public void TheInstanceDirectoryIsASiblingOfTheStackDirectories() {
+ // One folder per instance holds everything a rebuild needs: Watchtower's database next to the
+ // stacks whose volumes it describes.
+ Assert.Equal("prod/_watchtower", BackupNaming.InstanceDirectory("prod"));
+ Assert.Equal("prod/web-app", BackupNaming.StackDirectory("prod", "web-app"));
+ }
+
+ [Fact]
+ public void InstanceArchivesParticipateInRetentionLikeAnyOther() {
+ // Retention and the remote listing key off the timestamp suffix alone, so the instance stem is
+ // not a special case for either — which is what lets both paths share one implementation.
+ var taken = new DateTimeOffset(2026, 8, 26, 3, 30, 0, TimeSpan.Zero);
+ var name = BackupNaming.FileName(BackupNaming.InstanceFileStem, taken, encrypted: true);
+
+ Assert.Equal("watchtower_20260826T033000Z.tar.gz.enc", name);
+ Assert.Equal(taken, BackupNaming.ParseTimestamp(name));
+ }
+
+ [Theory]
+ [InlineData("_watchtower")]
+ [InlineData("_WATCHTOWER")]
+ [InlineData("_watchtower ")]
+ public void TheInstanceDirectoryNameIsReservedAgainstStacks(string stackName) =>
+ // A stack sanitizing onto it would write its archives among Watchtower's own, and retention —
+ // which prunes a directory, not a stack — would then count the two as one set.
+ Assert.True(BackupNaming.IsReserved(stackName));
+
+ [Theory]
+ [InlineData("watchtower")]
+ [InlineData("_watchtower-backups")]
+ [InlineData("web-app")]
+ public void OrdinaryStackNamesAreNotReserved(string stackName) =>
+ Assert.False(BackupNaming.IsReserved(stackName));
+
// ── Retention ────────────────────────────────────────────────────────────
[Fact]
diff --git a/src/Watchtower.Application.Tests/BackupScheduleJobTests.cs b/src/Watchtower.Application.Tests/BackupScheduleJobTests.cs
index 5b1de48..c787eb6 100644
--- a/src/Watchtower.Application.Tests/BackupScheduleJobTests.cs
+++ b/src/Watchtower.Application.Tests/BackupScheduleJobTests.cs
@@ -84,6 +84,83 @@ public async Task EnqueuesEachStackOnceForAWindowAndMovesTheCursor() {
Assert.Equal(Utc(17, 15, 30), await CursorAsync(host, web));
}
+ // ── Watchtower's own database (ADR-0027) ─────────────────────────────────
+
+ private static IReadOnlyList InstanceEnqueued(AuthTestHost host) =>
+ ((RecordingBackupQueue)host.Services.GetRequiredService()).InstanceEnqueued;
+
+ /// Enabled, with an encryption passphrase — what the self-backup additionally needs.
+ private static (string, string?)[] WithSelfBackup(params (string, string?)[] more) =>
+ Enabled([("Watchtower:Backup:EncryptionPassphrase", "s3cret"), .. more]);
+
+ [Fact]
+ public async Task TheInstanceRunsOnTheSameWindowAsTheStacksAndKeepsItsOwnCursor() {
+ using var host = Start(WithSelfBackup());
+ var web = await AddStackAsync(host, "web", last: Utc(16, 15, 30));
+
+ Assert.Equal(0, await TickAsync(host, Utc(17, 3, 29)));
+ // Two enqueues for one window: the stack and Watchtower's own database.
+ Assert.Equal(2, await TickAsync(host, Utc(17, 3, 30, 15)));
+ Assert.Equal(1, EnqueuedCount(host, web));
+ Assert.Equal(["schedule"], InstanceEnqueued(host));
+
+ // Same window on the next tick enqueues neither — the instance cursor moved with the stack's.
+ Assert.Equal(0, await TickAsync(host, Utc(17, 3, 31, 15)));
+ Assert.Single(InstanceEnqueued(host));
+
+ Assert.Equal(2, await TickAsync(host, Utc(17, 15, 30, 40)));
+ Assert.Equal(["schedule", "schedule"], InstanceEnqueued(host));
+ }
+
+ [Fact]
+ public async Task TheInstanceWindowRunsWithNoStacksAtAll() {
+ // A fresh instance with nothing deployed still has state worth backing up — the whole point of
+ // ADR-0027 — so the stackless early return must not swallow it.
+ using var host = Start(WithSelfBackup());
+
+ Assert.Equal(1, await TickAsync(host, Utc(17, 3, 30, 15)));
+ Assert.Equal(["schedule"], InstanceEnqueued(host));
+ }
+
+ [Fact]
+ public async Task IncludeSelfOffLeavesTheStacksAlone() {
+ using var host = Start(WithSelfBackup(("Watchtower:Backup:IncludeSelf", "false")));
+ var web = await AddStackAsync(host, "web", last: Utc(16, 15, 30));
+
+ Assert.Equal(1, await TickAsync(host, Utc(17, 3, 30, 15)));
+ Assert.Equal(1, EnqueuedCount(host, web));
+ Assert.Empty(InstanceEnqueued(host));
+ }
+
+ [Fact]
+ public async Task WithoutAPassphraseTheWindowIsSkippedButStillConsumed() {
+ // The dump carries every role's password hash, so it is never written unencrypted. The cursor
+ // still moves: an unconsumed window would re-open on every tick for the rest of the day.
+ using var host = Start(Enabled());
+ Assert.Equal(0, await TickAsync(host, Utc(17, 3, 30, 15)));
+ Assert.Empty(InstanceEnqueued(host));
+
+ // Passphrase appears; the window that was skipped stays skipped rather than firing late.
+ using var configured = host.Restart(WithSelfBackup());
+ Assert.Equal(0, await TickAsync(configured, Utc(17, 3, 31)));
+ Assert.Empty(InstanceEnqueued(configured));
+
+ // The next window runs normally.
+ Assert.Equal(1, await TickAsync(configured, Utc(17, 15, 30, 20)));
+ Assert.Equal(["schedule"], InstanceEnqueued(configured));
+ }
+
+ [Fact]
+ public async Task ARestartDoesNotDoubleFireTheInstanceWindow() {
+ using var host = Start(WithSelfBackup());
+ Assert.Equal(1, await TickAsync(host, Utc(17, 3, 30, 10)));
+
+ // The cursor is a settings row, so it survives the process exactly like a stack's column does.
+ using var restarted = host.Restart(WithSelfBackup());
+ Assert.Equal(0, await TickAsync(restarted, Utc(17, 3, 31)));
+ Assert.Empty(InstanceEnqueued(restarted));
+ }
+
[Fact]
public async Task TheProductionQueueGetsAQueuedEventPerWindow() {
// One window through the real queue (its worker never starts here): the enqueue is the
diff --git a/src/Watchtower.Application.Tests/BackupTestDoubles.cs b/src/Watchtower.Application.Tests/BackupTestDoubles.cs
index 1bb5a42..26da598 100644
--- a/src/Watchtower.Application.Tests/BackupTestDoubles.cs
+++ b/src/Watchtower.Application.Tests/BackupTestDoubles.cs
@@ -1,10 +1,71 @@
+using System.Text;
+using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
+using Watchtower.Application.Config;
using Watchtower.Application.Services;
namespace Watchtower.Application.Tests;
+///
+/// Answers "where is Watchtower's own database" without a Docker daemon, so a test of what a restore
+/// decides is not also a test of container detection (which
+/// covers on its own).
+///
+internal sealed class FakeSelfPostgresLocator(
+ DockerEngineClient docker, SelfProjectNameProvider selfProjects, IConfiguration configuration,
+ IOptionsMonitor options, ILogger logger)
+ : SelfPostgresLocator(docker, selfProjects, configuration, options, logger) {
+ /// Replaces the real locator on a test host.
+ public static readonly Action Register = services =>
+ services.Replace(ServiceDescriptor.Singleton());
+
+ public override Task LocateAsync(Action log, CancellationToken ct) =>
+ Task.FromResult(new SelfPostgresTarget(
+ "test-postgres", "watchtower-postgres-1", "postgres:18-alpine", "postgres", "watchtower",
+ "watchtower"));
+}
+
+///
+/// Produces a stand-in instance archive on the configured storage instead of dumping a real database,
+/// so a test of what a bundle contains needs neither a Docker daemon nor a second PostgreSQL.
+/// The bytes are a marker string rather than a real archive: nothing under test opens it — the bundle
+/// carries archives, it does not read them.
+///
+internal sealed class FakeInstanceBackup(
+ BackupArchiveService archiveService, PostgresDumpService postgres, SelfPostgresLocator locator,
+ BackupStorageFactory storageFactory, BackupRetentionRunner retention, IServiceScopeFactory scopeFactory,
+ IOptionsMonitor options, AuditLog audit, ILogger logger)
+ : InstanceBackupService(
+ archiveService, postgres, locator, storageFactory, retention, scopeFactory, options, audit, logger) {
+ /// The content every fake instance archive is written with.
+ public const string Content = "fake-instance-archive";
+
+ /// Held explicitly rather than captured, so the base class owns the only captured copy.
+ private readonly BackupStorageFactory _storageFactory = storageFactory;
+
+ /// Replaces the real service on a test host.
+ public static readonly Action Register = services =>
+ services.Replace(ServiceDescriptor.Singleton());
+
+ public override async Task RunAsync(
+ BackupOptions backup, Action log, CancellationToken ct) {
+ var takenAt = DateTimeOffset.UtcNow;
+ var directory = BackupNaming.InstanceDirectory(backup.ResolveInstanceName());
+ var fileName = BackupNaming.FileName(BackupNaming.InstanceFileStem, takenAt, encrypted: true);
+ var relativePath = $"{directory}/{fileName}";
+
+ using var storage = _storageFactory.Create(backup);
+ await storage.UploadAsync(relativePath, async (stream, token) =>
+ await stream.WriteAsync(Encoding.UTF8.GetBytes(Content), token), ct);
+ log($"Fake instance archive written to {relativePath}");
+ return new InstanceArchiveResult(
+ relativePath, fileName, directory, Content.Length, takenAt, ["watchtower"]);
+ }
+}
+
///
/// Records enqueues instead of queueing them; the worker loop never starts in these tests.
///
@@ -15,10 +76,13 @@ namespace Watchtower.Application.Tests;
/// contract of those paths, and it is invisible from a real queue that has not run yet.
///
internal sealed class RecordingBackupQueue(
- BackupService backupService, BackupChainCoordinator chain, IServiceScopeFactory scopeFactory,
+ BackupService backupService, InstanceBackupService instanceBackupService,
+ BackupBundleService bundleService, BackupChainCoordinator chain, IServiceScopeFactory scopeFactory,
ILogger logger)
- : BackupQueueService(backupService, chain, scopeFactory, logger) {
+ : BackupQueueService(backupService, instanceBackupService, bundleService, chain, scopeFactory, logger) {
private readonly List<(int StackId, string TriggeredBy, BackupChainStep? Chain)> _enqueued = [];
+ private readonly List _instanceEnqueued = [];
+ private readonly List _bundleEnqueued = [];
/// Replaces the real queue on a test host.
public static readonly Action Register = services =>
@@ -34,6 +98,11 @@ internal sealed class RecordingBackupQueue(
get { lock (_enqueued) return [.. _enqueued]; }
}
+ /// Every instance self-backup enqueue (ADR-0027), by trigger, in call order.
+ public IReadOnlyList InstanceEnqueued {
+ get { lock (_enqueued) return [.. _instanceEnqueued]; }
+ }
+
public override BackupEnqueueResult Enqueue(
int stackId, string triggeredBy, BackupChainStep? chainStep = null) {
lock (_enqueued) {
@@ -41,4 +110,25 @@ public override BackupEnqueueResult Enqueue(
return new BackupEnqueueResult(_enqueued.Count, "queued");
}
}
+
+ /// Every bundle export enqueue (ADR-0027 §4), by trigger, in call order.
+ public IReadOnlyList BundleEnqueued {
+ get { lock (_enqueued) return [.. _bundleEnqueued]; }
+ }
+
+ public override BackupEnqueueResult EnqueueBundleExport(string triggeredBy) {
+ lock (_enqueued) {
+ _bundleEnqueued.Add(triggeredBy);
+ return new BackupEnqueueResult(_enqueued.Count + _bundleEnqueued.Count, "queued");
+ }
+ }
+
+ public override BackupEnqueueResult EnqueueInstance(string triggeredBy) {
+ // Recorded without coalescing, for the reason the stack enqueues are: nothing drains this queue,
+ // so a coalesced second window would be invisible to a schedule test.
+ lock (_enqueued) {
+ _instanceEnqueued.Add(triggeredBy);
+ return new BackupEnqueueResult(_enqueued.Count + _instanceEnqueued.Count, "queued");
+ }
+ }
}
diff --git a/src/Watchtower.Application.Tests/InstanceBackupEventTests.cs b/src/Watchtower.Application.Tests/InstanceBackupEventTests.cs
new file mode 100644
index 0000000..6a92383
--- /dev/null
+++ b/src/Watchtower.Application.Tests/InstanceBackupEventTests.cs
@@ -0,0 +1,122 @@
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.DependencyInjection;
+using Watchtower.Application.Entities;
+using Watchtower.Application.Modules.Backups;
+using Watchtower.Application.Modules.Backups.Handlers;
+using Watchtower.Application.Persistence;
+using Watchtower.Application.Services;
+using Xunit;
+
+namespace Watchtower.Application.Tests;
+
+///
+/// The stackless half of the backup history (ADR-0027): the queue writes an instance run's event with no
+/// stack, and the history views can ask for one kind or the other without either becoming a special case.
+///
+public sealed class InstanceBackupEventTests {
+ private static CancellationToken Ct => TestContext.Current.CancellationToken;
+
+ private static async Task AddStackAsync(AuthTestHost host, string name) {
+ await using var scope = host.Services.CreateAsyncScope();
+ var db = scope.ServiceProvider.GetRequiredService();
+ var stack = new Stack { Name = name, ComposeProjectName = name, Product = TestProducts.New(name) };
+ db.Stacks.Add(stack);
+ await db.SaveChangesAsync(Ct);
+ return stack.Id;
+ }
+
+ private static async Task> EventsAsync(AuthTestHost host, string? kind) {
+ await using var scope = host.Services.CreateAsyncScope();
+ var handler = ActivatorUtilities.CreateInstance(scope.ServiceProvider);
+ var result = await handler.HandleAsync(new ListBackupEvents.Query(Kind: kind), Ct);
+ Assert.True(result.IsSuccess);
+ return result.Value!.Events;
+ }
+
+ [Fact]
+ public async Task TheQueueWritesAQueuedEventWithNoStack() {
+ using var host = AuthTestHost.Start();
+ var queue = host.Services.GetRequiredService();
+
+ var enqueued = queue.EnqueueInstance(BackupTriggers.Manual);
+
+ await using var scope = host.Services.CreateAsyncScope();
+ var db = scope.ServiceProvider.GetRequiredService();
+ var evt = await db.BackupEvents.AsNoTracking().SingleAsync(e => e.Id == enqueued.BackupEventId, Ct);
+ Assert.Null(evt.StackId);
+ Assert.Equal("manual", evt.TriggeredBy);
+ Assert.Equal("queued", evt.Status);
+ }
+
+ [Fact]
+ public async Task ASecondRequestCoalescesOntoTheWaitingRun() {
+ // Same reason the stack backups coalesce: a caller who asks twice wants the backup that is about
+ // to happen, not two of them competing for the disk.
+ using var host = AuthTestHost.Start();
+ var queue = host.Services.GetRequiredService();
+
+ var first = queue.EnqueueInstance(BackupTriggers.Manual);
+ var second = queue.EnqueueInstance(BackupTriggers.Schedule);
+
+ Assert.Equal(first.BackupEventId, second.BackupEventId);
+ await using var scope = host.Services.CreateAsyncScope();
+ var db = scope.ServiceProvider.GetRequiredService();
+ Assert.Equal(1, await db.BackupEvents.CountAsync(Ct));
+ }
+
+ [Fact]
+ public async Task TheHistoryReportsTheKindAndCanBeNarrowedToEither() {
+ using var host = AuthTestHost.Start();
+ var stackId = await AddStackAsync(host, "web");
+ var queue = host.Services.GetRequiredService();
+ queue.Enqueue(stackId, BackupTriggers.Manual);
+ queue.EnqueueInstance(BackupTriggers.Manual);
+
+ // Unfiltered is unchanged for every existing caller: an instance run is part of "what has this
+ // Watchtower been backing up", so it belongs in the instance-wide list.
+ var all = await EventsAsync(host, kind: null);
+ Assert.Equal(2, all.Count);
+
+ var instance = Assert.Single(await EventsAsync(host, BackupEventKinds.Instance));
+ Assert.Equal("instance", instance.Kind);
+ Assert.Null(instance.StackId);
+ Assert.Null(instance.StackName);
+
+ var stack = Assert.Single(await EventsAsync(host, BackupEventKinds.Stack));
+ Assert.Equal("stack", stack.Kind);
+ Assert.Equal(stackId, stack.StackId);
+ Assert.Equal("web", stack.StackName);
+ }
+
+ [Fact]
+ public async Task AnUnknownKindIsRefusedRatherThanIgnored() {
+ // Silently returning everything would make a typo look like "there are no instance backups".
+ using var host = AuthTestHost.Start();
+ await using var scope = host.Services.CreateAsyncScope();
+ var handler = ActivatorUtilities.CreateInstance(scope.ServiceProvider);
+
+ var result = await handler.HandleAsync(new ListBackupEvents.Query(Kind: "watchtower"), Ct);
+
+ Assert.False(result.IsSuccess);
+ Assert.Contains("Kind must be", result.Error!.Message);
+ }
+
+ [Fact]
+ public async Task DeletingAStackLeavesTheInstanceHistoryStanding() {
+ // The relationship still cascades, so a stack takes its own history with it — but the stackless
+ // rows outlive every stack, which is the point of keeping them in the same table.
+ using var host = AuthTestHost.Start();
+ var stackId = await AddStackAsync(host, "web");
+ var queue = host.Services.GetRequiredService();
+ queue.Enqueue(stackId, BackupTriggers.Manual);
+ queue.EnqueueInstance(BackupTriggers.Manual);
+
+ await using (var scope = host.Services.CreateAsyncScope()) {
+ var db = scope.ServiceProvider.GetRequiredService();
+ await db.Stacks.Where(s => s.Id == stackId).ExecuteDeleteAsync(Ct);
+ }
+
+ var remaining = Assert.Single(await EventsAsync(host, kind: null));
+ Assert.Equal("instance", remaining.Kind);
+ }
+}
diff --git a/src/Watchtower.Application.Tests/InstanceBackupManifestTests.cs b/src/Watchtower.Application.Tests/InstanceBackupManifestTests.cs
new file mode 100644
index 0000000..625bdf2
--- /dev/null
+++ b/src/Watchtower.Application.Tests/InstanceBackupManifestTests.cs
@@ -0,0 +1,70 @@
+using System.Text.Json;
+using Watchtower.Application.Services;
+using Xunit;
+
+namespace Watchtower.Application.Tests;
+
+///
+/// The instance archive's manifest (ADR-0027). It has to be self-describing enough that a restore can
+/// refuse it: kind tells a reader it is not a stack archive, and lastMigrationId is what a
+/// target instance checks before replaying, since migrations only roll forward.
+///
+public sealed class InstanceBackupManifestTests {
+ private static readonly DateTimeOffset TakenAt = new(2026, 8, 26, 3, 15, 0, TimeSpan.Zero);
+
+ private static SelfPostgresTarget Target() => new(
+ "abc123", "watchtower-postgres-1", "postgres:18-alpine", "postgres", "watchtower", "watchtower");
+
+ private static BackupService.BackupDumpEntry Dump() => new(
+ "watchtower", DumpEngine.Postgres, "_dumps/watchtower.sql", "postgres:18-alpine", "watchtower",
+ "watchtower-postgres-1", Volumes: [], ["postgres", "watchtower"], 65536);
+
+ private static JsonElement Parse(string json) => JsonDocument.Parse(json).RootElement;
+
+ [Fact]
+ public void DeclaresItsKindVersionAndSchema() {
+ var manifest = Parse(InstanceBackupService.BuildManifest(
+ "prod", Target(), TakenAt, Dump(), "20260826192454_AddInstanceBackupEvents"));
+
+ Assert.Equal(1, manifest.GetProperty("formatVersion").GetInt32());
+ // The discriminator: a reader holding one archive must be able to tell which of the two it is
+ // without inferring it from which keys happen to be missing.
+ Assert.Equal("watchtower-instance", manifest.GetProperty("kind").GetString());
+ Assert.Equal("watchtower", manifest.GetProperty("tool").GetString());
+ Assert.Equal("prod", manifest.GetProperty("instance").GetString());
+ Assert.Equal("watchtower", manifest.GetProperty("database").GetString());
+ Assert.Equal("2026-08-26T03:15:00.0000000Z", manifest.GetProperty("createdAtUtc").GetString());
+ Assert.Equal(
+ "20260826192454_AddInstanceBackupEvents", manifest.GetProperty("lastMigrationId").GetString());
+ // Always true: the run refuses to produce an unencrypted instance archive at all.
+ Assert.True(manifest.GetProperty("encrypted").GetBoolean());
+ Assert.False(string.IsNullOrWhiteSpace(manifest.GetProperty("appVersion").GetString()));
+ }
+
+ [Fact]
+ public void CarriesTheDumpInTheSameShapeAStackArchiveDoes() {
+ // Same node builder as the stack manifest, so tooling that reads one reads the other.
+ var dumps = Parse(InstanceBackupService.BuildManifest("prod", Target(), TakenAt, Dump(), "m"))
+ .GetProperty("dumps");
+
+ var dump = Assert.Single(dumps.EnumerateArray());
+ Assert.Equal("watchtower", dump.GetProperty("service").GetString());
+ Assert.Equal("postgres", dump.GetProperty("engine").GetString());
+ Assert.Equal("_dumps/watchtower.sql", dump.GetProperty("file").GetString());
+ Assert.Equal("watchtower-postgres-1", dump.GetProperty("container").GetString());
+ Assert.Equal(65536, dump.GetProperty("sizeBytes").GetInt64());
+ Assert.Equal(
+ ["postgres", "watchtower"],
+ dump.GetProperty("databases").EnumerateArray().Select(d => d.GetString()));
+ Assert.Empty(dump.GetProperty("volumes").EnumerateArray());
+ }
+
+ [Fact]
+ public void RecordsANullMigrationRatherThanOmittingTheKey() {
+ // A database with no migrations applied is a real state, and a key that is sometimes absent is
+ // harder for a reader to handle than one that is sometimes null.
+ var manifest = Parse(InstanceBackupService.BuildManifest("prod", Target(), TakenAt, Dump(), null));
+
+ Assert.Equal(JsonValueKind.Null, manifest.GetProperty("lastMigrationId").ValueKind);
+ }
+}
diff --git a/src/Watchtower.Application.Tests/InstanceRestoreValidationTests.cs b/src/Watchtower.Application.Tests/InstanceRestoreValidationTests.cs
new file mode 100644
index 0000000..9f2ede9
--- /dev/null
+++ b/src/Watchtower.Application.Tests/InstanceRestoreValidationTests.cs
@@ -0,0 +1,238 @@
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.DependencyInjection;
+using Watchtower.Application.Entities;
+using Watchtower.Application.Persistence;
+using Watchtower.Application.Services;
+using Xunit;
+
+namespace Watchtower.Application.Tests;
+
+///
+/// What an instance refuses to restore, and why (ADR-0027 §5). Every one of these decisions is made
+/// before anything is touched, which is the point: an instance that cannot read the bundle it
+/// was handed has to still be the instance it was. The messages are asserted as well as the outcomes —
+/// a refusal an operator cannot act on is only half a refusal.
+///
+public sealed class InstanceRestoreValidationTests {
+ private static CancellationToken Ct => TestContext.Current.CancellationToken;
+
+ private static AuthTestHost Start(params (string, string?)[] settings) =>
+ AuthTestHost.Start(FakeSelfPostgresLocator.Register, settings);
+
+ /// The migration this build actually applied, so a default bundle is a valid one.
+ private static async Task LastMigrationAsync(AuthTestHost host) {
+ await using var scope = host.Services.CreateAsyncScope();
+ var db = scope.ServiceProvider.GetRequiredService();
+ return await InstanceVersion.LastMigrationAsync(db, Ct);
+ }
+
+ private static async Task StageAsync(AuthTestHost host, byte[] bundle) {
+ using var stream = new MemoryStream(bundle);
+ return await host.Services.GetRequiredService().StageAsync(stream, Ct);
+ }
+
+ private static async Task StageDefaultAsync(
+ AuthTestHost host, TestBundles.Options? options = null) =>
+ await StageAsync(host, TestBundles.Build(await LastMigrationAsync(host), options));
+
+ private static string? Blocking(RestoreValidation validation, string code) =>
+ validation.Blocking.FirstOrDefault(f => f.Code == code)?.Message;
+
+ [Fact]
+ public async Task AValidBundleIsAccepted() {
+ using var host = Start();
+
+ var validation = await StageDefaultAsync(host, new TestBundles.Options(
+ Stacks: ["blog", "shop"], MissingStacks: ["never-backed-up"]));
+
+ Assert.True(validation.CanRestore, string.Join(" ", validation.Blocking.Select(b => b.Message)));
+ Assert.Empty(validation.Blocking);
+ Assert.Equal("source", validation.InstanceName);
+ Assert.Equal("9.9.9-test", validation.AppVersion);
+ Assert.Equal(2, validation.StackCount);
+ Assert.Equal(1, validation.MissingStackCount);
+ Assert.Equal(["blog", "shop", "never-backed-up"], validation.StackNames);
+ }
+
+ [Fact]
+ public async Task ABundleFromANewerSchemaIsRefused() {
+ // Migrations only roll forward, so this is exact rather than a version comparison: replaying a
+ // schema this binary has never known would leave a database it cannot migrate.
+ using var host = Start();
+
+ var validation = await StageDefaultAsync(
+ host, new TestBundles.Options(LastMigrationId: "29990101000000_FromTheFuture"));
+
+ Assert.False(validation.CanRestore);
+ var message = Blocking(validation, "newer-schema");
+ Assert.NotNull(message);
+ Assert.Contains("9.9.9-test", message);
+ Assert.Contains("Update this Watchtower", message);
+ }
+
+ [Fact]
+ public async Task AMismatchedKeyProtectionSecretIsRefusedWithTheRemedy() {
+ // The sharpest edge in the feature: the stored certificates and keys are AES-GCM under this
+ // secret, and it cannot be changed at runtime — so the message has to name the variable and say
+ // that a restart is part of the fix.
+ using var host = Start(("Watchtower:Auth:KeyProtectionSecret", "this-instances-secret"));
+
+ var validation = await StageDefaultAsync(
+ host, new TestBundles.Options(KeyProtectionSecret: "the-source-instances-secret"));
+
+ Assert.False(validation.CanRestore);
+ var message = Blocking(validation, "key-protection-secret");
+ Assert.NotNull(message);
+ Assert.Contains("WATCHTOWER__AUTH__KEYPROTECTIONSECRET", message);
+ Assert.Contains("restart", message);
+ // Never the secret itself, from either side.
+ Assert.DoesNotContain("this-instances-secret", message);
+ Assert.DoesNotContain("the-source-instances-secret", message);
+ }
+
+ [Fact]
+ public async Task AMatchingKeyProtectionSecretIsAccepted() {
+ using var host = Start(("Watchtower:Auth:KeyProtectionSecret", "shared-secret"));
+
+ var validation = await StageDefaultAsync(host, new TestBundles.Options(KeyProtectionSecret: "shared-secret"));
+
+ Assert.True(validation.CanRestore);
+ }
+
+ [Fact]
+ public async Task ABundleWithNoSecretIntoAnInstanceThatHasOneIsAWarningNotARefusal() {
+ // The restored rows are readable as they are, and later writes are encrypted. Worth saying,
+ // not worth stopping for.
+ using var host = Start(("Watchtower:Auth:KeyProtectionSecret", "this-instances-secret"));
+
+ var validation = await StageDefaultAsync(host);
+
+ Assert.True(validation.CanRestore);
+ Assert.Contains(validation.Warnings, w => w.Code == "key-protection-secret-new");
+ }
+
+ [Fact]
+ public async Task AnArchiveThatDoesNotMatchItsChecksumIsRefused() {
+ using var host = Start();
+
+ var validation = await StageDefaultAsync(host, new TestBundles.Options(CorruptInstanceDigest: true));
+
+ Assert.False(validation.CanRestore);
+ Assert.Contains("damaged in transit or altered", Blocking(validation, "corrupt-archive"));
+ }
+
+ [Fact]
+ public async Task AnArchiveTheManifestPromisesButTheTarLacksIsRefused() {
+ using var host = Start();
+
+ var validation = await StageDefaultAsync(host, new TestBundles.Options(OmitInstanceArchive: true));
+
+ Assert.False(validation.CanRestore);
+ Assert.Contains("incomplete or was repacked", Blocking(validation, "missing-archive"));
+ }
+
+ [Fact]
+ public async Task AnArchiveTheBundlesOwnPassphraseCannotOpenIsRefused() {
+ // Proving the passphrase now is the whole reason the probe exists: discovering it after the
+ // database has been dropped would be discovering it too late.
+ using var host = Start();
+
+ var validation = await StageDefaultAsync(host, new TestBundles.Options(WrongPassphrase: true));
+
+ Assert.False(validation.CanRestore);
+ Assert.NotNull(Blocking(validation, "unreadable-archive"));
+ }
+
+ [Fact]
+ public async Task AnArchiveWithNoDumpInItIsRefused() {
+ using var host = Start();
+
+ var validation = await StageDefaultAsync(host, new TestBundles.Options(WithoutDump: true));
+
+ Assert.False(validation.CanRestore);
+ Assert.Contains("nothing to restore from", Blocking(validation, "no-dump"));
+ }
+
+ [Fact]
+ public async Task AnUnknownBundleFormatIsRefused() {
+ using var host = Start();
+
+ var validation = await StageDefaultAsync(host, new TestBundles.Options(BundleFormatVersion: 99));
+
+ Assert.False(validation.CanRestore);
+ Assert.Contains("format version 99", Blocking(validation, "bundle-format"));
+ }
+
+ [Fact]
+ public async Task RestoringOverAWatchtowerThatIsInUseWarnsAboutWhatItReplaces() {
+ using var host = Start();
+ await using (var scope = host.Services.CreateAsyncScope()) {
+ var db = scope.ServiceProvider.GetRequiredService();
+ db.Stacks.Add(new Stack {
+ Name = "already-here", ComposeProjectName = "already-here",
+ Product = TestProducts.New("already-here"),
+ });
+ await db.SaveChangesAsync(Ct);
+ }
+
+ var validation = await StageDefaultAsync(host);
+
+ // A warning, not a refusal: replacing a working instance is a thing an operator may legitimately
+ // mean to do. The confirmation dialog is where they say so.
+ Assert.True(validation.CanRestore);
+ Assert.Contains("keep running unmanaged", validation.Warnings.Single(w => w.Code == "not-fresh").Message);
+ }
+
+ [Fact]
+ public async Task AFreshInstanceDoesNotGetTheWarning() {
+ using var host = Start();
+
+ var validation = await StageDefaultAsync(host);
+
+ Assert.DoesNotContain(validation.Warnings, w => w.Code == "not-fresh");
+ }
+
+ [Theory]
+ [InlineData("../escaped.json")]
+ [InlineData("stacks/../../escaped.json")]
+ [InlineData("/etc/watchtower-escaped.json")]
+ public async Task ATarThatWouldWriteOutsideItsDirectoryIsRejectedOutright(string entryName) {
+ // The entry names come from a file an operator uploaded, so this is the one check that has to
+ // happen before anything is written rather than after everything is. Refused rather than
+ // sanitized: a name that tries to escape is a name to stop on, not one to quietly rewrite.
+ using var host = Start();
+ using var stream = new MemoryStream(TestBundles.TraversalBundle(entryName));
+
+ var error = await Assert.ThrowsAsync(() =>
+ host.Services.GetRequiredService().StageAsync(stream, Ct));
+
+ Assert.Contains("written outside it", error.Message);
+ }
+
+ [Fact]
+ public async Task AnUploadThatIsNotABundleSaysSo() {
+ using var host = Start();
+ using var stream = new MemoryStream(TestBundles.NotABundle());
+
+ var error = await Assert.ThrowsAsync(() =>
+ host.Services.GetRequiredService().StageAsync(stream, Ct));
+
+ Assert.Contains("not a Watchtower backup bundle", error.Message);
+ }
+
+ [Fact]
+ public async Task AStagedBundleCanBeRevalidatedWithoutReUploadingIt() {
+ // What the wizard does on a page load: the bundle is already here, and re-hashing gigabytes to
+ // answer that would be the wrong trade.
+ using var host = Start();
+ await StageDefaultAsync(host, new TestBundles.Options(Stacks: ["blog"]));
+
+ var staging = host.Services.GetRequiredService();
+ var staged = Assert.IsType(staging.Current);
+ var revalidated = await host.Services.GetRequiredService()
+ .ValidateAsync(staged, Ct);
+
+ Assert.True(revalidated.CanRestore);
+ Assert.Equal(1, revalidated.StackCount);
+ }
+}
diff --git a/src/Watchtower.Application.Tests/PostgresDumpServiceTests.cs b/src/Watchtower.Application.Tests/PostgresDumpServiceTests.cs
index 3cb809c..c908689 100644
--- a/src/Watchtower.Application.Tests/PostgresDumpServiceTests.cs
+++ b/src/Watchtower.Application.Tests/PostgresDumpServiceTests.cs
@@ -1,4 +1,4 @@
-using System.Formats.Tar;
+using System.Formats.Tar;
using System.Net;
using System.Net.Http.Headers;
using System.Text;
@@ -332,6 +332,19 @@ private static string WriteSql(string sql) {
return path;
}
+ ///
+ /// Where the exec carrying in its command sits among the recorded
+ /// requests. Found rather than counted: one exec is two HTTP calls, and which client a step rides
+ /// is a transport decision that has already moved once — fixed offsets would say more about that
+ /// than about the order under test.
+ ///
+ private static int ExecIndex(DockerClientEstate estate, string fragment) {
+ var index = estate.Default.Bodies.FindIndex(
+ b => b is not null && b.Contains(fragment, StringComparison.Ordinal));
+ Assert.True(index >= 0, $"no exec was recorded whose command contains '{fragment}'");
+ return index;
+ }
+
[Fact]
public async Task TheReplayTerminatesSessions_CopiesTheSql_RunsPsql_AndCleansUp() {
using var estate = Estate();
@@ -352,14 +365,6 @@ public async Task TheReplayTerminatesSessions_CopiesTheSql_RunsPsql_AndCleansUp(
File.Delete(sql);
}
- // Every session has to go first: --clean cannot DROP DATABASE under a live connection, and
- // psql would then merge the dump into the old database instead of replacing it.
- using var terminate = JsonDocument.Parse(estate.Default.Bodies[0]!);
- Assert.Contains(
- "pg_terminate_backend",
- terminate.RootElement.GetProperty("Cmd").EnumerateArray().Last().GetString() ?? "");
- Assert.Contains(log, l => l.StartsWith("WARNING: closed 2 open session(s) on 'db'", StringComparison.Ordinal));
-
// The SQL goes in as a tar at /tmp, streamed from the host file — over the untimed client,
// because a dump is as large as the database it captures.
var put = estate.LongRunning.Requests.FindIndex(r => r.Contains("/archive?path=", StringComparison.Ordinal));
@@ -371,12 +376,16 @@ public async Task TheReplayTerminatesSessions_CopiesTheSql_RunsPsql_AndCleansUp(
// 0600: the dump carries every role's password hash.
Assert.Equal(UnixFileMode.UserRead | UnixFileMode.UserWrite, entry.Mode);
- // The psql exec is the second exec created (after the session terminate).
- var psqlCreate = estate.Default.Requests
- .Select((request, index) => (request, index))
- .Where(x => x.request.EndsWith("/containers/db-id/exec", StringComparison.Ordinal))
- .ElementAt(1).index;
- using var replay = JsonDocument.Parse(estate.Default.Bodies[psqlCreate]!);
+ // Every session has to go before psql: --clean cannot DROP DATABASE under a live connection,
+ // and the script would then merge the dump into the old database instead of replacing it. The
+ // execs are found by what they run rather than counted, so the staging step moving onto the
+ // untimed client (and off this recorder) cannot silently shift what is being asserted.
+ var terminateAt = ExecIndex(estate, "pg_terminate_backend");
+ var replayAt = ExecIndex(estate, "ON_ERROR_STOP=0");
+ Assert.True(terminateAt < replayAt, "the sessions should be closed before psql runs");
+ Assert.Contains(log, l => l.StartsWith("WARNING: closed 2 open session(s) on 'db'", StringComparison.Ordinal));
+
+ using var replay = JsonDocument.Parse(estate.Default.Bodies[replayAt]!);
Assert.Equal(
new string?[] { "psql", "-U", "app", "-d", "postgres", "-w", "-v", "ON_ERROR_STOP=0", "-f", "/tmp/db.sql" },
replay.RootElement.GetProperty("Cmd").EnumerateArray().Select(e => e.GetString()).ToArray());
diff --git a/src/Watchtower.Application.Tests/RestoreCompletionTests.cs b/src/Watchtower.Application.Tests/RestoreCompletionTests.cs
new file mode 100644
index 0000000..63d7ba4
--- /dev/null
+++ b/src/Watchtower.Application.Tests/RestoreCompletionTests.cs
@@ -0,0 +1,241 @@
+using Elarion.Settings;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.DependencyInjection.Extensions;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Logging.Abstractions;
+using Watchtower.Application.Config;
+using Watchtower.Application.Entities;
+using Watchtower.Application.Persistence;
+using Watchtower.Application.Services;
+using Xunit;
+
+namespace Watchtower.Application.Tests;
+
+///
+/// How the first start after a restore works out whether the restore happened (ADR-0027 §5), and what
+/// it repairs when it did.
+///
+///
+/// The verdict rests entirely on the nonce: the restore writes one into the database it is about to
+/// replace, and only a replay can remove it. That is the whole reason this can be decided at all — the
+/// process that would have watched the coordinator is the process the coordinator stopped.
+///
+public sealed class RestoreCompletionTests : IDisposable {
+ private static CancellationToken Ct => TestContext.Current.CancellationToken;
+
+ private readonly string _stagingRoot =
+ Directory.CreateTempSubdirectory("wt-restore-completion").FullName;
+
+ public void Dispose() {
+ try {
+ Directory.Delete(_stagingRoot, recursive: true);
+ } catch (IOException) {
+ // Scratch space the OS reclaims; never worth failing a passing test over.
+ }
+ }
+
+ /// A host whose restore staging is this test's own directory, not the shared temp one.
+ private AuthTestHost Start() =>
+ AuthTestHost.Start(
+ services => services.Replace(ServiceDescriptor.Singleton(sp =>
+ new InstanceRestoreStaging(
+ sp.GetService>()
+ ?? NullLogger.Instance,
+ _stagingRoot))));
+
+ private static InstanceRestoreStaging Staging(AuthTestHost host) =>
+ host.Services.GetRequiredService();
+
+ /// Writes the marker a restore leaves behind, as the real one does.
+ private static Task MarkInFlightAsync(AuthTestHost host, string nonce, params string[] stacks) =>
+ Staging(host).WriteProgressAsync(
+ new RestoreProgress(nonce, DateTimeOffset.UtcNow, "source", "coordinator-id", stacks), Ct);
+
+ /// Writes the nonce row into the database, as the real restore does before handing over.
+ private static async Task WriteNonceAsync(AuthTestHost host, string nonce) {
+ await using var scope = host.Services.CreateAsyncScope();
+ var settings = scope.ServiceProvider.GetRequiredService();
+ await settings.SetStringAsync(
+ WatchtowerSettingPaths.RestorePendingNonce, nonce, SettingsScope.Global,
+ expectedVersion: null, Ct);
+ }
+
+ private static async Task ReadSettingAsync(AuthTestHost host, string path) {
+ await using var scope = host.Services.CreateAsyncScope();
+ var settings = scope.ServiceProvider.GetRequiredService();
+ return await settings.GetStringAsync(path, SettingsScope.Global, Ct);
+ }
+
+ /// Runs the completion pass the way the host start does.
+ private static async Task CompleteAsync(AuthTestHost host) {
+ var completion = host.Services.GetRequiredService();
+ await completion.StartAsync(Ct);
+ return completion;
+ }
+
+ private static async Task AddStackAsync(AuthTestHost host, string name, DateTimeOffset? cursor) {
+ await using var scope = host.Services.CreateAsyncScope();
+ var db = scope.ServiceProvider.GetRequiredService();
+ var stack = new Stack {
+ Name = name,
+ ComposeProjectName = name,
+ Product = TestProducts.New(name),
+ LastScheduledBackupAt = cursor,
+ };
+ db.Stacks.Add(stack);
+ await db.SaveChangesAsync(Ct);
+ return stack.Id;
+ }
+
+ private static async Task RestoreAuditAsync(AuthTestHost host) {
+ await using var scope = host.Services.CreateAsyncScope();
+ var db = scope.ServiceProvider.GetRequiredService();
+ return await db.AuditEvents.AsNoTracking()
+ .FirstOrDefaultAsync(a => a.Action == "instance.restore", Ct);
+ }
+
+ [Fact]
+ public async Task WithNoMarkerNothingHappens() {
+ // The ordinary start, which every instance that has never restored anything does forever.
+ using var host = Start();
+
+ var completion = await CompleteAsync(host);
+
+ Assert.Equal(RestoreOutcome.None, completion.LastOutcome);
+ Assert.Null(await RestoreAuditAsync(host));
+ }
+
+ [Fact]
+ public async Task TheNonceBeingGoneMeansTheReplayCommitted() {
+ // A replayed database is the source instance's, and the source instance never knew this nonce.
+ using var host = Start();
+ await MarkInFlightAsync(host, "nonce-abc", "blog", "shop");
+
+ var completion = await CompleteAsync(host);
+
+ Assert.Equal(RestoreOutcome.Succeeded, completion.LastOutcome);
+ Assert.Null(completion.LastError);
+ var row = await RestoreAuditAsync(host);
+ Assert.NotNull(row);
+ Assert.True(row.Success);
+ Assert.Contains("restored from a bundle taken from 'source'", row.Detail);
+ }
+
+ [Fact]
+ public async Task TheNonceStillBeingThereMeansTheDatabaseWasNeverReplaced() {
+ // The coordinator failed and rolled back, or never got that far. Either way this instance is
+ // exactly as it was — which is a failure to report, not a silent no-op.
+ using var host = Start();
+ await WriteNonceAsync(host, "nonce-abc");
+ await MarkInFlightAsync(host, "nonce-abc");
+
+ var completion = await CompleteAsync(host);
+
+ Assert.Equal(RestoreOutcome.Failed, completion.LastOutcome);
+ Assert.Contains("running on the database it had", completion.LastError);
+ var row = await RestoreAuditAsync(host);
+ Assert.NotNull(row);
+ Assert.False(row.Success);
+ // The marker row is this instance's own litter now, and means nothing in a database that stays.
+ Assert.Null(await ReadSettingAsync(host, WatchtowerSettingPaths.RestorePendingNonce));
+ }
+
+ [Fact]
+ public async Task AFailedRestoreKeepsTheUploadSoItCanBeRetried() {
+ using var host = Start();
+ var uploadDirectory = Staging(host).NewUploadDirectory();
+ await WriteNonceAsync(host, "nonce-abc");
+ await MarkInFlightAsync(host, "nonce-abc");
+
+ await CompleteAsync(host);
+
+ Assert.True(Directory.Exists(uploadDirectory));
+ // The marker is gone, so a later restart does not re-report the same failure.
+ Assert.Null(Staging(host).ReadProgress());
+ }
+
+ [Fact]
+ public async Task ASucceededRestoreClearsTheBundleAndTheMarker() {
+ // The bundle carries every secret the source instance had; once it has been used it is only a
+ // copy of the instance lying around in a container.
+ using var host = Start();
+ await MarkInFlightAsync(host, "nonce-abc");
+
+ await CompleteAsync(host);
+
+ Assert.Null(Staging(host).ReadProgress());
+ Assert.Null(Staging(host).Current);
+ }
+
+ [Fact]
+ public async Task TheBackupCursorsAreClampedSoTheRestoreDoesNotFireAFleetOfBackups() {
+ // The restored rows carry the source instance's cursors. Left alone, every window between its
+ // dump and now looks missed, and the misfire grace would back up every stack at once — against
+ // volumes that have not been redeployed yet.
+ using var host = Start();
+ var stale = new DateTimeOffset(2020, 1, 1, 0, 0, 0, TimeSpan.Zero);
+ var stackId = await AddStackAsync(host, "blog", stale);
+ await MarkInFlightAsync(host, "nonce-abc");
+
+ await CompleteAsync(host);
+
+ await using var scope = host.Services.CreateAsyncScope();
+ var db = scope.ServiceProvider.GetRequiredService();
+ var cursor = await db.Stacks.AsNoTracking()
+ .Where(s => s.Id == stackId).Select(s => s.LastScheduledBackupAt).SingleAsync(Ct);
+ Assert.NotNull(cursor);
+ Assert.True(cursor > stale, "the stack's backup cursor should have been moved to now");
+ Assert.NotNull(await ReadSettingAsync(host, WatchtowerSettingPaths.BackupSelfLastScheduledAt));
+ }
+
+ [Fact]
+ public async Task TheProxyPlaneIsToldToReprojectTheRestoredRoutes() {
+ // The routes table arrived wholesale; without the bump the proxy keeps serving what this
+ // instance had before the restore.
+ using var host = Start();
+ var before = await ReadSettingAsync(host, WatchtowerSettingPaths.ProxyRoutesVersion);
+ await MarkInFlightAsync(host, "nonce-abc");
+
+ await CompleteAsync(host);
+
+ var after = await ReadSettingAsync(host, WatchtowerSettingPaths.ProxyRoutesVersion);
+ Assert.NotNull(after);
+ Assert.NotEqual(before, after);
+ }
+
+ [Fact]
+ public async Task TheRecoveryChecklistIsSeededFromTheRestoredDatabase() {
+ // From the database, not from the bundle's manifest: the ids the checklist has to act on are the
+ // restored ones.
+ using var host = Start();
+ var blog = await AddStackAsync(host, "blog", cursor: null);
+ var shop = await AddStackAsync(host, "shop", cursor: null);
+ await MarkInFlightAsync(host, "nonce-abc", "whatever-the-manifest-said");
+
+ await CompleteAsync(host);
+
+ await using var scope = host.Services.CreateAsyncScope();
+ var settings = scope.ServiceProvider.GetRequiredService();
+ var checklist = await StackRevivalState.LoadAsync(settings, Ct);
+ Assert.NotNull(checklist);
+ Assert.False(checklist.Dismissed);
+ Assert.Equal("source", checklist.SourceInstance);
+ Assert.Equal([blog, shop], checklist.Stacks.Select(s => s.StackId));
+ Assert.All(checklist.Stacks, s => Assert.Equal(RevivalStatus.Pending, s.Status));
+ }
+
+ [Fact]
+ public async Task AFailedRestoreLeavesNoChecklist() {
+ using var host = Start();
+ await AddStackAsync(host, "blog", cursor: null);
+ await WriteNonceAsync(host, "nonce-abc");
+ await MarkInFlightAsync(host, "nonce-abc");
+
+ await CompleteAsync(host);
+
+ await using var scope = host.Services.CreateAsyncScope();
+ var settings = scope.ServiceProvider.GetRequiredService();
+ Assert.Null(await StackRevivalState.LoadAsync(settings, Ct));
+ }
+}
diff --git a/src/Watchtower.Application.Tests/SelfPostgresLocatorTests.cs b/src/Watchtower.Application.Tests/SelfPostgresLocatorTests.cs
new file mode 100644
index 0000000..0300709
--- /dev/null
+++ b/src/Watchtower.Application.Tests/SelfPostgresLocatorTests.cs
@@ -0,0 +1,107 @@
+using Watchtower.Application.Services;
+using Xunit;
+
+namespace Watchtower.Application.Tests;
+
+///
+/// Which container the instance self-backup dumps (ADR-0027). The rule is pure, so the whole of it is
+/// exercised here without a daemon — and it has to be exact: the container this picks is the one whose
+/// contents become "the backup of Watchtower", so choosing a neighbouring database would produce an
+/// archive that looks entirely healthy and restores the wrong instance.
+///
+public sealed class SelfPostgresLocatorTests {
+ private static DockerContainerInfo Container(
+ string name, string image = "postgres:18-alpine", string? service = null, string state = "running") =>
+ new() {
+ Id = $"id-{name}",
+ Names = [$"/{name}"],
+ Image = image,
+ State = state,
+ Status = $"Up 2 hours",
+ Labels = service is null
+ ? []
+ : new Dictionary { ["com.docker.compose.service"] = service },
+ };
+
+ [Fact]
+ public void PicksTheContainerWhoseComposeServiceIsTheConnectionHost() {
+ var postgres = Container("watchtower-postgres-1", service: "postgres");
+ var other = Container("shop-db-1", service: "db");
+
+ Assert.Same(postgres, SelfPostgresLocator.Choose([other, postgres], "postgres"));
+ }
+
+ [Fact]
+ public void PicksTheContainerNamedAfterTheConnectionHost() {
+ var postgres = Container("watchtower-pg");
+ Assert.Same(postgres, SelfPostgresLocator.Choose([Container("other-db"), postgres], "watchtower-pg"));
+ }
+
+ [Fact]
+ public void SeesThroughComposesReplicaSuffix() {
+ // "Host=postgres" resolves to the service; the container it created is "{project}-postgres-1".
+ var postgres = Container("watchtower-postgres-1");
+ Assert.Same(postgres, SelfPostgresLocator.Choose([postgres, Container("shop-db-1")], "postgres"));
+ }
+
+ [Fact]
+ public void ASingleCandidateWinsEvenWhenTheHostNamesNothing() {
+ // A Compose install whose service is aliased differently from the host is ordinary, and with one
+ // database on the daemon there is nothing to confuse it with.
+ var postgres = Container("db-1", service: "db");
+ Assert.Same(postgres, SelfPostgresLocator.Choose([postgres], "postgres.internal"));
+ }
+
+ [Fact]
+ public void RefusesToGuessBetweenSeveralUnmatchedCandidates() {
+ var error = Assert.Throws(() =>
+ SelfPostgresLocator.Choose([Container("a-db-1"), Container("b-db-1")], "postgres.internal"));
+
+ Assert.Contains("more than one to choose from", error.Message);
+ Assert.Contains("a-db-1", error.Message);
+ Assert.Contains("b-db-1", error.Message);
+ // The way out is always named, so the message is actionable rather than merely correct.
+ Assert.Contains("Watchtower:Backup:SelfPostgresContainer", error.Message);
+ }
+
+ [Fact]
+ public void RefusesWhenSeveralContainersAnswerToTheSameHost() {
+ var error = Assert.Throws(() =>
+ SelfPostgresLocator.Choose(
+ [Container("postgres", service: "postgres"), Container("wt-postgres-1", service: "postgres")],
+ "postgres"));
+
+ Assert.Contains("More than one", error.Message);
+ Assert.Contains("Watchtower:Backup:SelfPostgresContainer", error.Message);
+ }
+
+ [Fact]
+ public void FailsLoudlyWhenThereIsNoDatabaseContainerAtAll() {
+ // The managed-PostgreSQL case, and also the daemon-unreachable case. Both have to say so: a
+ // self-backup that quietly does nothing is worse than one that fails, because it is invisible
+ // until the day it is needed.
+ var error = Assert.Throws(() =>
+ SelfPostgresLocator.Choose([], "db.eu-central-1.rds.amazonaws.com"));
+
+ Assert.Contains("db.eu-central-1.rds.amazonaws.com", error.Message);
+ Assert.Contains("managed or host-installed PostgreSQL", error.Message);
+ Assert.Contains("Docker daemon could not be reached", error.Message);
+ }
+
+ [Fact]
+ public void TheDumpTargetCarriesAStableServiceIdentity() {
+ // Whatever the container is called, the SQL lands at backup/_dumps/watchtower.sql — so a restore
+ // looks for one name rather than for whatever the source instance happened to name its container.
+ var target = new SelfPostgresTarget(
+ "abc123", "watchtower-postgres-1", "postgres:18-alpine", "postgres", "watchtower", "watchtower");
+ var dump = target.ToDumpTarget();
+
+ Assert.Equal("watchtower", dump.Service);
+ Assert.Equal("abc123", dump.ContainerId);
+ Assert.Equal(DumpEngine.Postgres, dump.Engine);
+ // No volumes: an instance archive is the dump and nothing else, so there is no file snapshot for
+ // a data volume to be excluded from.
+ Assert.Null(dump.DataVolume);
+ Assert.Empty(dump.MountedVolumes);
+ }
+}
diff --git a/src/Watchtower.Application.Tests/StackRevivalTests.cs b/src/Watchtower.Application.Tests/StackRevivalTests.cs
new file mode 100644
index 0000000..ba30546
--- /dev/null
+++ b/src/Watchtower.Application.Tests/StackRevivalTests.cs
@@ -0,0 +1,287 @@
+using Elarion.Settings;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging.Abstractions;
+using Microsoft.Extensions.Options;
+using Watchtower.Application.Config;
+using Watchtower.Application.Entities;
+using Watchtower.Application.Persistence;
+using Watchtower.Application.Services;
+using Xunit;
+
+namespace Watchtower.Application.Tests;
+
+///
+/// Bringing the stacks back after an instance restore (ADR-0027 §6). The order is the contract: a stack
+/// is deployed first, because only a deploy creates the volumes a restore needs, and restored second,
+/// because a deploy alone leaves it running on empty ones. Everything else here is about saying clearly
+/// which of the two went wrong.
+///
+public sealed class StackRevivalTests : IDisposable {
+ private static CancellationToken Ct => TestContext.Current.CancellationToken;
+
+ private readonly string _storageRoot = Directory.CreateTempSubdirectory("wt-revival-tests").FullName;
+
+ public void Dispose() {
+ try {
+ Directory.Delete(_storageRoot, recursive: true);
+ } catch (IOException) {
+ // Scratch space the OS reclaims.
+ }
+ }
+
+ private AuthTestHost Start() => AuthTestHost.Start(
+ ("Watchtower:Backup:Provider", "local"),
+ ("Watchtower:Backup:Local:BasePath", _storageRoot),
+ ("Watchtower:Backup:InstanceName", "prod"));
+
+ ///
+ /// A coordinator over queues whose runs are already finished. The state machine is what is under
+ /// test; how long a real deploy takes is the queues' business, and waiting for one here would only
+ /// test .
+ ///
+ private static (StackRevivalCoordinator Coordinator, TerminalDeployQueue Deploys, TerminalBackupQueue Backups)
+ Coordinator(AuthTestHost host, string deployStatus = "success", string restoreStatus = "success") {
+ var scopeFactory = host.Services.GetRequiredService();
+ var deploys = new TerminalDeployQueue(host.Services, deployStatus);
+ var backups = new TerminalBackupQueue(host.Services, restoreStatus);
+ return (
+ new StackRevivalCoordinator(
+ deploys, backups,
+ host.Services.GetRequiredService(),
+ scopeFactory,
+ host.Services.GetRequiredService>(),
+ TimeProvider.System,
+ NullLogger.Instance,
+ stepTimeout: TimeSpan.FromSeconds(10),
+ pollInterval: TimeSpan.FromMilliseconds(5)),
+ deploys, backups);
+ }
+
+ private static async Task AddStackAsync(AuthTestHost host, string name) {
+ await using var scope = host.Services.CreateAsyncScope();
+ var db = scope.ServiceProvider.GetRequiredService();
+ var stack = new Stack { Name = name, ComposeProjectName = name, Product = TestProducts.New(name) };
+ db.Stacks.Add(stack);
+ await db.SaveChangesAsync(Ct);
+ return stack.Id;
+ }
+
+ /// Seeds the checklist the completion pass would have written.
+ private static async Task SeedChecklistAsync(AuthTestHost host, params (int Id, string Name)[] stacks) {
+ await using var scope = host.Services.CreateAsyncScope();
+ var settings = scope.ServiceProvider.GetRequiredService();
+ await new StackRevivalState(
+ DateTimeOffset.UtcNow, "source", Dismissed: false,
+ [.. stacks.Select(s => new RevivalStack(s.Id, s.Name, RevivalStatus.Pending))])
+ .SaveAsync(settings, Ct);
+ }
+
+ /// Puts an archive on the storage where the stack's restore would look for it.
+ private async Task SeedArchiveAsync(string stackName) {
+ var name = BackupNaming.FileName(
+ stackName, new DateTimeOffset(2026, 8, 25, 3, 30, 0, TimeSpan.Zero), encrypted: true);
+ var directory = Path.Combine(_storageRoot, "prod", stackName);
+ Directory.CreateDirectory(directory);
+ await File.WriteAllTextAsync(Path.Combine(directory, name), "archive", Ct);
+ return name;
+ }
+
+ [Fact]
+ public async Task AStackIsDeployedThenRestoredFromItsNewestArchive() {
+ using var host = Start();
+ var stackId = await AddStackAsync(host, "blog");
+ await SeedChecklistAsync(host, (stackId, "blog"));
+ var archive = await SeedArchiveAsync("blog");
+ var (coordinator, deploys, backups) = Coordinator(host);
+
+ var result = await coordinator.ReviveAsync(stackId, Ct);
+
+ Assert.NotNull(result);
+ Assert.Equal(RevivalStatus.Done, result.Status);
+ Assert.Contains(archive, result.Detail);
+ Assert.Equal([stackId], deploys.Enqueued);
+ Assert.Equal([(stackId, archive)], backups.Restored);
+ // Both runs are linked from the row, so the operator can read either log.
+ Assert.NotNull(result.DeployEventId);
+ Assert.NotNull(result.BackupEventId);
+ }
+
+ [Fact]
+ public async Task AStackWithNoArchiveIsDeployedAndSaysNothingWasRestored() {
+ // Its definition came back with the database; its data never existed on the storage. Reporting
+ // that as done-with-a-note is honest — reporting it as failed would not be.
+ using var host = Start();
+ var stackId = await AddStackAsync(host, "fresh");
+ await SeedChecklistAsync(host, (stackId, "fresh"));
+ var (coordinator, deploys, backups) = Coordinator(host);
+
+ var result = await coordinator.ReviveAsync(stackId, Ct);
+
+ Assert.Equal(RevivalStatus.Done, result!.Status);
+ Assert.Contains("nothing was restored", result.Detail);
+ Assert.Equal([stackId], deploys.Enqueued);
+ Assert.Empty(backups.Restored);
+ }
+
+ [Fact]
+ public async Task AFailedDeployStopsBeforeTheRestore() {
+ // Restoring into volumes a failed deploy never created would either fail confusingly or, worse,
+ // succeed against the wrong ones.
+ using var host = Start();
+ var stackId = await AddStackAsync(host, "blog");
+ await SeedChecklistAsync(host, (stackId, "blog"));
+ await SeedArchiveAsync("blog");
+ var (coordinator, _, backups) = Coordinator(host, deployStatus: "failed");
+
+ var result = await coordinator.ReviveAsync(stackId, Ct);
+
+ Assert.Equal(RevivalStatus.Failed, result!.Status);
+ Assert.Contains("The deploy failed", result.Detail);
+ Assert.Empty(backups.Restored);
+ }
+
+ [Fact]
+ public async Task AFailedRestoreIsReportedAsSuch() {
+ using var host = Start();
+ var stackId = await AddStackAsync(host, "blog");
+ await SeedChecklistAsync(host, (stackId, "blog"));
+ await SeedArchiveAsync("blog");
+ var (coordinator, _, _) = Coordinator(host, restoreStatus: "failed");
+
+ var result = await coordinator.ReviveAsync(stackId, Ct);
+
+ Assert.Equal(RevivalStatus.Failed, result!.Status);
+ Assert.Contains("The restore failed", result.Detail);
+ }
+
+ [Fact]
+ public async Task ReviveAllTakesThePendingAndFailedOnesAndLeavesTheRest() {
+ using var host = Start();
+ var blog = await AddStackAsync(host, "blog");
+ var shop = await AddStackAsync(host, "shop");
+ var done = await AddStackAsync(host, "already-done");
+ await SeedChecklistAsync(host, (blog, "blog"), (shop, "shop"), (done, "already-done"));
+
+ // Mark one done and one failed, as a half-finished pass would have left them.
+ await using (var scope = host.Services.CreateAsyncScope()) {
+ var settings = scope.ServiceProvider.GetRequiredService();
+ var checklist = (await StackRevivalState.LoadAsync(settings, Ct))!;
+ await checklist
+ .With(new RevivalStack(done, "already-done", RevivalStatus.Done, "Deployed."))
+ .With(new RevivalStack(shop, "shop", RevivalStatus.Failed, "The deploy failed."))
+ .SaveAsync(settings, Ct);
+ }
+
+ var (coordinator, deploys, _) = Coordinator(host);
+ var revived = await coordinator.ReviveAllAsync(Ct);
+
+ Assert.Equal(2, revived);
+ // The pending one and the failed one — a failed stack is exactly what "revive all" should retry.
+ Assert.Equal([blog, shop], deploys.Enqueued.Order());
+ var checklistAfter = await coordinator.LoadAsync(Ct);
+ Assert.All(checklistAfter!.Stacks, s => Assert.Equal(RevivalStatus.Done, s.Status));
+ }
+
+ [Fact]
+ public async Task ASkippedStackIsLeftAloneByReviveAll() {
+ using var host = Start();
+ var blog = await AddStackAsync(host, "blog");
+ await SeedChecklistAsync(host, (blog, "blog"));
+ var (coordinator, deploys, _) = Coordinator(host);
+
+ var skipped = await coordinator.SkipAsync(blog, Ct);
+ Assert.Equal(RevivalStatus.Skipped, skipped!.Status);
+
+ Assert.Equal(0, await coordinator.ReviveAllAsync(Ct));
+ Assert.Empty(deploys.Enqueued);
+ }
+
+ [Fact]
+ public async Task DismissingKeepsTheChecklistButStopsOfferingIt() {
+ // The record of what happened is the audit trail; this is only the prompt.
+ using var host = Start();
+ var blog = await AddStackAsync(host, "blog");
+ await SeedChecklistAsync(host, (blog, "blog"));
+ var (coordinator, _, _) = Coordinator(host);
+
+ await coordinator.DismissAsync(Ct);
+
+ var checklist = await coordinator.LoadAsync(Ct);
+ Assert.NotNull(checklist);
+ Assert.True(checklist.Dismissed);
+ }
+
+ [Fact]
+ public async Task AStackThatIsNotOnTheChecklistIsNotRevived() {
+ using var host = Start();
+ var blog = await AddStackAsync(host, "blog");
+ await SeedChecklistAsync(host, (blog, "blog"));
+ var (coordinator, deploys, _) = Coordinator(host);
+
+ Assert.Null(await coordinator.ReviveAsync(stackId: 9999, Ct));
+ Assert.Empty(deploys.Enqueued);
+ }
+}
+
+/// A deploy queue whose runs are already over, with an outcome the test chose.
+internal sealed class TerminalDeployQueue(IServiceProvider services, string status)
+ : DeployQueueService(
+ services.GetRequiredService(),
+ services.GetRequiredService(),
+ services.GetRequiredService(),
+ services.GetRequiredService(),
+ services.GetRequiredService(),
+ services.GetRequiredService(),
+ services.GetRequiredService>(),
+ NullLogger.Instance) {
+ private readonly List _enqueued = [];
+
+ /// The stacks a deploy was asked for, in call order.
+ public IReadOnlyList Enqueued {
+ get { lock (_enqueued) return [.. _enqueued]; }
+ }
+
+ public override DeployEnqueueResult Enqueue(
+ int stackId, string triggeredBy, IReadOnlyList? removeVolumes = null) {
+ using var scope = services.GetRequiredService().CreateScope();
+ var db = scope.ServiceProvider.GetRequiredService();
+ var deployEvent = new DeployEvent {
+ StackId = stackId, TriggeredBy = triggeredBy, Status = status,
+ StartedAt = DateTimeOffset.UtcNow, FinishedAt = DateTimeOffset.UtcNow,
+ };
+ db.DeployEvents.Add(deployEvent);
+ db.SaveChanges();
+ lock (_enqueued) _enqueued.Add(stackId);
+ return new DeployEnqueueResult(deployEvent.Id, status);
+ }
+}
+
+/// A backup queue whose restores are already over, with an outcome the test chose.
+internal sealed class TerminalBackupQueue(IServiceProvider services, string status)
+ : BackupQueueService(
+ services.GetRequiredService(),
+ services.GetRequiredService(),
+ services.GetRequiredService(),
+ services.GetRequiredService(),
+ services.GetRequiredService(),
+ NullLogger.Instance) {
+ private readonly List<(int StackId, string FileName)> _restored = [];
+
+ /// Every restore asked for, in call order.
+ public IReadOnlyList<(int StackId, string FileName)> Restored {
+ get { lock (_restored) return [.. _restored]; }
+ }
+
+ public override BackupEnqueueResult? TryEnqueueRestore(int stackId, string fileName) {
+ using var scope = services.GetRequiredService().CreateScope();
+ var db = scope.ServiceProvider.GetRequiredService();
+ var backupEvent = new BackupEvent {
+ StackId = stackId, TriggeredBy = BackupTriggers.Restore, Status = status,
+ StartedAt = DateTimeOffset.UtcNow, FinishedAt = DateTimeOffset.UtcNow,
+ };
+ db.BackupEvents.Add(backupEvent);
+ db.SaveChanges();
+ lock (_restored) _restored.Add((stackId, fileName));
+ return new BackupEnqueueResult(backupEvent.Id, status);
+ }
+}
diff --git a/src/Watchtower.Application.Tests/TestBundles.cs b/src/Watchtower.Application.Tests/TestBundles.cs
new file mode 100644
index 0000000..e8ad4d8
--- /dev/null
+++ b/src/Watchtower.Application.Tests/TestBundles.cs
@@ -0,0 +1,192 @@
+using System.Formats.Tar;
+using System.IO.Compression;
+using System.Security.Cryptography;
+using System.Text;
+using System.Text.Json;
+using Watchtower.Application.Services;
+
+namespace Watchtower.Application.Tests;
+
+///
+/// Builds full backup bundles in memory, so the restore's refusals can be tested one at a time against
+/// a bundle that is correct in every other respect.
+///
+///
+/// The archives are assembled here rather than produced by a real backup run: the format is what the
+/// restore reads, so writing it directly is the honest way to test reading it — and a real run would
+/// need a Docker daemon and a second PostgreSQL to produce one byte of it.
+///
+internal static class TestBundles {
+ /// The passphrase every bundle this class builds is encrypted with.
+ public const string Passphrase = "bundle-passphrase";
+
+ /// What a valid instance archive's dump file is called inside it.
+ private const string DumpFile = "_dumps/watchtower.sql";
+
+ ///
+ /// An encrypted, gzipped instance archive — the real layout: a manifest and a dump under
+ /// backup/.
+ ///
+ /// The dump's content. Its bytes are never parsed, only carried.
+ /// False writes an archive carrying no dump at all.
+ public static byte[] InstanceArchive(string sql = "-- pg_dumpall output", bool withDump = true) {
+ var manifest = InstanceBackupService.BuildManifest(
+ "source", new SelfPostgresTarget(
+ "abc", "watchtower-postgres-1", "postgres:18-alpine", "postgres", "watchtower", "watchtower"),
+ DateTimeOffset.UtcNow,
+ new BackupService.BackupDumpEntry(
+ "watchtower", DumpEngine.Postgres, DumpFile, "postgres:18-alpine", "watchtower",
+ "watchtower-postgres-1", [], ["watchtower"], sql.Length),
+ // The archive's *own* manifest. Nothing reads this id — the restore decides on the bundle
+ // manifest's, which Build takes from the caller — so it is deliberately not a real one.
+ lastMigrationId: "00000000000000_TestArchive");
+
+ using var plain = new MemoryStream();
+ using (var gzip = new GZipStream(plain, CompressionLevel.Fastest, leaveOpen: true))
+ using (var writer = new TarWriter(gzip, TarEntryFormat.Pax, leaveOpen: true)) {
+ WriteEntry(writer, "backup/backup-manifest.json", Encoding.UTF8.GetBytes(manifest));
+ if (withDump) WriteEntry(writer, $"backup/{DumpFile}", Encoding.UTF8.GetBytes(sql));
+ }
+
+ using var encrypted = new MemoryStream();
+ var cipher = BackupEncryption.CreateEncryptingStream(encrypted, Passphrase);
+ plain.Position = 0;
+ plain.CopyTo(cipher);
+ cipher.Dispose();
+ return encrypted.ToArray();
+ }
+
+ /// How one bundle differs from the valid one, for a test that pins a single refusal.
+ /// Null keeps the migration this build knows.
+ /// The secret the source instance's keys are under.
+ /// Null keeps the current format version.
+ /// Records a checksum the archive does not have.
+ /// Lists the archive in the manifest but leaves it out of the tar.
+ /// Encrypts the archive with a passphrase the secrets file does not name.
+ /// Writes an instance archive that carries no dump.
+ /// Stack names to carry archives for.
+ /// Stack names to describe with no archive.
+ internal sealed record Options(
+ string? LastMigrationId = null,
+ string? KeyProtectionSecret = null,
+ int? BundleFormatVersion = null,
+ bool CorruptInstanceDigest = false,
+ bool OmitInstanceArchive = false,
+ bool WrongPassphrase = false,
+ bool WithoutDump = false,
+ IReadOnlyList? Stacks = null,
+ IReadOnlyList? MissingStacks = null);
+
+ /// Builds one bundle tar.
+ /// The migration this build actually knows, so the default is valid.
+ /// How this bundle should differ from a valid one.
+ public static byte[] Build(string? lastMigrationId, Options? options = null) {
+ var o = options ?? new Options();
+ var instanceBytes = o.WrongPassphrase
+ ? Reencrypt(InstanceArchive(withDump: !o.WithoutDump), "a-different-passphrase")
+ : InstanceArchive(withDump: !o.WithoutDump);
+ var instanceEntry = $"{BackupBundle.InstanceDirectory}/watchtower_20260826T033000Z.tar.gz.enc";
+ var instance = new BundleArchive(
+ instanceEntry, "source/_watchtower/watchtower_20260826T033000Z.tar.gz.enc",
+ instanceBytes.Length,
+ o.CorruptInstanceDigest ? new string('0', 64) : Sha256(instanceBytes),
+ DateTimeOffset.UtcNow, Encrypted: true);
+
+ var members = new List<(string Entry, byte[] Content)>();
+ if (!o.OmitInstanceArchive) members.Add((instanceEntry, instanceBytes));
+
+ var stacks = new List();
+ foreach (var (name, index) in (o.Stacks ?? []).Select((n, i) => (n, i))) {
+ var content = Encoding.UTF8.GetBytes($"archive-for-{name}");
+ var storagePath = $"source/{name}/{name}_2026082{index}T033000Z.tar.gz.enc";
+ var entry = $"{BackupBundle.StacksDirectory}/{storagePath}";
+ members.Add((entry, content));
+ stacks.Add(new BundleStack(
+ index + 1, name, name,
+ new BundleArchive(
+ entry, storagePath, content.Length, Sha256(content), DateTimeOffset.UtcNow, true),
+ null));
+ }
+ foreach (var (name, index) in (o.MissingStacks ?? []).Select((n, i) => (n, i)))
+ stacks.Add(new BundleStack(
+ 100 + index, name, name, null, "no archive on the backup storage"));
+
+ var manifest = new BundleManifest(
+ o.BundleFormatVersion ?? BackupBundle.FormatVersion,
+ "watchtower",
+ DateTimeOffset.UtcNow,
+ "source",
+ "9.9.9-test",
+ o.LastMigrationId ?? lastMigrationId,
+ KeyProtectionSecretConfigured: o.KeyProtectionSecret is { Length: > 0 },
+ instance,
+ stacks);
+
+ var secrets = new BundleSecrets(
+ SecretsFormatVersion: 1,
+ KeyProtectionSecret: o.KeyProtectionSecret,
+ BackupEncryptionPassphrase: Passphrase,
+ BackupInstanceName: "source",
+ Storage: new BundleStorageSecrets(
+ "local", new BundleSftpSecrets(null, 22, null, null, null, null, ""), "/backups"));
+
+ using var tar = new MemoryStream();
+ using (var writer = new TarWriter(tar, TarEntryFormat.Pax, leaveOpen: true)) {
+ WriteEntry(writer, BackupBundle.ManifestEntry, Json(manifest));
+ WriteEntry(writer, BackupBundle.SecretsEntry, Json(secrets));
+ foreach (var (entry, content) in members) WriteEntry(writer, entry, content);
+ }
+ return tar.ToArray();
+ }
+
+ ///
+ /// A tar whose entry name would escape the directory it is unpacked into. Both shapes are here: a
+ /// leading ../, and one buried after a legitimate-looking segment — the second is what a
+ /// prefix-stripping guard would miss.
+ ///
+ public static byte[] TraversalBundle(string entryName = "stacks/../../escaped.json") {
+ using var tar = new MemoryStream();
+ using (var writer = new TarWriter(tar, TarEntryFormat.Pax, leaveOpen: true))
+ WriteEntry(writer, entryName, Encoding.UTF8.GetBytes("{}"));
+ return tar.ToArray();
+ }
+
+ /// A tar with no manifest in it at all.
+ public static byte[] NotABundle() {
+ using var tar = new MemoryStream();
+ using (var writer = new TarWriter(tar, TarEntryFormat.Pax, leaveOpen: true))
+ WriteEntry(writer, "readme.txt", Encoding.UTF8.GetBytes("not a bundle"));
+ return tar.ToArray();
+ }
+
+ private static byte[] Reencrypt(byte[] archive, string passphrase) {
+ // Decrypt with the class passphrase and re-encrypt with another, so the bytes are a real
+ // archive that simply cannot be opened with what the secrets file names.
+ using var source = new MemoryStream(archive);
+ using var plain = new MemoryStream();
+ var decrypting = BackupEncryption.CreateDecryptingStream(source, Passphrase);
+ decrypting.CopyTo(plain);
+ decrypting.Dispose();
+
+ using var result = new MemoryStream();
+ var cipher = BackupEncryption.CreateEncryptingStream(result, passphrase);
+ plain.Position = 0;
+ plain.CopyTo(cipher);
+ cipher.Dispose();
+ return result.ToArray();
+ }
+
+ private static void WriteEntry(TarWriter writer, string name, byte[] content) {
+ using var data = new MemoryStream(content);
+ writer.WriteEntry(new PaxTarEntry(TarEntryType.RegularFile, name) {
+ Mode = UnixFileMode.UserRead | UnixFileMode.UserWrite,
+ DataStream = data,
+ });
+ }
+
+ private static byte[] Json(T value) =>
+ JsonSerializer.SerializeToUtf8Bytes(value, BackupBundle.JsonOptions);
+
+ private static string Sha256(byte[] content) =>
+ Convert.ToHexStringLower(SHA256.HashData(content));
+}
diff --git a/src/Watchtower.Application/Config/WatchtowerOptions.cs b/src/Watchtower.Application/Config/WatchtowerOptions.cs
index 8b27f42..2af6116 100644
--- a/src/Watchtower.Application/Config/WatchtowerOptions.cs
+++ b/src/Watchtower.Application/Config/WatchtowerOptions.cs
@@ -255,6 +255,22 @@ public sealed record BackupOptions {
/// The default for .
public const int DefaultStopTimeoutSeconds = 5;
+ ///
+ /// Whether the schedule also backs up Watchtower's own database (ADR-0027), as a dumps-only archive
+ /// under . On by default: an instance whose
+ /// stacks are backed up but whose own state is not can restore every stack's data and none of the
+ /// configuration that deploys it. Needs — the dump carries every
+ /// database role's password hash — and a PostgreSQL that runs as a container on this daemon.
+ ///
+ public bool IncludeSelf { get; init; } = true;
+
+ ///
+ /// The container running Watchtower's own PostgreSQL, by name or id, when
+ /// cannot work it out — several database containers on
+ /// one daemon and none of them answering to the connection string's host. Blank = detect it.
+ ///
+ public string? SelfPostgresContainer { get; init; }
+
/// Storage backend the archives are shipped to: sftp (default) or local.
public string Provider { get; init; } = "sftp";
diff --git a/src/Watchtower.Application/Config/WatchtowerSettingPaths.cs b/src/Watchtower.Application/Config/WatchtowerSettingPaths.cs
index 04b8a20..e127ca2 100644
--- a/src/Watchtower.Application/Config/WatchtowerSettingPaths.cs
+++ b/src/Watchtower.Application/Config/WatchtowerSettingPaths.cs
@@ -108,6 +108,30 @@ public static class WatchtowerSettingPaths {
public const string BackupEncryptionPassphrase = "Watchtower:Backup:EncryptionPassphrase";
public const string BackupHelperImage = "Watchtower:Backup:HelperImage";
public const string BackupProvider = "Watchtower:Backup:Provider";
+ /// Whether the schedule also dumps Watchtower's own database (ADR-0027).
+ public const string BackupIncludeSelf = "Watchtower:Backup:IncludeSelf";
+ /// Explicit container for Watchtower's own PostgreSQL, when detection cannot pick one.
+ public const string BackupSelfPostgresContainer = "Watchtower:Backup:SelfPostgresContainer";
+ ///
+ /// The instance self-backup's schedule cursor — the due time of the last window that was enqueued,
+ /// the stackless counterpart of . Written by the
+ /// schedule tick, never offered in the UI.
+ ///
+ public const string BackupSelfLastScheduledAt = "Watchtower:Backup:SelfLastScheduledAt";
+
+ ///
+ /// The nonce an in-flight instance restore writes into the database it is about to replace
+ /// (ADR-0027 §5). After the restart its absence is the proof that the replay committed —
+ /// nothing else can remove it, because nothing else knows it. Never offered in the UI.
+ ///
+ public const string RestorePendingNonce = "Watchtower:Restore:PendingNonce";
+
+ ///
+ /// The post-restore recovery checklist (ADR-0027 §6), as JSON. A settings row because there is at
+ /// most one, it has to survive the restart the restore itself causes, and a table for it would be a
+ /// schema change carried by every instance that never restores anything.
+ ///
+ public const string RestoreRecovery = "Watchtower:Restore:Recovery";
public const string BackupSftpHost = "Watchtower:Backup:Sftp:Host";
public const string BackupSftpPort = "Watchtower:Backup:Sftp:Port";
public const string BackupSftpUsername = "Watchtower:Backup:Sftp:Username";
diff --git a/src/Watchtower.Application/Entities/BackupEvent.cs b/src/Watchtower.Application/Entities/BackupEvent.cs
index 8e00d78..1c9fc2f 100644
--- a/src/Watchtower.Application/Entities/BackupEvent.cs
+++ b/src/Watchtower.Application/Entities/BackupEvent.cs
@@ -19,10 +19,19 @@ public static class BackupStatuses {
public const string Failed = "failed";
}
-/// Records the status and outcome of a single stack backup run (ADR-0016).
+///
+/// Records the status and outcome of a single backup run — a stack's (ADR-0016) or the instance's own
+/// (ADR-0027).
+///
public sealed class BackupEvent {
public int Id { get; set; }
- public int StackId { get; set; }
+
+ ///
+ /// The stack this run belongs to, or null for a run that has no stack: the instance self-backup and
+ /// the bundle export back up Watchtower itself (ADR-0027), so there is no stack to point at. The
+ /// history views and the single-flight queue both read this as the run's kind.
+ ///
+ public int? StackId { get; set; }
public Stack? Stack { get; set; }
/// Who triggered the backup: "manual" or "schedule".
public required string TriggeredBy { get; set; }
diff --git a/src/Watchtower.Application/Modules/Backups/BackupScheduleJob.cs b/src/Watchtower.Application/Modules/Backups/BackupScheduleJob.cs
index a31234c..0425d7f 100644
--- a/src/Watchtower.Application/Modules/Backups/BackupScheduleJob.cs
+++ b/src/Watchtower.Application/Modules/Backups/BackupScheduleJob.cs
@@ -1,5 +1,7 @@
using System.Collections.Concurrent;
+using System.Globalization;
using Elarion.Abstractions.Scheduling;
+using Elarion.Settings;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
@@ -33,6 +35,7 @@ namespace Watchtower.Application.Modules.Backups;
public sealed class BackupScheduleJob(
WatchtowerDbContext db,
BackupQueueService queue,
+ ISettingsManager settings,
IOptionsMonitor options,
TimeProvider timeProvider,
ILogger logger) {
@@ -43,6 +46,9 @@ public sealed class BackupScheduleJob(
// not move for a skip, so without this the same skip would be logged every minute until the next run.
private static readonly ConcurrentDictionary LoggedMisses = new();
+ /// The instance self-backup's missed window, deduplicated like .
+ private static DateTimeOffset? _loggedInstanceMiss;
+
[ScheduledJob(JobName, FixedRate = "1m", Overlap = ScheduledJobOverlap.Skip)]
public async ValueTask RunAsync(CancellationToken ct) =>
await TickAsync(timeProvider.GetUtcNow(), TimeZoneInfo.Local, ct);
@@ -64,6 +70,12 @@ public async ValueTask TickAsync(DateTimeOffset now, TimeZoneInfo timeZone,
}
var grace = BackupSchedule.ResolveMisfireGrace(backup);
+ // Watchtower's own database runs on the instance-wide expression — there is one instance, so
+ // there is nothing to override it with (ADR-0027).
+ var enqueuedInstance = globalCron is null
+ ? 0
+ : await TickInstanceAsync(backup, globalCron, now, grace, timeZone, ct);
+
// The ladder in SQL: a stack that says yes, or a tenant that says nothing over a template that
// says yes. Kept as a predicate rather than "load everything and resolve in memory" because this
// runs once a minute against every stack on the box. `BackupPolicyResolver` is still the only
@@ -74,9 +86,9 @@ public async ValueTask TickAsync(DateTimeOffset now, TimeZoneInfo timeZone,
|| (s.BackupEnabled == null && s.Template != null && s.Template.BackupEnabled == true))
.OrderBy(s => s.Name)
.ToListAsync(ct);
- if (stacks.Count == 0) return 0;
+ if (stacks.Count == 0) return enqueuedInstance;
- var enqueued = 0;
+ var enqueued = enqueuedInstance;
foreach (var stack in stacks) {
var policy = BackupPolicyResolver.Resolve(stack, stack.Template);
if (!policy.Enabled) continue;
@@ -119,4 +131,74 @@ public async ValueTask TickAsync(DateTimeOffset now, TimeZoneInfo timeZone,
if (enqueued > 0) await db.SaveChangesAsync(ct);
return enqueued;
}
+
+ ///
+ /// The same window evaluation for Watchtower's own database (ADR-0027). Returns 1 when it enqueued.
+ ///
+ ///
+ /// The cursor is a settings row rather than a column, because there is no instance table to put it on
+ /// — , read through the settings manager
+ /// rather than the options monitor so a value written last tick is certainly seen this tick (the
+ /// configuration snapshot reloads asynchronously, and a stale cursor would fire the window twice).
+ ///
+ private async ValueTask TickInstanceAsync(
+ BackupOptions backup, CronExpression cron, DateTimeOffset now, TimeSpan grace,
+ TimeZoneInfo timeZone, CancellationToken ct) {
+ if (!backup.IncludeSelf) return 0;
+
+ var cursor = ParseCursor(await settings.GetStringAsync(
+ WatchtowerSettingPaths.BackupSelfLastScheduledAt, SettingsScope.Global, ct));
+
+ ScheduleDecision decision;
+ try {
+ decision = BackupSchedule.Evaluate(cron, now, cursor, grace, timeZone);
+ } catch (InvalidOperationException ex) {
+ logger.LogWarning(ex, "The backup schedule has no upcoming window for Watchtower's own database");
+ return 0;
+ }
+
+ if (decision.MissedAt is { } missed && _loggedInstanceMiss != missed) {
+ _loggedInstanceMiss = missed;
+ logger.LogInformation(
+ "Backup window {Window:o} for Watchtower's own database was missed (older than the {Grace} "
+ + "misfire grace); skipped", missed, grace);
+ }
+
+ if (decision.DueAt is not { } due) return 0;
+
+ // Refused rather than run: without a passphrase the run would fail every night and fill the
+ // history with failures, and the dump it would have written carries every role's password hash.
+ // The cursor still moves — a window that was evaluated is a window that is over, and leaving it
+ // open would re-fire it (and re-log) on every tick until the passphrase appears.
+ await StoreCursorAsync(due, ct);
+ if (string.IsNullOrEmpty(backup.EncryptionPassphrase)) {
+ logger.LogWarning(
+ "Backup window {Window:o} open for Watchtower's own database, but no encryption passphrase "
+ + "is configured — skipping. Set one under Settings → Backups.", due);
+ return 0;
+ }
+
+ var result = queue.EnqueueInstance(BackupTriggers.Schedule);
+ logger.LogInformation(
+ "Backup window {Window:o} open — enqueued Watchtower's own database (event {EventId})",
+ due, result.BackupEventId);
+ return 1;
+ }
+
+ /// Writes the instance cursor round-trip formatted, so it parses back exactly.
+ private async ValueTask StoreCursorAsync(DateTimeOffset due, CancellationToken ct) =>
+ await settings.SetStringAsync(
+ WatchtowerSettingPaths.BackupSelfLastScheduledAt, due.UtcDateTime.ToString("O"),
+ SettingsScope.Global, expectedVersion: null, ct);
+
+ ///
+ /// The stored cursor, or null when there is none — or when it is unreadable, which is treated as
+ /// "never ran": the misfire grace then bounds how far back the first window can be.
+ ///
+ private static DateTimeOffset? ParseCursor(string? stored) =>
+ DateTimeOffset.TryParse(
+ stored, CultureInfo.InvariantCulture,
+ DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out var cursor)
+ ? cursor
+ : null;
}
diff --git a/src/Watchtower.Application/Modules/Backups/BackupsModule.cs b/src/Watchtower.Application/Modules/Backups/BackupsModule.cs
index 6bc5d71..ca268cf 100644
--- a/src/Watchtower.Application/Modules/Backups/BackupsModule.cs
+++ b/src/Watchtower.Application/Modules/Backups/BackupsModule.cs
@@ -34,7 +34,10 @@ public sealed record BackupConfigDto(
string Provider,
BackupSftpConfigDto Sftp,
string LocalBasePath,
- string[] PinnedPaths) {
+ string[] PinnedPaths,
+ bool IncludeSelf,
+ string? SelfPostgresContainer,
+ string InstanceDirectory) {
internal static BackupConfigDto From(BackupOptions backup, EnvironmentSettingPins pins) => new(
Enabled: backup.Enabled,
Cron: BackupSchedule.ResolveGlobalExpression(backup),
@@ -53,7 +56,12 @@ public sealed record BackupConfigDto(
HasPrivateKey: !string.IsNullOrEmpty(backup.Sftp.PrivateKey),
BasePath: backup.Sftp.BasePath),
LocalBasePath: backup.Local.BasePath,
- PinnedPaths: ResolvePinnedPaths(pins));
+ PinnedPaths: ResolvePinnedPaths(pins),
+ IncludeSelf: backup.IncludeSelf,
+ SelfPostgresContainer: backup.SelfPostgresContainer,
+ // Shown, not configured: the operator needs to know where to look on the storage, and the layout
+ // is derived from the instance name rather than settable (ADR-0027).
+ InstanceDirectory: BackupNaming.InstanceDirectory(backup.ResolveInstanceName()));
///
/// The pinned paths, with the legacy Backup:Time env var reported as pinning the schedule
@@ -77,18 +85,44 @@ public sealed record BackupSftpConfigDto(
bool HasPrivateKey,
string BasePath);
-/// One backup run for the history views (per stack and instance-wide).
+/// One backup run for the history views (per stack, per product, and instance-wide).
+/// The event's id.
+///
+/// The stack the run belongs to, or null for a run that backed up Watchtower itself (ADR-0027).
+///
+/// The stack's name, or null when there is no stack — see .
+/// Who or what started the run (see ).
+/// "queued", "running", "success", or "failed".
+/// Provider-relative path of the archive, once it has one.
+/// Size of the uploaded archive.
+/// The run log.
+/// When the run started.
+/// When it reached a terminal state, or null while it has not.
+///
+/// stack or instance: what this run backed up. Derived from
+/// so the UI can branch on a word rather than on a null.
+///
public sealed record BackupEventDto(
int Id,
- int StackId,
- string StackName,
+ int? StackId,
+ string? StackName,
string TriggeredBy,
string Status,
string? RemotePath,
long? SizeBytes,
string? Output,
DateTimeOffset StartedAt,
- DateTimeOffset? FinishedAt);
+ DateTimeOffset? FinishedAt,
+ string Kind);
+
+/// The values takes.
+public static class BackupEventKinds {
+ /// A stack's volumes and database dumps (ADR-0016).
+ public const string Stack = "stack";
+
+ /// Watchtower's own database (ADR-0027).
+ public const string Instance = "instance";
+}
///
/// A stack's backup participation: schedule opt-in, the stop-for-snapshot flag, how its stateful
@@ -398,6 +432,81 @@ public sealed record BackupRemoteFileDto(string Name, long SizeBytes, DateTimeOf
/// Returned immediately after a run is enqueued; the event tracks progress.
public sealed record BackupRunAcceptedDto(int BackupEventId, string Status);
+///
+/// The full backup bundle staged for download (ADR-0027 §4). Never carries a path: the file lives in
+/// Watchtower's own container and is only ever reached through GET /api/instance/bundle.
+///
+/// The name it downloads as.
+/// Its size.
+/// When the export finished.
+/// How many stack archives it carries.
+///
+/// How many stacks it describes but has no archive for — a stack that has never been backed up. Its
+/// definition still comes back with the database; only its data is absent.
+///
+public sealed record BackupBundleDto(
+ string FileName, long SizeBytes, DateTimeOffset CreatedAtUtc, int StackCount, int MissingStackCount);
+
+/// One stack on the post-restore recovery checklist (ADR-0027 §6).
+/// Its id in the restored database.
+/// Its name.
+///
+/// pending, deploying, restoring, done, failed or skipped.
+///
+/// What last happened to it, in a sentence.
+/// The deploy this revival started, for a link to its log.
+/// The restore this revival started, for a link to its log.
+public sealed record RecoveryStackDto(
+ int StackId, string Name, string Status, string? Detail, int? DeployEventId, int? BackupEventId) {
+ internal static RecoveryStackDto From(RevivalStack stack) => new(
+ stack.StackId, stack.Name, stack.Status.ToString().ToLowerInvariant(), stack.Detail,
+ stack.DeployEventId, stack.BackupEventId);
+}
+
+/// The checklist an operator works through after an instance restore (ADR-0027 §6).
+public sealed record RecoveryChecklistDto(
+ DateTimeOffset RestoredAtUtc,
+ string SourceInstance,
+ bool Dismissed,
+ IReadOnlyList Stacks) {
+ internal static RecoveryChecklistDto From(StackRevivalState state) => new(
+ state.RestoredAtUtc, state.SourceInstance, state.Dismissed,
+ [.. state.Stacks.Select(RecoveryStackDto.From)]);
+}
+
+/// One reason a bundle cannot be restored here, or one caveat about doing so (ADR-0027 §5).
+/// A stable key the UI can branch on, e.g. key-protection-secret.
+/// The operator-facing sentence, which always names what to do about it.
+public sealed record RestoreFindingDto(string Code, string Message) {
+ internal static RestoreFindingDto From(RestoreFinding finding) => new(finding.Code, finding.Message);
+}
+
+///
+/// An uploaded bundle and this instance's verdict on it (ADR-0027 §5) — everything the wizard needs to
+/// say what would happen, before anything does.
+///
+public sealed record RestoreValidationDto(
+ bool CanRestore,
+ IReadOnlyList Blocking,
+ IReadOnlyList Warnings,
+ string InstanceName,
+ string AppVersion,
+ DateTimeOffset CreatedAtUtc,
+ int StackCount,
+ int MissingStackCount,
+ IReadOnlyList StackNames) {
+ internal static RestoreValidationDto From(RestoreValidation validation) => new(
+ validation.CanRestore,
+ [.. validation.Blocking.Select(RestoreFindingDto.From)],
+ [.. validation.Warnings.Select(RestoreFindingDto.From)],
+ validation.InstanceName,
+ validation.AppVersion,
+ validation.CreatedAtUtc,
+ validation.StackCount,
+ validation.MissingStackCount,
+ validation.StackNames);
+}
+
/// JSON serializer context for Backups module request/response types.
[JsonSourceGenerationOptions(
PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase,
@@ -418,6 +527,33 @@ public sealed record BackupRunAcceptedDto(int BackupEventId, string Status);
[JsonSerializable(typeof(ListBackupEvents.Response), TypeInfoPropertyName = "ListBackupEventsResponse")]
[JsonSerializable(typeof(RunBackup.Command), TypeInfoPropertyName = "RunBackupCommand")]
[JsonSerializable(typeof(RunBackup.Response), TypeInfoPropertyName = "RunBackupResponse")]
+[JsonSerializable(typeof(RunInstanceBackup.Command), TypeInfoPropertyName = "RunInstanceBackupCommand")]
+[JsonSerializable(typeof(RunInstanceBackup.Response), TypeInfoPropertyName = "RunInstanceBackupResponse")]
+[JsonSerializable(typeof(ListInstanceBackups.Query), TypeInfoPropertyName = "ListInstanceBackupsQuery")]
+[JsonSerializable(typeof(ListInstanceBackups.Response), TypeInfoPropertyName = "ListInstanceBackupsResponse")]
+[JsonSerializable(typeof(BackupBundleDto))]
+[JsonSerializable(typeof(ExportBackupBundle.Command), TypeInfoPropertyName = "ExportBackupBundleCommand")]
+[JsonSerializable(typeof(ExportBackupBundle.Response), TypeInfoPropertyName = "ExportBackupBundleResponse")]
+[JsonSerializable(typeof(GetBundleStatus.Query), TypeInfoPropertyName = "GetBundleStatusQuery")]
+[JsonSerializable(typeof(GetBundleStatus.Response), TypeInfoPropertyName = "GetBundleStatusResponse")]
+[JsonSerializable(typeof(RestoreFindingDto))]
+[JsonSerializable(typeof(RestoreValidationDto))]
+[JsonSerializable(typeof(GetInstanceRestoreStatus.Query), TypeInfoPropertyName = "GetInstanceRestoreStatusQuery")]
+[JsonSerializable(typeof(GetInstanceRestoreStatus.Response), TypeInfoPropertyName = "GetInstanceRestoreStatusResponse")]
+[JsonSerializable(typeof(StartInstanceRestore.Command), TypeInfoPropertyName = "StartInstanceRestoreCommand")]
+[JsonSerializable(typeof(StartInstanceRestore.Response), TypeInfoPropertyName = "StartInstanceRestoreResponse")]
+[JsonSerializable(typeof(RecoveryStackDto))]
+[JsonSerializable(typeof(RecoveryChecklistDto))]
+[JsonSerializable(typeof(GetRecoveryChecklist.Query), TypeInfoPropertyName = "GetRecoveryChecklistQuery")]
+[JsonSerializable(typeof(GetRecoveryChecklist.Response), TypeInfoPropertyName = "GetRecoveryChecklistResponse")]
+[JsonSerializable(typeof(ReviveStack.Command), TypeInfoPropertyName = "ReviveStackCommand")]
+[JsonSerializable(typeof(ReviveStack.Response), TypeInfoPropertyName = "ReviveStackResponse")]
+[JsonSerializable(typeof(ReviveAllStacks.Command), TypeInfoPropertyName = "ReviveAllStacksCommand")]
+[JsonSerializable(typeof(ReviveAllStacks.Response), TypeInfoPropertyName = "ReviveAllStacksResponse")]
+[JsonSerializable(typeof(SkipRecoveryStack.Command), TypeInfoPropertyName = "SkipRecoveryStackCommand")]
+[JsonSerializable(typeof(SkipRecoveryStack.Response), TypeInfoPropertyName = "SkipRecoveryStackResponse")]
+[JsonSerializable(typeof(DismissRecovery.Command), TypeInfoPropertyName = "DismissRecoveryCommand")]
+[JsonSerializable(typeof(DismissRecovery.Response), TypeInfoPropertyName = "DismissRecoveryResponse")]
[JsonSerializable(typeof(BackupRemoteFileDto))]
[JsonSerializable(typeof(ListRemoteBackups.Query), TypeInfoPropertyName = "ListRemoteBackupsQuery")]
[JsonSerializable(typeof(ListRemoteBackups.Response), TypeInfoPropertyName = "ListRemoteBackupsResponse")]
diff --git a/src/Watchtower.Application/Modules/Backups/Handlers/ExportBackupBundle.cs b/src/Watchtower.Application/Modules/Backups/Handlers/ExportBackupBundle.cs
new file mode 100644
index 0000000..4806eb1
--- /dev/null
+++ b/src/Watchtower.Application/Modules/Backups/Handlers/ExportBackupBundle.cs
@@ -0,0 +1,51 @@
+using Elarion.Abstractions.Authorization;
+using Elarion.Abstractions.Identity;
+using Microsoft.Extensions.Options;
+using Watchtower.Application.Config;
+using Watchtower.Application.Services;
+
+namespace Watchtower.Application.Modules.Backups.Handlers;
+
+///
+/// Builds a full backup bundle (ADR-0027 §4) — a fresh dump of Watchtower's own database plus the
+/// newest archive of every stack, in one tar — and stages it for download at
+/// GET /api/instance/bundle. Returns the tracking event immediately; the export runs on the
+/// single-flight backup queue, since it dumps and downloads for as long as that takes.
+///
+///
+/// Admin-only, and audited on both sides: the bundle carries the key-protection secret, the backup
+/// passphrase and the storage credentials in plain text, so producing one is producing a portable copy
+/// of the instance.
+///
+[Handler("backups.exportBundle")]
+[RequireRole(WatchtowerClaims.AdminRole)]
+public sealed class ExportBackupBundle(
+ BackupQueueService queue,
+ IOptionsMonitor options,
+ AuditLog audit,
+ ICurrentUser currentUser)
+ : IHandler> {
+ public sealed record Command;
+
+ public sealed record Response(BackupRunAcceptedDto Export);
+
+ public async ValueTask> HandleAsync(Command command, CancellationToken ct) {
+ // Refused here rather than in the run, so the operator sees why in the dialog. Both conditions
+ // are about the archives the bundle is made of, not about the bundle itself.
+ var backup = options.CurrentValue.Backup;
+ if (string.IsNullOrEmpty(backup.EncryptionPassphrase))
+ return AppError.Validation(
+ "A full backup bundle needs an encryption passphrase: it carries a dump of Watchtower's "
+ + "own database, which holds every database role's password hash, the data-protection "
+ + "key ring and every certificate's private key. Set one under Settings → Backups first.");
+
+ var result = queue.EnqueueBundleExport(BackupTriggers.BundleExport);
+ // Recorded when the export is *asked for* as well as when it finishes: the request is the
+ // decision, and a bundle that fails to build is still an attempt to take one off the box.
+ await audit.RecordAsync(
+ BackupService.AuditCategory, "bundle.request", BackupBundleService.AuditTarget,
+ $"export requested (event {result.BackupEventId})",
+ actor: await audit.ActorAsync(currentUser, ct), ct: ct);
+ return new Response(new BackupRunAcceptedDto(result.BackupEventId, result.Status));
+ }
+}
diff --git a/src/Watchtower.Application/Modules/Backups/Handlers/GetBackupConfig.cs b/src/Watchtower.Application/Modules/Backups/Handlers/GetBackupConfig.cs
index 40b8e3b..fc3e7d1 100644
--- a/src/Watchtower.Application/Modules/Backups/Handlers/GetBackupConfig.cs
+++ b/src/Watchtower.Application/Modules/Backups/Handlers/GetBackupConfig.cs
@@ -36,6 +36,8 @@ public sealed record Response(BackupConfigDto Config);
WatchtowerSettingPaths.BackupSftpPrivateKeyPassphrase,
WatchtowerSettingPaths.BackupSftpBasePath,
WatchtowerSettingPaths.BackupLocalBasePath,
+ WatchtowerSettingPaths.BackupIncludeSelf,
+ WatchtowerSettingPaths.BackupSelfPostgresContainer,
];
public ValueTask> HandleAsync(Query query, CancellationToken ct) {
diff --git a/src/Watchtower.Application/Modules/Backups/Handlers/GetBundleStatus.cs b/src/Watchtower.Application/Modules/Backups/Handlers/GetBundleStatus.cs
new file mode 100644
index 0000000..d93a60f
--- /dev/null
+++ b/src/Watchtower.Application/Modules/Backups/Handlers/GetBundleStatus.cs
@@ -0,0 +1,25 @@
+using Elarion.Abstractions.Authorization;
+using Watchtower.Application.Services;
+
+namespace Watchtower.Application.Modules.Backups.Handlers;
+
+///
+/// Whether a full backup bundle is staged for download, and what it holds (ADR-0027 §4) — what the
+/// Settings card polls while an export runs and reads afterwards to offer the link.
+///
+[Handler("backups.getBundleStatus")]
+[RequireRole(WatchtowerClaims.AdminRole)]
+public sealed class GetBundleStatus(BundleExportState state)
+ : IHandler> {
+ public sealed record Query;
+
+ public sealed record Response(BackupBundleDto? Bundle);
+
+ public ValueTask> HandleAsync(Query query, CancellationToken ct) =>
+ ValueTask.FromResult>(new Response(
+ state.Current is { } staged
+ ? new BackupBundleDto(
+ staged.FileName, staged.SizeBytes, staged.CreatedAtUtc, staged.StackCount,
+ staged.MissingStackCount)
+ : null));
+}
diff --git a/src/Watchtower.Application/Modules/Backups/Handlers/GetInstanceRestoreStatus.cs b/src/Watchtower.Application/Modules/Backups/Handlers/GetInstanceRestoreStatus.cs
new file mode 100644
index 0000000..3b58a23
--- /dev/null
+++ b/src/Watchtower.Application/Modules/Backups/Handlers/GetInstanceRestoreStatus.cs
@@ -0,0 +1,50 @@
+using Elarion.Abstractions.Authorization;
+using Elarion.Settings;
+using Watchtower.Application.Services;
+
+namespace Watchtower.Application.Modules.Backups.Handlers;
+
+///
+/// What the restore wizard needs to decide what to show (ADR-0027): whether this instance looks brand
+/// new, whether a bundle is waiting to be restored and what it holds, how the last restore ended, and
+/// whether there is a recovery checklist still to work through.
+///
+[Handler("backups.getRestoreStatus")]
+[RequireRole(WatchtowerClaims.AdminRole)]
+public sealed class GetInstanceRestoreStatus(
+ InstanceRestoreService restore,
+ InstanceRestoreStaging staging,
+ RestoreCompletionService completion,
+ ISettingsManager settings)
+ : IHandler> {
+ public sealed record Query;
+
+ ///
+ /// No stacks, no deploys and one account — a Watchtower nobody has used yet. A hint for what to
+ /// offer, never a permission: the restore is gated on being an admin either way.
+ ///
+ /// The uploaded bundle, checked against this instance, or null.
+ /// How the last restore this instance attempted ended.
+ /// Why, when it failed.
+ /// Whether a post-restore checklist is still open.
+ public sealed record Response(
+ bool FreshInstance,
+ RestoreValidationDto? Staged,
+ string LastOutcome,
+ string? LastError,
+ bool RecoveryPending);
+
+ public async ValueTask> HandleAsync(Query query, CancellationToken ct) {
+ RestoreValidationDto? staged = null;
+ if (staging.Current is { } current)
+ staged = RestoreValidationDto.From(await restore.ValidateAsync(current, ct));
+
+ var recovery = await StackRevivalState.LoadAsync(settings, ct);
+ return new Response(
+ FreshInstance: await restore.IsFreshAsync(ct),
+ Staged: staged,
+ LastOutcome: completion.LastOutcome.ToString().ToLowerInvariant(),
+ LastError: completion.LastError,
+ RecoveryPending: recovery is { Dismissed: false });
+ }
+}
diff --git a/src/Watchtower.Application/Modules/Backups/Handlers/ListBackupEvents.cs b/src/Watchtower.Application/Modules/Backups/Handlers/ListBackupEvents.cs
index 9fe00a0..8139dc2 100644
--- a/src/Watchtower.Application/Modules/Backups/Handlers/ListBackupEvents.cs
+++ b/src/Watchtower.Application/Modules/Backups/Handlers/ListBackupEvents.cs
@@ -6,10 +6,11 @@ namespace Watchtower.Application.Modules.Backups.Handlers;
///
/// Returns backup history, newest first — for one stack (), for every
/// deployment of one product (, the product Backups tab's fleet history),
-/// or instance-wide. Output is included, so the UI can show the run log inline.
+/// for Watchtower's own database (), or instance-wide. Output is included, so
+/// the UI can show the run log inline.
///
///
-/// The two filters are independent and both optional, which is what keeps this additive: every existing
+/// The filters are independent and all optional, which is what keeps this additive: every existing
/// caller passes a stack id or nothing and gets exactly what it always did. Passing both narrows to the
/// intersection rather than being refused — a stack of the product answers both questions, and a stack
/// of another product legitimately answers neither.
@@ -20,7 +21,12 @@ public sealed class ListBackupEvents(WatchtowerDbContext db)
/// One stack, or null for every stack the other filters allow.
/// Row cap, clamped to 1…500.
/// Every deployment of this product, or null for no product filter.
- public sealed record Query(int? StackId = null, int Limit = 50, int? ProductId = null);
+ ///
+ /// stack for the stack runs, instance for Watchtower's own (ADR-0027), or null for
+ /// both. Unfiltered stays the default so the existing history views are unchanged: an instance run
+ /// is part of "what has this Watchtower been backing up" and belongs in the instance-wide list.
+ ///
+ public sealed record Query(int? StackId = null, int Limit = 50, int? ProductId = null, string? Kind = null);
public sealed record Response(IReadOnlyList Events);
@@ -30,18 +36,27 @@ public async ValueTask> HandleAsync(Query query, CancellationTo
if (query.ProductId is { } productId && !await db.Products.AnyAsync(p => p.Id == productId, ct))
return AppError.NotFound($"Product {productId} not found");
+ var kind = query.Kind?.Trim().ToLowerInvariant();
+ if (kind is not (null or "" or BackupEventKinds.Stack or BackupEventKinds.Instance))
+ return AppError.Validation(
+ $"Kind must be '{BackupEventKinds.Stack}', '{BackupEventKinds.Instance}', or omitted.");
+ var instanceOnly = kind == BackupEventKinds.Instance;
+ var stackOnly = kind == BackupEventKinds.Stack;
+
var limit = Math.Clamp(query.Limit, 1, 500);
// Id breaks ties: a stack-wide run writes several events within the same clock tick, and the
// limit below only means something over a total order.
var events = await db.BackupEvents.AsNoTracking()
.Where(e => query.StackId == null || e.StackId == query.StackId)
.Where(e => query.ProductId == null || e.Stack!.ProductId == query.ProductId)
+ .Where(e => (!instanceOnly || e.StackId == null) && (!stackOnly || e.StackId != null))
.OrderByDescending(e => e.StartedAt)
.ThenByDescending(e => e.Id)
.Take(limit)
.Select(e => new BackupEventDto(
- e.Id, e.StackId, e.Stack!.Name, e.TriggeredBy, e.Status, e.RemotePath, e.SizeBytes,
- e.Output, e.StartedAt, e.FinishedAt))
+ e.Id, e.StackId, e.Stack == null ? null : e.Stack.Name, e.TriggeredBy, e.Status,
+ e.RemotePath, e.SizeBytes, e.Output, e.StartedAt, e.FinishedAt,
+ e.StackId == null ? BackupEventKinds.Instance : BackupEventKinds.Stack))
.ToListAsync(ct);
return new Response(events);
}
diff --git a/src/Watchtower.Application/Modules/Backups/Handlers/ListInstanceBackups.cs b/src/Watchtower.Application/Modules/Backups/Handlers/ListInstanceBackups.cs
new file mode 100644
index 0000000..0a8b6a9
--- /dev/null
+++ b/src/Watchtower.Application/Modules/Backups/Handlers/ListInstanceBackups.cs
@@ -0,0 +1,46 @@
+using Elarion.Abstractions.Authorization;
+using Microsoft.Extensions.Options;
+using Watchtower.Application.Config;
+using Watchtower.Application.Services;
+
+namespace Watchtower.Application.Modules.Backups.Handlers;
+
+///
+/// Lists the archives of Watchtower's own database present on the configured storage, newest first
+/// (ADR-0027) — the instance counterpart of , and for the same reason the
+/// storage rather than backups.events is the source of truth: retention deletes files behind old
+/// events, and an archive written by a different instance of this Watchtower is still restorable here.
+///
+[Handler("backups.listInstance")]
+[RequireRole(WatchtowerClaims.AdminRole)]
+public sealed class ListInstanceBackups(
+ IOptionsMonitor options,
+ BackupStorageFactory storageFactory)
+ : IHandler> {
+ public sealed record Query;
+
+ /// The archives, newest first.
+ /// The provider-relative directory they were listed from.
+ public sealed record Response(IReadOnlyList Files, string Directory);
+
+ public async ValueTask> HandleAsync(Query query, CancellationToken ct) {
+ var backup = options.CurrentValue.Backup;
+ var directory = BackupNaming.InstanceDirectory(backup.ResolveInstanceName());
+ try {
+ using var storage = storageFactory.Create(backup);
+ var files = (await storage.ListFilesAsync(directory, ct))
+ .Select(f => (File: f, TakenAt: BackupNaming.ParseTimestamp(f.Name)))
+ .Where(x => x.TakenAt is not null)
+ .OrderByDescending(x => x.TakenAt)
+ .Select(x => new BackupRemoteFileDto(
+ x.File.Name,
+ x.File.SizeBytes,
+ x.TakenAt!.Value,
+ Encrypted: x.File.Name.EndsWith(".enc", StringComparison.Ordinal)))
+ .ToList();
+ return new Response(files, directory);
+ } catch (Exception ex) when (ex is not OperationCanceledException) {
+ return AppError.Validation($"Could not list the backup storage: {ex.Message}");
+ }
+ }
+}
diff --git a/src/Watchtower.Application/Modules/Backups/Handlers/RecoveryChecklistHandlers.cs b/src/Watchtower.Application/Modules/Backups/Handlers/RecoveryChecklistHandlers.cs
new file mode 100644
index 0000000..f5f4076
--- /dev/null
+++ b/src/Watchtower.Application/Modules/Backups/Handlers/RecoveryChecklistHandlers.cs
@@ -0,0 +1,113 @@
+using Elarion.Abstractions.Authorization;
+using Elarion.Abstractions.Identity;
+using Watchtower.Application.Services;
+
+namespace Watchtower.Application.Modules.Backups.Handlers;
+
+///
+/// The post-restore recovery checklist (ADR-0027 §6): every stack the restored database knows about,
+/// waiting to be redeployed from git and restored from its newest archive.
+///
+[Handler("backups.getRecoveryChecklist")]
+[RequireRole(WatchtowerClaims.AdminRole)]
+public sealed class GetRecoveryChecklist(StackRevivalCoordinator revival)
+ : IHandler> {
+ public sealed record Query;
+
+ /// The checklist, or null when there is nothing to recover.
+ public sealed record Response(RecoveryChecklistDto? Checklist);
+
+ public async ValueTask> HandleAsync(Query query, CancellationToken ct) =>
+ new Response(await revival.LoadAsync(ct) is { } state ? RecoveryChecklistDto.From(state) : null);
+}
+
+///
+/// Revives one stack: deploy it from git, then restore its newest archive into the volumes that deploy
+/// created (ADR-0027 §6). Runs to completion before returning — a single stack is one deploy and one
+/// restore, both of which the UI already knows how to wait on.
+///
+[Handler("backups.reviveStack")]
+[RequireRole(WatchtowerClaims.AdminRole)]
+public sealed class ReviveStack(StackRevivalCoordinator revival, AuditLog audit, ICurrentUser currentUser)
+ : IHandler> {
+ public sealed record Command(int StackId);
+
+ public sealed record Response(RecoveryStackDto Stack);
+
+ public async ValueTask> HandleAsync(Command command, CancellationToken ct) {
+ if (await revival.ReviveAsync(command.StackId, ct) is not { } stack)
+ return AppError.NotFound($"Stack {command.StackId} is not on the recovery checklist.");
+
+ await audit.RecordAsync(
+ BackupService.AuditCategory, "recovery.revive", stack.Name,
+ $"{stack.Status.ToString().ToLowerInvariant()} — {stack.Detail}",
+ success: stack.Status is not RevivalStatus.Failed,
+ actor: await audit.ActorAsync(currentUser, ct), ct: ct);
+ return new Response(RecoveryStackDto.From(stack));
+ }
+}
+
+///
+/// Revives every stack still pending or failed, one after another (ADR-0027 §6). A failure does not
+/// stop the rest: the stacks are independent, and stopping at the first would leave the operator to
+/// work out which of the others had been tried.
+///
+[Handler("backups.reviveAll")]
+[RequireRole(WatchtowerClaims.AdminRole)]
+public sealed class ReviveAllStacks(
+ StackRevivalCoordinator revival, AuditLog audit, ICurrentUser currentUser)
+ : IHandler> {
+ public sealed record Command;
+
+ /// How many stacks ended up fully back.
+ /// The checklist as it now stands.
+ public sealed record Response(int Revived, RecoveryChecklistDto? Checklist);
+
+ public async ValueTask> HandleAsync(Command command, CancellationToken ct) {
+ var revived = await revival.ReviveAllAsync(ct);
+ var checklist = await revival.LoadAsync(ct);
+ await audit.RecordAsync(
+ BackupService.AuditCategory, "recovery.revive", "all stacks",
+ $"{revived} of {checklist?.Stacks.Count ?? 0} stack(s) deployed and restored",
+ actor: await audit.ActorAsync(currentUser, ct), ct: ct);
+ return new Response(revived, checklist is null ? null : RecoveryChecklistDto.From(checklist));
+ }
+}
+
+/// Marks one stack as handled outside Watchtower, so "revive all" leaves it alone.
+[Handler("backups.skipRecoveryStack")]
+[RequireRole(WatchtowerClaims.AdminRole)]
+public sealed class SkipRecoveryStack(StackRevivalCoordinator revival)
+ : IHandler> {
+ public sealed record Command(int StackId);
+
+ public sealed record Response(RecoveryStackDto Stack);
+
+ public async ValueTask> HandleAsync(Command command, CancellationToken ct) =>
+ await revival.SkipAsync(command.StackId, ct) is { } stack
+ ? new Response(RecoveryStackDto.From(stack))
+ : AppError.NotFound($"Stack {command.StackId} is not on the recovery checklist.");
+}
+
+///
+/// Puts the checklist away. It is a prompt, not a record — what was restored and revived is in the audit
+/// trail, which is where that question belongs.
+///
+[Handler("backups.dismissRecovery")]
+[RequireRole(WatchtowerClaims.AdminRole)]
+public sealed class DismissRecovery(
+ StackRevivalCoordinator revival, AuditLog audit, ICurrentUser currentUser)
+ : IHandler> {
+ public sealed record Command;
+
+ public sealed record Response(bool Dismissed);
+
+ public async ValueTask> HandleAsync(Command command, CancellationToken ct) {
+ await revival.DismissAsync(ct);
+ await audit.RecordAsync(
+ BackupService.AuditCategory, "recovery.dismiss", InstanceRestoreService.AuditTarget,
+ "recovery checklist dismissed",
+ actor: await audit.ActorAsync(currentUser, ct), ct: ct);
+ return new Response(true);
+ }
+}
diff --git a/src/Watchtower.Application/Modules/Backups/Handlers/RunInstanceBackup.cs b/src/Watchtower.Application/Modules/Backups/Handlers/RunInstanceBackup.cs
new file mode 100644
index 0000000..5b0870f
--- /dev/null
+++ b/src/Watchtower.Application/Modules/Backups/Handlers/RunInstanceBackup.cs
@@ -0,0 +1,39 @@
+using Elarion.Abstractions.Authorization;
+using Microsoft.Extensions.Options;
+using Watchtower.Application.Config;
+using Watchtower.Application.Services;
+
+namespace Watchtower.Application.Modules.Backups.Handlers;
+
+///
+/// Enqueues a backup of Watchtower's own database on the single-flight backup queue (ADR-0027) and
+/// returns the tracking event immediately. Works regardless of the schedule master switch and of
+/// Backup:IncludeSelf — both govern the schedule, and an explicit run is an operator's decision.
+///
+///
+/// Admin-only, unlike the stack runs. The archive it produces carries every database role's password
+/// hash, the data-protection key ring and every certificate's private key, so the ability to place one on
+/// the backup storage is the ability to walk away with the instance.
+///
+[Handler("backups.runInstance")]
+[RequireRole(WatchtowerClaims.AdminRole)]
+public sealed class RunInstanceBackup(BackupQueueService queue, IOptionsMonitor options)
+ : IHandler> {
+ public sealed record Command;
+
+ public sealed record Response(BackupRunAcceptedDto Backup);
+
+ public ValueTask> HandleAsync(Command command, CancellationToken ct) {
+ // Refused here rather than in the run, so the operator gets the reason in the dialog instead of
+ // in a failed event's log.
+ if (string.IsNullOrEmpty(options.CurrentValue.Backup.EncryptionPassphrase))
+ return ValueTask.FromResult>(AppError.Validation(
+ "Backing up Watchtower itself needs an encryption passphrase: the dump carries every "
+ + "database role's password hash, the data-protection key ring and every certificate's "
+ + "private key. Set one under Settings → Backups first."));
+
+ var result = queue.EnqueueInstance(BackupTriggers.Manual);
+ return ValueTask.FromResult>(
+ new Response(new BackupRunAcceptedDto(result.BackupEventId, result.Status)));
+ }
+}
diff --git a/src/Watchtower.Application/Modules/Backups/Handlers/StartInstanceRestore.cs b/src/Watchtower.Application/Modules/Backups/Handlers/StartInstanceRestore.cs
new file mode 100644
index 0000000..fcd5401
--- /dev/null
+++ b/src/Watchtower.Application/Modules/Backups/Handlers/StartInstanceRestore.cs
@@ -0,0 +1,57 @@
+using Elarion.Abstractions.Authorization;
+using Elarion.Abstractions.Identity;
+using Microsoft.EntityFrameworkCore;
+using Watchtower.Application.Entities;
+using Watchtower.Application.Persistence;
+using Watchtower.Application.Services;
+
+namespace Watchtower.Application.Modules.Backups.Handlers;
+
+///
+/// Replaces this Watchtower's database with the one in the uploaded bundle (ADR-0027 §5). The
+/// destructive half of the restore: everything this instance currently knows — its stacks, accounts,
+/// routes, settings and keys — is replaced by the bundle's, and the containers it deployed keep running
+/// unmanaged until the recovery checklist redeploys them.
+///
+///
+/// Returns as soon as the coordinator container has been started; Watchtower stops answering a few
+/// seconds later and comes back on the restored database, where the caller's session no longer exists.
+/// The UI is expected to wait for the restart and send the operator to the login page.
+///
+[Handler("backups.startInstanceRestore")]
+[RequireRole(WatchtowerClaims.AdminRole)]
+public sealed class StartInstanceRestore(
+ WatchtowerDbContext db,
+ InstanceRestoreService restore,
+ InstanceRestoreStaging staging,
+ AuditLog audit,
+ ICurrentUser currentUser)
+ : IHandler> {
+ public sealed record Command;
+
+ /// The instance the bundle came from, for the "restarting" banner.
+ public sealed record Response(string SourceInstance);
+
+ public async ValueTask> HandleAsync(Command command, CancellationToken ct) {
+ if (staging.Current is not { } staged)
+ return AppError.Validation("Upload a backup bundle first.");
+
+ // Nothing that writes may be in flight: a deploy or backup finishing against a database that is
+ // being dropped would leave its own event row in a state nothing can explain afterwards.
+ if (await db.DeployEvents.AnyAsync(e => e.Status == "running" || e.Status == "queued", ct))
+ return AppError.Conflict("A deploy is in progress — restore once it has finished.");
+ if (await db.BackupEvents.AnyAsync(
+ e => e.Status == BackupStatuses.Running || e.Status == BackupStatuses.Queued, ct))
+ return AppError.Conflict("A backup or restore is in progress — restore once it has finished.");
+
+ var actor = await audit.ActorAsync(currentUser, ct);
+ try {
+ await restore.StartAsync(actor, ct);
+ } catch (InvalidOperationException ex) {
+ // Everything the restore refuses is a configuration or bundle problem the operator can act
+ // on, and none of it has touched the database yet.
+ return AppError.Validation(ex.Message);
+ }
+ return new Response(staged.Manifest.InstanceName);
+ }
+}
diff --git a/src/Watchtower.Application/Modules/Backups/Handlers/UpdateBackupConfig.cs b/src/Watchtower.Application/Modules/Backups/Handlers/UpdateBackupConfig.cs
index c3cd541..8532f24 100644
--- a/src/Watchtower.Application/Modules/Backups/Handlers/UpdateBackupConfig.cs
+++ b/src/Watchtower.Application/Modules/Backups/Handlers/UpdateBackupConfig.cs
@@ -39,7 +39,9 @@ public sealed record Command(
string? SftpPrivateKey = null,
string? SftpPrivateKeyPassphrase = null,
string? SftpBasePath = null,
- string? LocalBasePath = null);
+ string? LocalBasePath = null,
+ bool? IncludeSelf = null,
+ string? SelfPostgresContainer = null);
public sealed record Response(BackupConfigDto Config);
@@ -90,6 +92,10 @@ void Check(string path, bool changed) {
Check(WatchtowerSettingPaths.BackupSftpPrivateKeyPassphrase, command.SftpPrivateKeyPassphrase is not null);
Check(WatchtowerSettingPaths.BackupSftpBasePath, Changed(command.SftpBasePath, sftp.BasePath));
Check(WatchtowerSettingPaths.BackupLocalBasePath, Changed(command.LocalBasePath, backup.Local.BasePath));
+ Check(WatchtowerSettingPaths.BackupIncludeSelf,
+ command.IncludeSelf is { } includeSelf && includeSelf != backup.IncludeSelf);
+ Check(WatchtowerSettingPaths.BackupSelfPostgresContainer,
+ Changed(command.SelfPostgresContainer, backup.SelfPostgresContainer));
if (violations.Count > 0)
return EnvironmentSettingPins.PinnedError(violations);
@@ -126,6 +132,12 @@ await SetUnlessPinnedAsync(WatchtowerSettingPaths.BackupRetentionMaxCount,
await SetUnlessPinnedAsync(WatchtowerSettingPaths.BackupSftpBasePath, command.SftpBasePath.Trim(), ct);
if (command.LocalBasePath is not null)
await SetUnlessPinnedAsync(WatchtowerSettingPaths.BackupLocalBasePath, command.LocalBasePath.Trim(), ct);
+ if (command.IncludeSelf is { } includeSelfValue)
+ await SetUnlessPinnedAsync(
+ WatchtowerSettingPaths.BackupIncludeSelf, includeSelfValue ? "true" : "false", ct);
+ if (command.SelfPostgresContainer is not null)
+ await SetUnlessPinnedAsync(
+ WatchtowerSettingPaths.BackupSelfPostgresContainer, command.SelfPostgresContainer.Trim(), ct);
// Echo the written values (the config provider reloads asynchronously — same reasoning as
// proxy.updateConfig): immediately consistent for the caller.
@@ -151,6 +163,8 @@ await SetUnlessPinnedAsync(WatchtowerSettingPaths.BackupRetentionMaxCount,
Local = backup.Local with {
BasePath = Coalesce(command.LocalBasePath, backup.Local.BasePath) ?? "",
},
+ IncludeSelf = command.IncludeSelf ?? backup.IncludeSelf,
+ SelfPostgresContainer = Coalesce(command.SelfPostgresContainer, backup.SelfPostgresContainer),
};
// Recorded post-write so the trail answers "what was the configuration at that time" —
@@ -169,6 +183,7 @@ await SetUnlessPinnedAsync(WatchtowerSettingPaths.BackupRetentionMaxCount,
+ $" · provider {provider}"
+ $" · {BackupService.RetentionSummary(echoed)}"
+ (string.IsNullOrEmpty(echoed.EncryptionPassphrase) ? "" : " · encrypted")
+ + $" · Watchtower's own database {(echoed.IncludeSelf ? "included" : "excluded")}"
+ (updatedSecrets.Count > 0 ? $" · secrets updated: {string.Join(", ", updatedSecrets)}" : "");
await audit.RecordAsync(BackupService.AuditCategory, "config.update", "backup settings", detail,
actor: await audit.ActorAsync(currentUser, ct), ct: ct);
diff --git a/src/Watchtower.Application/Modules/Stacks/Handlers/CreateStack.cs b/src/Watchtower.Application/Modules/Stacks/Handlers/CreateStack.cs
index 34f1dc2..d9591a6 100644
--- a/src/Watchtower.Application/Modules/Stacks/Handlers/CreateStack.cs
+++ b/src/Watchtower.Application/Modules/Stacks/Handlers/CreateStack.cs
@@ -58,7 +58,8 @@ public async ValueTask> HandleAsync(Command command, Cancellati
// visibility. Enforced here because the default name is the lowercased stack name. Watchtower's
// own project is reserved for the same reason.
var projectName = StackMapping.ResolveProjectName(command.Name, command.ComposeProjectName);
- if (await StackProjectNames.ValidateAsync(db, selfProjects, projectName, excludeStackId: null, ct)
+ if (await StackProjectNames.ValidateAsync(
+ db, selfProjects, projectName, excludeStackId: null, ct, command.Name)
is { } projectNameError)
return AppError.Validation(projectNameError);
diff --git a/src/Watchtower.Application/Modules/Stacks/Handlers/UpdateStack.cs b/src/Watchtower.Application/Modules/Stacks/Handlers/UpdateStack.cs
index 67d33ce..6e2b741 100644
--- a/src/Watchtower.Application/Modules/Stacks/Handlers/UpdateStack.cs
+++ b/src/Watchtower.Application/Modules/Stacks/Handlers/UpdateStack.cs
@@ -70,7 +70,8 @@ public async ValueTask> HandleAsync(Command command, Cancellati
// own — which would make them share containers (and App API visibility). Checked before
// anything is mutated.
var projectName = StackMapping.ResolveProjectName(command.Name, command.ComposeProjectName);
- if (await StackProjectNames.ValidateAsync(db, selfProjects, projectName, excludeStackId: stack.Id, ct)
+ if (await StackProjectNames.ValidateAsync(
+ db, selfProjects, projectName, excludeStackId: stack.Id, ct, command.Name)
is { } projectNameError)
return AppError.Validation(projectNameError);
diff --git a/src/Watchtower.Application/Persistence/Configurations/WatchtowerEntityConfigurations.cs b/src/Watchtower.Application/Persistence/Configurations/WatchtowerEntityConfigurations.cs
index cf3d146..ef84649 100644
--- a/src/Watchtower.Application/Persistence/Configurations/WatchtowerEntityConfigurations.cs
+++ b/src/Watchtower.Application/Persistence/Configurations/WatchtowerEntityConfigurations.cs
@@ -394,9 +394,12 @@ public void Configure(EntityTypeBuilder b) {
// The history view reads newest-first per stack; the startup sweep scans by status.
b.HasIndex(x => new { x.StackId, x.StartedAt });
b.HasIndex(x => x.Status);
+ // Optional since ADR-0027: an instance self-backup has no stack. Still cascading, so a deleted
+ // stack takes its own history with it — only the stackless rows outlive every stack.
b.HasOne(x => x.Stack)
.WithMany()
.HasForeignKey(x => x.StackId)
+ .IsRequired(false)
.OnDelete(DeleteBehavior.Cascade);
}
}
diff --git a/src/Watchtower.Application/Persistence/Migrations/20260826192544_AddInstanceBackupEvents.Designer.cs b/src/Watchtower.Application/Persistence/Migrations/20260826192544_AddInstanceBackupEvents.Designer.cs
new file mode 100644
index 0000000..09ce3fd
--- /dev/null
+++ b/src/Watchtower.Application/Persistence/Migrations/20260826192544_AddInstanceBackupEvents.Designer.cs
@@ -0,0 +1,2411 @@
+//
+using System;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
+using Watchtower.Application.Persistence;
+
+#nullable disable
+
+namespace Watchtower.Application.Persistence.Migrations
+{
+ [DbContext(typeof(WatchtowerDbContext))]
+ [Migration("20260826192544_AddInstanceBackupEvents")]
+ partial class AddInstanceBackupEvents
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasAnnotation("ProductVersion", "10.0.11")
+ .HasAnnotation("Relational:MaxIdentifierLength", 63);
+
+ NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
+
+ modelBuilder.Entity("Elarion.Coordination.PostgreSql.RoleLeaseEntity", b =>
+ {
+ b.Property("Role")
+ .HasMaxLength(128)
+ .HasColumnType("character varying(128)")
+ .HasColumnName("role");
+
+ b.Property("Address")
+ .HasMaxLength(512)
+ .HasColumnType("character varying(512)")
+ .HasColumnName("address");
+
+ b.Property("ExpiresOnUtc")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("expires_on_utc");
+
+ b.Property("Owner")
+ .IsRequired()
+ .HasMaxLength(256)
+ .HasColumnType("character varying(256)")
+ .HasColumnName("owner");
+
+ b.HasKey("Role")
+ .HasName("pk_elarion_role_leases");
+
+ b.ToTable("elarion_role_leases", (string)null);
+ });
+
+ modelBuilder.Entity("Elarion.Scheduling.EntityFrameworkCore.SchedulerClaimEntity", b =>
+ {
+ b.Property("JobName")
+ .HasMaxLength(256)
+ .HasColumnType("character varying(256)")
+ .HasColumnName("job_name");
+
+ b.Property("OccurrenceUtc")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("occurrence_utc");
+
+ b.Property("ClaimedAtUtc")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("claimed_at_utc");
+
+ b.HasKey("JobName", "OccurrenceUtc")
+ .HasName("pk_elarion_scheduler_claims");
+
+ b.HasIndex("OccurrenceUtc")
+ .HasDatabaseName("ix_elarion_scheduler_claims_purge");
+
+ b.ToTable("elarion_scheduler_claims", (string)null);
+ });
+
+ modelBuilder.Entity("Elarion.Settings.EntityFrameworkCore.Setting", b =>
+ {
+ b.Property("Kind")
+ .HasMaxLength(64)
+ .HasColumnType("character varying(64)")
+ .HasColumnName("kind");
+
+ b.Property("Owner")
+ .HasMaxLength(256)
+ .HasColumnType("character varying(256)")
+ .HasColumnName("owner");
+
+ b.Property("Key")
+ .HasMaxLength(512)
+ .HasColumnType("character varying(512)")
+ .HasColumnName("key");
+
+ b.Property("UpdatedOnUtc")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("updated_on_utc");
+
+ b.Property("Value")
+ .HasColumnType("text")
+ .HasColumnName("value");
+
+ b.Property("Version")
+ .IsConcurrencyToken()
+ .HasColumnType("integer")
+ .HasColumnName("version");
+
+ b.HasKey("Kind", "Owner", "Key")
+ .HasName("pk_elarion_settings");
+
+ b.ToTable("elarion_settings", (string)null);
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.DataProtection.EntityFrameworkCore.DataProtectionKey", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer")
+ .HasColumnName("id");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("FriendlyName")
+ .HasColumnType("text")
+ .HasColumnName("friendly_name");
+
+ b.Property("Xml")
+ .HasColumnType("text")
+ .HasColumnName("xml");
+
+ b.HasKey("Id")
+ .HasName("pk_data_protection_keys");
+
+ b.ToTable("data_protection_keys", (string)null);
+ });
+
+ modelBuilder.Entity("Watchtower.Application.Entities.AcmeAccount", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer")
+ .HasColumnName("id");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("AccountUrl")
+ .HasColumnType("text")
+ .HasColumnName("account_url");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at");
+
+ b.Property("DirectoryUrl")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("directory_url");
+
+ b.Property("PrivateKey")
+ .IsRequired()
+ .HasColumnType("bytea")
+ .HasColumnName("private_key");
+
+ b.Property("Protection")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("protection");
+
+ b.HasKey("Id")
+ .HasName("pk_acme_accounts");
+
+ b.HasIndex("DirectoryUrl")
+ .IsUnique()
+ .HasDatabaseName("ix_acme_accounts_directory_url");
+
+ b.ToTable("acme_accounts", (string)null);
+ });
+
+ modelBuilder.Entity("Watchtower.Application.Entities.AcmeHttpChallenge", b =>
+ {
+ b.Property("Token")
+ .HasColumnType("text")
+ .HasColumnName("token");
+
+ b.Property("ExpiresAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("expires_at");
+
+ b.Property("Host")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("host");
+
+ b.Property("KeyAuthorization")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("key_authorization");
+
+ b.HasKey("Token")
+ .HasName("pk_acme_http_challenges");
+
+ b.HasIndex("ExpiresAt")
+ .HasDatabaseName("ix_acme_http_challenges_expires_at");
+
+ b.ToTable("acme_http_challenges", (string)null);
+ });
+
+ modelBuilder.Entity("Watchtower.Application.Entities.AuditEvent", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer")
+ .HasColumnName("id");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("Action")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("action");
+
+ b.Property("Actor")
+ .HasColumnType("text")
+ .HasColumnName("actor");
+
+ b.Property("Category")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("category");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at");
+
+ b.Property("Detail")
+ .HasColumnType("text")
+ .HasColumnName("detail");
+
+ b.Property("Error")
+ .HasColumnType("text")
+ .HasColumnName("error");
+
+ b.Property("Success")
+ .HasColumnType("boolean")
+ .HasColumnName("success");
+
+ b.Property("Target")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("target");
+
+ b.HasKey("Id")
+ .HasName("pk_audit_events");
+
+ b.HasIndex("Category")
+ .HasDatabaseName("ix_audit_events_category");
+
+ b.HasIndex("CreatedAt")
+ .HasDatabaseName("ix_audit_events_created_at");
+
+ b.ToTable("audit_events", (string)null);
+ });
+
+ modelBuilder.Entity("Watchtower.Application.Entities.AuthSession", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer")
+ .HasColumnName("id");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at");
+
+ b.Property("ExpiresAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("expires_at");
+
+ b.Property("Kind")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("kind");
+
+ b.Property("RouteId")
+ .HasColumnType("integer")
+ .HasColumnName("route_id");
+
+ b.Property("TokenHash")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("token_hash");
+
+ b.Property("UserId")
+ .HasColumnType("integer")
+ .HasColumnName("user_id");
+
+ b.HasKey("Id")
+ .HasName("pk_auth_sessions");
+
+ b.HasIndex("ExpiresAt")
+ .HasDatabaseName("ix_auth_sessions_expires_at");
+
+ b.HasIndex("RouteId")
+ .HasDatabaseName("ix_auth_sessions_route_id");
+
+ b.HasIndex("TokenHash")
+ .IsUnique()
+ .HasDatabaseName("ix_auth_sessions_token_hash");
+
+ b.HasIndex("UserId")
+ .HasDatabaseName("ix_auth_sessions_user_id");
+
+ b.ToTable("auth_sessions", (string)null);
+ });
+
+ modelBuilder.Entity("Watchtower.Application.Entities.BackupEvent", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer")
+ .HasColumnName("id");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("FinishedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("finished_at");
+
+ b.Property("Output")
+ .HasColumnType("text")
+ .HasColumnName("output");
+
+ b.Property("RemotePath")
+ .HasColumnType("text")
+ .HasColumnName("remote_path");
+
+ b.Property("SizeBytes")
+ .HasColumnType("bigint")
+ .HasColumnName("size_bytes");
+
+ b.Property("StackId")
+ .HasColumnType("integer")
+ .HasColumnName("stack_id");
+
+ b.Property("StartedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("started_at");
+
+ b.Property("Status")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("status");
+
+ b.Property("TriggeredBy")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("triggered_by");
+
+ b.HasKey("Id")
+ .HasName("pk_backup_events");
+
+ b.HasIndex("Status")
+ .HasDatabaseName("ix_backup_events_status");
+
+ b.HasIndex("StackId", "StartedAt")
+ .HasDatabaseName("ix_backup_events_stack_id_started_at");
+
+ b.ToTable("backup_events", (string)null);
+ });
+
+ modelBuilder.Entity("Watchtower.Application.Entities.BackupPausedContainer", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer")
+ .HasColumnName("id");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("ContainerId")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("container_id");
+
+ b.Property("ContainerName")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("container_name");
+
+ b.Property("PausedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("paused_at");
+
+ b.Property("StackName")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("stack_name");
+
+ b.HasKey("Id")
+ .HasName("pk_backup_paused_containers");
+
+ b.HasIndex("ContainerId")
+ .HasDatabaseName("ix_backup_paused_containers_container_id");
+
+ b.ToTable("backup_paused_containers", (string)null);
+ });
+
+ modelBuilder.Entity("Watchtower.Application.Entities.CiRepo", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer")
+ .HasColumnName("id");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("AllowDockerSocket")
+ .HasColumnType("boolean")
+ .HasColumnName("allow_docker_socket");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at");
+
+ b.Property("CredentialId")
+ .HasColumnType("integer")
+ .HasColumnName("credential_id");
+
+ b.Property("Enabled")
+ .HasColumnType("boolean")
+ .HasColumnName("enabled");
+
+ b.Property("ExtraLabels")
+ .HasColumnType("text")
+ .HasColumnName("extra_labels");
+
+ b.Property("LastRegistrySyncError")
+ .HasColumnType("text")
+ .HasColumnName("last_registry_sync_error");
+
+ b.Property("LastWarmError")
+ .HasColumnType("text")
+ .HasColumnName("last_warm_error");
+
+ b.Property("LastWarmedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("last_warmed_at");
+
+ b.Property("MaxConcurrentRunners")
+ .HasColumnType("integer")
+ .HasColumnName("max_concurrent_runners");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("name");
+
+ b.Property("Owner")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("owner");
+
+ b.Property("RegistrySyncedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("registry_synced_at");
+
+ b.Property("RegistrySyncedHash")
+ .HasColumnType("text")
+ .HasColumnName("registry_synced_hash");
+
+ b.Property