diff --git a/deploy/server-backup/README.md b/deploy/server-backup/README.md index bb78780..d2b0c50 100644 --- a/deploy/server-backup/README.md +++ b/deploy/server-backup/README.md @@ -63,6 +63,21 @@ sudo systemctl start server-backup.service journalctl -u server-backup.service -f ``` +### Ops-agent wiring (na stap 1-7) + +Voor de **/flows/server-backup**-pagina en **/settings/backups** in het dashboard +moet ops-agent ook weten van de wrappers, commands, flow-YAMLs en de +NOPASSWD-sudoers-regels. Dat doet een idempotent install-script: + +```bash +sudo bash deploy/server-backup/install-flows.sh +``` + +Wat het regelt (en wat het bewust **niet** doet) staat in de header van het +script. Re-run safe; backups van `commands.yml` en `sudoers.d/ops-agent` worden +bewaard met `.bak.`-suffix. Daarna is de UI op +`/flows/server-backup` direct te gebruiken. + ## Verifiëren ```bash diff --git a/deploy/server-backup/install-flows.sh b/deploy/server-backup/install-flows.sh new file mode 100755 index 0000000..b0b094b --- /dev/null +++ b/deploy/server-backup/install-flows.sh @@ -0,0 +1,264 @@ +#!/usr/bin/env bash +# Idempotent installer that wires the server-backup flow into ops-agent. +# +# What this DOES install: +# 1. /srv/backups/scripts/wrappers/*.sh (wrapper scripts used by ops-agent) +# 2. /etc/ops-agent/flows/server_backup_*.yml (flow YAMLs for full + restore-test) +# 3. /etc/ops-agent/commands.yml (appends backup commands if missing) +# 4. /etc/sudoers.d/ops-agent (appends wrapper allowlist, visudo-validated) +# 5. systemctl restart ops-agent (pick up new commands/flows) +# 6. systemctl enable --now server-backup.timer (daily backup) +# +# What this DOES NOT do (do manually first — see README "Snelle installatie"): +# - Create /etc/restic-backup.env (with NAS path, B2 keys, Forgejo container name) +# - Create /etc/restic-backup.password +# - Initialise the restic repos (NAS + B2) +# - Install /srv/backups/scripts/{server-backup.sh,restore-test.sh} +# - Install /etc/systemd/system/server-backup.{service,timer} +# +# Re-run safe: each step checks for prior state and skips. Backups of mutated +# files (commands.yml, sudoers) are kept with a .bak. suffix. +# +# Usage: +# sudo bash deploy/server-backup/install-flows.sh + +set -euo pipefail + +# Resolve repo root from this script's location, so it works regardless of cwd. +SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" &>/dev/null && pwd)" +REPO="$(cd -- "$SCRIPT_DIR/../.." &>/dev/null && pwd)" + +WRAPPERS_SRC="$REPO/deploy/server-backup/wrappers" +FLOWS_SRC="$REPO/ops-agent/flows.example" +COMMANDS_SRC="$REPO/ops-agent/commands.yml.example" + +WRAPPERS_DST=/srv/backups/scripts/wrappers +FLOWS_DST=/etc/ops-agent/flows +COMMANDS_DST=/etc/ops-agent/commands.yml +SUDOERS_DST=/etc/sudoers.d/ops-agent + +if [[ $EUID -ne 0 ]]; then + echo "ERROR: run as root (sudo)." >&2 + exit 1 +fi + +step() { echo; echo "── $* ──"; } +ok() { echo " ✓ $*"; } +skip() { echo " · $* (already in place)"; } +note() { echo " ! $*"; } + +# ────────────────────────────────────────────────────────────────────────────── +step "1. Install wrappers to $WRAPPERS_DST" + +mkdir -p "$WRAPPERS_DST" +chown root:root "$WRAPPERS_DST" +chmod 0750 "$WRAPPERS_DST" + +if [[ ! -d "$WRAPPERS_SRC" ]]; then + echo "ERROR: $WRAPPERS_SRC not found — repo state unexpected (expected at $REPO)" >&2 + exit 2 +fi + +for src in "$WRAPPERS_SRC"/*.sh; do + name=$(basename "$src") + dst="$WRAPPERS_DST/$name" + if [[ -f "$dst" ]] && cmp -s "$src" "$dst"; then + skip "$name" + else + install -o root -g root -m 0750 "$src" "$dst" + ok "$name installed" + fi +done + +# ────────────────────────────────────────────────────────────────────────────── +step "2. Install flow YAMLs to $FLOWS_DST" + +mkdir -p "$FLOWS_DST" + +for f in server_backup_full.yml server_backup_restore_test.yml; do + src="$FLOWS_SRC/$f" + dst="$FLOWS_DST/$f" + if [[ ! -f "$src" ]]; then + echo "ERROR: $src missing — repo state unexpected" >&2 + exit 3 + fi + if [[ -f "$dst" ]] && cmp -s "$src" "$dst"; then + skip "$f" + else + install -o root -g root -m 0644 "$src" "$dst" + ok "$f installed" + fi +done + +# ────────────────────────────────────────────────────────────────────────────── +step "3. Append missing commands to $COMMANDS_DST" + +# Commands we want to ensure exist. Names must match the YAML in commands.yml.example. +NEEDED_CMDS=( + trigger_server_backup + trigger_restore_test + tail_backup_log_today + read_backup_status + restic_snapshots_nas + restic_snapshots_b2 + restic_stats_nas + restic_stats_b2 +) + +if [[ ! -f "$COMMANDS_DST" ]]; then + echo "ERROR: $COMMANDS_DST missing — run base ops-agent install first" >&2 + exit 4 +fi + +TS=$(date +%Y%m%d-%H%M%S) +cp -p "$COMMANDS_DST" "${COMMANDS_DST}.bak.${TS}" + +# Check which commands are missing +missing_cmds=() +for cmd in "${NEEDED_CMDS[@]}"; do + if grep -qE "^ ${cmd}:" "$COMMANDS_DST"; then + skip "command $cmd already in commands.yml" + else + missing_cmds+=("$cmd") + fi +done + +if [[ ${#missing_cmds[@]} -eq 0 ]]; then + skip "no commands to add" + rm "${COMMANDS_DST}.bak.${TS}" # no-op edit, drop the backup +else + # Extract each missing command's YAML block from commands.yml.example. + # The block-detection regex MUST include digits — command names like + # restic_snapshots_b2 / restic_stats_b2 contain digits, otherwise the + # following block (e.g. restic_stats_nas) would swallow them. + tmp=$(mktemp) + python3 - "$COMMANDS_SRC" "${missing_cmds[@]}" >> "$tmp" <<'PY' +import sys, re +src_path = sys.argv[1] +wanted = sys.argv[2:] +with open(src_path) as f: + src = f.read() +# Top-level command blocks: each starts at column-2 with ":" line. +# A block ends at the next sibling-key line (same indentation) or EOF. +pattern = re.compile(r"(^ [a-z0-9_]+:[\s\S]*?)(?=^ [a-z0-9_]+:|\Z)", re.M) +blocks = {} +for m in pattern.finditer(src): + block = m.group(1) + name_match = re.match(r"^ ([a-z0-9_]+):", block) + if name_match: + blocks[name_match.group(1)] = block.rstrip() + "\n" +exit_code = 0 +for cmd in wanted: + if cmd in blocks: + sys.stdout.write("\n" + blocks[cmd]) + else: + sys.stderr.write(f"ERROR: {cmd} not found in {src_path}\n") + exit_code = 1 +sys.exit(exit_code) +PY + + if [[ -s "$tmp" ]]; then + cat "$tmp" >> "$COMMANDS_DST" + rm "$tmp" + ok "appended ${#missing_cmds[@]} commands: ${missing_cmds[*]}" + note "backup at ${COMMANDS_DST}.bak.${TS}" + else + rm "$tmp" + echo "ERROR: extraction produced empty output — aborting" >&2 + exit 5 + fi +fi + +# ────────────────────────────────────────────────────────────────────────────── +step "4. Ensure sudoers allows ops-agent to run wrappers" + +WRAPPER_PATHS=( + /srv/backups/scripts/wrappers/trigger-backup.sh + /srv/backups/scripts/wrappers/trigger-restore-test.sh + /srv/backups/scripts/wrappers/read-status.sh + /srv/backups/scripts/wrappers/restic-snapshots.sh + /srv/backups/scripts/wrappers/restic-stats.sh + /srv/backups/scripts/wrappers/restic-check.sh +) + +# Build proposed sudoers content: existing file + missing wrapper-NOPASSWD lines. +SUDOERS_TMP=$(mktemp /tmp/sudoers-ops-agent.XXXXXX) +chmod 0440 "$SUDOERS_TMP" +cp "$SUDOERS_DST" "$SUDOERS_TMP" + +added_lines=0 +for path in "${WRAPPER_PATHS[@]}"; do + pattern="NOPASSWD:[[:space:]]*${path//\//\\/}\\b" + if grep -qE "$pattern" "$SUDOERS_TMP"; then + skip "$(basename "$path") already in sudoers" + else + echo "ops-agent ALL=(root) NOPASSWD: $path *" >> "$SUDOERS_TMP" + ok "added NOPASSWD for $(basename "$path")" + added_lines=$((added_lines + 1)) + fi +done + +if [[ $added_lines -gt 0 ]]; then + # Validate with visudo before swapping in — bail loud if invalid (prevents lockout). + if visudo -c -f "$SUDOERS_TMP" >/dev/null; then + cp -p "$SUDOERS_DST" "${SUDOERS_DST}.bak.${TS}" + install -o root -g root -m 0440 "$SUDOERS_TMP" "$SUDOERS_DST" + rm "$SUDOERS_TMP" + ok "sudoers updated (visudo-validated); backup at ${SUDOERS_DST}.bak.${TS}" + else + echo "ERROR: visudo validation failed — sudoers not modified" >&2 + echo " check $SUDOERS_TMP" >&2 + exit 6 + fi +else + rm "$SUDOERS_TMP" + skip "sudoers already complete" +fi + +# ────────────────────────────────────────────────────────────────────────────── +step "5. Restart ops-agent (reload commands.yml + flows)" + +systemctl restart ops-agent +sleep 1 +if systemctl is-active --quiet ops-agent; then + ok "ops-agent restarted ($(systemctl show -p ActiveEnterTimestamp ops-agent --value))" +else + echo "ERROR: ops-agent failed to start — check 'journalctl -u ops-agent -n 50'" >&2 + exit 7 +fi + +# ────────────────────────────────────────────────────────────────────────────── +step "6. Enable server-backup.timer" + +if systemctl is-enabled --quiet server-backup.timer; then + skip "server-backup.timer already enabled" +else + systemctl enable server-backup.timer + ok "server-backup.timer enabled" +fi + +if systemctl is-active --quiet server-backup.timer; then + skip "server-backup.timer already active" +else + systemctl start server-backup.timer + ok "server-backup.timer started" +fi + +# Show next-firing +echo +note "next scheduled runs:" +systemctl list-timers --no-pager | grep -E "NEXT|server-backup" | head -5 + +# ────────────────────────────────────────────────────────────────────────────── +step "Done" + +echo +echo "Test via the UI:" +echo " /flows/server-backup → click 'Run restore test' (non-destructive)" +echo +echo "Or test via curl on this host:" +echo " TOKEN=\$(cat /etc/ops-agent/secret)" +echo " curl -sS -H \"Authorization: Bearer \$TOKEN\" \\" +echo " -H 'Content-Type: application/json' \\" +echo " -X POST http://127.0.0.1:3099/agent/v1/flow \\" +echo " --data '{\"flow_key\":\"server_backup_restore_test\",\"dry_run\":false}'"