backup-client: restic backup of k8s state to pi4 (#45) #50

Open
hermes-bot wants to merge 2 commits from issue-45-k8s-backup into main
Member

Implements Option A from the thread: OS-level restic on the storage hosts, zero cluster diff.

What lands

  • modules/backup-client.nix (new): one nightly services.restic.backups profile per host, creds via sops-nix (environmentFile, never inlined into unit files). Modes:
    • btrfsSnapshot = true (horse/moose): pre-start stages read-only btrfs snapshots of every PV subvolume under /volume/k8s into /volume/.backup-snapshots/<name>/, restic uploads that tree, post-stop deletes it. A missing source (e.g. unmounted /volume) or a non-btrfs tree fails the run loudly — verified behaviorally, never backs up an empty tree silently.
    • plain (lurch): ships etcd snapshot files to pi4.
  • Repo init idempotent; prune keeps daily×14 / weekly×8 / monthly×6 (module auto-runs forget --prune when pruneOpts is set); runCheck with --read-data-subset=2% re-verifies every run.
  • lurch: etcd-snapshot.timer (6h, Persistent) runs etcdctl snapshot save; 12 local files kept. The kubernetes module's etcd listens TLS-only on 127.0.0.1:2379 with clientCertAuth = true (verified by evaluating lurch's config), so the job authenticates with the same client certs kube-apiserver uses (/var/lib/kubernetes/secrets/kube-apiserver-etcd-client*.pem, certmgr-managed).
  • pi4: backup.serverUsers extends the htpasswd with per-host users from bcrypt-hash secrets — one leaked host credential can't touch other repos. Server itself otherwise untouched; no new ports (8000 already open).
  • Hosts wired in flake.nix for lurch/horse/moose; serverUsers set for pi4.

Secrets to add before deploy (sops modules/secrets.yaml)

Values minted by you; repo password must be shared per-repo between client and any restore site:

Host Secret key Contents
horse restic-repo-k8s-horse repo password for k8s-horse
horse restic-htpasswd-horse plaintext REST password (server stores its bcrypt hash)
moose restic-repo-k8s-moose repo password
moose restic-htpasswd-moose plaintext REST password
lurch restic-repo-k8s-etcd repo password
lurch restic-htpasswd-lurch plaintext REST password
pi4 restic-htpasswd-hash-horse bcrypt hash only — the field after the colon in htpasswd -bnB horse output (drop its trailing :*: padding line)
pi4 restic-htpasswd-hash-moose ditto moose
pi4 restic-htpasswd-hash-lurch ditto lurch

Hosts will refuse to activate until their secrets exist (sops-nix default) — deploy pi4 first (it only gains users), then the clients.

Known caveat: moose /volume

hosts/moose.nix still has /volume commented out (placeholder UUID) — the nightly job will fail loudly with "backup source missing" until you land that mount. Intentional; the alternative is a green checkmark over an empty tree.

