From 212ddfe22667a57148189dfe2da8c5ce82207a72 Mon Sep 17 00:00:00 2001 From: Steve Cliff Date: Sun, 3 May 2026 22:05:53 +0100 Subject: [PATCH] =?UTF-8?q?store:=20migration=200009=20=E2=80=94=20admin-c?= =?UTF-8?q?reds=20kind=20+=20host=5Frepo=5Fstats?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../0009_admin_creds_and_repo_stats.sql | 58 +++++++++++++++++++ internal/store/store_test.go | 49 ++++++++++++++++ 2 files changed, 107 insertions(+) create mode 100644 internal/store/migrations/0009_admin_creds_and_repo_stats.sql diff --git a/internal/store/migrations/0009_admin_creds_and_repo_stats.sql b/internal/store/migrations/0009_admin_creds_and_repo_stats.sql new file mode 100644 index 0000000..4627940 --- /dev/null +++ b/internal/store/migrations/0009_admin_creds_and_repo_stats.sql @@ -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 +); diff --git a/internal/store/store_test.go b/internal/store/store_test.go index 87aff9b..236ba10 100644 --- a/internal/store/store_test.go +++ b/internal/store/store_test.go @@ -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)