Adds deploy/server-backup/install-flows.sh — een idempotent installer die de ops-agent-zijde van de server-backup feature aan elkaar plakt: 1. wrappers/*.sh → /srv/backups/scripts/wrappers/ 2. flows.example/server_backup_* → /etc/ops-agent/flows/ 3. commands.yml.example commands → /etc/ops-agent/commands.yml (append, met backup) 4. NOPASSWD-regels voor wrappers → /etc/sudoers.d/ops-agent (visudo-validated) 5. systemctl restart ops-agent 6. systemctl enable --now server-backup.timer Wat het bewust *niet* doet (staat in scriptheader): restic env/password aanmaken, repos initialiseren, base-scripts of systemd-units plaatsen — die secrets-stappen blijven handwerk per README "Snelle installatie". Re-run safe: - cmp-check per file in stappen 1-2 (skip als identiek) - grep-check op command-name in stap 3 (skip als al aanwezig) - visudo-validatie in stap 4 voorkomt lockout bij syntax-fout - backups van mutaties: commands.yml.bak.<ts> en sudoers.d/ops-agent.bak.<ts> Regex-fix t.o.v. eerste handmatige run vandaag: command-block-extractie gebruikt nu [a-z0-9_]+ ipv [a-z_]+, zodat namen met digits (restic_*_b2) als losse blocks gezien worden. Het oude pattern miste ze maar sleepte ze toevallig mee in het vorige block — eindresultaat correct, output misleidend. Nieuwe versie faalt expliciet als een command echt ontbreekt. README aangevuld met sectie "Ops-agent wiring (na stap 1-7)" die naar het script verwijst. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
264 lines
9.6 KiB
Bash
Executable file
264 lines
9.6 KiB
Bash
Executable file
#!/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.<timestamp> 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 "<name>:" 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}'"
|