diff --git a/app/flows/page.tsx b/app/flows/page.tsx index ee16447..e2790a7 100644 --- a/app/flows/page.tsx +++ b/app/flows/page.tsx @@ -20,6 +20,11 @@ const FLOWS = [ title: 'Update Caddy config', desc: 'Reload Caddy met nieuwe Caddyfile + cert renewal check', }, + { + href: '/flows/server-backup', + title: 'Server backup', + desc: 'pg_dumpall + restic naar NAS én B2 — handmatige backup of restore-test', + }, ] export default async function FlowsIndex() { diff --git a/app/flows/server-backup/_components/flow-panel.tsx b/app/flows/server-backup/_components/flow-panel.tsx new file mode 100644 index 0000000..e61c25a --- /dev/null +++ b/app/flows/server-backup/_components/flow-panel.tsx @@ -0,0 +1,178 @@ +'use client' + +import { useState, useCallback } from 'react' +import Link from 'next/link' +import { useFlowRun } from '@/hooks/useFlowRun' +import StreamingTerminal from '@/components/StreamingTerminal' +import ConfirmDialog from '@/components/ConfirmDialog' + +// One panel runs either flow; we switch step-list + description based on the +// currently active or last-triggered action. Wording mirrors the existing +// /settings/backups → server-backup-section so the two entry points stay +// consistent. The actual work in both flows runs out-of-band via systemd +// (server-backup.service) — the ops-agent flow just kicks it off and tails +// the resulting log / status file. + +type Kind = 'backup' | 'restore' + +type FlowSpec = { + flowKey: string + buttonLabel: string + shortDescription: string + steps: string[] + confirmTitle: string + confirmBody: string +} + +const FLOWS: Record = { + backup: { + flowKey: 'server_backup_full', + buttonLabel: 'Backup now', + shortDescription: + 'Volledige server-backup: pg_dumpall van alle databases + restic snapshot naar NAS én Backblaze B2 (Object Lock). Draait dagelijks via timer; deze knop triggert handmatig.', + steps: [ + 'trigger_server_backup (systemctl start server-backup.service)', + 'tail_backup_log_today (live log mee-stream)', + 'read_backup_status (lees status.json met repo-totalen + duur)', + ], + confirmTitle: 'Trigger server backup', + confirmBody: + 'flow: server_backup_full\n\nSteps:\n 1. trigger_server_backup (systemctl start server-backup.service)\n 2. tail_backup_log_today\n 3. read_backup_status\n\nThe actual work happens in systemd; this flow kicks it off and tails the log.', + }, + restore: { + flowKey: 'server_backup_restore_test', + buttonLabel: 'Run restore test', + shortDescription: + 'Non-destructieve restore-test: haalt de laatste snapshot uit de NAS-repo terug naar /tmp/restore-test en verifieert dat kritieke files er zijn. Raakt niets in de live stack.', + steps: [ + 'trigger_restore_test (restore latest NAS snapshot to /tmp/restore-test/)', + 'read_backup_status (lees assertions + per-file outcome)', + ], + confirmTitle: 'Run restore test (NAS)', + confirmBody: + 'flow: server_backup_restore_test\n\nSteps:\n 1. trigger_restore_test (restore latest NAS snapshot to /tmp/restore-test/)\n 2. read_backup_status\n\nNon-destructive — restores into /tmp only and asserts critical files exist.', + }, +} + +export default function FlowPanel() { + // `displayKind` drives the steps/description card; updated optimistically + // when the user presses a button so the displayed flow matches the pending + // confirm. `activeKind` only flips once the flow actually starts. + const [displayKind, setDisplayKind] = useState('backup') + const [pendingKind, setPendingKind] = useState(null) + const [activeKind, setActiveKind] = useState(null) + const [completedFlowRunId, setCompletedFlowRunId] = useState(null) + + const handleComplete = useCallback((flowRunId: string) => { + setCompletedFlowRunId(flowRunId) + }, []) + + const flowRun = useFlowRun(handleComplete) + + const handleClickKind = useCallback((kind: Kind) => { + setDisplayKind(kind) + setPendingKind(kind) + }, []) + + const handleConfirm = useCallback(() => { + if (pendingKind === null) return + const kind = pendingKind + setPendingKind(null) + setCompletedFlowRunId(null) + setActiveKind(kind) + flowRun.startFlow(FLOWS[kind].flowKey, false) + }, [pendingKind, flowRun]) + + const handleReset = useCallback(() => { + flowRun.reset() + setCompletedFlowRunId(null) + setActiveKind(null) + }, [flowRun]) + + const spec = FLOWS[displayKind] + + return ( +
+
+
+

{spec.shortDescription}

+

+ flow: {spec.flowKey} +

+
+
    + {spec.steps.map((step, i) => ( +
  1. + {i + 1}. + {step} +
  2. + ))} +
+
+ +
+ + + {flowRun.status !== 'idle' && flowRun.status !== 'running' && ( + + )} +
+ + {flowRun.status !== 'idle' && ( +
+
+ + Output{activeKind ? ` — ${FLOWS[activeKind].buttonLabel}` : ''} + + {completedFlowRunId && ( + + View in audit log → + + )} +
+ +
+ )} + + setPendingKind(null)} + /> +
+ ) +} diff --git a/app/flows/server-backup/page.tsx b/app/flows/server-backup/page.tsx new file mode 100644 index 0000000..7568e16 --- /dev/null +++ b/app/flows/server-backup/page.tsx @@ -0,0 +1,31 @@ +import Link from 'next/link' +import { redirect } from 'next/navigation' +import { getCurrentUser } from '@/lib/session' +import FlowPanel from './_components/flow-panel' + +export const dynamic = 'force-dynamic' + +export default async function ServerBackupPage() { + const user = await getCurrentUser() + if (!user) redirect('/login') + + return ( +
+
+
+ + ← Home + + / + + Flows + + / +

Server backup

+
+ + +
+
+ ) +} 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}'"