Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 35 additions & 9 deletions src/backrest/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand All @@ -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 `<deploymentId>/` 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
Expand All @@ -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
Expand All @@ -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

Expand Down
2 changes: 1 addition & 1 deletion src/backrest/package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down
118 changes: 118 additions & 0 deletions src/backrest/src/compose.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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 <procedure> <json-body-file|->
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
43 changes: 36 additions & 7 deletions src/backrest/src/manifest.json
Original file line number Diff line number Diff line change
@@ -1,23 +1,52 @@
{
"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",
"ingress": {
"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": {
Expand Down
Loading