store: migration 0009 — admin-creds kind + host_repo_stats
This commit is contained in:
@@ -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) {
|
func TestForeignKeysEnforced(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
s := openTestStore(t)
|
s := openTestStore(t)
|
||||||
|
|||||||
Reference in New Issue
Block a user