Backup System — Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking. T7 is the controller-driven e2e — do NOT delegate it to a subagent. Task T3 is marked ADVERSARIAL-review — the admin API is the settings/floor/graceful-empty spine; review it harder. Sequencing (important): the backup sidecar reads thebackup_settingsrow from Postgres viapsql, so the table must exist first. T1 (migration + settings foundation) is intentionally ordered before T2 (the sidecar) — this is the spec's "sequence T2-before-T1" guidance applied. The sidecar is also written to tolerate a missing/unreadable table (falls back to env → safe defaults, never crashes), so a boot race can't break it.
Goal: A real, restorable disaster-recovery backup of the whole system — the Postgres database + all MinIO document blobs — taken on an admin-configured schedule, retained count-based, and recovered via a documented operator procedure (CORE platform, no license gate). The existing internal/backup context only exports a corpus manifest (a JSON inventory of live documents + content hashes); it tells you what you lost but can't restore it. This adds the missing DR layer around that feature without changing it. Incident-motivated: a docker compose down -v once wiped the demo pgdata + miniodata with no way to restore.
Architecture:
- Backup engine = a compose sidecar, not the app. The obscura container is distroless (no pg_dump). A dedicated obscura-backup sidecar (deploy/Dockerfile.backup = FROM postgres:17 + the mc MinIO client) runs battle-tested pg_dump -Fc + mc mirror. Its entrypoint is a poll loop (~60s): each cycle it reads backup_settings (enabled/interval_hours/retention_count/run_requested_at) via psql, derives the last successful backup from the newest completed set on disk, and runs backup.sh when enabled AND now−last ≥ interval_hours, or when run_requested_at is set (which it clears after running). The DB is the ONLY coupling — no app↔sidecar RPC.
- Opt-in via a backup compose profile (like ldap/keycloak/peruri), so it never surprises the demo. Target = a host-mounted ./backups bind mount; the obscura app mounts the same dir read-only (:ro) for visibility only — it can never mutate or delete a backup.
- A backup set ($BACKUP_DIR/<TS>/, TS=date -u +%Y%m%dT%H%M%SZ) is built in <TS>.partial/ and atomically renamed on full success (a crashed run never looks complete): db.dump (pg_dump -Fc), blobs/ (mc mirror bucket → blobs/), status.json ({ts,result,db_bytes,blob_count,blob_bytes,duration_seconds,error?}), and on success latest.json. Then prune to retention_count (completed sets newest-first, never delete the newest).
- Admin-configurable settings live in a singleton backup_settings row (migration 00087), edited via GET/PUT /api/v1/admin/backups/settings (perm backup.admin) behind anti-footgun floors (interval_hours ≥ 1, retention_count ≥ 1 — mirrors the rate-limit floor), and POST /api/v1/admin/backups/run (sets run_requested_at, returns 202). The app writes the row; the sidecar reads it.
- App-side read-only visibility. A new filesystem catalog (internal/backup/adapters/FSCatalog) reads the mounted $BACKUP_DIR (metadata only — it never serves db.dump/blob bytes): GET /api/v1/admin/backups/status → {enabled, backup_dir, retention_count, last_run, sets[]}, gracefully enabled:false when the dir is absent/empty. A new Admin → Backups tab surfaces the editable settings + the read-only set history + a "Run backup now" button; restore is documented, never a UI control.
- Restore = scripts/restore.sh <TS> + docs/BACKUP.md runbook, never an in-app button. It prints what it overwrites, requires explicit confirmation (CONFIRM=yes / interactive y/N), stops obscura, pg_restore --clean --if-exists, mc mirror --overwrite --remove, restarts obscura, verifies /readyz + a doc count.
- Observability tie-in (the exact blind spot behind the incident): a scrape-time gauge obscura_backup_last_success_timestamp_seconds (read from latest.json; 0 when none) on the existing private Prometheus registry, reusing the Phase-1 collector pattern.
Tech Stack: Go modular monolith (go/, chi router, pgx v5, goose migrations) — no new Go deps (filesystem os/encoding/json + a psql-in-sidecar reader; no Go code talks to the sidecar). React + Carbon (@carbon/react) SPA (web/), TanStack Query, openapi-typescript-generated client — no new npm deps. The sidecar is a postgres:17 image + the mc binary (downloaded at build time) + three shell scripts. Tested by curl/docker against the deployed stack, demo-safe.
Spec: docs/superpowers/specs/2026-07-06-backup-system-design.md
Global Constraints (every task)
- NEVER
go test— the test DSN (:55432) IS the live demo Postgres (deploy-postgres-1). Go verify iscd go && go build ./... && go vet ./...only. - Web verify:
cd web && npx tsc --noEmit && npx vite build. npm run gen:apiafter ANYapi/openapi.yamledit (regeneratesweb/src/api/schema.ts). Run it fromweb/. Commit the regeneratedschema.tswith the task.- NO new npm dependencies (
npm installis broken: npm11/node25 arborist crash). NO new Go dependencies expected (filesystem +psql-in-sidecar only). If any Go dep proves essential:go get <pkg>@<ver> && go mod tidyfromgo/, commitgo/go.mod+go/go.sum. - Deploy ONLY from repo root:
docker compose -f deploy/docker-compose.yml --env-file deploy/mekari.env up -d --build obscura web. After deploy assert/me enabled_modules == [ai, correspondence, esign, semantic, watermarking](dev-logindirector@obscura.local, host port 38080). - The
backupprofile is OPT-IN (profiles: ["backup"]). The default stack (up -d) never startsobscura-backup; the demo is byte-for-byte untouched until an operator runsdocker compose --profile backup up -d obscura-backup. backup_settingsfloors prevent runaway/no-retention:interval_hours ≥ 1(never 0 → a runaway loop that fills the disk) andretention_count ≥ 1(never prune to nothing). A malformed/out-of-range stored row falls back to seeded defaults (never bricks the Admin view or the sidecar).- Commit per task on
main, do NOT push. NEVERgit add -A(go/obscura-serveris a tracked ELF binary; there is also an untrackedscripts/.migrate-seeddms-state.json.pre-incident-bak— do not commit it) — alwaysgit addexplicit paths. - i18n en/id parity (tsc-enforced): feature-co-located
web/src/features/admin/i18n.ts; nested groups;enandididentical in shape. Smart-quote gotcha: the Edit tool can mangle’/“”; use ASCII quotes, and after editing an i18n file verifynpx tsc --noEmit— if quotes broke, rewrite the whole file with Write. down -vis FORBIDDEN (a prior incident wiped the demo pgdata + miniodata). Neverdocker compose down -v. Single-service teardown usesdocker compose stop <svc>/start <svc>(NOTrm); throwaways viadocker rm -f. The restore e2e (T7) restores into a THROWAWAY scratch DB (obscura_restore_test) + a temp bucket — NEVER the live DB/bucket.- No outer-mux gotcha here. All new routes are
/api/v1/admin/backups/*inside the chi router (the existing manifest routes already live there,server.go:719-722). The sidecar↔app coupling is ONLY thebackup_settingsDB row — no RPC, no new top-level path prefix. - e2e restores all state + cleans throwaways — reset
backup_settingsto seeded defaults, drop the scratch DB + temp bucket, delete the test backup sets, leave thebackupprofile down. Re-assert the demo (5 modules).
File Map
| File | Task | Role |
|---|---|---|
go/migrations/00087_backup_settings.sql (new) |
T1 | singleton backup_settings (id=1 CHECK, seeded) |
go/internal/backup/domain/settings.go (new) |
T1 | Settings + floors + Defaults/Validate + SetStatus/CatalogView value types |
go/internal/backup/adapters/settings_pg.go (new) |
T1 | SettingsStore (Load/Save/RequestRun; Load falls back to Defaults) |
go/internal/backup/adapters/fscatalog.go (new) |
T1 | FSCatalog reading $BACKUP_DIR (newest-first sets, tolerant parse) + LatestSuccessUnix |
go/internal/platform/config/config.go |
T1 | BackupDir string env:"BACKUP_DIR" envDefault:"/backups" on Config |
deploy/Dockerfile.backup (new) |
T2 | FROM postgres:17 + mc + the three scripts |
scripts/backup.sh (new) |
T2 | one backup set (partial→atomic-rename, status/latest json, prune) |
scripts/entrypoint.sh (new) |
T2 | poll loop (read settings via psql, run when due / on-demand, clear flag) |
deploy/docker-compose.yml |
T2 | obscura-backup service (profile backup) + ./backups:/backups:ro on obscura + BACKUP_DIR env |
.gitignore |
T2 | ignore deploy/backups/ (backup artifacts) |
go/internal/httpapi/handlers_backup.go |
T3 | GetBackupSettings/PutBackupSettings/GetBackupStatus/RunBackupNow + view/body structs |
go/internal/httpapi/server.go |
T3 | BackupSettingsStore/BackupCatalog interfaces + Deps/Server fields + 4 routes |
go/cmd/obscura-server/wire.go |
T3, T4 | build SettingsStore/FSCatalog, inject into Deps (T3); register the backup collector (T4) |
api/openapi.yaml |
T3 | /admin/backups/settings GET/PUT, /status GET, /run POST + schemas |
web/src/api/schema.ts |
T3 | regenerated via gen:api |
go/internal/httpapi/metrics_collectors.go |
T4 | BackupCollector (scrape-time obscura_backup_last_success_timestamp_seconds) |
scripts/restore.sh (new) |
T5 | operator restore (confirm gate, stop→pg_restore→mc mirror→start→verify) |
docs/BACKUP.md (new) |
T5 | operator runbook (enable profile, settings, copy-off-box, restore, troubleshooting) |
web/src/features/admin/data.ts |
T6 | useBackupSettings/useSaveBackupSettings/useBackupStatus/useRunBackup |
web/src/features/admin/BackupsTab.tsx (new) |
T6 | settings editor + Run-now + read-only set history |
web/src/features/admin/AdminPage.tsx |
T6 | register the Backups tab (Tab + TabPanel + import) |
web/src/features/admin/i18n.ts |
T6 | admin.backups.* + admin.tabs.backups (en/id) |
web/src/styles/app.css |
T6 | small .backup-* layout block |
Scouted anchors (verified this session — cite these while implementing)
- Existing backup Service surface (do NOT break it).
go/internal/backup/app/service.go:NewService(repo, blobs, uow) *Service(:55) withRunBackup(ctx, p) (string, error)(:71),ListBackups(:109),GetBackup(:114),OpenManifest(:124). Portapp.Repository(:28-40):CorpusEntries/InsertBackup/ListBackups/GetBackup.domain.Backup(domain/backup.go:15) +BuildManifest(:55). Storeadapters.NewStore(*db.DB) *Store(adapters/pg.go:32). The new work ADDSSettingsStore+FSCatalogin the SAMEbackup/adapterspackage and settings/catalog value types inbackup/domain— the manifest feature is untouched. backup.adminperm already exists and gates the manifest routes ingo/internal/httpapi/server.go:719-722(r.With(s.requirePerm("backup.admin")).Post/Get("/admin/backups"…)). The perm is NOT in a migration/seed file (it is referenced only here); it is already granted to the admin/director role in the running demo (the existing manifest export works). The new routes reuse the SAME perm — no perm-catalogue change needed. Add the 4 new routes in this same block.Deps/Serverstructs (server.go):Deps(:82-119) —Backup *backupapp.Service(:109),Metrics *Metrics(:114), and theRateLimitStore RateLimitStoreinterface field (:117, interface defined:73-78) is the template to mirror forBackupSettingsStore.Serverstruct fieldsbackup(:150),metrics(:155),rateLimitStore(:165);NewServer(d Deps)mapsd.X → s.x(:206mapsbackup,:225mapsrateLimitStore). AddBackupSettings/BackupCatalogto Deps + Server + the NewServer mapping.- Rate-limit settings pattern to MIRROR (observability Phase 3):
go/internal/ratelimit/domain/ratelimit.go—Config+ per-limit floor consts (FloorUser=60…) +Defaults()(:28) +Validate()(:33, returns a clear per-field floor error).go/internal/ratelimit/adapters/pg.go—Store{db}withLoad(:23,errors.Is(err, pgx.ErrNoRows)→Defaults(); a failedValidate()→Defaults()) +Save(:41). Migration00086_rate_limit_settings.sql—id int PRIMARY KEY DEFAULT 1 CHECK (id = 1), seededINSERT … (id) VALUES (1). Mirror all three forbackup_settings. - Rate-limit HTTP handler to MIRROR
go/internal/httpapi/handlers_ratelimit.go:rateLimitLimits/rateLimitViewbody+view structs (:12-25),rateLimitView(cfg)builder (:27),GetRateLimits(:36,writeJSON(w, 200, …)),PutRateLimits(:42) —json.NewDecoder(r.Body).Decode,cfg.Validate()→writeProblem(&kernel.Error{Kind: ErrValidation, Code: "ratelimit.floor", …})on 400,Savethen live-apply thenwriteJSON. Mirror shape for backup (Code: "backup.floor"). - Migrations run to
00086(ls go/migrations:…00085_scim_group_roles,00086_rate_limit_settings). Next free =00087. Goose style:-- +goose Up/-- +goose Down, aCREATE TABLE+ seedINSERT, drop on Down (see00086). - Config env pattern (
go/internal/platform/config/config.go): top-levelConfigstruct (:17-56) withenv:"…" envDefault:"…"tags (e.g.GotenbergURL … envDefault:"http://localhost:33000":43);env.ParseAs[Config]()(:406);Validate()(:417) fail-closed switch.BACKUP_DIRis a plain path (no validation needed — an absent path just reports the feature disabled) → add the field, novalidate()helper. - Metrics collector pattern to MIRROR (observability Phase 1):
go/internal/httpapi/metrics_collectors.go—DBPoolCollector/AITokensCollectorare scrape-timeprometheus.Collectors (Describe/Collect;NewDesc+MustNewConstMetric).Metrics.Register(c prometheus.Collector) error(metrics.go) adds to the private registry. wire.go registers them right aftermetrics := httpapi.NewMetrics()(wire.go:466):metrics.Register(httpapi.NewDBPoolCollector(database.Stat))(:469),metrics.Register(httpapi.NewAITokensCollector(…))(:472). AddNewBackupCollector+ register it the same way (wire.go:474). - wire.go (
go/cmd/obscura-server/wire.go):backupSvc := backupapp.NewService(backupadapters.NewStore(database), blobStore, database)(:299);backupadaptersimported (:21),backupapp(:22).Deps{…}literal (:476-524) —Backup: backupSvc(:503),Metrics: metrics(:508),RateLimitStore: rateLimitStore(:510),Config: httpapi.Config{…}(:512).cfgis the parsedconfig.Config(has the newBackupDir). Buildbackupadapters.NewSettingsStore(database)+backupadapters.NewFSCatalog(cfg.BackupDir)near:299, inject both into Deps, register the collector. - OpenAPI shape to MIRROR (
api/openapi.yaml):/api/v1/admin/rate-limitsGET/PUT (:5460) —tags: [admin],200+400/401/403$ref '#/components/responses/Problem'; schemasRateLimitLimits/RateLimitView(:7722-7739). Existing manifest paths/api/v1/admin/backups(:6023),/{id}(:6059),/{id}/manifest(:6081). Add/admin/backups/settings,/status,/run+BackupSettings/BackupSettingsView/BackupStatus/BackupSetschemas. In chi, static segments (settings/status/run) match before the{id}param route, so the new statics are NOT swallowed by/admin/backups/{id}. - compose (
deploy/docker-compose.yml): profile patternprofiles: ["ldap"|"keycloak"|"peruri"](:206,:226,:246).postgres=pgvector/pgvector:pg17, user/pass/dbobscura/obscura/obscura, in-network hostpostgres:5432(:6-13,DATABASE_URLat:97).minio=minio/minio:latest, rootobscura/obscura-dev-secret, bucketobscura, in-network hostminio:9000(:20-27; obscura passesS3_ENDPOINT: minio:9000/S3_BUCKET/S3_ACCESS_KEY/S3_SECRET_KEY/S3_USE_SSLat:103-107). obscuravolumes:block (:176-186),ports: ["38080:8080"](:186). Namedvolumes:block (:274-278). Build contexts are relative to the compose file dir (deploy/):context: ..= repo root. Addobscura-backup(profilebackup,context: ..,./backups:/backups), and- ./backups:/backups:ro+BACKUP_DIR: /backupson obscura. .dockerignore(repo root) excludesdeployand*.md,**/*_test.go(contents shown:.git ui stego *.md deploy data datalog logs **/*_test.go go/bin dist tmp). A repo-root build context (context: ..) therefore cannot COPY anything underdeploy/— butscripts/is NOT ignored.deploy/Dockerfileproves the pattern:context: ..+dockerfile: deploy/Dockerfileworks (the Dockerfile is read out-of-band), and it COPYs onlygo/+api/openapi.yaml. ThereforeDockerfile.backupusescontext: ..and COPYsscripts/backup.sh,scripts/entrypoint.sh,scripts/restore.sh(all under the non-ignoredscripts/).deploy/Dockerfile(multi-stage →gcr.io/distroless/static-debian12:nonroot,USER nonroot) is the style reference;Dockerfile.backupis single-stageFROM postgres:17(Debian; hasbash,psql,pg_dump,pg_restore,createdb, GNUdate -d,du -b,stat -c) +ADDthemcbinary.- AdminPage tab registration (
web/src/features/admin/AdminPage.tsx): tab labels<Tab>{t('admin.tabs.X')}</Tab>(:108-119, last isobservability:119); panels in<TabPanels>(:122-247, last is<TabPanel><ObservabilityTab/></TabPanel>:244-246); components imported (:38-48,ObservabilityTab:48). The order of<Tab>s MUST match the order of<TabPanel>s — register Backups as the LAST tab AND the LAST panel. - Settings-editor tab to MIRROR (
web/src/features/admin/ObservabilityTab.tsx):useTranslation,NumberInput/Button/InlineNotificationfrom@carbon/react, adraftstate seeded fromdataviauseEffect, a Save that surfaces the backend floor error by JSON-parsing(e as Error).messagefor.detail, and a read-only strip. data hooks (web/src/features/admin/data.ts:748-785):useRateLimits/useSaveRateLimits(api.GET/PUT('/api/v1/admin/…').then(ok),refetchInterval,invalidateQueries), typed viacomponents['schemas']['…']. i18n (web/src/features/admin/i18n.ts):export const en = {…}thenexport const id: typeof en = {…};admin.tabs.*group (en:12-25,observability:'Observability':24; id:551-564,observability:'Observabilitas':563);observability:{…}copy group (en:26-41; id:565). ASCII quotes only in NEW keys.
Task 1: Migration 00087 + backup settings domain + adapters (SettingsStore + FSCatalog) + config env
Files: go/migrations/00087_backup_settings.sql (new), go/internal/backup/domain/settings.go (new), go/internal/backup/adapters/settings_pg.go (new), go/internal/backup/adapters/fscatalog.go (new), go/internal/platform/config/config.go.
Interface produced (consumed by T2 sidecar reads, T3 handlers, T4 collector): the backup_settings table; backupdomain.Settings/SetStatus/CatalogView + floors + Defaults/Validate; backupadapters.NewSettingsStore(*db.DB) *SettingsStore (Load/Save/RequestRun); backupadapters.NewFSCatalog(dir) *FSCatalog (Read()/LatestSuccessUnix()); cfg.BackupDir.
- [ ] Step 1 — Migration
00087. Creatego/migrations/00087_backup_settings.sql:
-- +goose Up
-- Admin-tunable backup schedule/retention (singleton row id=1). Read by the obscura-backup sidecar
-- each poll (via psql) AND by the app's Admin -> Backups view. Seeded with safe defaults so a fresh
-- deploy has a sane schedule the moment the `backup` profile is brought up; until then the sidecar
-- isn't running, so enabled=true is a no-op. run_requested_at is the "Run backup now" flag: the app
-- sets it, the sidecar runs one backup on its next poll and clears it. Anti-footgun floors
-- (interval_hours>=1, retention_count>=1) are enforced in app validation (a runaway 0-hour schedule
-- would fill the disk; a 0 retention would prune every backup away).
CREATE TABLE backup_settings (
id int PRIMARY KEY DEFAULT 1 CHECK (id = 1),
enabled bool NOT NULL DEFAULT true,
interval_hours int NOT NULL DEFAULT 24,
retention_count int NOT NULL DEFAULT 7,
run_requested_at timestamptz,
updated_at timestamptz NOT NULL DEFAULT now()
);
INSERT INTO backup_settings (id) VALUES (1);
-- +goose Down
DROP TABLE backup_settings;
- [ ] Step 2 — Settings + catalog domain. Create
go/internal/backup/domain/settings.go(same package asbackup.go; stdlib only):
package domain
import "fmt"
// Settings is the admin-tunable backup schedule/retention (the singleton backup_settings row). The
// obscura-backup sidecar reads these via psql each poll cycle; the app reads/writes them for the
// Admin -> Backups view. run_requested_at is NOT modeled here (it is a write-only flag owned by the
// SettingsStore.RequestRun trigger + the sidecar's clear).
type Settings struct {
Enabled bool
IntervalHours int
RetentionCount int
}
// Anti-footgun floors (mirrors the rate-limit floor pattern): interval >= 1 (never a runaway
// 0-hour loop that fills the disk), retention >= 1 (never prune to nothing).
const (
FloorIntervalHours = 1
FloorRetentionCount = 1
)
// Defaults returns the seeded settings (matches migration 00087): enabled, daily, keep 7.
func Defaults() Settings {
return Settings{Enabled: true, IntervalHours: 24, RetentionCount: 7}
}
// Validate enforces the safety floors. A rejected save returns a clear, admin-facing error.
func (s Settings) Validate() error {
if s.IntervalHours < FloorIntervalHours {
return fmt.Errorf("interval_hours must be at least %d (safety floor to prevent runaway backups)", FloorIntervalHours)
}
if s.RetentionCount < FloorRetentionCount {
return fmt.Errorf("retention_count must be at least %d (safety floor to keep at least one backup)", FloorRetentionCount)
}
return nil
}
// SetStatus is one backup set's status.json (metadata only — never the db.dump/blob bytes).
// Written by the sidecar's backup.sh; parsed best-effort by the FSCatalog. The JSON tags match the
// sidecar's on-disk shape AND the API response.
type SetStatus struct {
TS string `json:"ts"`
Result string `json:"result"` // "ok" | "error"
DBBytes int64 `json:"db_bytes"`
BlobBytes int64 `json:"blob_bytes"`
BlobCount int64 `json:"blob_count"`
DurationSeconds int64 `json:"duration_seconds"`
Error string `json:"error,omitempty"`
}
// CatalogView is the app-side, read-only view of the mounted backup dir: the newest-first set
// history + the latest-run summary. Enabled is filesystem-derived (dir present AND >= 1 completed
// set), distinct from Settings.Enabled (the auto-backup toggle). Sets is always non-nil so the API
// serializes [] not null.
type CatalogView struct {
Enabled bool `json:"enabled"`
BackupDir string `json:"backup_dir"`
LastRun *SetStatus `json:"last_run"`
Sets []SetStatus `json:"sets"`
}
- [ ] Step 3 — SettingsStore. Create
go/internal/backup/adapters/settings_pg.go:
package adapters
import (
"context"
"errors"
"fmt"
"github.com/jackc/pgx/v5"
"github.com/Virtue-Digital-Indonesia/obscura/internal/backup/domain"
"github.com/Virtue-Digital-Indonesia/obscura/internal/platform/db"
)
// SettingsStore reads/writes the singleton backup_settings row (id=1). The obscura-backup sidecar
// reads the SAME row directly via psql — the app never RPCs the sidecar; the DB row is the only
// coupling. Mirrors the ratelimit adapters.Store fallback discipline.
type SettingsStore struct{ db *db.DB }
// NewSettingsStore builds the backup-settings store.
func NewSettingsStore(d *db.DB) *SettingsStore { return &SettingsStore{db: d} }
// Load reads the singleton settings, falling back to seeded defaults when the row is missing or
// out-of-range (a corrupt row must never brick the Admin view).
func (s *SettingsStore) Load(ctx context.Context) (domain.Settings, error) {
var st domain.Settings
err := s.db.Exec(ctx).QueryRow(ctx,
`SELECT enabled, interval_hours, retention_count FROM backup_settings WHERE id = 1`).
Scan(&st.Enabled, &st.IntervalHours, &st.RetentionCount)
if errors.Is(err, pgx.ErrNoRows) {
return domain.Defaults(), nil
}
if err != nil {
return domain.Defaults(), fmt.Errorf("backup settings load: %w", err)
}
if st.Validate() != nil {
return domain.Defaults(), nil
}
return st, nil
}
// Save writes the singleton settings and bumps updated_at. It does NOT touch run_requested_at (that
// flag is owned by RequestRun + the sidecar's clear).
func (s *SettingsStore) Save(ctx context.Context, st domain.Settings) error {
if _, err := s.db.Exec(ctx).Exec(ctx,
`UPDATE backup_settings
SET enabled = $1, interval_hours = $2, retention_count = $3, updated_at = now()
WHERE id = 1`,
st.Enabled, st.IntervalHours, st.RetentionCount); err != nil {
return fmt.Errorf("backup settings save: %w", err)
}
return nil
}
// RequestRun sets run_requested_at = now() (the "Run backup now" trigger). COALESCE keeps an already
// pending request from being restacked (the earliest pending timestamp wins). The sidecar clears it
// after running one backup on its next poll.
func (s *SettingsStore) RequestRun(ctx context.Context) error {
if _, err := s.db.Exec(ctx).Exec(ctx,
`UPDATE backup_settings SET run_requested_at = COALESCE(run_requested_at, now()) WHERE id = 1`); err != nil {
return fmt.Errorf("backup settings request run: %w", err)
}
return nil
}
- [ ] Step 4 — FSCatalog. Create
go/internal/backup/adapters/fscatalog.go:
package adapters
import (
"encoding/json"
"os"
"path/filepath"
"sort"
"strings"
"time"
"github.com/Virtue-Digital-Indonesia/obscura/internal/backup/domain"
)
// tsLayout is the sidecar's compact UTC set-name format (date -u +%Y%m%dT%H%M%SZ). Because it is
// zero-padded and big-endian, a plain reverse string sort is chronological newest-first.
const tsLayout = "20060102T150405Z"
// FSCatalog is the app's read-only view of the mounted backup directory ($BACKUP_DIR, bind-mounted
// :ro). It reads metadata ONLY (each set's status.json + latest.json) — it NEVER opens db.dump or
// blob bytes, and never writes. Tolerant by construction: a missing/unreadable dir -> Enabled=false;
// a set with a missing/malformed status.json is skipped; nothing here can 500 the status endpoint.
type FSCatalog struct{ dir string }
// NewFSCatalog builds the catalog over a mounted backup dir (cfg.BackupDir, default /backups).
func NewFSCatalog(dir string) *FSCatalog { return &FSCatalog{dir: dir} }
// Read scans the backup dir. A completed set is a subdir named <TS> (NOT <TS>.partial) containing a
// status.json. Sets are returned newest-first; LastRun comes from latest.json (written on success).
func (c *FSCatalog) Read() domain.CatalogView {
out := domain.CatalogView{Enabled: false, BackupDir: c.dir, Sets: []domain.SetStatus{}}
entries, err := os.ReadDir(c.dir)
if err != nil {
return out // dir absent/unreadable -> disabled (profile not enabled / nothing mounted)
}
names := make([]string, 0, len(entries))
for _, e := range entries {
if !e.IsDir() || strings.HasSuffix(e.Name(), ".partial") {
continue
}
names = append(names, e.Name())
}
sort.Sort(sort.Reverse(sort.StringSlice(names))) // newest-first
for _, name := range names {
if st, ok := readStatus(filepath.Join(c.dir, name, "status.json")); ok {
out.Sets = append(out.Sets, st)
}
}
if len(out.Sets) > 0 {
out.Enabled = true
}
if last, ok := readStatus(filepath.Join(c.dir, "latest.json")); ok {
out.LastRun = &last
}
return out
}
// LatestSuccessUnix returns the unix seconds of the most recent SUCCESSFUL backup (from latest.json),
// or (0,false) when there is none. Feeds the obscura_backup_last_success_timestamp_seconds gauge.
func (c *FSCatalog) LatestSuccessUnix() (float64, bool) {
last, ok := readStatus(filepath.Join(c.dir, "latest.json"))
if !ok || last.Result != "ok" || last.TS == "" {
return 0, false
}
t, err := time.Parse(tsLayout, last.TS)
if err != nil {
return 0, false
}
return float64(t.Unix()), true
}
func readStatus(path string) (domain.SetStatus, bool) {
b, err := os.ReadFile(path)
if err != nil {
return domain.SetStatus{}, false
}
var st domain.SetStatus
if err := json.Unmarshal(b, &st); err != nil {
return domain.SetStatus{}, false
}
return st, true
}
- [ ] Step 5 — Config
BACKUP_DIR. Ingo/internal/platform/config/config.go, add a field to the top-levelConfigstruct (nearGotenbergURL,:43):
// BackupDir is the mounted read-only backup directory the app inspects for the Admin -> Backups
// view + the stale-backup metric (the obscura-backup sidecar writes it; the app only reads it).
// When the path does not exist the feature reports disabled. Default matches the compose mount.
BackupDir string `env:"BACKUP_DIR" envDefault:"/backups"`
No Validate() change — an absent path is a valid "feature disabled" state, not a boot error.
- [ ] Step 6 — Verify + commit.
cd go && go build ./... && go vet ./...(the new packages compile standalone; nothing wires them yet — fine). Commit:
git add go/migrations/00087_backup_settings.sql go/internal/backup/domain/settings.go go/internal/backup/adapters/settings_pg.go go/internal/backup/adapters/fscatalog.go go/internal/platform/config/config.go
git commit -m "feat(backup): migration 00087 backup_settings + settings domain, SettingsStore, FSCatalog, BACKUP_DIR"
Task 2: The backup sidecar — Dockerfile.backup + backup.sh + entrypoint.sh + compose service + app :ro mount
Files: deploy/Dockerfile.backup (new), scripts/backup.sh (new), scripts/entrypoint.sh (new), deploy/docker-compose.yml, .gitignore.
Depends on T1 (the backup_settings table). The scripts also tolerate a missing/unreadable table so a boot race never crashes the loop.
- [ ] Step 1 — Dockerfile.backup. Create
deploy/Dockerfile.backup:
# syntax=docker/dockerfile:1
# Disaster-recovery backup sidecar. Real pg_dump/pg_restore (version-matched to the
# pgvector/pgvector:pg17 server) + the MinIO client `mc`, plus the backup/entrypoint/restore
# scripts. OFF by default: only runs under the `backup` compose profile, never in the demo stack.
# Built from the REPO ROOT context (context: ..) because the repo-root .dockerignore excludes
# deploy/ — so the scripts live under scripts/ (not ignored) and are COPYed from there.
FROM postgres:17
# MinIO client (static amd64 binary). Air-gapped installs can pre-place /usr/local/bin/mc and
# override MC_URL to a file:// or internal mirror. ADD fetches at build time (connected build host).
ARG MC_URL=https://dl.min.io/client/mc/release/linux-amd64/mc
ADD ${MC_URL} /usr/local/bin/mc
RUN chmod +x /usr/local/bin/mc
COPY scripts/backup.sh /backup.sh
COPY scripts/entrypoint.sh /entrypoint.sh
COPY scripts/restore.sh /restore.sh
RUN chmod +x /backup.sh /entrypoint.sh /restore.sh
# Runs as root (the postgres image's default before its own entrypoint steps down — which we
# replace): needed to write the host-mounted ./backups bind volume regardless of host ownership.
ENTRYPOINT ["/entrypoint.sh"]
(Note: scripts/restore.sh is authored in T5. If you implement strictly task-by-task, either land T5's restore.sh first, or temporarily create a one-line placeholder scripts/restore.sh so the COPY succeeds and replace it in T5. Recommended: author restore.sh now as an empty executable stub #!/bin/bash\nexit 0 and fill it in T5, OR reorder to do T5's restore.sh before this build. The build only needs the file to exist.)
- [ ] Step 2 — backup.sh. Create
scripts/backup.sh(bash; the postgres:17 image is Debian with bash + GNU coreutils):
#!/bin/bash
# One backup set: pg_dump -Fc + mc mirror of the MinIO bucket into $BACKUP_DIR/<TS>/, built in a
# <TS>.partial dir and atomically renamed to <TS>/ ONLY on full success (a crashed run never looks
# complete). Writes status.json (ok|error), overwrites latest.json on success, and prunes completed
# sets to RETENTION_COUNT (never deleting the newest). Any failure -> status.json result:error in
# the .partial dir, prior good sets untouched, non-zero exit (visible in `docker logs`).
set -uo pipefail
BACKUP_DIR="${BACKUP_DIR:-/backups}"
RETENTION_COUNT="${RETENTION_COUNT:-7}"
BUCKET="${S3_BUCKET:-obscura}"
TS="$(date -u +%Y%m%dT%H%M%SZ)"
PARTIAL="$BACKUP_DIR/$TS.partial"
FINAL="$BACKUP_DIR/$TS"
start=$(date -u +%s)
mkdir -p "$PARTIAL/blobs"
fail() {
local msg="$1" dur
dur=$(( $(date -u +%s) - start ))
cat > "$PARTIAL/status.json" <<EOF
{"ts":"$TS","result":"error","db_bytes":0,"blob_count":0,"blob_bytes":0,"duration_seconds":$dur,"error":"$msg"}
EOF
echo "obscura-backup: FAILED: $msg" >&2
exit 1
}
# 1) Postgres custom-format dump (compressed, selective restore).
pg_dump -Fc -d "$DATABASE_URL" -f "$PARTIAL/db.dump" || fail "pg_dump failed"
db_bytes=$(stat -c%s "$PARTIAL/db.dump" 2>/dev/null || echo 0)
[ "$db_bytes" -gt 0 ] || fail "db.dump is empty"
# 2) MinIO blob mirror (all document bytes). --remove keeps the fresh partial blobs/ exactly equal
# to the bucket (harmless on an empty partial dir; guards against a stale re-run leftover).
scheme=http; [ "${S3_USE_SSL:-false}" = "true" ] && scheme=https
mc alias set obscura "$scheme://$S3_ENDPOINT" "$S3_ACCESS_KEY" "$S3_SECRET_KEY" >/dev/null 2>&1 || fail "mc alias set failed"
mc mirror --overwrite --remove "obscura/$BUCKET" "$PARTIAL/blobs" || fail "mc mirror failed"
blob_count=$(find "$PARTIAL/blobs" -type f | wc -l | tr -d ' ')
blob_bytes=$(du -sb "$PARTIAL/blobs" 2>/dev/null | cut -f1); [ -n "$blob_bytes" ] || blob_bytes=0
dur=$(( $(date -u +%s) - start ))
# 3) status.json (ok) inside the partial, then ATOMIC rename to the final set name.
cat > "$PARTIAL/status.json" <<EOF
{"ts":"$TS","result":"ok","db_bytes":$db_bytes,"blob_count":$blob_count,"blob_bytes":$blob_bytes,"duration_seconds":$dur}
EOF
mv "$PARTIAL" "$FINAL" || fail "rename to final set failed"
cp "$FINAL/status.json" "$BACKUP_DIR/latest.json"
echo "obscura-backup: set $TS ok (db=${db_bytes}B blobs=${blob_count} files/${blob_bytes}B in ${dur}s)"
# 4) Prune to RETENTION_COUNT: completed sets (name matches the TS pattern) newest-first, delete
# beyond N, never the newest. Count-based is more predictable than age-based.
mapfile -t sets < <(ls -1 "$BACKUP_DIR" 2>/dev/null | grep -E '^[0-9]{8}T[0-9]{6}Z$' | sort -r)
i=0
for s in "${sets[@]}"; do
i=$((i+1))
if [ "$i" -gt "$RETENTION_COUNT" ]; then
rm -rf "${BACKUP_DIR:?}/$s"
echo "obscura-backup: pruned old set $s (retention=$RETENTION_COUNT)"
fi
done
- [ ] Step 3 — entrypoint.sh (poll loop). Create
scripts/entrypoint.sh:
#!/bin/bash
# obscura-backup entrypoint. With ARGS -> exec them (used by restore.sh via
# `docker compose run --rm obscura-backup <cmd>`). With NO args -> the poll loop: every
# BACKUP_POLL_SECONDS (~60s) read backup_settings from Postgres and run backup.sh when a scheduled
# backup is due OR an on-demand run was requested. The DB row is the only coupling to the app.
set -uo pipefail
if [ "$#" -gt 0 ]; then exec "$@"; fi
BACKUP_DIR="${BACKUP_DIR:-/backups}"
POLL_SECONDS="${BACKUP_POLL_SECONDS:-60}"
echo "obscura-backup: poll loop every ${POLL_SECONDS}s; target=${BACKUP_DIR}"
# Read settings, tolerating a missing backup_settings table (before migration 00087 lands) or an
# unreachable DB: on any error psql prints nothing and the caller falls back to env/defaults. The
# loop NEVER crashes on a DB hiccup.
read_settings() {
psql "$DATABASE_URL" -tAF'|' -c \
"SELECT enabled, interval_hours, retention_count, COALESCE(EXTRACT(EPOCH FROM run_requested_at)::bigint, 0) FROM backup_settings WHERE id = 1" 2>/dev/null || true
}
# Newest completed set's timestamp as epoch seconds (0 if none), to derive last-success on disk.
latest_success_epoch() {
local newest y hms
newest=$(ls -1 "$BACKUP_DIR" 2>/dev/null | grep -E '^[0-9]{8}T[0-9]{6}Z$' | sort -r | head -n1)
[ -n "$newest" ] || { echo 0; return; }
y=${newest%%T*}; hms=${newest#*T}; hms=${hms%Z}
date -u -d "${y:0:4}-${y:4:2}-${y:6:2} ${hms:0:2}:${hms:2:2}:${hms:4:2}" +%s 2>/dev/null || echo 0
}
while true; do
row="$(read_settings)"
enabled="$(printf '%s' "$row" | cut -d'|' -f1)"
interval="$(printf '%s' "$row" | cut -d'|' -f2)"
retention="$(printf '%s' "$row" | cut -d'|' -f3)"
run_req="$(printf '%s' "$row" | cut -d'|' -f4)"
# Fallbacks when the row is unreadable: env, then safe defaults.
[ -n "$enabled" ] || enabled="${BACKUP_ENABLED:-t}"
[ -n "$interval" ] || interval="${BACKUP_INTERVAL_HOURS:-24}"
[ -n "$retention" ] || retention="${BACKUP_RETENTION_COUNT:-7}"
[ -n "$run_req" ] || run_req=0
# Floors (mirror the app validation; never 0 -> runaway, never prune to nothing).
{ [ "$interval" -ge 1 ] 2>/dev/null; } || interval=24
{ [ "$retention" -ge 1 ] 2>/dev/null; } || retention=7
now=$(date -u +%s)
last=$(latest_success_epoch)
due=0
if [ "$run_req" -gt 0 ] 2>/dev/null; then
due=1 # on-demand "Run backup now"
elif [ "$enabled" = "t" ] || [ "$enabled" = "true" ]; then
[ $((now - last)) -ge $((interval * 3600)) ] && due=1
fi
if [ "$due" -eq 1 ]; then
RETENTION_COUNT="$retention" /backup.sh || echo "obscura-backup: backup.sh returned non-zero (see logs above)"
# Clear the on-demand flag after the run (best-effort; a failed run still clears the request so
# it isn't retried forever — the next scheduled cycle will retry).
if [ "$run_req" -gt 0 ] 2>/dev/null; then
psql "$DATABASE_URL" -c "UPDATE backup_settings SET run_requested_at = NULL WHERE id = 1" >/dev/null 2>&1 || true
fi
fi
sleep "$POLL_SECONDS"
done
- [ ] Step 4 — Compose service + app mount. In
deploy/docker-compose.yml:
1. Add the sidecar service (place it after thewebservice, before the OIDC-IdP block,~:197):
# Disaster-recovery backup sidecar (pg_dump + mc mirror). OFF by default — only runs under the
# `backup` profile so the demo is untouched:
# docker compose -f deploy/docker-compose.yml --profile backup up -d obscura-backup
# Reads its schedule/retention/on-off from the backup_settings row in Postgres each poll (~60s);
# writes sets to the host-mounted ./backups dir (point it at a NAS/external disk in production).
# Restore is an operator procedure — see scripts/restore.sh + docs/BACKUP.md.
obscura-backup:
build:
context: ..
dockerfile: deploy/Dockerfile.backup
profiles: ["backup"]
depends_on:
postgres:
condition: service_healthy
minio:
condition: service_started
environment:
DATABASE_URL: "postgres://obscura:obscura@postgres:5432/obscura?sslmode=disable"
S3_ENDPOINT: "minio:9000"
S3_BUCKET: obscura
S3_ACCESS_KEY: obscura
S3_SECRET_KEY: obscura-dev-secret
S3_USE_SSL: "false"
BACKUP_DIR: /backups
BACKUP_POLL_SECONDS: "60"
volumes:
- ./backups:/backups
restart: unless-stopped
- On the
obscuraservice, add the read-only mount to itsvolumes:block (:176-186, after the peruri_sharefolder line):
# Read-only view of the backup sets the obscura-backup sidecar writes, for the Admin ->
# Backups status view + the stale-backup metric. Read-only: the app can never mutate/delete a
# backup. Absent/empty (profile down) -> the feature reports disabled.
- ./backups:/backups:ro
- On the
obscuraserviceenvironment:block, add (nearMETRICS_ADDR,:175):
BACKUP_DIR: /backups
- [ ] Step 5 — .gitignore. Append to
.gitignore(repo root) so backup artifacts + the host mount dir are never committed:
# Backup sets written by the obscura-backup sidecar (host-mounted ./backups).
deploy/backups/
- [ ] Step 6 — Verify + commit. No Go/web changes. Validate the compose file parses and the scripts are syntactically valid:
docker compose -f deploy/docker-compose.yml config >/dev/null && echo "compose OK"
bash -n scripts/backup.sh && bash -n scripts/entrypoint.sh && echo "scripts OK"
(Do NOT build/run the sidecar here — that happens in T7.) Commit:
git add deploy/Dockerfile.backup scripts/backup.sh scripts/entrypoint.sh deploy/docker-compose.yml .gitignore
git commit -m "feat(backup): obscura-backup sidecar (pg_dump+mc poll loop, profile backup) + app :ro mount"
Task 3 (ADVERSARIAL-review this task — settings/floor/graceful-empty/perm spine): Admin API — settings GET/PUT + status + run
Files: go/internal/httpapi/handlers_backup.go, go/internal/httpapi/server.go, go/cmd/obscura-server/wire.go, api/openapi.yaml, web/src/api/schema.ts.
Contract:
- GET /api/v1/admin/backups/settings (perm backup.admin) → {enabled, interval_hours, retention_count, floors:{interval_hours, retention_count}}.
- PUT /api/v1/admin/backups/settings → validates floors (400 backup.floor below min), live-applies by writing the row (the sidecar picks it up next poll), returns the updated view.
- GET /api/v1/admin/backups/status → {enabled, backup_dir, retention_count, last_run, sets[]} from the FSCatalog; gracefully enabled:false (never 500) when the dir is absent/empty.
- POST /api/v1/admin/backups/run → sets run_requested_at, returns 202 {requested:true}.
Adversarial checks to make while reviewing: (a) a below-floor interval_hours=0 OR retention_count=0 PUT returns 400 backup.floor, not a silent clamp; (b) GET /status with NO ./backups mount returns 200 {enabled:false, sets:[]}, not a 500; (c) all four routes are gated by requirePerm("backup.admin") and a non-admin gets 403; (d) the four static routes are NOT shadowed by /admin/backups/{id} (chi statics win); (e) the existing manifest routes (POST/GET /admin/backups, /{id}, /{id}/manifest) still resolve.
- [ ] Step 1 — Handlers. Append to
go/internal/httpapi/handlers_backup.go. First extend the import block to:
import (
"encoding/json"
"io"
"net/http"
"github.com/go-chi/chi/v5"
backupdomain "github.com/Virtue-Digital-Indonesia/obscura/internal/backup/domain"
"github.com/Virtue-Digital-Indonesia/obscura/internal/kernel"
)
Then add:
// backupSettingsBody is the editable schedule/retention (shared by the PUT body + the view).
type backupSettingsBody struct {
Enabled bool `json:"enabled"`
IntervalHours int `json:"interval_hours"`
RetentionCount int `json:"retention_count"`
}
// backupFloors is the anti-footgun minimums surfaced to the UI.
type backupFloors struct {
IntervalHours int `json:"interval_hours"`
RetentionCount int `json:"retention_count"`
}
// backupSettingsView is GET/PUT /admin/backups/settings: the current settings + the enforced floors.
type backupSettingsView struct {
backupSettingsBody
Floors backupFloors `json:"floors"`
}
func backupSettingsToView(s backupdomain.Settings) backupSettingsView {
return backupSettingsView{
backupSettingsBody: backupSettingsBody{Enabled: s.Enabled, IntervalHours: s.IntervalHours, RetentionCount: s.RetentionCount},
Floors: backupFloors{IntervalHours: backupdomain.FloorIntervalHours, RetentionCount: backupdomain.FloorRetentionCount},
}
}
// GetBackupSettings returns the current schedule/retention + floors.
func (s *Server) GetBackupSettings(w http.ResponseWriter, r *http.Request) {
st, err := s.backupSettings.Load(r.Context())
if err != nil {
writeProblem(w, err)
return
}
writeJSON(w, http.StatusOK, backupSettingsToView(st))
}
// PutBackupSettings validates the floors and live-applies by writing the row (the sidecar picks it
// up on its next poll). A below-floor value returns 400 backup.floor.
func (s *Server) PutBackupSettings(w http.ResponseWriter, r *http.Request) {
var body backupSettingsBody
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeProblem(w, &kernel.Error{Kind: kernel.ErrValidation, Code: "backup.invalid", Message: "invalid request body"})
return
}
st := backupdomain.Settings{Enabled: body.Enabled, IntervalHours: body.IntervalHours, RetentionCount: body.RetentionCount}
if err := st.Validate(); err != nil {
writeProblem(w, &kernel.Error{Kind: kernel.ErrValidation, Code: "backup.floor", Message: err.Error()})
return
}
if err := s.backupSettings.Save(r.Context(), st); err != nil {
writeProblem(w, err)
return
}
writeJSON(w, http.StatusOK, backupSettingsToView(st))
}
// GetBackupStatus returns the read-only catalog of the mounted backup dir (metadata only) plus the
// admin-set retention. Gracefully reports enabled:false when the dir is absent/empty (profile down).
func (s *Server) GetBackupStatus(w http.ResponseWriter, r *http.Request) {
cat := s.backupCatalog.Read()
retention := 0
if st, err := s.backupSettings.Load(r.Context()); err == nil {
retention = st.RetentionCount
}
writeJSON(w, http.StatusOK, map[string]any{
"enabled": cat.Enabled,
"backup_dir": cat.BackupDir,
"retention_count": retention,
"last_run": cat.LastRun,
"sets": cat.Sets,
})
}
// RunBackupNow sets the run_requested_at flag; the obscura-backup sidecar runs one backup on its next
// poll and clears it. 202 (the app has no direct clock coupling to the sidecar).
func (s *Server) RunBackupNow(w http.ResponseWriter, r *http.Request) {
if err := s.backupSettings.RequestRun(r.Context()); err != nil {
writeProblem(w, err)
return
}
writeJSON(w, http.StatusAccepted, map[string]any{"requested": true})
}
- [ ] Step 2 — Server wiring (interfaces + Deps + fields + routes). In
go/internal/httpapi/server.go:
1. Add thebackupdomainimport to the import block:
backupdomain "github.com/Virtue-Digital-Indonesia/obscura/internal/backup/domain"
- Just after the
RateLimitStoreinterface (:78), add:
// BackupSettingsStore persists the admin-tunable backup schedule/retention singleton (read live by
// the obscura-backup sidecar via psql). *backupadapters.SettingsStore satisfies it.
type BackupSettingsStore interface {
Load(ctx context.Context) (backupdomain.Settings, error)
Save(ctx context.Context, s backupdomain.Settings) error
RequestRun(ctx context.Context) error
}
// BackupCatalog is the read-only view of the mounted backup dir (metadata only, never blob/db
// bytes). *backupadapters.FSCatalog satisfies it.
type BackupCatalog interface {
Read() backupdomain.CatalogView
}
- In
Deps(afterBackup *backupapp.Service,:109) add:
BackupSettings BackupSettingsStore
BackupCatalog BackupCatalog
- In the
Serverstruct (afterbackup *backupapp.Service,:150) add:
backupSettings BackupSettingsStore
backupCatalog BackupCatalog
- In
NewServer(afterbackup: d.Backup,:206) add:
backupSettings: d.BackupSettings,
backupCatalog: d.BackupCatalog,
- In the router, next to the existing manifest routes (after
:722), add the four new routes:
// Admin-configurable backup schedule/retention + read-only set catalog + run-now trigger.
// Static segments (settings/status/run) match before /admin/backups/{id} in chi.
r.With(s.requirePerm("backup.admin")).Get("/admin/backups/settings", s.GetBackupSettings)
r.With(s.requirePerm("backup.admin")).Put("/admin/backups/settings", s.PutBackupSettings)
r.With(s.requirePerm("backup.admin")).Get("/admin/backups/status", s.GetBackupStatus)
r.With(s.requirePerm("backup.admin")).Post("/admin/backups/run", s.RunBackupNow)
- [ ] Step 3 — wire.go injection. In
go/cmd/obscura-server/wire.go, right afterbackupSvc := backupapp.NewService(…)(:299), add:
backupSettingsStore := backupadapters.NewSettingsStore(database)
backupCatalog := backupadapters.NewFSCatalog(cfg.BackupDir)
Then in the httpapi.Deps{…} literal (after Backup: backupSvc,, :503) add:
BackupSettings: backupSettingsStore,
BackupCatalog: backupCatalog,
(The T4 collector registration uses backupCatalog too — that step adds the metrics.Register(...) line.)
- [ ] Step 4 — OpenAPI. In
api/openapi.yaml, add the three paths (place them right after the existing/api/v1/admin/backups/{id}/manifestblock,~:6081):
/api/v1/admin/backups/settings:
get:
operationId: getBackupSettings
summary: Current backup schedule/retention + floors
description: The admin-tunable auto-backup schedule (enabled, interval, retention) and the safety floors. Requires backup.admin.
tags: [admin]
responses:
'200':
description: Backup settings view.
content:
application/json:
schema: { $ref: '#/components/schemas/BackupSettingsView' }
'401': { $ref: '#/components/responses/Problem' }
'403': { $ref: '#/components/responses/Problem' }
put:
operationId: putBackupSettings
summary: Update the backup schedule/retention
description: Set the auto-backup on/off, interval (hours), and retention count (validated against the safety floors; too-low values are rejected). Applied live — the sidecar picks it up on its next poll. Requires backup.admin.
tags: [admin]
requestBody:
required: true
content:
application/json:
schema: { $ref: '#/components/schemas/BackupSettings' }
responses:
'200':
description: The stored settings view.
content:
application/json:
schema: { $ref: '#/components/schemas/BackupSettingsView' }
'400': { $ref: '#/components/responses/Problem' }
'401': { $ref: '#/components/responses/Problem' }
'403': { $ref: '#/components/responses/Problem' }
/api/v1/admin/backups/status:
get:
operationId: getBackupStatus
summary: Read-only catalog of available backup sets
description: The mounted backup directory's set history (newest-first, metadata only — never the dump/blob bytes) plus the last-run summary and the configured retention. Reports enabled:false when the backup profile is not running. Requires backup.admin.
tags: [admin]
responses:
'200':
description: Backup status + set history.
content:
application/json:
schema: { $ref: '#/components/schemas/BackupStatus' }
'401': { $ref: '#/components/responses/Problem' }
'403': { $ref: '#/components/responses/Problem' }
/api/v1/admin/backups/run:
post:
operationId: runBackupNow
summary: Request an on-demand backup
description: Sets the run-now flag; the obscura-backup sidecar runs one backup on its next poll and clears it. Idempotent-ish (a pending request isn't restacked). Requires backup.admin.
tags: [admin]
responses:
'202':
description: Backup requested.
content:
application/json:
schema:
type: object
required: [requested]
properties:
requested: { type: boolean }
'401': { $ref: '#/components/responses/Problem' }
'403': { $ref: '#/components/responses/Problem' }
Then add the schemas next to RateLimitView (~:7734, under components: schemas:):
BackupSettings:
type: object
description: The admin-tunable auto-backup schedule/retention.
required: [enabled, interval_hours, retention_count]
properties:
enabled: { type: boolean }
interval_hours: { type: integer }
retention_count: { type: integer }
BackupSettingsView:
allOf:
- $ref: '#/components/schemas/BackupSettings'
- type: object
required: [floors]
properties:
floors:
type: object
required: [interval_hours, retention_count]
properties:
interval_hours: { type: integer }
retention_count: { type: integer }
BackupSet:
type: object
description: One completed backup set's status (metadata only).
required: [ts, result, db_bytes, blob_bytes, blob_count, duration_seconds]
properties:
ts: { type: string }
result: { type: string, enum: [ok, error] }
db_bytes: { type: integer, format: int64 }
blob_bytes: { type: integer, format: int64 }
blob_count: { type: integer, format: int64 }
duration_seconds: { type: integer, format: int64 }
error: { type: string }
BackupStatus:
type: object
description: Read-only backup catalog — the set history + last-run + configured retention.
required: [enabled, backup_dir, retention_count, sets]
properties:
enabled: { type: boolean }
backup_dir: { type: string }
retention_count: { type: integer }
last_run:
allOf: [ { $ref: '#/components/schemas/BackupSet' } ]
nullable: true
sets:
type: array
items: { $ref: '#/components/schemas/BackupSet' }
-
[ ] Step 5 — Regenerate the client. From
web/:npm run gen:api(regeneratesweb/src/api/schema.tswith the new operations + schemas). -
[ ] Step 6 — Verify + commit.
cd go && go build ./... && go vet ./...;cd web && npx tsc --noEmit && npx vite build. Commit:
git add go/internal/httpapi/handlers_backup.go go/internal/httpapi/server.go go/cmd/obscura-server/wire.go api/openapi.yaml web/src/api/schema.ts
git commit -m "feat(backup): admin API — settings GET/PUT (floors) + status catalog + run-now (backup.admin)"
Task 4: Stale-backup metric — obscura_backup_last_success_timestamp_seconds
Files: go/internal/httpapi/metrics_collectors.go, go/cmd/obscura-server/wire.go.
Interface produced: httpapi.NewBackupCollector(func() (float64, bool)) prometheus.Collector, registered on the private registry served at /metrics.
- [ ] Step 1 — Collector. Append to
go/internal/httpapi/metrics_collectors.go(importstime,prometheusare already present in that file):
// ---------------------------------------------------------------------------
// Backup freshness collector — reads latest.json at scrape time (no background goroutine). This is
// the exact blind spot behind the down -v incident: a stale/failed backup becomes visible + alertable.
// ---------------------------------------------------------------------------
var backupLastSuccessDesc = prometheus.NewDesc(
"obscura_backup_last_success_timestamp_seconds",
"Unix timestamp of the most recent successful backup (0 when there is none). Read from $BACKUP_DIR/latest.json at scrape time.",
nil, nil,
)
// BackupCollector exports the last-successful-backup timestamp. lastSuccess is the FSCatalog reader
// (0/false when no successful backup exists). Reading a small JSON file per scrape is cheap and keeps
// the value always current with no background work.
type BackupCollector struct{ lastSuccess func() (float64, bool) }
// NewBackupCollector builds the collector over a last-success reader (pass fsCatalog.LatestSuccessUnix).
func NewBackupCollector(lastSuccess func() (float64, bool)) *BackupCollector {
return &BackupCollector{lastSuccess: lastSuccess}
}
// Describe implements prometheus.Collector.
func (c *BackupCollector) Describe(ch chan<- *prometheus.Desc) { ch <- backupLastSuccessDesc }
// Collect implements prometheus.Collector.
func (c *BackupCollector) Collect(ch chan<- prometheus.Metric) {
v, ok := c.lastSuccess()
if !ok {
v = 0
}
ch <- prometheus.MustNewConstMetric(backupLastSuccessDesc, prometheus.GaugeValue, v)
}
- [ ] Step 2 — Register in wire.go. In
go/cmd/obscura-server/wire.go, next to the other collector registrations (aftermetrics.Register(httpapi.NewAITokensCollector(…)),:472-474), add:
if err := metrics.Register(httpapi.NewBackupCollector(backupCatalog.LatestSuccessUnix)); err != nil {
return fmt.Errorf("register backup collector: %w", err)
}
(backupCatalog was constructed in T3 Step 3. If implementing T4 before T3's wire edit, construct backupCatalog := backupadapters.NewFSCatalog(cfg.BackupDir) here first.)
- [ ] Step 3 — Verify + commit.
cd go && go build ./... && go vet ./.... Commit:
git add go/internal/httpapi/metrics_collectors.go go/cmd/obscura-server/wire.go
git commit -m "feat(metrics): obscura_backup_last_success_timestamp_seconds gauge (reads latest.json)"
Task 5: scripts/restore.sh + docs/BACKUP.md operator runbook
Files: scripts/restore.sh (new — or replace the T2 stub), docs/BACKUP.md (new).
- [ ] Step 1 — restore.sh. Create/replace
scripts/restore.sh. It runs on the HOST (needs thedockerCLI + a running stack) and drives the DB/blob steps through theobscura-backupsidecar container so the operator needs no local pg/mc tooling. Requires thebackupprofile to be up.
#!/bin/bash
# Restore the whole system (Postgres + MinIO blobs) from a backup set. OPERATOR PROCEDURE — run on
# the host, never from the app. Overwrites the LIVE database + bucket, so it prints exactly what it
# will do and REQUIRES explicit confirmation (CONFIRM=yes env, or an interactive y/N).
#
# Usage: ./scripts/restore.sh <TS> (interactive confirm)
# CONFIRM=yes ./scripts/restore.sh <TS> (non-interactive)
#
# The `backup` profile must be up (the sidecar runs pg_restore + mc):
# docker compose -f deploy/docker-compose.yml --profile backup up -d obscura-backup
set -euo pipefail
COMPOSE="docker compose -f deploy/docker-compose.yml"
TS="${1:-}"
[ -n "$TS" ] || { echo "usage: $0 <TS> (e.g. 20260706T101112Z)"; exit 2; }
SET_DIR="deploy/backups/$TS"
[ -d "$SET_DIR" ] || { echo "restore: backup set not found: $SET_DIR"; exit 2; }
[ -f "$SET_DIR/db.dump" ] || { echo "restore: $SET_DIR/db.dump missing — set is incomplete"; exit 2; }
echo "This will OVERWRITE the live system from backup set $TS:"
echo " - Postgres : pg_restore --clean --if-exists (drops + recreates every object)"
echo " - MinIO blobs: mc mirror --overwrite --remove (bucket becomes an EXACT copy — extras deleted)"
echo " - obscura will be STOPPED during the restore and STARTED again after."
echo
if [ "${CONFIRM:-}" != "yes" ]; then
read -r -p "Type 'y' to proceed: " ans
[ "$ans" = "y" ] || { echo "aborted."; exit 1; }
fi
# In-container paths (the sidecar mounts ./backups at /backups).
DUMP="/backups/$TS/db.dump"
BLOBS="/backups/$TS/blobs"
echo "restore: stopping obscura (no writes during restore)..."
$COMPOSE stop obscura
echo "restore: pg_restore into the live database..."
$COMPOSE exec -T obscura-backup \
pg_restore --clean --if-exists --no-owner -d "$DATABASE_URL_OVERRIDE" "$DUMP" \
|| $COMPOSE exec -T obscura-backup bash -lc \
'pg_restore --clean --if-exists --no-owner -d "$DATABASE_URL" "'"$DUMP"'"'
echo "restore: mc mirror blobs -> bucket (exact state)..."
$COMPOSE exec -T obscura-backup bash -lc '
scheme=http; [ "${S3_USE_SSL:-false}" = "true" ] && scheme=https
mc alias set obscura "$scheme://$S3_ENDPOINT" "$S3_ACCESS_KEY" "$S3_SECRET_KEY" >/dev/null
mc mirror --overwrite --remove "'"$BLOBS"'" "obscura/${S3_BUCKET:-obscura}"
'
echo "restore: starting obscura..."
$COMPOSE start obscura
echo "restore: waiting for /readyz..."
for i in $(seq 1 30); do
code=$(curl -s -o /dev/null -w '%{http_code}' http://localhost:38080/readyz || true)
[ "$code" = "200" ] && { echo "restore: /readyz 200"; break; }
sleep 2
done
echo "restore: document sanity check —"
$COMPOSE exec -T obscura-backup bash -lc \
'psql "$DATABASE_URL" -tAc "SELECT count(*) FROM documents"' | sed 's/^/ documents=/'
echo "restore: done. Verify the app manually before resuming normal operations."
(The $DATABASE_URL_OVERRIDE || … fallback lets the operator export a custom URL; the default path uses the sidecar's own DATABASE_URL env. Keep whichever branch you prefer — the intent is "pg_restore into the live DB via the sidecar".)
chmod +x scripts/restore.sh
-
[ ] Step 2 — docs/BACKUP.md. Create
docs/BACKUP.md— the operator runbook. Cover, in this order: (1) What it backs up (Postgres viapg_dump -Fc+ all MinIO blobs viamc mirror; NOT mailpit/keycloak/ldap). (2) Enable the profile (docker compose -f deploy/docker-compose.yml --profile backup up -d obscura-backup; note it's off by default, demo untouched). (3) Admin settings (Admin → Backups: on/off, interval hours w/ floor 1, retention count w/ floor 1, Run backup now; the sidecar readsbackup_settingseach ~60s poll so changes apply within a minute). (4) Where sets live (deploy/backups/<TS>/=db.dump+blobs/+status.json;latest.json;<TS>.partial/= a crashed/in-progress run, ignore it). (5) Copy off-box (the host./backupscan be a mounted NAS/external disk;rsync deploy/backups/ user@nas:/obscura-backups/). (6) Restore procedure — the fullscripts/restore.sh <TS>walkthrough incl. the confirmation gate, that obscura is stopped during restore, and the--clean/--overwrite --removeoverwrite semantics. (7) Verify a restore (/readyz200 + document count + spot-check a document opens). (8) Troubleshooting table: disk full (prune retention / mount a bigger disk), MinIO creds wrong (mc alias set failedin logs → checkS3_*), Postgres version mismatch (sidecar ispostgres:17, matched to thepgvector/pgvector:pg17server), a.partialset that never completed (safe to delete; checkdocker logs obscura-backupfor the error), stale/failed backup surfaced byobscura_backup_last_success_timestamp_secondsin/metrics. (9) A one-line NEVERdocker compose down -vwarning (it wiped the demo once). -
[ ] Step 3 — Verify + upload + commit.
bash -n scripts/restore.sh && echo ok. Upload the runbook per CLAUDE.md and record the URL in the report:
curl -F "file=@docs/BACKUP.md" https://x056.think.val.id/upload
Commit:
git add scripts/restore.sh docs/BACKUP.md
git commit -m "docs(backup): restore.sh operator script (confirm gate) + BACKUP.md runbook"
Task 6: Web Admin → Backups tab
Files: web/src/features/admin/data.ts, web/src/features/admin/BackupsTab.tsx (new), web/src/features/admin/AdminPage.tsx, web/src/features/admin/i18n.ts, web/src/styles/app.css.
- [ ] Step 1 — data hooks. Append to
web/src/features/admin/data.ts(near the observability hooks,:744):
// --- Admin -> Backups (DR backup schedule/retention + read-only set catalog), gated on backup.admin.
type BackupSettings = components['schemas']['BackupSettings']
export function useBackupSettings() {
return useQuery({
queryKey: ['admin', 'backup-settings'],
queryFn: () => api.GET('/api/v1/admin/backups/settings', {}).then(ok),
})
}
export function useSaveBackupSettings() {
const qc = useQueryClient()
return useMutation({
mutationFn: (body: BackupSettings) => api.PUT('/api/v1/admin/backups/settings', { body }).then(ok),
onSuccess: () => qc.invalidateQueries({ queryKey: ['admin', 'backup-settings'] }),
})
}
export function useBackupStatus() {
return useQuery({
queryKey: ['admin', 'backup-status'],
queryFn: () => api.GET('/api/v1/admin/backups/status', {}).then(ok),
refetchInterval: 15000, // reflect new sets + the sidecar picking up a run-now
})
}
export function useRunBackup() {
const qc = useQueryClient()
return useMutation({
mutationFn: () => api.POST('/api/v1/admin/backups/run', {}).then(ok),
onSuccess: () => qc.invalidateQueries({ queryKey: ['admin', 'backup-status'] }),
})
}
- [ ] Step 2 — BackupsTab. Create
web/src/features/admin/BackupsTab.tsx. Settings area = enabled toggle + an interval control (a Daily/Weekly/HourlySelectmapping to 24/168/1, with a NumberInput fallback floored at 1) + retention NumberInput floored at 1 + Save (PUT, surfaces the backendbackup.floormessage) + a "Run backup now" button (POST run). Available-backups area = a read-onlyDataTable/plain table of sets newest-first (ts / result / db_bytes / blob_count / blob_bytes) + last-run status + the note.
// Admin -> Backups: disaster-recovery backup schedule/retention + the read-only set catalog. Gated
// server-side on backup.admin. Restore is an OPERATOR procedure (scripts/restore.sh + docs/BACKUP.md)
// — there is intentionally no restore control here.
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Button, Toggle, NumberInput, Select, SelectItem, InlineNotification } from '@carbon/react'
import { StatusTag } from '@/components/StatusTag'
import { useBackupSettings, useSaveBackupSettings, useBackupStatus, useRunBackup } from './data'
type Settings = { enabled: boolean; interval_hours: number; retention_count: number }
// Byte formatting for the set table (KB/MB/GB), ASCII only.
function fmtBytes(n: number): string {
if (!n) return '0 B'
const u = ['B', 'KB', 'MB', 'GB', 'TB']
let i = 0
let v = n
while (v >= 1024 && i < u.length - 1) { v /= 1024; i++ }
return `${v.toFixed(i === 0 ? 0 : 1)} ${u[i]}`
}
export function BackupsTab() {
const { t } = useTranslation()
const { data, isError } = useBackupSettings()
const save = useSaveBackupSettings()
const status = useBackupStatus()
const run = useRunBackup()
const [draft, setDraft] = useState<Settings | null>(null)
const [err, setErr] = useState<string | null>(null)
useEffect(() => {
if (data) setDraft({ enabled: data.enabled, interval_hours: data.interval_hours, retention_count: data.retention_count })
}, [data])
const floors = data?.floors ?? { interval_hours: 1, retention_count: 1 }
const cat = status.data
const sets = cat?.sets ?? []
const onSave = () => {
if (!draft) return
setErr(null)
save.mutate(draft, {
onError: (e) => {
const raw = (e as Error).message
try {
const p = JSON.parse(raw) as { detail?: string; title?: string }
setErr(p.detail || p.title || raw)
} catch { setErr(raw) }
},
})
}
// Interval presets (Daily/Weekly/Hourly) map to hours; any other stored value shows as "custom".
const preset = draft?.interval_hours === 1 ? '1' : draft?.interval_hours === 168 ? '168' : draft?.interval_hours === 24 ? '24' : 'custom'
return (
<div className="admin-split__main">
<p className="page__lead muted">{t('admin.backups.lead')}</p>
{isError && <p className="muted">{t('admin.loadError')}</p>}
{/* Settings (editable). */}
<h3 className="backup-heading">{t('admin.backups.settings.title')}</h3>
{err && <InlineNotification kind="error" lowContrast title={err} onCloseButtonClick={() => setErr(null)} />}
{draft && (
<div className="backup-settings">
<Toggle
id="backup-enabled"
labelText={t('admin.backups.settings.enabled')}
toggled={draft.enabled}
onToggle={(v) => setDraft({ ...draft, enabled: v })}
/>
<Select
id="backup-interval-preset"
labelText={t('admin.backups.settings.interval')}
value={preset}
onChange={(e) => {
const v = e.target.value
if (v !== 'custom') setDraft({ ...draft, interval_hours: Number(v) })
}}
>
<SelectItem value="1" text={t('admin.backups.settings.hourly')} />
<SelectItem value="24" text={t('admin.backups.settings.daily')} />
<SelectItem value="168" text={t('admin.backups.settings.weekly')} />
<SelectItem value="custom" text={t('admin.backups.settings.custom')} />
</Select>
<NumberInput
id="backup-interval-hours"
label={t('admin.backups.settings.intervalHours')}
helperText={t('admin.backups.settings.floor', { n: floors.interval_hours })}
min={floors.interval_hours}
value={draft.interval_hours}
onChange={(_e, s) => setDraft({ ...draft, interval_hours: Number((s as { value: number | string }).value) || 0 })}
/>
<NumberInput
id="backup-retention"
label={t('admin.backups.settings.retention')}
helperText={t('admin.backups.settings.floor', { n: floors.retention_count })}
min={floors.retention_count}
value={draft.retention_count}
onChange={(_e, s) => setDraft({ ...draft, retention_count: Number((s as { value: number | string }).value) || 0 })}
/>
</div>
)}
<div className="backup-actions">
<Button onClick={onSave} disabled={!draft || save.isPending}>{t('admin.backups.settings.save')}</Button>
<Button kind="tertiary" onClick={() => run.mutate()} disabled={run.isPending}>{t('admin.backups.settings.runNow')}</Button>
{save.isSuccess && !err && <span className="muted">{t('admin.backups.settings.saved')}</span>}
{run.isSuccess && <span className="muted">{t('admin.backups.settings.runRequested')}</span>}
</div>
{/* Available backups (read-only). */}
<h3 className="backup-heading">{t('admin.backups.sets.title')}</h3>
{cat && !cat.enabled && <p className="muted">{t('admin.backups.sets.disabled')}</p>}
{cat?.enabled && (
<table className="backup-sets">
<thead>
<tr>
<th>{t('admin.backups.sets.ts')}</th>
<th>{t('admin.backups.sets.result')}</th>
<th>{t('admin.backups.sets.db')}</th>
<th>{t('admin.backups.sets.blobs')}</th>
<th>{t('admin.backups.sets.blobBytes')}</th>
</tr>
</thead>
<tbody>
{sets.map((s) => (
<tr key={s.ts}>
<td className="mono">{s.ts}</td>
<td><StatusTag tone={s.result === 'ok' ? 'success' : 'danger'} label={s.result} /></td>
<td className="mono">{fmtBytes(s.db_bytes)}</td>
<td className="mono">{s.blob_count}</td>
<td className="mono">{fmtBytes(s.blob_bytes)}</td>
</tr>
))}
</tbody>
</table>
)}
<p className="muted backup-restore-note">{t('admin.backups.sets.restoreNote')}</p>
</div>
)
}
(If Toggle's onToggle signature differs in this Carbon version, mirror how other tabs use it — grep onToggle in web/src/features/admin. If StatusTag tone values differ, mirror ObservabilityTab's usage.)
- [ ] Step 3 — Register the tab. In
web/src/features/admin/AdminPage.tsx: add the import (near:48):
import { BackupsTab } from './BackupsTab'
Add the LAST tab label (after the observability <Tab>, :119):
<Tab>{t('admin.tabs.backups')}</Tab>
Add the LAST panel (after the observability <TabPanel>, :244-246, before </TabPanels>):
{/* Backups ----------------------------------------------------- */}
<TabPanel>
<BackupsTab />
</TabPanel>
- [ ] Step 4 — i18n (en + id). In
web/src/features/admin/i18n.ts, addbackups: 'Backups'to the enadmin.tabsgroup andbackups: 'Cadangan'to the idadmin.tabsgroup. Add anadmin.backupscopy group to BOTHenandid(identical shape). English:
backups: {
lead: 'Scheduled disaster-recovery backups of the database and document blobs. Restore is an operator procedure run from the host - see docs/BACKUP.md.',
settings: {
title: 'Schedule and retention',
enabled: 'Automatic backups',
interval: 'Frequency',
hourly: 'Hourly',
daily: 'Daily',
weekly: 'Weekly',
custom: 'Custom (hours)',
intervalHours: 'Interval (hours)',
retention: 'Keep last N backups',
floor: 'Minimum {{n}}',
save: 'Save settings',
saved: 'Saved',
runNow: 'Run backup now',
runRequested: 'Backup requested - it will run on the next sidecar poll.',
},
sets: {
title: 'Available backups',
disabled: 'No backups yet. Enable the backup profile on the server (see docs/BACKUP.md); sets will appear here.',
ts: 'Timestamp (UTC)',
result: 'Result',
db: 'Database',
blobs: 'Blobs',
blobBytes: 'Blob size',
restoreNote: 'Restore is an operator procedure - see docs/BACKUP.md. There is no restore button here on purpose.',
},
},
Indonesian (same keys; translate the values, ASCII quotes only). After editing, npx tsc --noEmit; if smart quotes crept in and broke the build, rewrite the whole file with Write using ASCII quotes.
- [ ] Step 5 — CSS. In
web/src/styles/app.css, add a small block near the.obs-*block:
.backup-heading { margin-top: 1.5rem; }
.backup-settings { display: flex; flex-direction: column; gap: 1rem; max-width: 28rem; }
.backup-actions { display: flex; align-items: center; gap: 1rem; margin-top: 1rem; }
.backup-sets { width: 100%; border-collapse: collapse; margin-top: 0.5rem; }
.backup-sets th, .backup-sets td { text-align: left; padding: 0.4rem 0.75rem; border-bottom: 1px solid var(--cds-border-subtle, #e0e0e0); }
.backup-restore-note { margin-top: 1rem; }
- [ ] Step 6 — Verify + commit.
cd web && npx tsc --noEmit && npx vite build. Commit:
git add web/src/features/admin/data.ts web/src/features/admin/BackupsTab.tsx web/src/features/admin/AdminPage.tsx web/src/features/admin/i18n.ts web/src/styles/app.css
git commit -m "feat(web): Admin -> Backups tab (schedule/retention editor + run-now + read-only set history)"
Task 7 (CONTROLLER-DRIVEN e2e — do NOT delegate to a subagent): deploy + prove backup/restore/retention/metric, demo-safe
Prereqs: T1–T6 committed on main and building. NEVER docker compose down -v. Single-service teardown only. The restore proof uses a THROWAWAY scratch DB + temp bucket — never the live ones.
-
[ ] Step 1 — Deploy + baseline. From repo root:
docker compose -f deploy/docker-compose.yml --env-file deploy/mekari.env up -d --build obscura web. Dev-logindirector@obscura.local(:38080) →TOKEN;GET /api/v1/me→ assertenabled_modules == [ai, correspondence, esign, semantic, watermarking]. Assert thebackupprofile is OFF:docker compose -f deploy/docker-compose.yml psshows NOobscura-backup. -
[ ] Step 2 — Settings API + floors.
GET /api/v1/admin/backups/settings(Bearer TOKEN) → assert seeded defaults{enabled:true, interval_hours:24, retention_count:7, floors:{interval_hours:1, retention_count:1}}.PUT{enabled:true, interval_hours:1, retention_count:3}→ 200 with those values.PUTa below-floor{interval_hours:0}→ 400 withcode/detail mentioningbackup.floor; same for{retention_count:0}.GET /api/v1/admin/backups/statuswith the profile still down → 200{enabled:false, sets:[]}(graceful empty, no 500). -
[ ] Step 3 — Bring the sidecar up + run one backup.
docker compose -f deploy/docker-compose.yml --profile backup up -d obscura-backup. Run one backup deterministically:docker compose -f deploy/docker-compose.yml exec -T obscura-backup /backup.sh(orPOST /api/v1/admin/backups/runand wait ~1-2 poll cycles). Capture the newTS. -
[ ] Step 4 — Assert set completeness. In the sidecar (
docker compose exec -T obscura-backup bash -lc '…'):db.dumpis non-empty (stat -c%s /backups/<TS>/db.dump> 0);status.jsonresult == "ok";blobs/file count == the live bucket's object count — comparefind /backups/<TS>/blobs -type f | wc -lagainstmc alias set … && mc ls --recursive obscura/obscura | wc -l. ThenGET /api/v1/admin/backups/status→enabled:true, the set is listed with matchingdb_bytes/blob_count, andlast_run.ts == <TS>. -
[ ] Step 5 — Metric present.
curl -s :38080/metrics | grep obscura_backup_last_success_timestamp_seconds→ a single sample > 0 (the unix time of<TS>). -
[ ] Step 6 — RESTORE PROOF into THROWAWAY targets (never live). All via the sidecar container:
- Scratch DB:
psql "$DATABASE_URL" -c 'DROP DATABASE IF EXISTS obscura_restore_test';createdb -T template0 obscura_restore_test(orpsql -c 'CREATE DATABASE obscura_restore_test'); build a scratch URLpostgres://obscura:obscura@postgres:5432/obscura_restore_test?sslmode=disable;pg_restore --clean --if-exists --no-owner -d "$SCRATCH_URL" /backups/<TS>/db.dump. - Assert row counts MATCH live: for
documents,folders,userscompareSELECT count(*)inobscura_restore_testvs the liveobscuraDB — must be equal. - Temp bucket:
mc mb obscura/restore-test-bucket;mc mirror /backups/<TS>/blobs obscura/restore-test-bucket; assertmc ls --recursive obscura/restore-test-bucket | wc -l== the live bucket object count. -
Cleanup:
psql -c 'DROP DATABASE obscura_restore_test';mc rb --force obscura/restore-test-bucket. -
[ ] Step 7 — Retention prune. With
retention_countset to a small N (e.g. 2 via PUT), createN+1sets with distinct timestamps (e.g. loopexec … /backup.shwith a 1s sleep, or fabricate older set dirs by copying + renaming to earlier TS names then run one more/backup.shso prune fires). Assert exactly N completed sets remain (ls /backups | grep -E '^[0-9]{8}T[0-9]{6}Z$' | wc -l == N) and the OLDEST was removed, newest kept. -
[ ] Step 8 — Restore-script smoke (optional, guarded). Optionally run
CONFIRM=yes ./scripts/restore.sh <TS>to prove the host script drives stop→restore→start→/readyz. Since this overwrites the LIVE demo from a set that WAS just taken of the same demo, it is idempotent (no data change) — but only do this if comfortable; the row-count proof in Step 6 already proves restore correctness without touching live. If run, re-assert/me5 modules after. -
[ ] Step 9 — Restore all state + demo-intact assert. Reset settings to defaults:
PUT {enabled:true, interval_hours:24, retention_count:7}. Take thebackupprofile down (single-service):docker compose -f deploy/docker-compose.yml stop obscura-backupthendocker compose -f deploy/docker-compose.yml rm -f obscura-backup(removes the profile container; NEVERdown -v). Delete the test sets:rm -rf deploy/backups/*(host side) or via the sidecar before removing it. Confirm all default servicesrunning/healthy(docker compose ps).GET /api/v1/me→ 5 modules intact. Report which assertions passed, the observed metric value, both floor 400s, and the restore row-count equalities. No commit unless an e2e-driven fix was needed (then commit it explicitly with its own message).
Self-review (spec coverage · placeholder scan · type consistency)
Spec coverage — every spec surface has a task:
- Sidecar (Dockerfile.backup + backup.sh + entrypoint.sh poll loop + compose obscura-backup profile + app :ro mount) → T2.
- backup_settings singleton migration + floors + run_requested_at → T1 (table/domain/adapters) + T3 (API).
- Admin API GET/PUT settings, GET status, POST run → T3.
- App-side FSCatalog (metadata-only, graceful-empty) → T1 (adapter) + T3 (endpoint).
- Stale-backup metric obscura_backup_last_success_timestamp_seconds → T4.
- restore.sh (confirm gate) + docs/BACKUP.md runbook → T5.
- Admin → Backups tab (settings + run-now + read-only set history + restore note) + en/id → T6.
- e2e (backup/settings/floor/restore-into-throwaway/retention/metric/demo-safe) → T7.
- Out-of-scope (in-app restore, WAL/PITR, at-rest encryption, off-site replication, mailpit/keycloak/ldap backup, incremental) → not built, matching the spec.
Placeholder scan: no TODO/.../FIXME in the task code. The one deliberate ordering note is T2's restore.sh COPY dependency on T5 — flagged with a concrete stub instruction so the build never breaks.
Type consistency across tasks: the on-disk status.json field names (ts/result/db_bytes/blob_count/blob_bytes/duration_seconds/error) written by backup.sh (T2) match backupdomain.SetStatus JSON tags (T1), the OpenAPI BackupSet schema (T3), and the BackupsTab table (T6). Settings field names (enabled/interval_hours/retention_count) match across the migration (T1), backupSettingsBody/BackupSettings schema (T3), and the tab (T6). Floors (FloorIntervalHours=1/FloorRetentionCount=1, T1) are echoed by the API floors object (T3), the sidecar's shell floor clamps (T2), and the UI min= (T6) — one source of truth in backupdomain. The interface methods (Load/Save/RequestRun, Read, LatestSuccessUnix) are declared in httpapi (T3)/collector (T4) exactly as implemented by the adapters (T1).
Sequencing note (restated): T1 before T2 (the sidecar's psql read needs the backup_settings table; also written tolerant of a missing table). T3 before T4's metrics.Register(...) line only in that both reference backupCatalog — either constructs it; the plan constructs it in T3 and T4 just registers. T5's restore.sh is COPYed by T2's Dockerfile — land the stub in T2 or reorder T5's file creation before the T7 build.