From b109465edbfc0a85fff95fe22986d1b4992bf3eb Mon Sep 17 00:00:00 2001 From: Paul O'Fallon Date: Sun, 20 Sep 2026 21:42:24 +0000 Subject: [PATCH] feat(catalog): declare how each app is restored (restore@1, Hola spec 007) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hola spec 007 (restore-on-install) lets an operator name an existing deployment of an app as a restore source during a fresh install. The server captures that source's data root and lays it down before any container starts. What it cannot know on its own is what a restore MEANS for a given app — so each app says it here, and the server interprets every declaration identically with no per-app logic anywhere. `accepts: ["restore@1"]` marks an app as restorable. A `restore` block adds detail per backup participation. The two states are meaningfully different: an app declaring `restore@1` with NO block is restored by plain file copy, which is correct for every SQLite and flat-file app here; an app declaring neither has simply never been considered and is not offered as a source. Twelve apps already sit in the first state by virtue of declaring `accepts: ["backup@1"]` with no hooks — this gives that existing shape a meaning rather than asking anyone to adopt a new one. It is the same third state `backup@1` already relies on, for the same reason. The five apps touched here back onto Postgres and already dump with `pg_dump` into `${HOLA_APP_DATA}/backups`, inside the data root, so the dump is already captured by any data-root copy. `discard` is the non-obvious field and the reason these five need more than a file copy. A file-level tar of a LIVE Postgres data directory is read over minutes while the database writes throughout: it is a smear across time, not a snapshot, and for Postgres a smear is corruption. The `.sql` dump is the real payload. So the captured data directory is discarded, the container runs `initdb` on a clean cluster, and the hook loads the dump into it. Each `discard` path was read from that app's own compose rather than copied between apps — paperless-ngx mounts `pgdata` where the other four mount `postgres`, and a wrong path fails silently by discarding nothing and restoring the smear. postiz gets TWO entries, because #163 gave it two backup participations: `app-db` (pg_dump of postiz-db-local) and `temporal-db` (pg_dumpall of the temporal cluster). Each keys to its own participation id, discards its own data directory, and loads its own dump — `temporal-db` against the maintenance database, since pg_dumpall emits a whole-cluster script carrying its own \connect lines. Every hook carries `-v ON_ERROR_STOP=1`. Without it `psql` reports success after a failed statement, which would hand a partially loaded database to an app that then migrates it — precisely the corruption the server's fail-closed hook policy exists to prevent. Hola waits for each hook's service on the service's OWN declared healthcheck (`docker compose up -d --wait `) rather than any bespoke readiness poll, since per-app readiness knowledge in the server is what its generic-primitive rule forbids. All five hook services already declare one, so no compose change was needed. `restore@1` is added to three places that each enforce a different half of the rule, and missing any one of them fails CI rather than failing quietly: `$defs/contractRef` and `$defs/acceptableContractRef` in the schema (the latter is what `accepts` actually validates against), and the CONTRACTS table in bin/validate-manifest.mjs, which encodes what a JSON Schema enum cannot — that acceptance is declared rather than derived, and that a `restore` block without the matching `accepts` entry is an error. All three records note the same exception: `restore@1` is a participation marker, not a brokered capability contract. Hola's CONTRACTS registry gains no entry for it and the server never brokers it between two parties; it only says "this app has considered being restored". Schema and validator changes are additive; all 18 manifests validate. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Vck5KSX2CLxhohx14nb5Sh --- bin/validate-manifest.mjs | 17 ++++++++++++ schemas/manifest.schema.json | 41 ++++++++++++++++++++++++++--- src/guacamole/src/manifest.json | 12 ++++++++- src/immich/src/manifest.json | 18 +++++++++++-- src/mealie/src/manifest.json | 16 ++++++++++- src/paperless-ngx/src/manifest.json | 16 ++++++++++- src/postiz/src/manifest.json | 33 ++++++++++++++++++++++- 7 files changed, 143 insertions(+), 10 deletions(-) diff --git a/bin/validate-manifest.mjs b/bin/validate-manifest.mjs index 7494adb..bb27067 100755 --- a/bin/validate-manifest.mjs +++ b/bin/validate-manifest.mjs @@ -254,6 +254,23 @@ const CONTRACTS = { // server drops an `accepts` naming it with a warning; here it is an error, so // a manifest saying something meaningless fails CI rather than deploying. 'container-logs@1': { block: null, blockRequired: false, appProvided: true, impliedByBlock: false, acceptable: false }, + // NOT a brokered contract, and the only entry here that isn't: Hola's CONTRACTS + // registry gains no entry for restore@1 and the server never brokers it between + // two parties. It is a participation MARKER (Hola spec 007, restore-on-install) + // meaning "this app has considered being restored", and it lives in this table + // only because `accepts` is where an app declares it. + // + // `blockRequired` is false for the same reason backup@1's is, and it is the + // whole point: `restore@1` with no `restore` block is the positive claim "a + // plain file copy back is all I need" — true for every SQLite and flat-file app + // — which must stay distinguishable from an app nobody considered. Acceptance + // is declared, never derived from the block (ADR 0004 §2). + // + // `appProvided: false` because nothing provides restore@1 — not the platform, + // not an app. A manifest naming it in `provides` is an error, which is right, + // though the shared message says "provided by the Hola platform itself" and is + // imprecise for this one ref. + 'restore@1': { block: 'restore', blockRequired: false, appProvided: false, impliedByBlock: false }, }; /** Manifest fields that take a bare string or an array of them. */ diff --git a/schemas/manifest.schema.json b/schemas/manifest.schema.json index 578226e..59de02b 100644 --- a/schemas/manifest.schema.json +++ b/schemas/manifest.schema.json @@ -417,6 +417,37 @@ } ] }, + "restore": { + "type": "array", + "description": "Per-backup-participation restore declarations (Hola spec 007: restore-on-install). Each entry names the BACKUP participation id it restores (\"default\" for the legacy singular `backup` block above, which every app in this catalog currently uses), the data-root-relative paths to discard after the restored files land and before any container starts, and an optional hook -- reusing the exact `#/$defs/backupHook` shape, run after discards against the started-and-healthy service. An app that declares `accepts: [\"restore@1\"]` with NO entry here is restored by plain file copy (nothing discarded, no hook) -- meaningfully distinct from an app that doesn't declare `restore@1` at all, which isn't offered as restorable. `restore@1` here is a participation marker this feature's app declares, not a brokered capability contract.", + "minItems": 1, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["id"], + "properties": { + "id": { + "type": "string", + "minLength": 1, + "description": "The backup participation id this restores (\"default\" for the legacy singular `backup` block)." + }, + "discard": { + "type": "array", + "minItems": 1, + "items": { "type": "string", "minLength": 1 }, + "description": "Data-root-relative paths removed after the restored files land, before any container starts. A file-level tar of a LIVE Postgres data dir is only crash-consistent (a smear across the capture window); discarding it and loading the pg_dump the restore hook applies is what makes the restored data correct rather than merely present." + }, + "hook": { + "$ref": "#/$defs/backupHook", + "description": "Run after discards, against the started-and-healthy service (Hola waits on the service's OWN declared healthcheck, not a bespoke poll)." + }, + "requiresEnv": { + "type": "boolean", + "description": "true turns a restore candidate's missing captured configuration from a warning into a REFUSAL -- for an app that cannot sensibly restore without it. Default false." + } + } + } + }, "push": { "type": "array", "minItems": 1, @@ -513,21 +544,23 @@ "$defs": { "contractRef": { "type": "string", - "description": "A capability contract reference, `@`. CLOSED SET: a contract is a promise about *server* behavior, so matching two strings does nothing unless Hola implements the middle. Mirrors CONTRACTS in try-hola/hola packages/shared/src/contracts.ts -- adding a contract there is what makes it declarable here. Enumerated rather than left open because the server drops an unrecognized ref with a warning (ADR 0003 forward-compat), which would make a typo'd `backups@1` a silent no-op instead of a CI failure.", + "description": "A value an app may declare in a contract list. Most are capability contract refs (`@`) the server BROKERS -- a promise about *server* behavior, so matching two strings does nothing unless Hola implements the middle; those mirror CONTRACTS in try-hola/hola packages/shared/src/contracts.ts, and adding a contract there is what makes one declarable here. `restore@1` is the one exception: a participation MARKER for Hola spec 007 (restore-on-install), NOT a brokered contract -- CONTRACTS gains no entry for it and the server never brokers it between two parties; it only says \"this app has considered being restored\" (see the top-level `restore` block). Enumerated rather than left open because the server drops an unrecognized ref with a warning (ADR 0003 forward-compat), which would make a typo'd `backups@1` a silent no-op instead of a CI failure.", "enum": [ "auth@1", "backup@1", "container-logs@1", - "push@1" + "push@1", + "restore@1" ] }, "acceptableContractRef": { "type": "string", - "description": "The subset of #/$defs/contractRef an app can OPT IN to, i.e. those whose participation is `declared`. An `implicit` contract (container-logs@1) has no acceptor side: every install is a subject by virtue of running, and the server drops an `accepts` naming one with a warning, so declaring it here is a manifest error rather than a silent no-op.", + "description": "The subset of #/$defs/contractRef an app can OPT IN to, i.e. those whose participation is `declared`, plus the `restore@1` participation marker. An `implicit` contract (container-logs@1) has no acceptor side: every install is a subject by virtue of running, and the server drops an `accepts` naming one with a warning, so declaring it here is a manifest error rather than a silent no-op.", "enum": [ "auth@1", "backup@1", - "push@1" + "push@1", + "restore@1" ] }, "appProvidedContractRef": { diff --git a/src/guacamole/src/manifest.json b/src/guacamole/src/manifest.json index f369ee4..b66f398 100644 --- a/src/guacamole/src/manifest.json +++ b/src/guacamole/src/manifest.json @@ -35,10 +35,20 @@ "command": ["sh", "-c", "rm -f /backups/guacamole_db.sql"] } }, + "restore": [ + { + "id": "default", + "discard": ["postgres"], + "hook": { + "service": "postgres", + "command": ["sh", "-c", "psql -v ON_ERROR_STOP=1 -U guacamole_user -d guacamole_db -f /backups/guacamole_db.sql"] + } + } + ], "upgrade": { "breaking": true, "preUpgradeBackup": "required", "upgradeNotesUrl": "https://github.com/pgautoupgrade/docker-pgautoupgrade" }, - "accepts": ["backup@1"] + "accepts": ["backup@1", "restore@1"] } diff --git a/src/immich/src/manifest.json b/src/immich/src/manifest.json index 4312d43..adcf7bd 100644 --- a/src/immich/src/manifest.json +++ b/src/immich/src/manifest.json @@ -57,7 +57,7 @@ "upgradeNotesUrl": "https://immich.app/blog/v3-migration", "preUpgradeBackup": "recommended" }, - "accepts": ["backup@1"], + "accepts": ["backup@1", "restore@1"], "backup": { "preHook": { "service": "immich-postgres", @@ -75,5 +75,19 @@ "rm -f /backups/immich.sql" ] } - } + }, + "restore": [ + { + "id": "default", + "discard": ["postgres"], + "hook": { + "service": "immich-postgres", + "command": [ + "sh", + "-c", + "psql -v ON_ERROR_STOP=1 --dbname=immich --username=immich -f /backups/immich.sql" + ] + } + } + ] } diff --git a/src/mealie/src/manifest.json b/src/mealie/src/manifest.json index 3cda7c2..3bfa337 100644 --- a/src/mealie/src/manifest.json +++ b/src/mealie/src/manifest.json @@ -65,10 +65,24 @@ ] } }, + "restore": [ + { + "id": "default", + "discard": ["postgres"], + "hook": { + "service": "mealie-postgres", + "command": [ + "sh", + "-c", + "psql -v ON_ERROR_STOP=1 -U mealie -d mealie -f /backups/mealie.sql" + ] + } + } + ], "upgrade": { "breaking": true, "preUpgradeBackup": "required", "upgradeNotesUrl": "https://github.com/pgautoupgrade/docker-pgautoupgrade" }, - "accepts": ["backup@1"] + "accepts": ["backup@1", "restore@1"] } diff --git a/src/paperless-ngx/src/manifest.json b/src/paperless-ngx/src/manifest.json index 96e8819..d17faaa 100644 --- a/src/paperless-ngx/src/manifest.json +++ b/src/paperless-ngx/src/manifest.json @@ -80,10 +80,24 @@ ] } }, + "restore": [ + { + "id": "default", + "discard": ["pgdata"], + "hook": { + "service": "db", + "command": [ + "sh", + "-c", + "psql -v ON_ERROR_STOP=1 -U paperless -d paperless -f /backups/paperless.sql" + ] + } + } + ], "upgrade": { "breaking": true, "preUpgradeBackup": "required", "upgradeNotesUrl": "https://github.com/pgautoupgrade/docker-pgautoupgrade" }, - "accepts": ["backup@1"] + "accepts": ["backup@1", "restore@1"] } diff --git a/src/postiz/src/manifest.json b/src/postiz/src/manifest.json index b26f148..40acec2 100644 --- a/src/postiz/src/manifest.json +++ b/src/postiz/src/manifest.json @@ -115,6 +115,37 @@ "upgradeNotesUrl": "https://github.com/pgautoupgrade/docker-pgautoupgrade" }, "accepts": [ - "backup@1" + "backup@1", + "restore@1" + ], + "restore": [ + { + "id": "app-db", + "discard": [ + "postgres" + ], + "hook": { + "service": "postiz-postgres", + "command": [ + "sh", + "-c", + "psql -v ON_ERROR_STOP=1 -U postiz-user -d postiz-db-local -f /backups/postiz.sql" + ] + } + }, + { + "id": "temporal-db", + "discard": [ + "temporal-postgres" + ], + "hook": { + "service": "temporal-postgres", + "command": [ + "sh", + "-c", + "psql -v ON_ERROR_STOP=1 -U temporal -d postgres -f /backups/temporal.sql" + ] + } + } ] }