Merge pull request 'feat(flows): add /flows/server-backup page + ops-agent install script' (#1) from feat/flows-server-backup-page into main

Reviewed-on: #1
This commit is contained in:
Janpeter Visser 2026-05-15 21:27:35 +02:00
commit 0d76fc32ca
5 changed files with 493 additions and 0 deletions

View file

@ -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() {

View file

@ -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<Kind, FlowSpec> = {
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<Kind>('backup')
const [pendingKind, setPendingKind] = useState<Kind | null>(null)
const [activeKind, setActiveKind] = useState<Kind | null>(null)
const [completedFlowRunId, setCompletedFlowRunId] = useState<string | null>(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 (
<div className="space-y-6">
<div className="rounded-lg border border-border p-5 space-y-4">
<div>
<p className="text-sm text-muted-foreground">{spec.shortDescription}</p>
<p className="mt-1 text-xs text-muted-foreground font-mono">
flow: {spec.flowKey}
</p>
</div>
<ol className="space-y-1">
{spec.steps.map((step, i) => (
<li key={i} className="flex gap-2 text-xs font-mono text-muted-foreground">
<span className="text-border min-w-[1.5rem]">{i + 1}.</span>
<span>{step}</span>
</li>
))}
</ol>
</div>
<div className="flex items-center gap-3">
<button
onClick={() => handleClickKind('backup')}
disabled={flowRun.status === 'running'}
className={`rounded-lg px-4 py-2 text-sm font-medium transition-opacity disabled:opacity-50 ${
displayKind === 'backup'
? 'bg-foreground text-background hover:opacity-90'
: 'border border-border hover:bg-muted/50'
}`}
>
{FLOWS.backup.buttonLabel}
</button>
<button
onClick={() => handleClickKind('restore')}
disabled={flowRun.status === 'running'}
className={`rounded-lg px-4 py-2 text-sm font-medium transition-opacity disabled:opacity-50 ${
displayKind === 'restore'
? 'bg-foreground text-background hover:opacity-90'
: 'border border-border hover:bg-muted/50'
}`}
>
{FLOWS.restore.buttonLabel}
</button>
{flowRun.status !== 'idle' && flowRun.status !== 'running' && (
<button
onClick={handleReset}
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
>
Reset
</button>
)}
</div>
{flowRun.status !== 'idle' && (
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-sm font-medium">
Output{activeKind ? `${FLOWS[activeKind].buttonLabel}` : ''}
</span>
{completedFlowRunId && (
<Link
href={`/audit/${completedFlowRunId}`}
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
>
View in audit log
</Link>
)}
</div>
<StreamingTerminal
lines={flowRun.lines}
status={flowRun.status}
error={flowRun.error}
/>
</div>
)}
<ConfirmDialog
open={pendingKind !== null}
title={pendingKind ? FLOWS[pendingKind].confirmTitle : ''}
commandPreview={pendingKind ? FLOWS[pendingKind].confirmBody : ''}
onConfirm={handleConfirm}
onCancel={() => setPendingKind(null)}
/>
</div>
)
}

View file

@ -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 (
<div className="min-h-screen bg-background p-6">
<div className="mx-auto max-w-4xl space-y-6">
<div className="flex items-center gap-3">
<Link href="/" className="text-sm text-muted-foreground hover:text-foreground">
Home
</Link>
<span className="text-muted-foreground">/</span>
<Link href="/flows" className="text-sm text-muted-foreground hover:text-foreground">
Flows
</Link>
<span className="text-muted-foreground">/</span>
<h1 className="text-2xl font-semibold tracking-tight">Server backup</h1>
</div>
<FlowPanel />
</div>
</div>
)
}

View file

@ -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.<timestamp>`-suffix. Daarna is de UI op
`/flows/server-backup` direct te gebruiken.
## Verifiëren
```bash

View file

@ -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.<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}'"