Verification (all executed against this branch)

  • Full nix eval of nixosConfigurations.{lurch,horse,moose,pi4}: units render — restic-backups-k8s(.timer) on horse/moose, restic-backups-k8s-etcd + etcd-snapshot(.timer) on lurch, EnvironmentFile → sops-rendered env templates on all three.
  • Rendered pi4 htpasswd template: backup + horse/moose/lurch lines with SOPS placeholders.
  • Lurch etcd TLS facts confirmed from the live eval: listenClientUrls=[https://127.0.0.1:2379], clientCertAuth=true, apiserver client cert = kube-apiserver-etcd-client.pem → snapshot script renders matching --cacert/--cert/--key flags.
  • Extracted the generated restic pre/post scripts from their derivations: bash -n clean; staged-sweep logic behavior-tested with a stubbed btrfs (fails loudly on missing source and on non-btrfs tree).
  • pkgs.etcd (3.6.14) resolved for lurch includes etcdctl.

Not done here (deliberately): the restore drill + restic check run on live data, and moose's /volume mount — both need the hardware/credentials. Suggest running the drill before the nixos-anywhere migration, as discussed.

Closes #45

Implements Option A from the thread: OS-level restic on the storage hosts, zero cluster diff. ## What lands - **`modules/backup-client.nix`** (new): one nightly `services.restic.backups` profile per host, creds via sops-nix (`environmentFile`, never inlined into unit files). Modes: - `btrfsSnapshot = true` (horse/moose): pre-start stages read-only btrfs snapshots of every PV subvolume under `/volume/k8s` into `/volume/.backup-snapshots/<name>/`, restic uploads that tree, post-stop deletes it. A missing source (e.g. unmounted `/volume`) or a non-btrfs tree **fails the run loudly** — verified behaviorally, never backs up an empty tree silently. - plain (lurch): ships etcd snapshot files to pi4. - Repo init idempotent; prune keeps daily×14 / weekly×8 / monthly×6 (module auto-runs `forget --prune` when `pruneOpts` is set); `runCheck` with `--read-data-subset=2%` re-verifies every run. - **lurch**: `etcd-snapshot.timer` (6h, Persistent) runs `etcdctl snapshot save`; 12 local files kept. The kubernetes module's etcd listens **TLS-only** on `127.0.0.1:2379` with `clientCertAuth = true` (verified by evaluating lurch's config), so the job authenticates with the same client certs kube-apiserver uses (`/var/lib/kubernetes/secrets/kube-apiserver-etcd-client*.pem`, certmgr-managed). - **pi4**: `backup.serverUsers` extends the htpasswd with per-host users from bcrypt-hash secrets — one leaked host credential can't touch other repos. Server itself otherwise untouched; no new ports (8000 already open). - Hosts wired in `flake.nix` for lurch/horse/moose; serverUsers set for pi4. ## Secrets to add before deploy (sops `modules/secrets.yaml`) Values minted by you; repo password must be shared per-repo between client and any restore site: | Host | Secret key | Contents | |---|---|---| | horse | `restic-repo-k8s-horse` | repo password for `k8s-horse` | | horse | `restic-htpasswd-horse` | plaintext REST password (server stores its bcrypt hash) | | moose | `restic-repo-k8s-moose` | repo password | | moose | `restic-htpasswd-moose` | plaintext REST password | | lurch | `restic-repo-k8s-etcd` | repo password | | lurch | `restic-htpasswd-lurch` | plaintext REST password | | pi4 | `restic-htpasswd-hash-horse` | bcrypt hash only — the field after the colon in `htpasswd -bnB horse` output (drop its trailing `:*:` padding line) | | pi4 | `restic-htpasswd-hash-moose` | ditto moose | | pi4 | `restic-htpasswd-hash-lurch` | ditto lurch | Hosts will refuse to activate until their secrets exist (sops-nix default) — deploy pi4 first (it only gains users), then the clients. ## Known caveat: moose `/volume` `hosts/moose.nix` still has `/volume` commented out (placeholder UUID) — the nightly job will **fail loudly** with "backup source missing" until you land that mount. Intentional; the alternative is a green checkmark over an empty tree. ## Verification (all executed against this branch) - Full `nix eval` of `nixosConfigurations.{lurch,horse,moose,pi4}`: units render — `restic-backups-k8s(.timer)` on horse/moose, `restic-backups-k8s-etcd` + `etcd-snapshot(.timer)` on lurch, `EnvironmentFile` → sops-rendered env templates on all three. - Rendered pi4 htpasswd template: `backup` + `horse`/`moose`/`lurch` lines with SOPS placeholders. - Lurch etcd TLS facts confirmed from the live eval: `listenClientUrls=[https://127.0.0.1:2379]`, `clientCertAuth=true`, apiserver client cert = `kube-apiserver-etcd-client.pem` → snapshot script renders matching `--cacert/--cert/--key` flags. - Extracted the generated restic pre/post scripts from their derivations: `bash -n` clean; staged-sweep logic behavior-tested with a stubbed `btrfs` (fails loudly on missing source and on non-btrfs tree). - `pkgs.etcd` (3.6.14) resolved for lurch includes `etcdctl`. Not done here (deliberately): the restore drill + `restic check` run on live data, and moose's `/volume` mount — both need the hardware/credentials. Suggest running the drill before the nixos-anywhere migration, as discussed. Closes #45
New module 'backup-client' declares one nightly restic job per host
with credentials from sops-nix:

- horse/moose: read-only btrfs snapshots of the PV subvolumes under
  /volume/k8s are staged, uploaded, and deleted; the backup never
  reads live database pages, and a missing source (unmounted
  /volume) fails the run loudly instead of skipping silently.
- lurch: an etcd-snapshot timer runs 'etcdctl snapshot save' against
  the local etcd every 6h, the nightly restic job uploads the
  snapshots; 12 local files are kept. The kubernetes module's etcd
  listens TLS-only on 127.0.0.1:2379 with client-cert auth (verified
  by evaluating the lurch config), so the snapshot job authenticates
  with the kube-apiserver etcd client certificates.
- pi4: backup.serverUsers adds per-host REST users to the htpasswd
  from per-user bcrypt hash secrets (restic-htpasswd-hash-<user>).

Repo init is idempotent (restic module), prune keeps daily 14 /
weekly 8 / monthly 6, and every run re-verifies a 2% random subset
of the repository. Restore: 'restic-<name> restore latest'.
Owner

There appears to be an error in your service definition

Job for restic-backups-k8s-etcd.service failed because of unavailable resources or another system error.
See "systemctl status restic-backups-k8s-etcd.service" and "journalctl -xeu restic-backups-k8s-etcd.service" for details.

[root@lurch:~]# journalctl -xeu restic-backups-k8s-etcd.service
~
~
~
~
~
Nov 07 07:42:30 lurch systemd[1]: restic-backups-k8s-etcd.service: Failed to load environment files: No such file or directory
Nov 07 07:42:30 lurch systemd[1]: restic-backups-k8s-etcd.service: Failed to spawn 'start-pre' task: No such file or directory
Nov 07 07:42:30 lurch systemd[1]: restic-backups-k8s-etcd.service: Failed with result 'resources'.
░░ Subject: Unit failed
░░ Defined-By: systemd
░░ Support: https://lists.freedesktop.org/mailman/listinfo/systemd-devel
░░
░░ The unit restic-backups-k8s-etcd.service has entered the 'failed' state with result 'resources'.
Nov 07 07:42:30 lurch systemd[1]: Failed to start restic-backups-k8s-etcd.service.
░░ Subject: A start job for unit restic-backups-k8s-etcd.service has failed
░░ Defined-By: systemd
░░ Support: https://lists.freedesktop.org/mailman/listinfo/systemd-devel
░░
░░ A start job for unit restic-backups-k8s-etcd.service has finished with a failure.
░░
░░ The job identifier is 755093 and the job result is failed.
[root@horse:~]# journalctl -xeu restic-backups-k8s.service
~
~
~
~
~
Nov 07 07:52:07 horse systemd[1]: restic-backups-k8s.service: Failed to load environment files: No such file or directory
Nov 07 07:52:07 horse systemd[1]: restic-backups-k8s.service: Failed to spawn 'start-pre' task: No such file or directory
Nov 07 07:52:07 horse systemd[1]: restic-backups-k8s.service: Failed with result 'resources'.
░░ Subject: Unit failed
░░ Defined-By: systemd
░░ Support: https://lists.freedesktop.org/mailman/listinfo/systemd-devel
░░
░░ The unit restic-backups-k8s.service has entered the 'failed' state with result 'resources'.
Nov 07 07:52:07 horse systemd[1]: Failed to start restic-backups-k8s.service.
░░ Subject: A start job for unit restic-backups-k8s.service has failed
░░ Defined-By: systemd
░░ Support: https://lists.freedesktop.org/mailman/listinfo/systemd-devel
░░
░░ A start job for unit restic-backups-k8s.service has finished with a failure.
░░
░░ The job identifier is 279566 and the job result is failed.
There appears to be an error in your service definition ```[root@lurch:~]# systemctl start restic-backups-k8s-etcd.service Job for restic-backups-k8s-etcd.service failed because of unavailable resources or another system error. See "systemctl status restic-backups-k8s-etcd.service" and "journalctl -xeu restic-backups-k8s-etcd.service" for details. [root@lurch:~]# journalctl -xeu restic-backups-k8s-etcd.service ~ ~ ~ ~ ~ Nov 07 07:42:30 lurch systemd[1]: restic-backups-k8s-etcd.service: Failed to load environment files: No such file or directory Nov 07 07:42:30 lurch systemd[1]: restic-backups-k8s-etcd.service: Failed to spawn 'start-pre' task: No such file or directory Nov 07 07:42:30 lurch systemd[1]: restic-backups-k8s-etcd.service: Failed with result 'resources'. ░░ Subject: Unit failed ░░ Defined-By: systemd ░░ Support: https://lists.freedesktop.org/mailman/listinfo/systemd-devel ░░ ░░ The unit restic-backups-k8s-etcd.service has entered the 'failed' state with result 'resources'. Nov 07 07:42:30 lurch systemd[1]: Failed to start restic-backups-k8s-etcd.service. ░░ Subject: A start job for unit restic-backups-k8s-etcd.service has failed ░░ Defined-By: systemd ░░ Support: https://lists.freedesktop.org/mailman/listinfo/systemd-devel ░░ ░░ A start job for unit restic-backups-k8s-etcd.service has finished with a failure. ░░ ░░ The job identifier is 755093 and the job result is failed. ``` ``` [root@horse:~]# journalctl -xeu restic-backups-k8s.service ~ ~ ~ ~ ~ Nov 07 07:52:07 horse systemd[1]: restic-backups-k8s.service: Failed to load environment files: No such file or directory Nov 07 07:52:07 horse systemd[1]: restic-backups-k8s.service: Failed to spawn 'start-pre' task: No such file or directory Nov 07 07:52:07 horse systemd[1]: restic-backups-k8s.service: Failed with result 'resources'. ░░ Subject: Unit failed ░░ Defined-By: systemd ░░ Support: https://lists.freedesktop.org/mailman/listinfo/systemd-devel ░░ ░░ The unit restic-backups-k8s.service has entered the 'failed' state with result 'resources'. Nov 07 07:52:07 horse systemd[1]: Failed to start restic-backups-k8s.service. ░░ Subject: A start job for unit restic-backups-k8s.service has failed ░░ Defined-By: systemd ░░ Support: https://lists.freedesktop.org/mailman/listinfo/systemd-devel ░░ ░░ A start job for unit restic-backups-k8s.service has finished with a failure. ░░ ░░ The job identifier is 279566 and the job result is failed. ```
Author
Member

Root cause: not the unit definitions — the sops secrets this PR references were never added to modules/secrets.yaml.

The branch references nine vault keys that do not exist in the file:

Host Keys needed
horse restic-repo-k8s-horse, restic-htpasswd-horse
moose restic-repo-k8s-moose, restic-htpasswd-moose
lurch restic-repo-k8s-etcd, restic-htpasswd-lurch
pi4 (via backup.serverUsers) restic-htpasswd-hash-horse, restic-htpasswd-hash-moose, restic-htpasswd-hash-lurch

sops-install-secrets is all-or-nothing: the first unknown key aborts the whole manifest, so nothing gets (re)installed and /run/secrets/rendered/backup-client-k8s-env never appears. systemd then can't load the restic unit's EnvironmentFile → Failed to load environment files / result 'resources'. On lurch it's the k8s-etcd unit, on horse the k8s unit — same cause both hosts. Confirm on either:

journalctl -u sops-install-secrets.service | grep 'cannot be found'
# secret restic-repo-k8s-horse in /nix/store/...-secrets.yaml is not valid: the key 'restic-repo-k8s-horse' cannot be found

⚠️ Hosts already running keep their pre-existing /run/secrets files (a failed run skips install, it doesn't delete), but a horse/moose/lurch reboot before this is fixed boots with an empty /run/secrets — every sops-fed service on those hosts breaks, not just restic.

Fix

sops edit modules/secrets.yaml and add the nine keys (I can't do this step — no recipient key for the vault from my side). Values are exact strings with no trailing newline, and the REST password and its bcrypt hash must be a pair (client sends the password; pi4's htpasswd checks the hash):

gen()  { openssl rand -base64 32 | tr -d '\n'; }   # repo passwords
pw()   { openssl rand -base64 18 | tr -d '\n'; }   # REST passwords
hash() { htpasswd -bnB "$1" "$2" | tr -d '\n'; }   # bcrypt hash for that password
restic-repo-k8s-horse: <gen>
restic-htpasswd-horse: <pw>
restic-htpasswd-hash-horse: "<hash horse <that pw>>"   # quote: bcrypt contains $ and :
# same triplet for moose; for lurch: restic-repo-k8s-etcd + restic-htpasswd-lurch + restic-htpasswd-hash-lurch

Rollout

  1. Add the keys, commit to this branch.
  2. pi4 first: nixos-rebuild switch (renders the three new users into the htpasswd; pi4's live system is still on main, which is why it is healthy today).
  3. Clients: nixos-rebuild switch, then verify:
    systemctl restart sops-install-secrets
    systemctl start restic-backups-k8s && journalctl -u restic-backups-k8s -e   # horse
    systemctl start restic-backups-k8s-etcd && journalctl -u restic-backups-k8s-etcd -e   # lurch
    

Expected after that: repo init on first run, then snapshot→upload→prune→check. moose will still fail nightly with backup source missing: /volume/k8s until its /volume mount is uncommented — intentional, it refuses to back up nothing.

Root cause: not the unit definitions — the sops secrets this PR references were never added to `modules/secrets.yaml`. The branch references nine vault keys that do not exist in the file: | Host | Keys needed | |---|---| | horse | `restic-repo-k8s-horse`, `restic-htpasswd-horse` | | moose | `restic-repo-k8s-moose`, `restic-htpasswd-moose` | | lurch | `restic-repo-k8s-etcd`, `restic-htpasswd-lurch` | | pi4 (via `backup.serverUsers`) | `restic-htpasswd-hash-horse`, `restic-htpasswd-hash-moose`, `restic-htpasswd-hash-lurch` | `sops-install-secrets` is all-or-nothing: the first unknown key aborts the whole manifest, so nothing gets (re)installed and `/run/secrets/rendered/backup-client-k8s-env` never appears. systemd then can't load the restic unit's `EnvironmentFile` → `Failed to load environment files` / `result 'resources'`. On lurch it's the `k8s-etcd` unit, on horse the `k8s` unit — same cause both hosts. Confirm on either: ``` journalctl -u sops-install-secrets.service | grep 'cannot be found' # secret restic-repo-k8s-horse in /nix/store/...-secrets.yaml is not valid: the key 'restic-repo-k8s-horse' cannot be found ``` ⚠️ Hosts already running keep their pre-existing `/run/secrets` files (a failed run skips install, it doesn't delete), but **a horse/moose/lurch reboot before this is fixed boots with an empty `/run/secrets`** — every sops-fed service on those hosts breaks, not just restic. ## Fix `sops edit modules/secrets.yaml` and add the nine keys (I can't do this step — no recipient key for the vault from my side). Values are exact strings with **no trailing newline**, and the REST password and its bcrypt hash must be a pair (client sends the password; pi4's htpasswd checks the hash): ```bash gen() { openssl rand -base64 32 | tr -d '\n'; } # repo passwords pw() { openssl rand -base64 18 | tr -d '\n'; } # REST passwords hash() { htpasswd -bnB "$1" "$2" | tr -d '\n'; } # bcrypt hash for that password ``` ```yaml restic-repo-k8s-horse: <gen> restic-htpasswd-horse: <pw> restic-htpasswd-hash-horse: "<hash horse <that pw>>" # quote: bcrypt contains $ and : # same triplet for moose; for lurch: restic-repo-k8s-etcd + restic-htpasswd-lurch + restic-htpasswd-hash-lurch ``` ## Rollout 1. Add the keys, commit to this branch. 2. **pi4 first**: `nixos-rebuild switch` (renders the three new users into the htpasswd; pi4's live system is still on main, which is why it is healthy today). 3. Clients: `nixos-rebuild switch`, then verify: ``` systemctl restart sops-install-secrets systemctl start restic-backups-k8s && journalctl -u restic-backups-k8s -e # horse systemctl start restic-backups-k8s-etcd && journalctl -u restic-backups-k8s-etcd -e # lurch ``` Expected after that: repo init on first run, then snapshot→upload→prune→check. moose will still fail nightly with `backup source missing: /volume/k8s` until its `/volume` mount is uncommented — intentional, it refuses to back up nothing.
This pull request can be merged automatically.
You are not authorized to merge this pull request.
View command line instructions

Checkout

From your project repository, check out a new branch and test the changes.
git fetch -u origin issue-45-k8s-backup:issue-45-k8s-backup
git switch issue-45-k8s-backup

Merge

Merge the changes and update on Forgejo.

Warning: The "Autodetect manual merge" setting is not enabled for this repository, you will have to mark this pull request as manually merged afterwards.

git switch main
git merge --no-ff issue-45-k8s-backup
git switch issue-45-k8s-backup
git rebase main
git switch main
git merge --ff-only issue-45-k8s-backup
git switch issue-45-k8s-backup
git rebase main
git switch main
git merge --no-ff issue-45-k8s-backup
git switch main
git merge --squash issue-45-k8s-backup
git switch main
git merge --ff-only issue-45-k8s-backup
git switch main
git merge issue-45-k8s-backup
git push origin main
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
2 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
richardpickler.com/nixos!50
No description provided.