- 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>
175 lines
5.3 KiB
Go
175 lines
5.3 KiB
Go
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
|
|
}
|