c275f4ff4c
Lands the bottom three layers of Phase 1: P1-08 internal/api: protocol_version + envelope + every WS message shape from spec.md §6.2 (Hello, Heartbeat, Job*, Schedule*, etc). Wire-format tests pin the JSON shape so a rename here breaks tests instead of silently breaking the agent. P1-02 + P1-03 internal/store: SQLite via modernc.org/sqlite, embed.FS + a tiny version table for hand-rolled migrations. 0001_initial.sql covers every table from spec.md §5 plus enrollment_tokens and host_schedule_version. Typed accessors for users / sessions / enrollment / audit. WAL + foreign_keys + busy_timeout on by default. P1-06 internal/crypto: XChaCha20-Poly1305 AEAD wrapper with per-message random nonce. Key file lifecycle (generate + refuse-to-overwrite, load with size validation). Optional additionalData binds ciphertext to the row that owns it. P1-04 internal/auth (partial — passwords + tokens; sessions middleware lands with the HTTP handlers): argon2id following RFC 9106 (64 MiB / t=3 / p=4 / 32B), constant-time verify. HashToken stores SHA-256 of session/agent/enrollment tokens so a stolen DB doesn't hand over credentials. Build floor moves to Go 1.25 (modernc.org/sqlite v1.50+ requires it); CI + Dockerfile + README updated. Markdown lint diagnostics on tasks.md cleared. All packages tested. ~70 new tests pass in <1s. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
200 lines
8.4 KiB
SQL
200 lines
8.4 KiB
SQL
-- 0001_initial.sql
|
|
--
|
|
-- Initial schema for restic-manager. Mirrors the domain model in
|
|
-- spec.md §5. We use TEXT primary keys (ULIDs) throughout: sortable,
|
|
-- URL-safe, no autoincrement contention. JSON blobs are stored as
|
|
-- TEXT; SQLite's json1 extension is available but we read/write
|
|
-- raw and parse in Go for portability.
|
|
--
|
|
-- All timestamps are stored as RFC 3339 TEXT (UTC). SQLite's INTEGER
|
|
-- (unix epoch) would be cheaper but text is human-readable in dumps
|
|
-- and the storage cost is negligible at this scale.
|
|
|
|
CREATE TABLE users (
|
|
id TEXT PRIMARY KEY,
|
|
username TEXT NOT NULL UNIQUE,
|
|
password_hash TEXT NOT NULL,
|
|
role TEXT NOT NULL CHECK (role IN ('admin','operator','viewer')),
|
|
created_at TEXT NOT NULL,
|
|
last_login_at TEXT
|
|
);
|
|
|
|
CREATE TABLE sessions (
|
|
id TEXT PRIMARY KEY, -- session token (high-entropy)
|
|
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
created_at TEXT NOT NULL,
|
|
expires_at TEXT NOT NULL,
|
|
ip TEXT,
|
|
ua TEXT
|
|
);
|
|
CREATE INDEX sessions_user_id ON sessions(user_id);
|
|
CREATE INDEX sessions_expires_at ON sessions(expires_at);
|
|
|
|
CREATE TABLE credentials (
|
|
id TEXT PRIMARY KEY,
|
|
kind TEXT NOT NULL, -- 'rest','s3','local'
|
|
username TEXT,
|
|
-- secret_ref is the AEAD ciphertext (nonce || ciphertext, base64).
|
|
-- The plaintext never lands on disk.
|
|
secret_ref TEXT NOT NULL,
|
|
rotated_at TEXT NOT NULL
|
|
);
|
|
|
|
CREATE TABLE repos (
|
|
id TEXT PRIMARY KEY,
|
|
name TEXT NOT NULL,
|
|
url TEXT NOT NULL,
|
|
kind TEXT NOT NULL CHECK (kind IN ('rest','s3','local')),
|
|
credential_id TEXT REFERENCES credentials(id) ON DELETE RESTRICT,
|
|
password_secret_id TEXT REFERENCES credentials(id) ON DELETE RESTRICT,
|
|
-- Cached projection from `restic stats` + lock-file inspection.
|
|
size_bytes INTEGER NOT NULL DEFAULT 0,
|
|
snapshot_count INTEGER NOT NULL DEFAULT 0,
|
|
dedup_ratio REAL NOT NULL DEFAULT 0,
|
|
last_check_at TEXT,
|
|
last_check_status TEXT,
|
|
lock_state TEXT NOT NULL DEFAULT 'unlocked'
|
|
CHECK (lock_state IN ('locked','unlocked')),
|
|
append_only INTEGER NOT NULL DEFAULT 1, -- bool
|
|
credential_rotated_at TEXT
|
|
);
|
|
|
|
CREATE TABLE hosts (
|
|
id TEXT PRIMARY KEY,
|
|
name TEXT NOT NULL UNIQUE,
|
|
os TEXT NOT NULL,
|
|
arch TEXT NOT NULL,
|
|
agent_version TEXT NOT NULL DEFAULT '',
|
|
restic_version TEXT NOT NULL DEFAULT '',
|
|
protocol_version INTEGER NOT NULL DEFAULT 0,
|
|
enrolled_at TEXT NOT NULL,
|
|
last_seen_at TEXT,
|
|
status TEXT NOT NULL DEFAULT 'offline'
|
|
CHECK (status IN ('online','offline','degraded')),
|
|
repo_id TEXT REFERENCES repos(id) ON DELETE SET NULL,
|
|
tags TEXT NOT NULL DEFAULT '[]', -- json array
|
|
current_job_id TEXT,
|
|
-- Denormalised projections (refreshed on job.finished etc).
|
|
last_backup_at TEXT,
|
|
last_backup_status TEXT
|
|
CHECK (last_backup_status IN
|
|
('succeeded','failed','cancelled') OR
|
|
last_backup_status IS NULL),
|
|
repo_size_bytes INTEGER NOT NULL DEFAULT 0,
|
|
snapshot_count INTEGER NOT NULL DEFAULT 0,
|
|
open_alert_count INTEGER NOT NULL DEFAULT 0,
|
|
applied_schedule_version INTEGER NOT NULL DEFAULT 0,
|
|
-- Server-issued credentials for the agent ↔ server WS.
|
|
agent_token_hash TEXT NOT NULL DEFAULT '',
|
|
cert_pin_sha256 TEXT NOT NULL DEFAULT ''
|
|
);
|
|
CREATE INDEX hosts_status ON hosts(status);
|
|
CREATE INDEX hosts_last_seen_at ON hosts(last_seen_at);
|
|
|
|
-- Pending one-time enrollment tokens (TTL'd, single-use).
|
|
CREATE TABLE enrollment_tokens (
|
|
token_hash TEXT PRIMARY KEY, -- argon2id of token
|
|
created_at TEXT NOT NULL,
|
|
expires_at TEXT NOT NULL,
|
|
consumed_at TEXT,
|
|
consumed_host TEXT REFERENCES hosts(id) ON DELETE SET NULL
|
|
);
|
|
CREATE INDEX enrollment_tokens_expires_at ON enrollment_tokens(expires_at);
|
|
|
|
CREATE TABLE schedules (
|
|
id TEXT PRIMARY KEY,
|
|
host_id TEXT NOT NULL REFERENCES hosts(id) ON DELETE CASCADE,
|
|
kind TEXT NOT NULL CHECK (kind IN ('backup','forget','prune','check')),
|
|
cron_expr TEXT NOT NULL,
|
|
paths TEXT NOT NULL DEFAULT '[]', -- json array
|
|
excludes TEXT NOT NULL DEFAULT '[]',
|
|
tags TEXT NOT NULL DEFAULT '[]',
|
|
retention_policy TEXT NOT NULL DEFAULT '{}', -- json object
|
|
options TEXT NOT NULL DEFAULT '{}', -- json object (bandwidth)
|
|
-- Hooks are encrypted at rest (AEAD ciphertext). Constraint enforced
|
|
-- in application code: hooks must be empty unless kind='backup'.
|
|
pre_hook TEXT NOT NULL DEFAULT '',
|
|
post_hook TEXT NOT NULL DEFAULT '',
|
|
enabled INTEGER NOT NULL DEFAULT 1,
|
|
created_at TEXT NOT NULL,
|
|
updated_at TEXT NOT NULL
|
|
);
|
|
CREATE INDEX schedules_host_id ON schedules(host_id);
|
|
|
|
-- Per-host monotonic schedule version. Bumped on any schedules INSERT/
|
|
-- UPDATE/DELETE for that host. Pushed to the agent in schedule.set;
|
|
-- the agent acks back the same version in schedule.ack.
|
|
CREATE TABLE host_schedule_version (
|
|
host_id TEXT PRIMARY KEY REFERENCES hosts(id) ON DELETE CASCADE,
|
|
version INTEGER NOT NULL DEFAULT 0
|
|
);
|
|
|
|
CREATE TABLE jobs (
|
|
id TEXT PRIMARY KEY,
|
|
host_id TEXT NOT NULL REFERENCES hosts(id) ON DELETE CASCADE,
|
|
kind TEXT NOT NULL CHECK (kind IN ('backup','forget','prune','check','unlock')),
|
|
status TEXT NOT NULL CHECK (status IN ('queued','running','succeeded','failed','cancelled')),
|
|
scheduled_id TEXT REFERENCES schedules(id) ON DELETE SET NULL,
|
|
actor_kind TEXT NOT NULL CHECK (actor_kind IN ('user','schedule','system')),
|
|
actor_id TEXT, -- user id, schedule id, or null
|
|
started_at TEXT,
|
|
finished_at TEXT,
|
|
exit_code INTEGER,
|
|
stats TEXT, -- json blob from restic
|
|
error TEXT,
|
|
created_at TEXT NOT NULL
|
|
);
|
|
CREATE INDEX jobs_host_id ON jobs(host_id);
|
|
CREATE INDEX jobs_status ON jobs(status);
|
|
CREATE INDEX jobs_created_at ON jobs(created_at);
|
|
|
|
CREATE TABLE job_logs (
|
|
job_id TEXT NOT NULL REFERENCES jobs(id) ON DELETE CASCADE,
|
|
seq INTEGER NOT NULL,
|
|
ts TEXT NOT NULL,
|
|
stream TEXT NOT NULL CHECK (stream IN ('stdout','stderr','event')),
|
|
payload TEXT NOT NULL,
|
|
PRIMARY KEY (job_id, seq)
|
|
);
|
|
|
|
CREATE TABLE snapshots (
|
|
id TEXT PRIMARY KEY, -- restic snapshot id
|
|
host_id TEXT NOT NULL REFERENCES hosts(id) ON DELETE CASCADE,
|
|
repo_id TEXT NOT NULL REFERENCES repos(id) ON DELETE CASCADE,
|
|
time TEXT NOT NULL,
|
|
hostname TEXT NOT NULL,
|
|
paths TEXT NOT NULL DEFAULT '[]',
|
|
tags TEXT NOT NULL DEFAULT '[]',
|
|
size_bytes INTEGER NOT NULL DEFAULT 0,
|
|
file_count INTEGER NOT NULL DEFAULT 0
|
|
);
|
|
CREATE INDEX snapshots_host_id ON snapshots(host_id);
|
|
CREATE INDEX snapshots_time ON snapshots(time);
|
|
|
|
CREATE TABLE alerts (
|
|
id TEXT PRIMARY KEY,
|
|
host_id TEXT REFERENCES hosts(id) ON DELETE CASCADE,
|
|
kind TEXT NOT NULL,
|
|
severity TEXT NOT NULL CHECK (severity IN ('info','warning','critical')),
|
|
message TEXT NOT NULL,
|
|
created_at TEXT NOT NULL,
|
|
acknowledged_at TEXT,
|
|
acknowledged_by TEXT REFERENCES users(id) ON DELETE SET NULL,
|
|
resolved_at TEXT
|
|
);
|
|
CREATE INDEX alerts_host_id ON alerts(host_id);
|
|
CREATE INDEX alerts_open ON alerts(host_id) WHERE resolved_at IS NULL;
|
|
|
|
CREATE TABLE audit_log (
|
|
id TEXT PRIMARY KEY,
|
|
user_id TEXT REFERENCES users(id) ON DELETE SET NULL,
|
|
actor TEXT NOT NULL CHECK (actor IN ('user','agent','system')),
|
|
action TEXT NOT NULL,
|
|
target_kind TEXT,
|
|
target_id TEXT,
|
|
ts TEXT NOT NULL,
|
|
payload TEXT NOT NULL DEFAULT '{}'
|
|
);
|
|
CREATE INDEX audit_log_ts ON audit_log(ts);
|
|
CREATE INDEX audit_log_user ON audit_log(user_id);
|