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:
174
internal/db/migrate.go
Normal file
174
internal/db/migrate.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user