From 0b17c5eeebbbd55a68c050f5f8c5946fda44f6f7 Mon Sep 17 00:00:00 2001 From: Paul O'Fallon Date: Sun, 20 Sep 2026 02:36:40 +0000 Subject: [PATCH] fix(backrest): register the backup-contract hooks, don't just write them (#159) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 2.0 wrote /config/hola/backup-{prepare,finalize}.sh and stopped. Backrest has no global hooks — `Hook` hangs off `Repo` and `Plan` only — so a script on disk is never called until it is attached to a repository, and at install time there is no repository to attach it to. Unless the operator hand-wired two Command hooks in the UI, the broker was never invoked and every snapshot was a live-file copy, while Hola's dashboard (which only knows the app *declares* `provides: backup@1`) reported the accepting apps as quiesced. Both facts the operator saw were wrong in the reassuring direction. Adds `backrest-hola-autowire`, a reconciler that attaches both hooks to every repository through Backrest's own API and keeps them attached, so a repository created next month is wired within 30 seconds. Through the API, not the file: ConfigManager caches the parsed config in memory and invalidates only on its own writes, so an edit to config.json under a running Backrest is read back only after a restart and the next UI save overwrites it. SetConfig is the path the UI uses — `modno` gives optimistic concurrency, and GetConfig does not redact repository passwords, so a read-modify-write round-trip preserves them. At repo level, not plan level: TasksTriggeredByEvent iterates the repo's hooks for every plan backing onto it, so one wiring covers every plan the operator ever adds. `mikefarah/yq` is the image Homepage's bolt-on already uses; it carries yq (native JSON), BusyBox wget (POST with headers) and a shell — no custom build. `BACKREST_HOLA_AUTOWIRE=false` hands the hooks back to the operator: nothing is added, nothing already present is removed. Hand-wired hooks are recognised by command path and left alone. Verified against a real backrest v1.14.1 with a real restic repo and a stub broker: hooks registered idempotently; a repository added later wired on the next pass; SNAPSHOT_START fired the prepare script, which POSTed with its Bearer token and polled to completion; SNAPSHOT_END fired finalize; with the broker unreachable the prepare hook exited 1 and ON_ERROR_CANCEL cancelled the snapshot, so fail-closed holds; repository password survived the round-trip; opt-out and an unreachable Backrest both stay running without crash-looping. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Vck5KSX2CLxhohx14nb5Sh --- src/backrest/README.md | 44 +++++++++--- src/backrest/package.json | 2 +- src/backrest/src/compose.yaml | 118 +++++++++++++++++++++++++++++++++ src/backrest/src/manifest.json | 43 ++++++++++-- 4 files changed, 190 insertions(+), 17 deletions(-) diff --git a/src/backrest/README.md b/src/backrest/README.md index b4b7b62..6a35627 100644 --- a/src/backrest/README.md +++ b/src/backrest/README.md @@ -21,7 +21,7 @@ it. up, and some need work done around the copy — a `pg_dump` before, a cleanup after. Backrest never reaches into another app to do that. It *announces* the start and end of a run, and Hola runs each accepting app's hooks inside that app's own -containers. See [Consistent backups](#consistent-backups-wire-up-the-hooks). +containers. See [Consistent backups](#consistent-backups-the-hooks-wire-themselves). After installing, open the Backrest UI and: @@ -30,12 +30,12 @@ After installing, open the Backrest UI and: unrecoverable. 2. **Add a backup plan** — point it at the apps data root (default `/srv/hola/apps`), set a schedule and retention. One plan captures every app. -3. **Wire up the hooks** — see below. Without them a scheduled run copies live - database files. +3. That is all — the backup hooks wire themselves onto every repository you + create. See [Consistent backups](#consistent-backups-the-hooks-wire-themselves). 4. Restore is whole-directory or per-path (restic supports restoring a single app's `/` subtree). -## Consistent backups: wire up the hooks +## Consistent backups: the hooks wire themselves A file-level copy of a running database is crash-consistent at best. Hola fixes that by running each app's own pre/post hooks around the capture — but it has to be @@ -48,8 +48,26 @@ This bundle installs two scripts for the purpose: | `/config/hola/backup-prepare.sh` | `CONDITION_SNAPSHOT_START` | **`ON_ERROR_CANCEL`** | | `/config/hola/backup-finalize.sh` | `CONDITION_SNAPSHOT_END` | `ON_ERROR_IGNORE` | -Add both as **Command** hooks on the repo (or on an individual plan) in the -Backrest UI, with the command set to the script path. +**You do not have to add these yourself.** Backrest has no global hooks — a hook +hangs off a repository or a plan — so a script on disk does nothing until it is +attached to something, and at install time there is no repository yet. This +bundle therefore runs a small reconciler alongside Backrest that attaches both +hooks to every repository you create, through Backrest's own API, and keeps them +attached. A repository you add next month is wired within 30 seconds. + +They attach at the **repository** level, which covers every plan backing onto +that repository — add as many plans as you like, the wiring is already done. + +Before 2.1.0 this was a manual step, and skipping it was silent: the snapshot +still ran, it just copied live database files, while Hola's dashboard reported +the accepting apps as quiesced (#159). If you wired the hooks by hand on an +earlier version, nothing changes — the reconciler recognises them by command +path and leaves them alone. + +**To manage the hooks yourself**, set `BACKREST_HOLA_AUTOWIRE=false` in the app's +configuration. Nothing is added and nothing already there is removed; add both as +**Command** hooks on the repository (or an individual plan) in the Backrest UI, +with the command set to the script path and the error behavior in the table above. `backup-prepare.sh` asks Hola to run every accepting app's `preHook` and waits for them to finish before returning, so restic starts reading only once the dumps are @@ -75,9 +93,17 @@ the contract, but now **disclosed to you for consent at install** rather than on to whoever reviewed the bundle. Expect a permission prompt when you promote; the app keeps the mount only if you approve it. -2.0 also adds the hook scripts above. They're new files under `/config/hola/` — -your existing repos, plans, schedules and Backrest config are untouched, and the -hooks do nothing until you add them in the UI. +2.0 also adds the hook scripts above, as new files under `/config/hola/`. + +## Upgrading from 2.0.x + +2.0 wrote the hook scripts but never registered them, so unless you wired them by +hand in the Backrest UI they never ran (#159) — and the dashboard reported the +accepting apps as quiesced anyway. 2.1 adds the reconciler that registers them +for you. On first start after the upgrade it adds both hooks to every existing +repository; if you already wired them by hand, it recognises them and leaves them +alone. Your repos, plans, schedules, retention and encryption passwords are +untouched. ## Notes diff --git a/src/backrest/package.json b/src/backrest/package.json index ebe86d0..8581ee9 100644 --- a/src/backrest/package.json +++ b/src/backrest/package.json @@ -1,6 +1,6 @@ { "name": "backrest", - "version": "2.0.0", + "version": "2.1.0", "description": "Backrest — backup orchestrator (restic) with a web UI (Hola app package)", "license": "MIT", "oci": { diff --git a/src/backrest/src/compose.yaml b/src/backrest/src/compose.yaml index 88762fa..4e68048 100644 --- a/src/backrest/src/compose.yaml +++ b/src/backrest/src/compose.yaml @@ -155,3 +155,121 @@ services: echo "[hola] backup contract hooks installed in /config/hola" volumes: - ${HOLA_APP_DATA}/config:/config + + # Hola backup-contract bolt-on, part two: KEEPING the hooks wired. + # + # Part one (above) writes the scripts. On its own that is not enough, and the + # gap was silent: Backrest has no global hooks — `Hook` hangs off `Repo` and + # `Plan` only — so a script sitting in /config/hola is never called until it is + # attached to a repository. At install time there is no repository to attach it + # to; the operator creates one later, in the UI. Until #159 this bundle stopped + # after writing the files, so unless the operator hand-wired two Command hooks + # every snapshot was a live-file copy — while Hola's dashboard, which only + # knows that this app *declares* `provides: backup@1`, reported the accepting + # apps as quiesced. Both facts the operator saw were wrong in the reassuring + # direction. + # + # So this reconciler keeps every repository wired, for as long as the app runs. + # It attaches the hooks at REPO level, not plan level: Backrest fires a repo's + # hooks for every plan that backs onto it (internal/hook/hook.go, + # TasksTriggeredByEvent), so one wiring covers every plan the operator ever + # adds to that repository. + # + # It goes through Backrest's own API rather than editing config.json, and that + # is not a style preference — ConfigManager caches the parsed config in memory + # and only invalidates on its own writes, so a file edit under a running + # Backrest is read back only after a restart, and the next UI save silently + # overwrites it. SetConfig takes the read-modify-write through the same path + # the UI uses: `modno` gives optimistic concurrency (a mismatch is refused, and + # the next pass re-reads), and GetConfig does not redact repository passwords, + # so a round-trip preserves them. + # + # Backrest's own auth is disabled by default (config.NewDefaultConfig sets + # auth.disabled), and Hola gates the app at Traefik with forward-auth, so this + # call needs no credential. If an operator turns Backrest's login on, this + # reconciler starts getting 401s — it says so once per failure and keeps + # trying, and Hola's Backups page reports the provider as never having + # announced a backup. + # + # `mikefarah/yq` is the same image Homepage's bolt-on uses. It carries `yq` + # (which reads and writes JSON natively), BusyBox `wget` (which does POST with + # headers) and a shell — the three things this needs, on a pinned digest, with + # no custom build. + backrest-hola-autowire: + image: mikefarah/yq:4.53.3@sha256:11a1f0b604b13dbbdc662260d8db6f644b22d8553122a25c1b5b2e8713ca6977 + user: "0:0" + depends_on: + - backrest + environment: + # `false` hands the two hooks back to the operator: nothing is added, and + # nothing already there is removed. + BACKREST_HOLA_AUTOWIRE: ${BACKREST_HOLA_AUTOWIRE:-true} + entrypoint: ["/bin/sh", "-c"] + # NOTE for editors: `$$` is a literal `$` after Compose interpolation, and + # `${HOLA_...}` is a Hola deploy-time token — neither survives being written + # the other way round. + command: + - | + set -u + api=http://backrest:9898 + prepare=/config/hola/backup-prepare.sh + finalize=/config/hola/backup-finalize.sh + interval=30 + warned= + + call() { # call + wget -q -O - --header 'Content-Type: application/json' \ + --post-file "$$2" "$$api/v1.Backrest/$$1" + } + + if [ "$${BACKREST_HOLA_AUTOWIRE:-true}" != "true" ]; then + echo "[hola] BACKREST_HOLA_AUTOWIRE=false — leaving Backrest's hooks to you." + echo "[hola] Wire /config/hola/backup-prepare.sh on SNAPSHOT_START (ON_ERROR_CANCEL)" + echo "[hola] and /config/hola/backup-finalize.sh on SNAPSHOT_END (ON_ERROR_IGNORE)," + echo "[hola] or every snapshot copies live database files." + # Stay up rather than exit: a one-shot that completes looks identical + # to one that was never asked to run, and Compose would restart it. + while :; do sleep 3600; done + fi + + echo "[hola] keeping Backrest's repositories wired to the backup contract (every $${interval}s)" + + while :; do + if ! printf '{}' > /tmp/req.json || ! cur=$$(call GetConfig /tmp/req.json); then + if [ "$$warned" != "unreachable" ]; then + echo "[hola] cannot read Backrest's config — is its own login enabled? retrying" >&2 + warned=unreachable + fi + sleep "$$interval"; continue + fi + warned= + + next=$$(printf '%s' "$$cur" | yq -p json -o json -I 0 " + with(.repos[] | select([.hooks[].actionCommand.command] | contains([\"$$prepare\"]) | not); + .hooks = ((.hooks // []) + [{ + \"conditions\": [\"CONDITION_SNAPSHOT_START\"], + \"onError\": \"ON_ERROR_CANCEL\", + \"actionCommand\": {\"command\": \"$$prepare\"} + }])) | + with(.repos[] | select([.hooks[].actionCommand.command] | contains([\"$$finalize\"]) | not); + .hooks = ((.hooks // []) + [{ + \"conditions\": [\"CONDITION_SNAPSHOT_END\"], + \"onError\": \"ON_ERROR_IGNORE\", + \"actionCommand\": {\"command\": \"$$finalize\"} + }])) + " 2>/dev/null) + + if [ -z "$$next" ]; then + echo "[hola] could not read Backrest's config as JSON — leaving it untouched" >&2 + elif [ "$$next" != "$$cur" ]; then + printf '%s' "$$next" > /tmp/next.json + if call SetConfig /tmp/next.json >/dev/null; then + echo "[hola] wired the backup-contract hooks into Backrest" + else + # Almost always a modno race with someone saving in the UI. The + # next pass re-reads and reapplies, so this is noted, not retried. + echo "[hola] could not write Backrest's config (config changed under us?) — retrying" >&2 + fi + fi + sleep "$$interval" + done diff --git a/src/backrest/src/manifest.json b/src/backrest/src/manifest.json index c3783ec..26e2d11 100644 --- a/src/backrest/src/manifest.json +++ b/src/backrest/src/manifest.json @@ -1,6 +1,6 @@ { "name": "backrest", - "version": "2.0.0", + "version": "2.1.0", "title": "Backrest", "description": "Backup orchestrator (restic) with a web UI", "icon": "https://raw.githubusercontent.com/try-hola/apps/main/icons/backrest.svg", @@ -8,16 +8,45 @@ "service": "backrest", "port": 9898 }, - "provides": ["backup@1"], - "accepts": ["backup@1"], - "defaultEnv": [], + "provides": [ + "backup@1" + ], + "accepts": [ + "backup@1" + ], + "defaultEnv": [ + { + "key": "BACKREST_HOLA_AUTOWIRE", + "value": "true", + "isSecret": false, + "label": "Wire the Hola backup hooks automatically", + "type": "enum", + "options": [ + { + "value": "true" + }, + { + "value": "false" + } + ], + "advanced": true, + "description": "Keep every Backrest repository wired to Hola’s backup-contract hooks, so a snapshot quiesces the apps that need it. Set to false to manage the two Command hooks yourself in the Backrest UI — Hola will then no longer add them back, and an unwired repository captures live database files." + } + ], "defaults": { "ports": [ - { "container": 9898, "protocol": "tcp" } + { + "container": 9898, + "protocol": "tcp" + } ], "volumes": [ - { "containerPath": "/config" }, - { "containerPath": "/data" } + { + "containerPath": "/config" + }, + { + "containerPath": "/data" + } ] }, "auth": {