P2 redesign Phase 5 — prune/check/unlock + maintenance ticker + repo stats + pending-runs queue #3
@@ -0,0 +1,58 @@
|
||||
-- 0009_admin_creds_and_repo_stats.sql
|
||||
--
|
||||
-- Phase 5 of the P2 redesign needs two things in the schema:
|
||||
--
|
||||
-- 1. A second credential row per host. Today host_credentials is
|
||||
-- 1:1 with hosts. For prune (and any future destructive op) we
|
||||
-- want a rest-server admin user whose password gives delete
|
||||
-- access — separate from the append-only user used on every
|
||||
-- backup. Add a `kind` column with default 'repo'; existing rows
|
||||
-- become kind='repo'. Future admin rows live alongside.
|
||||
--
|
||||
-- 2. A small singleton-per-host projection for repo size, snapshot
|
||||
-- count, last-prune freed bytes, lock state, and last-check
|
||||
-- result. Backed by `restic stats --json` + sniffed `restic
|
||||
-- check` stderr.
|
||||
--
|
||||
-- Use column-level ALTERs only; host_credentials has no inbound
|
||||
-- FKs but the rule from CLAUDE.md still applies.
|
||||
|
||||
ALTER TABLE host_credentials ADD COLUMN kind TEXT NOT NULL DEFAULT 'repo';
|
||||
|
||||
-- The PK on host_credentials is currently (host_id) — we need a
|
||||
-- composite (host_id, kind). SQLite has no ALTER TABLE …
|
||||
-- ADD/CHANGE PRIMARY KEY, so this is the one place a rebuild is
|
||||
-- justified. host_credentials has no inbound FKs, so the cascade
|
||||
-- trap doesn't apply here. Verified against schema/0002.
|
||||
|
||||
CREATE TABLE host_credentials_new (
|
||||
host_id TEXT NOT NULL REFERENCES hosts(id) ON DELETE CASCADE,
|
||||
kind TEXT NOT NULL DEFAULT 'repo'
|
||||
CHECK (kind IN ('repo', 'admin')),
|
||||
enc_repo_creds TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
PRIMARY KEY (host_id, kind)
|
||||
);
|
||||
INSERT INTO host_credentials_new (host_id, kind, enc_repo_creds, updated_at)
|
||||
SELECT host_id, kind, enc_repo_creds, updated_at FROM host_credentials;
|
||||
DROP TABLE host_credentials;
|
||||
ALTER TABLE host_credentials_new RENAME TO host_credentials;
|
||||
|
||||
-- Repo stats projection. One row per host, upserted by the agent's
|
||||
-- stats.report envelope (which fires after every successful backup
|
||||
-- and after every check / prune). All fields nullable so a freshly
|
||||
-- enrolled host with no jobs yet is representable.
|
||||
|
||||
CREATE TABLE host_repo_stats (
|
||||
host_id TEXT PRIMARY KEY REFERENCES hosts(id) ON DELETE CASCADE,
|
||||
total_size_bytes INTEGER,
|
||||
raw_size_bytes INTEGER,
|
||||
unique_files INTEGER,
|
||||
snapshot_count INTEGER,
|
||||
last_check_at TEXT,
|
||||
last_check_status TEXT, -- 'ok' | 'errors_found' | 'failed'
|
||||
lock_present INTEGER NOT NULL DEFAULT 0,
|
||||
last_prune_at TEXT,
|
||||
last_prune_freed_bytes INTEGER,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
@@ -84,6 +84,55 @@ func TestMigrateIsIdempotent(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigration0009Schema(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := openTestStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
// host_credentials must have a composite PK (host_id, kind).
|
||||
// We verify this by inserting two rows for the same host_id (different kinds)
|
||||
// and confirming a duplicate (host_id, kind) fails.
|
||||
_, err := s.DB().ExecContext(ctx,
|
||||
`INSERT INTO hosts (id, name, os, arch, enrolled_at) VALUES (?,?,?,?,?)`,
|
||||
"h-0009", "test-host", "linux", "amd64", "2026-01-01T00:00:00Z")
|
||||
if err != nil {
|
||||
t.Fatalf("insert host: %v", err)
|
||||
}
|
||||
now := "2026-01-01T00:00:00Z"
|
||||
if _, err := s.DB().ExecContext(ctx,
|
||||
`INSERT INTO host_credentials (host_id, kind, enc_repo_creds, updated_at) VALUES (?,?,?,?)`,
|
||||
"h-0009", "repo", "enc-repo", now); err != nil {
|
||||
t.Fatalf("insert repo creds: %v", err)
|
||||
}
|
||||
if _, err := s.DB().ExecContext(ctx,
|
||||
`INSERT INTO host_credentials (host_id, kind, enc_repo_creds, updated_at) VALUES (?,?,?,?)`,
|
||||
"h-0009", "admin", "enc-admin", now); err != nil {
|
||||
t.Fatalf("insert admin creds: %v", err)
|
||||
}
|
||||
// Duplicate (host_id, kind) must fail.
|
||||
if _, err := s.DB().ExecContext(ctx,
|
||||
`INSERT INTO host_credentials (host_id, kind, enc_repo_creds, updated_at) VALUES (?,?,?,?)`,
|
||||
"h-0009", "repo", "enc-repo-2", now); err == nil {
|
||||
t.Fatal("expected unique constraint violation on (host_id, kind), got nil")
|
||||
}
|
||||
|
||||
// host_repo_stats table must exist with expected columns.
|
||||
if _, err := s.DB().ExecContext(ctx,
|
||||
`INSERT INTO host_repo_stats (host_id, lock_present, updated_at) VALUES (?,?,?)`,
|
||||
"h-0009", 0, now); err != nil {
|
||||
t.Fatalf("insert host_repo_stats: %v", err)
|
||||
}
|
||||
var lockPresent int
|
||||
if err := s.DB().QueryRowContext(ctx,
|
||||
`SELECT lock_present FROM host_repo_stats WHERE host_id = ?`, "h-0009",
|
||||
).Scan(&lockPresent); err != nil {
|
||||
t.Fatalf("select host_repo_stats: %v", err)
|
||||
}
|
||||
if lockPresent != 0 {
|
||||
t.Errorf("expected lock_present=0, got %d", lockPresent)
|
||||
}
|
||||
}
|
||||
|
||||
func TestForeignKeysEnforced(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := openTestStore(t)
|
||||
|
||||
Reference in New Issue
Block a user