C1: fix borgmatic shower SQLite dump (ssh to ringtail) (#357)
## Summary Nightly borgmatic backups have been failing for 2 days. Root cause: the shower SQLite dump `before_backup` hook (added in PR #349) referenced `kubectl --context=k3s-ringtail`, but indri's kubeconfig deliberately doesn't carry the ringtail credentials. The hook's failure aborted the entire run, taking out *both* the local sifaka repo and the BorgBase offsite. Verified the last good archive was `indri-2026-05-11T02:00`. ## Approach ssh into ringtail and run `k3s kubectl` there — no indri-side kubeconfig needed. `/etc/rancher/k3s/k3s.yaml` is mode 644 so no sudo required, and the existing ssh access from indri to ringtail works. Inline-shell quoting got hairy fast (fish on ringtail rejected `POD=...` bash syntax; the nix shower image lacks `tar` so `kubectl cp` fails). Pulled the dump logic into `~/bin/borgmatic-k8s-sqlite-dump`, deployed by the ansible role. Each dump entry now declares a `target`: - `local:<context>` — local kubectl with explicit context (mealie) - `ssh:<user@host>` — ssh + `k3s kubectl` on the cluster host (shower) Bytes come back via `kubectl exec ... -- cat` instead of `kubectl cp` since `cp` needs `tar` in the pod (nix-built containers don't bundle it). ## Test plan - [x] `mise run provision-indri -- --tags borgmatic --check --diff` shows expected diff - [x] Apply, helper script deployed at `~/bin/borgmatic-k8s-sqlite-dump` - [x] Helper invoked directly with `ssh:eblume@ringtail` produces a valid 288 KB SQLite file - [x] Full `borgmatic create` completes without errors — both mealie.db (1.7 MB) and shower.db (288 KB) appear in `~/.local/share/borgmatic/k8s-dumps/`, archive `indri-2026-05-13T17:31:02` written to sifaka borg repo 🤖 Generated with [Claude Code](https://claude.com/claude-code) Reviewed-on: #357
This commit is contained in:
parent
947e4310c3
commit
dc69b8c68b
5 changed files with 116 additions and 5 deletions
|
|
@ -56,12 +56,16 @@ borgmatic_k8s_sqlite_dumps:
|
||||||
namespace: mealie
|
namespace: mealie
|
||||||
label_selector: app=mealie
|
label_selector: app=mealie
|
||||||
db_path: /app/data/mealie.db
|
db_path: /app/data/mealie.db
|
||||||
context: minikube
|
# local kubectl, --context=minikube (indri's only configured ctx)
|
||||||
|
target: local:minikube
|
||||||
- name: shower
|
- name: shower
|
||||||
namespace: shower
|
namespace: shower
|
||||||
label_selector: app=shower
|
label_selector: app=shower
|
||||||
db_path: /app/data/db.sqlite3
|
db_path: /app/data/db.sqlite3
|
||||||
context: k3s-ringtail
|
# ssh to ringtail and run k3s kubectl there — avoids needing a
|
||||||
|
# ringtail kubeconfig on indri. k3s.yaml on ringtail is
|
||||||
|
# world-readable (mode 644), so no sudo required.
|
||||||
|
target: ssh:eblume@ringtail
|
||||||
|
|
||||||
# Exclude patterns
|
# Exclude patterns
|
||||||
borgmatic_exclude_patterns: []
|
borgmatic_exclude_patterns: []
|
||||||
|
|
|
||||||
|
|
@ -49,6 +49,20 @@
|
||||||
mode: '0700'
|
mode: '0700'
|
||||||
when: borgmatic_k8s_sqlite_dumps | length > 0
|
when: borgmatic_k8s_sqlite_dumps | length > 0
|
||||||
|
|
||||||
|
- name: Ensure ~/bin exists
|
||||||
|
ansible.builtin.file:
|
||||||
|
path: "{{ ansible_env.HOME }}/bin"
|
||||||
|
state: directory
|
||||||
|
mode: '0755'
|
||||||
|
when: borgmatic_k8s_sqlite_dumps | length > 0
|
||||||
|
|
||||||
|
- name: Deploy k8s SQLite dump helper script
|
||||||
|
ansible.builtin.template:
|
||||||
|
src: k8s-sqlite-dump.sh.j2
|
||||||
|
dest: "{{ ansible_env.HOME }}/bin/borgmatic-k8s-sqlite-dump"
|
||||||
|
mode: '0755'
|
||||||
|
when: borgmatic_k8s_sqlite_dumps | length > 0
|
||||||
|
|
||||||
- name: Deploy borgmatic configuration
|
- name: Deploy borgmatic configuration
|
||||||
ansible.builtin.template:
|
ansible.builtin.template:
|
||||||
src: config.yaml.j2
|
src: config.yaml.j2
|
||||||
|
|
|
||||||
|
|
@ -32,12 +32,20 @@ exclude_patterns:
|
||||||
encryption_passcommand: {{ borgmatic_encryption_passcommand }}
|
encryption_passcommand: {{ borgmatic_encryption_passcommand }}
|
||||||
|
|
||||||
{% if borgmatic_k8s_sqlite_dumps %}
|
{% if borgmatic_k8s_sqlite_dumps %}
|
||||||
# Pre-backup: dump SQLite databases from k8s pods
|
# Pre-backup: dump SQLite databases from k8s pods.
|
||||||
# Uses sqlite3 .backup for a safe, consistent copy (no corruption from concurrent writes)
|
# Uses sqlite3.backup() for a safe, consistent copy.
|
||||||
|
#
|
||||||
|
# Quoting/escaping is delegated to ~/bin/borgmatic-k8s-sqlite-dump
|
||||||
|
# (deployed by the borgmatic ansible role). Each entry's `target`
|
||||||
|
# is either:
|
||||||
|
# - local:<context> -> local kubectl with --context (mealie etc.)
|
||||||
|
# - ssh:<user@host> -> ssh + k3s kubectl on the cluster host,
|
||||||
|
# used for ringtail since indri's kubeconfig
|
||||||
|
# deliberately doesn't carry that context.
|
||||||
before_backup:
|
before_backup:
|
||||||
- mkdir -p {{ borgmatic_k8s_dump_dir }}
|
- mkdir -p {{ borgmatic_k8s_dump_dir }}
|
||||||
{% for db in borgmatic_k8s_sqlite_dumps %}
|
{% for db in borgmatic_k8s_sqlite_dumps %}
|
||||||
- /opt/homebrew/bin/kubectl --context={{ db.context }} exec -n {{ db.namespace }} deploy/{{ db.name }} -- python3 -c "import sqlite3; sqlite3.connect('{{ db.db_path }}').backup(sqlite3.connect('/tmp/{{ db.name }}-backup.db'))" && /opt/homebrew/bin/kubectl --context={{ db.context }} cp {{ db.namespace }}/$(/opt/homebrew/bin/kubectl --context={{ db.context }} get pod -n {{ db.namespace }} -l {{ db.label_selector }} -o jsonpath='{.items[0].metadata.name}'):/tmp/{{ db.name }}-backup.db {{ borgmatic_k8s_dump_dir }}/{{ db.name }}.db
|
- {{ ansible_env.HOME }}/bin/borgmatic-k8s-sqlite-dump {{ db.target }} {{ db.namespace }} {{ db.label_selector }} {{ db.db_path }} {{ db.name }} {{ borgmatic_k8s_dump_dir }}/{{ db.name }}.db
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
|
|
||||||
71
ansible/roles/borgmatic/templates/k8s-sqlite-dump.sh.j2
Normal file
71
ansible/roles/borgmatic/templates/k8s-sqlite-dump.sh.j2
Normal file
|
|
@ -0,0 +1,71 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# {{ ansible_managed }}
|
||||||
|
#
|
||||||
|
# Helper script invoked by borgmatic's before_backup hook to capture a
|
||||||
|
# k8s pod's SQLite database. Keeps the borgmatic config readable by
|
||||||
|
# pulling all the quoting out of YAML.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# borgmatic-k8s-sqlite-dump <target> <namespace> <selector> \
|
||||||
|
# <db_path> <name> <dump_target>
|
||||||
|
#
|
||||||
|
# <target> is one of:
|
||||||
|
# local:<context> - run local kubectl with --context=<context>
|
||||||
|
# ssh:<user@host> - ssh to host and run k3s kubectl there
|
||||||
|
# (no indri-side kubeconfig needed)
|
||||||
|
#
|
||||||
|
# <namespace> - k8s namespace of the pod
|
||||||
|
# <selector> - label selector to find the pod (e.g. app=shower)
|
||||||
|
# <db_path> - absolute path inside the pod to the SQLite DB
|
||||||
|
# <name> - short name used for temp filenames
|
||||||
|
# <dump_target> - file on this host to receive the dump
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
target=${1:?missing target}
|
||||||
|
namespace=${2:?missing namespace}
|
||||||
|
selector=${3:?missing selector}
|
||||||
|
db_path=${4:?missing db path}
|
||||||
|
name=${5:?missing name}
|
||||||
|
dump_target=${6:?missing dump target}
|
||||||
|
|
||||||
|
pod_tmp="/tmp/${name}-backup.db"
|
||||||
|
|
||||||
|
python_backup='import sqlite3; sqlite3.connect("'"$db_path"'").backup(sqlite3.connect("'"$pod_tmp"'"))'
|
||||||
|
|
||||||
|
mode=${target%%:*}
|
||||||
|
ref=${target#*:}
|
||||||
|
|
||||||
|
case "$mode" in
|
||||||
|
local)
|
||||||
|
# Pulls dump bytes out via "kubectl exec -- cat" rather than
|
||||||
|
# "kubectl cp", which would otherwise need tar inside the pod
|
||||||
|
# (nix-built images like shower don't bundle tar).
|
||||||
|
context=$ref
|
||||||
|
kubectl="/opt/homebrew/bin/kubectl --context=$context -n $namespace"
|
||||||
|
pod=$($kubectl get pod -l "$selector" \
|
||||||
|
-o jsonpath='{.items[0].metadata.name}')
|
||||||
|
$kubectl exec "$pod" -- python3 -c "$python_backup"
|
||||||
|
$kubectl exec "$pod" -- cat "$pod_tmp" > "$dump_target"
|
||||||
|
$kubectl exec "$pod" -- rm -f "$pod_tmp"
|
||||||
|
;;
|
||||||
|
ssh)
|
||||||
|
host=$ref
|
||||||
|
# Force bash on the remote (user's login shell on ringtail is
|
||||||
|
# fish). Pipe the script via stdin to dodge nested quoting.
|
||||||
|
# The dump bytes come back over the ssh stdout stream — no
|
||||||
|
# intermediate scp, no tar requirement in the pod.
|
||||||
|
ssh "$host" bash <<EOF > "$dump_target"
|
||||||
|
set -euo pipefail
|
||||||
|
export KUBECONFIG=/etc/rancher/k3s/k3s.yaml
|
||||||
|
pod=\$(k3s kubectl -n "$namespace" get pod -l "$selector" -o jsonpath='{.items[0].metadata.name}')
|
||||||
|
k3s kubectl -n "$namespace" exec "\$pod" -- python3 -c '$python_backup' 1>&2
|
||||||
|
k3s kubectl -n "$namespace" exec "\$pod" -- cat "$pod_tmp"
|
||||||
|
k3s kubectl -n "$namespace" exec "\$pod" -- rm -f "$pod_tmp" 1>&2
|
||||||
|
EOF
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "borgmatic-k8s-sqlite-dump: unknown target mode: $mode" >&2
|
||||||
|
echo " expected local:<context> or ssh:<user@host>" >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
14
docs/changelog.d/fix-borgmatic-shower-via-ssh.bugfix.md
Normal file
14
docs/changelog.d/fix-borgmatic-shower-via-ssh.bugfix.md
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
Fix nightly borgmatic backups failing for 2 days. The shower SQLite
|
||||||
|
dump hook referenced `kubectl --context=k3s-ringtail`, but indri's
|
||||||
|
kubeconfig deliberately doesn't carry the ringtail credentials. The
|
||||||
|
`before_backup` hook's failure aborted the entire run, taking out
|
||||||
|
*both* the local sifaka repo and the BorgBase offsite. Replaced
|
||||||
|
the inline-shell dump with a `~/bin/borgmatic-k8s-sqlite-dump`
|
||||||
|
helper deployed by the ansible role. Each dump entry now declares a
|
||||||
|
`target` of either `local:<context>` (mealie — kubectl uses indri's
|
||||||
|
kubeconfig) or `ssh:<user@host>` (shower — ssh into ringtail and
|
||||||
|
run `k3s kubectl` there, no indri-side kubeconfig needed; k3s.yaml
|
||||||
|
on ringtail is mode 644 so no sudo required). Bytes stream back via
|
||||||
|
`kubectl exec ... -- cat` rather than `kubectl cp`, since `kubectl
|
||||||
|
cp` requires `tar` inside the pod and nix-built images like shower
|
||||||
|
don't bundle it.
|
||||||
Loading…
Add table
Add a link
Reference in a new issue