feat(catalog): declare how each app is restored (restore@1, Hola spec 007) - #164
Conversation
… 007)
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 <svc>`) 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vck5KSX2CLxhohx14nb5Sh
907369c to
b109465
Compare
Rebased onto current
|
| id | discard | hook |
|---|---|---|
app-db |
postgres |
psql … -d postiz-db-local -f /backups/postiz.sql |
temporal-db |
temporal-postgres |
psql … -d postgres -f /backups/temporal.sql |
temporal-db loads against the maintenance database rather than a named one, because its capture uses pg_dumpall, which emits a whole-cluster script carrying its own \connect lines.
This is exactly the plural case spec 007 designed for (its FR-027 / SC-010), so it's a good sign the shape held — but the value was wrong and only the rebase exposed it.
2. accepts validates against acceptableContractRef, not contractRef
Upstream split the enum in two. My original change added restore@1 only to contractRef — so every manifest declaring it would have failed validation.
There turned out to be a third gate as well: bin/validate-manifest.mjs carries its own CONTRACTS table, independent of the schema, encoding what a JSON Schema enum can't — which block an acceptor's details live in, and whether acceptance can be derived. restore@1 needed an entry there too.
All three now carry it, each enforcing a different half of the rule:
| Where | Enforces |
|---|---|
$defs/contractRef |
the ref is known |
$defs/acceptableContractRef |
it's legal in accepts |
CONTRACTS in the validator |
a restore block without the matching accepts entry is an error |
restore@1 takes blockRequired: false for the same reason backup@1 does, and that file's own comment already explains it better than I would: the hook-free declaration is *"the positive claim 'safe to copy as it sits', which has to be distinguishable from an app nobody considered."appProvided: false because nothing provides restore@1 — not the platform, not an app.
Verification
All 18 manifests pass both gates. I mutation-tested the two new validator paths rather than assuming they work:
- a
restoreblock withrestore@1removed fromaccepts→ correctly fails ("the block says HOW the app participates, accepts[] says WHETHER it does") restore@1inprovides→ correctly fails
One imprecision I left rather than widen scope: the provides rejection reuses the shared message "is provided by the Hola platform itself", which isn't accurate for restore@1 — nothing provides it. The message still tells the author the right thing to do, so I noted it in the code instead of changing shared wording.
Sibling to try-hola/hola#491 (spec 007, restore-on-install). That PR builds the install-side machinery; this one tells it what a restore means for each app.
The platform half works without this — 12 of the 17 apps here restore by plain file copy today — so the two can land independently. The five Postgres-backed apps need both.
What an app declares
accepts: ["restore@1"]marks an app restorable. Arestoreblock adds per-participation detail. The two states are meaningfully different:restore@1restore@1, no blockrestore@1+ blockThe middle state already exists in this catalog: twelve apps declare
accepts: ["backup@1"]with no hooks. This change gives that shape a meaning rather than asking anyone to adopt a new one.Why
discardexistsIt's the non-obvious field, and the reason these five need more than a copy.
A file-level tar of a live Postgres data directory is read over minutes while the database writes throughout — page 1 at T+0s, page 100000 at T+180s. That's a smear across time, not a snapshot, and for Postgres a smear is corruption. Hola's own capture helper concedes as much: it suppresses tar's "file changed as we read it" warning because crash-consistency is all it promises.
The
.sqldump is the real payload, and it's already captured — all five apps dump into${HOLA_APP_DATA}/backups, inside the data root. So: discard the smeared data directory, let the containerinitdba clean cluster, load the dump into it.Per-app
discardpostgrespostgresimmich-postgrespostgresmealie-postgrespostgresdbpgdatapostiz-postgrespostgresEvery
discardpath was read from that app's owncompose.yaml, not copied between apps. paperless-ngx mountspgdatawhere the other four mountpostgres— and a wrong path here fails silently, discarding nothing and restoring the smear.Every hook carries
-v ON_ERROR_STOP=1. Without itpsqlexits 0 after a failed statement, which would hand a partially loaded database to an app that then migrates it — exactly the corruption the server's fail-closed hook policy exists to prevent.Hola waits on each hook service's own declared healthcheck (
docker compose up -d --wait <svc>) rather than a bespoke readiness poll, since per-app readiness knowledge in the server is what its generic-primitive rule forbids. All five already declare one, so no compose change was needed.Schema
Additive.
restoreis a new optional array;hookreuses the existing$defs/backupHookrather than introducing a second hook format.restore@1joins thecontractRefenum, and that enum's description is extended to record the one thing that makes it different from its neighbours: it is not a brokered capability contract. Hola'sCONTRACTSregistry gains no entry for it, and the server never brokers it between two parties — it only says "this app has considered being restored." The enum stays closed for the reason it always was: the server drops an unrecognised ref with a warning, so an open enum would turn a typo into a silent no-op instead of a CI failure.Verification
All 17 manifests in this catalog validate against the updated schema (
ajv), unchanged ones included.Not yet exercised against real containers. The end-to-end pass — a real restore of one of these five, with the captured data directory discarded and the dump loaded — needs a published bundle carrying these declarations, so it runs after this merges. That gap is called out on try-hola/hola#491 too.
🤖 Generated with Claude Code
https://claude.ai/code/session_01Vck5KSX2CLxhohx14nb5Sh