Adds a server-wide backup capability beyond the existing ops_dashboard pg_dump flow: - Daily systemd timer (03:30) runs pg_dumpall + Forgejo dump, then restic to a local NAS repo and an offsite Backblaze B2 repo with Object Lock. Phase-based script with single-instance flock, structured statusfile, systemd hardening, and live-datadir excludes (Postgres / Forgejo) so the dumps stay authoritative. - Ops-agent gets nine new read-only/trigger commands (snapshots, stats, status, logs, plus two triggers) backed by sudoers-whitelisted wrapper scripts that source /etc/restic-backup.env so the agent never sees the restic password or B2 keys. - Two new flows (server_backup_full, server_backup_restore_test) drive the dashboard's "Backup now" and "Restore test" buttons. - /settings/backups gains a Server backup section with overall + per-phase status, NAS / B2 snapshot tables, restore-size / raw-data / dedup-ratio stats, and the last restore-test result. The existing pg_dump section is preserved unchanged. - Runbook docs/runbooks/server-backup.md follows the tailscale-setup pattern (plan + addendum) and covers B2 Object Lock + scoped keys, Forgejo subplan with isolated restore-test stack, the off-server maintenance flow for B2 prune, and the integrity-check schedule. Code-only change — installation on scrum4me-srv follows the runbook. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
129 lines
3.9 KiB
TypeScript
129 lines
3.9 KiB
TypeScript
import Link from 'next/link'
|
|
import { redirect } from 'next/navigation'
|
|
import { getCurrentUser } from '@/lib/session'
|
|
import { execAgent } from '@/lib/agent-client'
|
|
import BackupsPanel from './_components/backups-panel'
|
|
import {
|
|
parseResticSnapshots,
|
|
parseResticStats,
|
|
parseStatusEnvelope,
|
|
} from './_lib/parse'
|
|
import type {
|
|
BackupStatusEnvelope,
|
|
ResticSnapshot,
|
|
ResticStats,
|
|
} from './_lib/types'
|
|
|
|
export const dynamic = 'force-dynamic'
|
|
|
|
export interface BackupFile {
|
|
name: string
|
|
sizeBytes: number
|
|
label: string
|
|
}
|
|
|
|
function parseBackupList(output: string): BackupFile[] {
|
|
return output
|
|
.split('\n')
|
|
.map((line) => line.trim())
|
|
.filter(Boolean)
|
|
.map((line) => {
|
|
const [name, sizeStr] = line.split('\t')
|
|
const sizeBytes = parseInt(sizeStr ?? '0', 10) || 0
|
|
const m = name?.match(/ops_db_(\d{4})(\d{2})(\d{2})_(\d{2})(\d{2})\.dump/)
|
|
const label = m ? `${m[1]}-${m[2]}-${m[3]} ${m[4]}:${m[5]}` : (name ?? '')
|
|
return { name: name ?? '', sizeBytes, label }
|
|
})
|
|
.filter((b) => b.name)
|
|
}
|
|
|
|
function errorMessage(err: unknown): string {
|
|
return err instanceof Error ? err.message : 'agent call failed'
|
|
}
|
|
|
|
async function tryExec(command: string): Promise<{ output: string | null; error: string | null }> {
|
|
try {
|
|
const output = await execAgent(command)
|
|
return { output, error: null }
|
|
} catch (err) {
|
|
return { output: null, error: errorMessage(err) }
|
|
}
|
|
}
|
|
|
|
export default async function BackupsPage() {
|
|
const user = await getCurrentUser()
|
|
if (!user) redirect('/login')
|
|
|
|
// Run all agent calls in parallel; per-call error isolation so one failure
|
|
// does not blank the entire page.
|
|
const [
|
|
backupListResult,
|
|
statusResult,
|
|
nasSnapshotsResult,
|
|
b2SnapshotsResult,
|
|
nasStatsResult,
|
|
b2StatsResult,
|
|
] = await Promise.all([
|
|
tryExec('list_ops_backups'),
|
|
tryExec('read_backup_status'),
|
|
tryExec('restic_snapshots_nas'),
|
|
tryExec('restic_snapshots_b2'),
|
|
tryExec('restic_stats_nas'),
|
|
tryExec('restic_stats_b2'),
|
|
])
|
|
|
|
const backups: BackupFile[] = backupListResult.output
|
|
? parseBackupList(backupListResult.output)
|
|
: []
|
|
const listError = backupListResult.error
|
|
|
|
const envelope: BackupStatusEnvelope = statusResult.output
|
|
? parseStatusEnvelope(statusResult.output)
|
|
: { lastRun: null, lastRestoreTest: null }
|
|
|
|
const nasSnapshots: ResticSnapshot[] = nasSnapshotsResult.output
|
|
? parseResticSnapshots(nasSnapshotsResult.output, 'nas')
|
|
: []
|
|
const b2Snapshots: ResticSnapshot[] = b2SnapshotsResult.output
|
|
? parseResticSnapshots(b2SnapshotsResult.output, 'b2')
|
|
: []
|
|
const nasStats: ResticStats | null = nasStatsResult.output
|
|
? parseResticStats(nasStatsResult.output, 'nas')
|
|
: null
|
|
const b2Stats: ResticStats | null = b2StatsResult.output
|
|
? parseResticStats(b2StatsResult.output, 'b2')
|
|
: null
|
|
|
|
const serverBackupErrors = {
|
|
status: statusResult.error ?? undefined,
|
|
nasSnapshots: nasSnapshotsResult.error ?? undefined,
|
|
b2Snapshots: b2SnapshotsResult.error ?? undefined,
|
|
nasStats: nasStatsResult.error ?? undefined,
|
|
b2Stats: b2StatsResult.error ?? undefined,
|
|
}
|
|
|
|
return (
|
|
<div className="min-h-screen bg-background p-6">
|
|
<div className="mx-auto max-w-6xl 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>
|
|
<h1 className="text-2xl font-semibold tracking-tight">Backups</h1>
|
|
</div>
|
|
|
|
<BackupsPanel
|
|
backups={backups}
|
|
listError={listError}
|
|
envelope={envelope}
|
|
nasSnapshots={nasSnapshots}
|
|
b2Snapshots={b2Snapshots}
|
|
nasStats={nasStats}
|
|
b2Stats={b2Stats}
|
|
serverBackupErrors={serverBackupErrors}
|
|
/>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|