store: migration 0009 — admin-creds kind + host_repo_stats

This commit is contained in:
2026-05-03 22:05:53 +01:00
parent 88216d29d0
commit 1c7b471e75
2 changed files with 107 additions and 0 deletions
@@ -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
);
+49
View File
@@ -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)