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" + ] + } + } ] }