Phase 1: config loading, database migrations, audit log

- internal/config: TOML config with env overrides (MCR_ prefix),
  required field validation, same-filesystem check, defaults
- internal/db: SQLite via modernc.org/sqlite, WAL mode, 2 migrations
  (core registry tables + policy/audit), foreign key cascades
- internal/db: audit log write/list with filtering and pagination
- deploy/examples/mcr.toml: annotated example configuration
- .golangci.yaml: disable fieldalignment (readability over micro-opt)
- checkpoint skill copied from mcias

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-19 13:14:19 -07:00
parent 369558132b
commit fde66be9c1
15 changed files with 1433 additions and 9 deletions

174
internal/db/migrate.go Normal file
View File

@@ -0,0 +1,174 @@
package db
import (
"database/sql"
"fmt"
)
// migration is a numbered schema change.
type migration struct {
version int
name string
sql string
}
// migrations is the ordered list of schema migrations.
var migrations = []migration{
{
version: 1,
name: "core registry tables",
sql: `
CREATE TABLE IF NOT EXISTS repositories (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now'))
);
CREATE TABLE IF NOT EXISTS manifests (
id INTEGER PRIMARY KEY,
repository_id INTEGER NOT NULL REFERENCES repositories(id) ON DELETE CASCADE,
digest TEXT NOT NULL,
media_type TEXT NOT NULL,
content BLOB NOT NULL,
size INTEGER NOT NULL,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')),
UNIQUE(repository_id, digest)
);
CREATE INDEX IF NOT EXISTS idx_manifests_repo ON manifests (repository_id);
CREATE INDEX IF NOT EXISTS idx_manifests_digest ON manifests (digest);
CREATE TABLE IF NOT EXISTS tags (
id INTEGER PRIMARY KEY,
repository_id INTEGER NOT NULL REFERENCES repositories(id) ON DELETE CASCADE,
name TEXT NOT NULL,
manifest_id INTEGER NOT NULL REFERENCES manifests(id) ON DELETE CASCADE,
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')),
UNIQUE(repository_id, name)
);
CREATE INDEX IF NOT EXISTS idx_tags_repo ON tags (repository_id);
CREATE INDEX IF NOT EXISTS idx_tags_manifest ON tags (manifest_id);
CREATE TABLE IF NOT EXISTS blobs (
id INTEGER PRIMARY KEY,
digest TEXT NOT NULL UNIQUE,
size INTEGER NOT NULL,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now'))
);
CREATE TABLE IF NOT EXISTS manifest_blobs (
manifest_id INTEGER NOT NULL REFERENCES manifests(id) ON DELETE CASCADE,
blob_id INTEGER NOT NULL REFERENCES blobs(id),
PRIMARY KEY (manifest_id, blob_id)
);
CREATE INDEX IF NOT EXISTS idx_manifest_blobs_blob ON manifest_blobs (blob_id);
CREATE TABLE IF NOT EXISTS uploads (
id INTEGER PRIMARY KEY,
uuid TEXT NOT NULL UNIQUE,
repository_id INTEGER NOT NULL REFERENCES repositories(id) ON DELETE CASCADE,
byte_offset INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now'))
);
`,
},
{
version: 2,
name: "policy and audit tables",
sql: `
CREATE TABLE IF NOT EXISTS policy_rules (
id INTEGER PRIMARY KEY,
priority INTEGER NOT NULL DEFAULT 100,
description TEXT NOT NULL,
rule_json TEXT NOT NULL,
enabled INTEGER NOT NULL DEFAULT 1 CHECK (enabled IN (0,1)),
created_by TEXT,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')),
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now'))
);
CREATE TABLE IF NOT EXISTS audit_log (
id INTEGER PRIMARY KEY,
event_time TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')),
event_type TEXT NOT NULL,
actor_id TEXT,
repository TEXT,
digest TEXT,
ip_address TEXT,
details TEXT
);
CREATE INDEX IF NOT EXISTS idx_audit_time ON audit_log (event_time);
CREATE INDEX IF NOT EXISTS idx_audit_actor ON audit_log (actor_id);
CREATE INDEX IF NOT EXISTS idx_audit_event ON audit_log (event_type);
`,
},
}
// Migrate applies all pending migrations. It creates the schema_migrations
// tracking table if it does not exist. Migrations are idempotent.
func (d *DB) Migrate() error {
_, err := d.Exec(`CREATE TABLE IF NOT EXISTS schema_migrations (
version INTEGER PRIMARY KEY,
applied_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now'))
)`)
if err != nil {
return fmt.Errorf("db: create schema_migrations: %w", err)
}
for _, m := range migrations {
applied, err := d.migrationApplied(m.version)
if err != nil {
return err
}
if applied {
continue
}
tx, err := d.Begin()
if err != nil {
return fmt.Errorf("db: begin migration %d (%s): %w", m.version, m.name, err)
}
if _, err := tx.Exec(m.sql); err != nil {
_ = tx.Rollback()
return fmt.Errorf("db: migration %d (%s): %w", m.version, m.name, err)
}
if _, err := tx.Exec(`INSERT INTO schema_migrations (version) VALUES (?)`, m.version); err != nil {
_ = tx.Rollback()
return fmt.Errorf("db: record migration %d: %w", m.version, err)
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("db: commit migration %d: %w", m.version, err)
}
}
return nil
}
func (d *DB) migrationApplied(version int) (bool, error) {
var count int
err := d.QueryRow(`SELECT COUNT(*) FROM schema_migrations WHERE version = ?`, version).Scan(&count)
if err != nil {
return false, fmt.Errorf("db: check migration %d: %w", version, err)
}
return count > 0, nil
}
// SchemaVersion returns the highest applied migration version, or 0 if
// no migrations have been applied.
func (d *DB) SchemaVersion() (int, error) {
var version sql.NullInt64
err := d.QueryRow(`SELECT MAX(version) FROM schema_migrations`).Scan(&version)
if err != nil {
return 0, fmt.Errorf("db: schema version: %w", err)
}
if !version.Valid {
return 0, nil
}
return int(version.Int64), nil
}