#!/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}'"