From 369558132b7174692501e6c7adc27deb9b6fb3e4 Mon Sep 17 00:00:00 2001 From: Kyle Isom Date: Thu, 19 Mar 2026 11:29:32 -0700 Subject: [PATCH] Initial scaffolding: module, directory structure, Makefile, linter config --- #PROJECT_PLAN.md# | 799 +++++++++++++++++++++++++++++++ .#PROJECT_PLAN.md | 1 + .gitignore | 19 + .golangci.yaml | 122 +++++ ARCHITECTURE.md | 1094 +++++++++++++++++++++++++++++++++++++++++++ CLAUDE.md | 95 ++++ Makefile | 46 ++ PROGRESS.md | 49 ++ PROJECT_PLAN.md | 799 +++++++++++++++++++++++++++++++ README.md | 3 + buf.yaml | 9 + cmd/mcr-web/main.go | 34 ++ cmd/mcrctl/main.go | 152 ++++++ cmd/mcrsrv/main.go | 56 +++ go.mod | 9 + go.sum | 10 + 16 files changed, 3297 insertions(+) create mode 100644 #PROJECT_PLAN.md# create mode 120000 .#PROJECT_PLAN.md create mode 100644 .gitignore create mode 100644 .golangci.yaml create mode 100644 ARCHITECTURE.md create mode 100644 CLAUDE.md create mode 100644 Makefile create mode 100644 PROGRESS.md create mode 100644 PROJECT_PLAN.md create mode 100644 README.md create mode 100644 buf.yaml create mode 100644 cmd/mcr-web/main.go create mode 100644 cmd/mcrctl/main.go create mode 100644 cmd/mcrsrv/main.go create mode 100644 go.mod create mode 100644 go.sum diff --git a/#PROJECT_PLAN.md# b/#PROJECT_PLAN.md# new file mode 100644 index 0000000..a9dda92 --- /dev/null +++ b/#PROJECT_PLAN.md# @@ -0,0 +1,799 @@ +# MCR Project Plan + +Implementation plan for the Metacircular Container Registry. Each phase +contains discrete steps with acceptance criteria. Steps within a phase are +sequential unless noted as batchable. See `ARCHITECTURE.md` for the full +design specification. + +## Status + +| Phase | Description | Status | +|-------|-------------|--------| +| 0 | Project scaffolding | Not started | +| 1 | Configuration & database | Not started | +| 2 | Blob storage layer | Not started | +| 3 | MCIAS authentication | Not started | +| 4 | Policy engine | Not started | +| 5 | OCI API — pull path | Not started | +| 6 | OCI API — push path | Not started | +| 7 | OCI API — delete path | Not started | +| 8 | Admin REST API | Not started | +| 9 | Garbage collection | Not started | +| 10 | gRPC admin API | Not started | +| 11 | CLI tool (mcrctl) | Not started | +| 12 | Web UI | Not started | +| 13 | Deployment artifacts | Not started | + +### Dependency Graph + +``` +Phase 0 (scaffolding) + └─► Phase 1 (config + db) + ├─► Phase 2 (blob storage) ──┐ + └─► Phase 3 (MCIAS auth) │ + └─► Phase 4 (policy) │ + └─► Phase 5 (pull) ◄──┘ + └─► Phase 6 (push) + └─► Phase 7 (delete) + └─► Phase 9 (GC) + └─► Phase 8 (admin REST) ◄── Phase 1 + └─► Phase 10 (gRPC) + ├─► Phase 11 (mcrctl) + └─► Phase 12 (web UI) + +Phase 13 (deployment) depends on all above. +``` + +### Batchable Work + +The following phases are independent and can be assigned to different +engineers simultaneously: + +- **Batch A** (after Phase 1): Phase 2 (blob storage) and Phase 3 (MCIAS auth) +- **Batch B** (after Phase 4): Phase 5 (OCI pull) and Phase 8 (admin REST) +- **Batch C** (after Phase 10): Phase 11 (mcrctl) and Phase 12 (web UI) + +--- + +## Phase 0: Project Scaffolding + +Set up the Go module, build system, and binary skeletons. This phase +produces a project that builds and lints cleanly with no functionality. + +### Step 0.1: Go module and directory structure + +**Acceptance criteria:** +- `go.mod` initialized with module path `git.wntrmute.dev/kyle/mcr` +- Directory skeleton created per `ARCHITECTURE.md` §13: + `cmd/mcrsrv/`, `cmd/mcr-web/`, `cmd/mcrctl/`, `internal/`, `proto/mcr/v1/`, + `gen/mcr/v1/`, `web/`, `deploy/` +- `.gitignore` excludes: `mcrsrv`, `mcr-web`, `mcrctl`, `srv/`, `*.db`, + `*.db-wal`, `*.db-shm` + +### Step 0.2: Makefile + +**Acceptance criteria:** +- Standard targets per engineering standards: `build`, `test`, `vet`, `lint`, + `proto`, `proto-lint`, `clean`, `docker`, `all`, `devserver` +- `all` target runs: `vet` → `lint` → `test` → build binaries +- Binary targets: `mcrsrv`, `mcr-web`, `mcrctl` with version injection via + `-X main.version=$(shell git describe --tags --always --dirty)` +- `CGO_ENABLED=0` for all builds +- `make all` passes (empty binaries link successfully) + +### Step 0.3: Linter and protobuf configuration + +**Acceptance criteria:** +- `.golangci.yaml` with required linters per engineering standards: + errcheck, govet, ineffassign, unused, errorlint, gosec, staticcheck, + revive, gofmt, goimports +- `errcheck.check-type-assertions: true` +- `govet`: all analyzers except `shadow` +- `gosec`: exclude `G101` in test files +- `buf.yaml` for proto linting +- `golangci-lint run ./...` passes cleanly + +### Step 0.4: Binary entry points with cobra + +**Acceptance criteria:** +- `cmd/mcrsrv/main.go`: root command with `server`, `init`, `snapshot` + subcommands (stubs that print "not implemented" and exit 1) +- `cmd/mcr-web/main.go`: root command with `server` subcommand (stub) +- `cmd/mcrctl/main.go`: root command with `status`, `repo`, `gc`, `policy`, + `audit`, `snapshot` subcommand groups (stubs) +- All three binaries accept `--version` flag, printing the injected version +- `make all` builds all three binaries and passes lint/vet/test + +--- + +## Phase 1: Configuration & Database + +Implement config loading, SQLite database setup, and schema migrations. +Steps 1.1 and 1.2 can be **batched** (no dependency between them). + +### Step 1.1: Configuration loading (`internal/config`) + +**Acceptance criteria:** +- TOML config struct matching `ARCHITECTURE.md` §10 (all sections: + `[server]`, `[database]`, `[storage]`, `[mcias]`, `[web]`, `[log]`) +- Parsed with `go-toml/v2` +- Environment variable overrides via `MCR_` prefix + (e.g., `MCR_SERVER_LISTEN_ADDR`) +- Startup validation: refuse to start if required fields are missing + (`listen_addr`, `tls_cert`, `tls_key`, `database.path`, + `storage.layers_path`, `mcias.server_url`) +- Same-filesystem check for `layers_path` and `uploads_path` via device ID +- Default values: `uploads_path` defaults to `/../uploads`, + `read_timeout` = 30s, `write_timeout` = 0, `idle_timeout` = 120s, + `shutdown_timeout` = 60s, `log.level` = "info" +- `mcr.toml.example` created in `deploy/examples/` +- Tests: valid config, missing required fields, env override, device ID check + +### Step 1.2: Database setup and migrations (`internal/db`) + +**Acceptance criteria:** +- SQLite opened with `modernc.org/sqlite` (pure-Go, no CGo) +- Connection pragmas: `journal_mode=WAL`, `foreign_keys=ON`, + `busy_timeout=5000` +- File permissions: `0600` +- Migration framework: Go functions registered sequentially, tracked in + `schema_migrations` table, idempotent (`CREATE TABLE IF NOT EXISTS`) +- Migration 000001: `repositories`, `manifests`, `tags`, `blobs`, + `manifest_blobs`, `uploads` tables per `ARCHITECTURE.md` §8 +- Migration 000002: `policy_rules`, `audit_log` tables per §8 +- All indexes created per schema +- `db.Open(path)` → `*DB`, `db.Close()`, `db.Migrate()` public API +- Tests: open fresh DB, run migrations, verify tables exist, run migrations + again (idempotent), verify foreign key enforcement works + +### Step 1.3: Audit log helpers (`internal/db`) + +**Acceptance criteria:** +- `db.WriteAuditEvent(event_type, actor_id, repository, digest, ip, details)` + inserts into `audit_log` +- `db.ListAuditEvents(filters)` with pagination (offset/limit), filtering + by event_type, actor_id, repository, time range +- `details` parameter is `map[string]string`, serialized as JSON +- Tests: write events, list with filters, pagination + +--- + +## Phase 2: Blob Storage Layer + +Implement content-addressed filesystem operations for blob data. + +### Step 2.1: Blob writer (`internal/storage`) + +**Acceptance criteria:** +- `storage.New(layersPath, uploadsPath)` constructor +- `storage.StartUpload(uuid)` creates a temp file at + `/` and returns a `*BlobWriter` +- `BlobWriter.Write([]byte)` appends data and updates a running SHA-256 hash +- `BlobWriter.Commit(expectedDigest)`: + - Finalizes SHA-256 + - Rejects with error if computed digest != expected digest + - Creates two-char prefix directory under `/sha256//` + - Renames temp file to `/sha256//` + - Returns the verified `sha256:` digest string +- `BlobWriter.Cancel()` removes the temp file +- `BlobWriter.BytesWritten()` returns current offset +- Tests: write blob, verify file at expected path, digest mismatch rejection, + cancel cleanup, concurrent writes to different UUIDs + +### Step 2.2: Blob reader and metadata (`internal/storage`) + +**Acceptance criteria:** +- `storage.Open(digest)` returns an `io.ReadCloser` for the blob file, or + `ErrBlobNotFound` +- `storage.Stat(digest)` returns size and existence check without opening +- `storage.Delete(digest)` removes the blob file and its prefix directory + if empty +- `storage.Exists(digest)` returns bool +- Digest format validated: must match `sha256:[a-f0-9]{64}` +- Tests: read after write, stat, delete, not-found error, invalid digest + format rejected + +--- + +## Phase 3: MCIAS Authentication + +Implement token validation and the OCI token endpoint. + +### Step 3.1: MCIAS client (`internal/auth`) + +**Acceptance criteria:** +- `auth.NewClient(mciastURL, caCert, serviceName, tags)` constructor +- `client.Login(username, password)` calls MCIAS `POST /v1/auth/login` + with `service_name` and `tags` in the request body; returns JWT string + and expiry +- `client.ValidateToken(token)` calls MCIAS `POST /v1/token/validate`; + returns parsed claims (subject UUID, account type, roles) or error +- Validation results cached by `sha256(token)` with 30-second TTL; + cache entries evicted on expiry +- TLS: custom CA cert supported; TLS 1.3 minimum enforced via + `tls.Config.MinVersion` +- HTTP client timeout: 10 seconds +- Errors wrapped with `fmt.Errorf("auth: %w", err)` +- Tests: use `httptest.Server` to mock MCIAS; test login success, + login failure (401), validate success, validate with revoked token, + cache hit within TTL, cache miss after TTL + +### Step 3.2: Auth middleware (`internal/server`) + +**Acceptance criteria:** +- `middleware.RequireAuth(authClient)` extracts `Authorization: Bearer ` + header, calls `authClient.ValidateToken`, injects claims into + `context.Context` +- Missing/invalid token returns OCI error format: + `{"errors":[{"code":"UNAUTHORIZED","message":"..."}]}` with HTTP 401 +- 401 responses include `WWW-Authenticate: Bearer realm="/v2/token",service=""` + header +- Claims retrievable from context via `auth.ClaimsFromContext(ctx)` +- Tests: valid token passes, missing header returns 401 with + WWW-Authenticate, invalid token returns 401, claims accessible in handler + +### Step 3.3: Token endpoint (`GET /v2/token`) + +**Acceptance criteria:** +- Accepts HTTP Basic auth (username:password from `Authorization` header) +- Accepts `scope` and `service` query parameters (logged but not used for + scoping) +- Calls `authClient.Login(username, password)` +- On success: returns `{"token":"","expires_in":,"issued_at":""}` +- On failure: returns `{"errors":[{"code":"UNAUTHORIZED","message":"..."}]}` + with HTTP 401 +- Tests: valid credentials, invalid credentials, missing auth header + +### Step 3.4: Version check endpoint (`GET /v2/`) + +**Acceptance criteria:** +- Requires valid bearer token (via RequireAuth middleware) +- Returns `200 OK` with body `{}` +- Unauthenticated requests return 401 with WWW-Authenticate header + (this is the entry point for the OCI auth handshake) +- Tests: authenticated returns 200, unauthenticated returns 401 with + correct WWW-Authenticate header + +--- + +## Phase 4: Policy Engine + +Implement the registry-specific authorization engine. + +### Step 4.1: Core policy types and evaluation (`internal/policy`) + +**Acceptance criteria:** +- Types defined per `ARCHITECTURE.md` §4: `Action`, `Effect`, `PolicyInput`, + `Rule` +- All action constants: `registry:version_check`, `registry:pull`, + `registry:push`, `registry:delete`, `registry:catalog`, `policy:manage` +- `Evaluate(input PolicyInput, rules []Rule) (Effect, *Rule)`: + - Sorts rules by priority (stable) + - Collects all matching rules + - Deny-wins: any matching deny → return deny + - First allow → return allow + - Default deny if no match +- Rule matching: all populated fields ANDed; empty fields are wildcards +- `Repositories` glob matching via `path.Match`; empty list = match all +- When `input.Repository` is empty (global ops), only rules with empty + `Repositories` match +- Tests: admin wildcard, user allow, system account deny (no rules), + exact repo match, glob match (`production/*`), deny-wins over allow, + priority ordering, empty repository global operation, multiple + matching rules + +### Step 4.2: Built-in defaults (`internal/policy`) + +**Acceptance criteria:** +- `DefaultRules()` returns the built-in rules per `ARCHITECTURE.md` §4: + admin wildcard, human user full access, version check allow +- Default rules use negative IDs (-1, -2, -3) +- Default rules have priority 0 +- Tests: admin gets allow for all actions, user gets allow for pull/push/ + delete/catalog, system account gets deny for everything except + version_check, user gets allow for version_check + +### Step 4.3: Policy engine wrapper with DB integration (`internal/policy`) + +**Acceptance criteria:** +- `Engine` struct wraps `Evaluate` with DB-backed rule loading +- `engine.SetRules(rules)` caches rules in memory (merges with defaults) +- `engine.Evaluate(input)` calls stateless `Evaluate` with cached rules +- Thread-safe: `sync.RWMutex` protects the cached rule set +- `engine.Reload(db)` loads enabled rules from `policy_rules` table and + calls `SetRules` +- Tests: engine with only defaults, engine with custom rules, reload + picks up new rules, disabled rules excluded + +### Step 4.4: Policy middleware (`internal/server`) + +**Acceptance criteria:** +- `middleware.RequirePolicy(engine, action)` middleware: + - Extracts claims from context (set by RequireAuth) + - Extracts repository name from URL path (empty for global ops) + - Assembles `PolicyInput` + - Calls `engine.Evaluate` + - On deny: returns OCI error `{"errors":[{"code":"DENIED","message":"..."}]}` + with HTTP 403; writes `policy_deny` audit event + - On allow: proceeds to handler +- Tests: admin allowed, user allowed, system account denied (no rules), + system account with matching rule allowed, deny rule blocks access + +--- + +## Phase 5: OCI API — Pull Path + +Implement the read side of the OCI Distribution Spec. Requires Phase 2 +(storage), Phase 3 (auth), and Phase 4 (policy). + +### Step 5.1: OCI handler scaffolding (`internal/oci`) + +**Acceptance criteria:** +- `oci.NewHandler(db, storage, authClient, policyEngine)` constructor +- Chi router with `/v2/` prefix; all routes wrapped in RequireAuth middleware +- Repository name extracted from URL path; names may contain `/` + (chi wildcard catch-all) +- OCI error response helper: `writeOCIError(w, code, status, message)` + producing `{"errors":[{"code":"...","message":"..."}]}` format +- All OCI handlers share the same `*oci.Handler` receiver +- Tests: error response format matches OCI spec + +### Step 5.2: Manifest pull (`GET /v2//manifests/`) + +**Acceptance criteria:** +- Policy check: `registry:pull` action on the target repository +- If `` is a tag: look up tag → manifest in DB +- If `` is a digest (`sha256:...`): look up manifest by + digest in DB +- Returns manifest content with: + - `Content-Type` header set to manifest's `media_type` + - `Docker-Content-Digest` header set to manifest's digest + - `Content-Length` header set to manifest's size +- `HEAD` variant returns same headers but no body +- Repository not found → `NAME_UNKNOWN` (404) +- Manifest not found → `MANIFEST_UNKNOWN` (404) +- Writes `manifest_pulled` audit event +- Tests: pull by tag, pull by digest, HEAD returns headers only, + nonexistent repo, nonexistent tag, nonexistent digest + +### Step 5.3: Blob download (`GET /v2//blobs/`) + +**Acceptance criteria:** +- Policy check: `registry:pull` action on the target repository +- Verify blob exists in `blobs` table AND is referenced by a manifest + in the target repository (via `manifest_blobs`) +- Open blob from storage, stream to response with: + - `Content-Type: application/octet-stream` + - `Docker-Content-Digest` header + - `Content-Length` header +- `HEAD` variant returns headers only +- Blob not in repo → `BLOB_UNKNOWN` (404) +- Tests: download blob, HEAD blob, blob not found, blob exists + globally but not in this repo → 404 + +### Step 5.4: Tag listing (`GET /v2//tags/list`) + +**Acceptance criteria:** +- Policy check: `registry:pull` action on the target repository +- Returns `{"name":"","tags":["tag1","tag2",...]}` sorted + alphabetically +- Pagination via `n` (limit) and `last` (cursor) query parameters + per OCI spec +- If more results: `Link` header with next page URL +- Empty tag list returns `{"name":"","tags":[]}` +- Repository not found → `NAME_UNKNOWN` (404) +- Tests: list tags, pagination, empty repo, nonexistent repo + +### Step 5.5: Catalog listing (`GET /v2/_catalog`) + +**Acceptance criteria:** +- Policy check: `registry:catalog` action (no repository context) +- Returns `{"repositories":["repo1","repo2",...]}` sorted alphabetically +- Pagination via `n` and `last` query parameters +- If more results: `Link` header with next page URL +- Tests: list repos, pagination, empty registry + +--- + +## Phase 6: OCI API — Push Path + +Implement blob uploads and manifest pushes. Requires Phase 5 (shared +OCI infrastructure). + +### Step 6.1: Blob upload — initiate (`POST /v2//blobs/uploads/`) + +**Acceptance criteria:** +- Policy check: `registry:push` action on the target repository +- Creates repository if it doesn't exist (implicit creation) +- Generates upload UUID (`crypto/rand`) +- Inserts row in `uploads` table +- Creates temp file via `storage.StartUpload(uuid)` +- Returns `202 Accepted` with: + - `Location: /v2//blobs/uploads/` header + - `Docker-Upload-UUID: ` header + - `Range: 0-0` header +- Tests: initiate returns 202 with correct headers, implicit repo + creation, UUID is unique + +### Step 6.2: Blob upload — chunked and monolithic + +**Acceptance criteria:** +- `PATCH /v2//blobs/uploads/`: + - Appends request body to the upload's temp file + - Updates `byte_offset` in `uploads` table + - `Content-Range` header processed if present + - Returns `202 Accepted` with updated `Range` and `Location` headers +- `PUT /v2//blobs/uploads/?digest=`: + - If request body is non-empty, appends it first (monolithic upload) + - Calls `BlobWriter.Commit(digest)` + - On digest mismatch: `DIGEST_INVALID` (400) + - Inserts row in `blobs` table (or no-op if digest already exists) + - Deletes row from `uploads` table + - Returns `201 Created` with: + - `Location: /v2//blobs/` header + - `Docker-Content-Digest` header + - Writes `blob_uploaded` audit event +- `GET /v2//blobs/uploads/`: + - Returns `204 No Content` with `Range: 0-` header +- `DELETE /v2//blobs/uploads/`: + - Cancels upload: deletes temp file, removes `uploads` row + - Returns `204 No Content` +- Upload UUID not found → `BLOB_UPLOAD_UNKNOWN` (404) +- Tests: monolithic upload (POST then PUT with body), chunked upload + (POST → PATCH → PATCH → PUT), digest mismatch, check progress, + cancel upload, nonexistent UUID + +### Step 6.3: Manifest push (`PUT /v2//manifests/`) + +**Acceptance criteria:** +- Policy check: `registry:push` action on the target repository +- Implements the full manifest push flow per `ARCHITECTURE.md` §5: + 1. Parse manifest JSON; reject malformed → `MANIFEST_INVALID` (400) + 2. Compute SHA-256 digest of raw bytes + 3. If reference is a digest, verify match → `DIGEST_INVALID` (400) + 4. Parse layer and config descriptors from manifest + 5. Verify all referenced blobs exist → `MANIFEST_BLOB_UNKNOWN` (400) + 6. Single SQLite transaction: + a. Create repository if not exists + b. Insert/update manifest row + c. Populate `manifest_blobs` join table + d. If reference is a tag, insert/update tag row + 7. Return `201 Created` with `Docker-Content-Digest` and `Location` + headers +- Writes `manifest_pushed` audit event (includes repo, tag, digest) +- Tests: push by tag, push by digest, push updates existing tag (atomic + tag move), missing blob → 400, malformed manifest → 400, digest + mismatch → 400, re-push same manifest (idempotent) + +--- + +## Phase 7: OCI API — Delete Path + +Implement manifest and blob deletion. + +### Step 7.1: Manifest delete (`DELETE /v2//manifests/`) + +**Acceptance criteria:** +- Policy check: `registry:delete` action on the target repository +- Reference must be a digest (not a tag) → `UNSUPPORTED` (405) if tag +- Deletes manifest row; cascades to `manifest_blobs` and `tags` + (ON DELETE CASCADE) +- Returns `202 Accepted` +- Writes `manifest_deleted` audit event +- Tests: delete by digest, attempt delete by tag → 405, nonexistent + manifest → `MANIFEST_UNKNOWN` (404), cascading tag deletion verified + +### Step 7.2: Blob delete (`DELETE /v2//blobs/`) + +**Acceptance criteria:** +- Policy check: `registry:delete` action on the target repository +- Verify blob exists and is referenced in this repository +- Removes the `manifest_blobs` rows for this repo's manifests (does NOT + delete the blob row or file — that's GC's job, since other repos may + reference it) +- Returns `202 Accepted` +- Writes `blob_deleted` audit event +- Tests: delete blob, blob still on disk (not GC'd yet), blob not in + repo → `BLOB_UNKNOWN` (404) + +--- + +## Phase 8: Admin REST API + +Implement management endpoints under `/v1/`. Can be **batched** with +Phase 5 (OCI pull) since both depend on Phase 4 but not on each other. + +### Step 8.1: Auth endpoints (`/v1/auth`) + +**Acceptance criteria:** +- `POST /v1/auth/login`: accepts `{"username":"...","password":"..."}` + body, forwards to MCIAS, returns `{"token":"...","expires_at":"..."}` +- `POST /v1/auth/logout`: requires bearer token, calls MCIAS token + revocation (if supported), returns `204 No Content` +- `GET /v1/health`: returns `{"status":"ok"}` (no auth required) +- Error format: `{"error":"..."}` (platform standard) +- Tests: login success, login failure, logout, health check + +### Step 8.2: Repository management endpoints + +**Acceptance criteria:** +- `GET /v1/repositories`: list repositories with metadata + (tag count, manifest count, total size). Paginated. Requires bearer. +- `GET /v1/repositories/{name}`: repository detail (tags with digests, + manifests, total size). Requires bearer. Name may contain `/`. +- `DELETE /v1/repositories/{name}`: delete repository and all associated + manifests, tags, manifest_blobs rows. Requires admin role. Writes + `repo_deleted` audit event. +- Tests: list repos, repo detail, delete repo cascades correctly, + non-admin delete → 403 + +### Step 8.3: Policy management endpoints + +**Acceptance criteria:** +- Full CRUD per `ARCHITECTURE.md` §6: + - `GET /v1/policy/rules`: list all rules (paginated) + - `POST /v1/policy/rules`: create rule from JSON body + - `GET /v1/policy/rules/{id}`: get single rule + - `PATCH /v1/policy/rules/{id}`: update priority, enabled, description + - `DELETE /v1/policy/rules/{id}`: delete rule +- All endpoints require admin role +- Write operations trigger policy engine reload +- Audit events: `policy_rule_created`, `policy_rule_updated`, + `policy_rule_deleted` +- Input validation: priority must be >= 1 (0 reserved for built-ins), + actions must be valid constants, effect must be "allow" or "deny" +- Tests: full CRUD cycle, validation errors, non-admin → 403, + engine reload after create/update/delete + +### Step 8.4: Audit log endpoint + +**Acceptance criteria:** +- `GET /v1/audit`: list audit events. Requires admin role. +- Query parameters: `event_type`, `actor_id`, `repository`, `since`, + `until`, `n` (limit, default 50), `offset` +- Returns JSON array of audit events +- Tests: list events, filter by type, filter by repository, pagination + +### Step 8.5: Garbage collection endpoints + +**Acceptance criteria:** +- `POST /v1/gc`: trigger async GC run. Requires admin role. Returns + `202 Accepted` with `{"id":""}`. Returns `409 Conflict` + if GC is already running. +- `GET /v1/gc/status`: returns current/last GC status: + `{"running":bool,"last_run":{"started_at":"...","completed_at":"...", + "blobs_removed":N,"bytes_freed":N}}` +- Writes `gc_started` and `gc_completed` audit events +- Tests: trigger GC, check status, concurrent trigger → 409 + +--- + +## Phase 9: Garbage Collection + +Implement the two-phase GC algorithm. Requires Phase 7 (delete path +creates unreferenced blobs). + +### Step 9.1: GC engine (`internal/gc`) + +**Acceptance criteria:** +- `gc.New(db, storage)` constructor +- `gc.Run(ctx)` executes the two-phase algorithm per `ARCHITECTURE.md` §9: + - Phase 1 (DB): acquire lock, begin write tx, find unreferenced blobs, + delete blob rows, commit + - Phase 2 (filesystem): delete blob files, remove empty prefix dirs, + release lock +- Registry-wide lock (`sync.Mutex`) blocks new blob uploads during phase 1 +- Lock integration: upload initiation (Step 6.1) must check the GC lock + before creating new uploads +- Returns `GCResult{BlobsRemoved int, BytesFreed int64, Duration time.Duration}` +- `gc.Reconcile(ctx)` scans filesystem, deletes files with no `blobs` row + (crash recovery) +- Tests: GC removes unreferenced blobs, GC does not remove referenced blobs, + concurrent GC rejected, reconcile cleans orphaned files + +### Step 9.2: Wire GC into server and CLI + +**Acceptance criteria:** +- `POST /v1/gc` and gRPC `GarbageCollect` call `gc.Run` in a goroutine +- GC status tracked in memory (running flag, last result) +- `mcrctl gc` triggers via REST/gRPC +- `mcrctl gc status` fetches status +- `mcrctl gc --reconcile` runs filesystem reconciliation +- Tests: end-to-end GC via API trigger + +--- + +## Phase 10: gRPC Admin API + +Implement the protobuf definitions and gRPC server. Requires Phase 8 +(admin REST, to share business logic). + +### Step 10.1: Proto definitions + +**Acceptance criteria:** +- Proto files per `ARCHITECTURE.md` §7: + `registry.proto`, `policy.proto`, `audit.proto`, `admin.proto`, + `common.proto` +- All RPCs defined per §7 service definitions table +- `buf lint` passes +- `make proto` generates Go stubs in `gen/mcr/v1/` +- Generated code committed + +### Step 10.2: gRPC server implementation (`internal/grpcserver`) + +**Acceptance criteria:** +- `RegistryService`: `ListRepositories`, `GetRepository`, + `DeleteRepository`, `GarbageCollect`, `GetGCStatus` +- `PolicyService`: full CRUD for policy rules +- `AuditService`: `ListAuditEvents` +- `AdminService`: `Health` +- All RPCs call the same business logic as REST handlers (shared + `internal/db` and `internal/gc` packages) +- Tests: at least one RPC per service via `grpc.NewServer` + in-process + client + +### Step 10.3: Interceptor chain + +**Acceptance criteria:** +- Interceptor chain per `ARCHITECTURE.md` §7: + Request Logger → Auth Interceptor → Admin Interceptor → Handler +- Auth interceptor extracts `authorization` metadata, validates via + MCIAS, injects claims. `Health` bypasses auth. +- Admin interceptor requires admin role for GC, policy, delete, audit. +- Request logger logs method, peer IP, status code, duration. Never + logs the authorization metadata value. +- gRPC errors: `codes.Unauthenticated` for missing/invalid token, + `codes.PermissionDenied` for insufficient role +- Tests: unauthenticated → Unauthenticated, non-admin on admin + RPC → PermissionDenied, Health bypasses auth + +### Step 10.4: TLS and server startup + +**Acceptance criteria:** +- gRPC server uses same TLS cert/key as REST server +- `tls.Config.MinVersion = tls.VersionTLS13` +- Server starts on `grpc_addr` from config; disabled if `grpc_addr` + is empty +- Graceful shutdown: `grpcServer.GracefulStop()` called on SIGINT/SIGTERM +- Tests: server starts and accepts TLS connections + +--- + +## Phase 11: CLI Tool (mcrctl) + +Implement the admin CLI. Can be **batched** with Phase 12 (web UI) +since both depend on Phase 10 but not on each other. + +### Step 11.1: Client and connection setup + +**Acceptance criteria:** +- Global flags: `--server` (REST URL), `--grpc` (gRPC address), + `--token` (bearer token), `--ca-cert` (custom CA) +- Token can be loaded from `MCR_TOKEN` env var +- gRPC client with TLS, using same CA cert if provided +- REST client with TLS, `Authorization: Bearer` header +- Connection errors produce clear messages + +### Step 11.2: Status and repository commands + +**Acceptance criteria:** +- `mcrctl status` → calls `GET /v1/health`, prints status +- `mcrctl repo list` → calls `GET /v1/repositories`, prints table +- `mcrctl repo delete ` → calls `DELETE /v1/repositories/`, + confirms before deletion +- Output: human-readable by default, `--json` for machine-readable +- Tests: at minimum, flag parsing tests + +### Step 11.3: Policy, audit, GC, and snapshot commands + +**Acceptance criteria:** +- `mcrctl policy list|create|update|delete` → full CRUD via REST/gRPC +- `mcrctl policy create` accepts `--json` flag for rule body +- `mcrctl audit tail [--n N]` → calls `GET /v1/audit` +- `mcrctl gc` → calls `POST /v1/gc` +- `mcrctl gc status` → calls `GET /v1/gc/status` +- `mcrctl gc --reconcile` → calls reconciliation endpoint +- `mcrctl snapshot` → triggers database backup +- Tests: flag parsing, output formatting + +--- + +## Phase 12: Web UI + +Implement the HTMX-based web interface. Requires Phase 10 (gRPC). + +### Step 12.1: Web server scaffolding + +**Acceptance criteria:** +- `cmd/mcr-web/` binary reads `[web]` config section +- Connects to mcrsrv via gRPC at `web.grpc_addr` +- Go `html/template` with `web/templates/layout.html` base template +- Static files embedded via `//go:embed` (`web/static/`: CSS, htmx) +- CSRF protection: signed double-submit cookies on POST/PUT/PATCH/DELETE +- Session cookie: `HttpOnly`, `Secure`, `SameSite=Strict`, stores + MCIAS JWT +- Chi router with middleware chain + +### Step 12.2: Login and authentication + +**Acceptance criteria:** +- `/login` page with username/password form +- Form submission POSTs to mcr-web, which calls MCIAS login via mcrsrv + gRPC (or directly via MCIAS client) +- On success: sets session cookie, redirects to `/` +- On failure: re-renders login with error message +- Logout link clears session cookie + +### Step 12.3: Dashboard and repository browsing + +**Acceptance criteria:** +- `/` dashboard: repository count, total size, recent pushes + (last 10 `manifest_pushed` audit events) +- `/repositories` list: table with name, tag count, manifest count, + total size +- `/repositories/{name}` detail: tag list (name → digest), manifest + list (digest, media type, size, created), layer list +- `/repositories/{name}/manifests/{digest}` detail: full manifest + JSON, referenced layers with sizes +- All data fetched from mcrsrv via gRPC + +### Step 12.4: Policy management (admin only) + +**Acceptance criteria:** +- `/policies` page: list all policy rules in a table +- Create form: HTMX form that submits new rule (priority, description, + effect, actions, account types, subject UUID, repositories) +- Edit: inline HTMX toggle for enabled/disabled, edit priority/description +- Delete: confirm dialog, HTMX delete +- Non-admin users see "Access denied" or are redirected + +### Step 12.5: Audit log viewer (admin only) + +**Acceptance criteria:** +- `/audit` page: paginated table of audit events +- Filters: event type dropdown, repository name, date range +- HTMX partial page updates for filter changes +- Non-admin users see "Access denied" + +--- + +## Phase 13: Deployment Artifacts + +Package everything for production deployment. + +### Step 13.1: Dockerfile + +**Acceptance criteria:** +- Multi-stage build per `ARCHITECTURE.md` §14: + Builder `golang:1.25-alpine`, runtime `alpine:3.21` +- `CGO_ENABLED=0`, `-trimpath -ldflags="-s -w"` +- Builds all three binaries +- Runtime: non-root user `mcr` (uid 10001) +- `EXPOSE 8443 9443` +- `VOLUME /srv/mcr` +- `ENTRYPOINT ["mcrsrv"]`, `CMD ["server", "--config", "/srv/mcr/mcr.toml"]` +- `make docker` builds image with version tag + +### Step 13.2: systemd units + +**Acceptance criteria:** +- `deploy/systemd/mcr.service`: registry server unit with security + hardening per engineering standards (`NoNewPrivileges`, `ProtectSystem`, + `ReadWritePaths=/srv/mcr`, etc.) +- `deploy/systemd/mcr-web.service`: web UI unit with + `ReadOnlyPaths=/srv/mcr` +- `deploy/systemd/mcr-backup.service`: oneshot backup unit running + `mcrsrv snapshot` +- `deploy/systemd/mcr-backup.timer`: daily 02:00 UTC with 5-min jitter +- All units run as `User=mcr`, `Group=mcr` + +### Step 13.3: Install script and example configs + +**Acceptance criteria:** +- `deploy/scripts/install.sh`: idempotent script that creates system + user/group, installs binaries to `/usr/local/bin/`, creates `/srv/mcr/` + directory structure, installs example config if none exists, installs + systemd units and reloads daemon +- `deploy/examples/mcr.toml` with annotated production defaults +- `deploy/examples/mcr-dev.toml` with local development defaults +- Script tested: runs twice without error (idempotent) diff --git a/.#PROJECT_PLAN.md b/.#PROJECT_PLAN.md new file mode 120000 index 0000000..8b7d049 --- /dev/null +++ b/.#PROJECT_PLAN.md @@ -0,0 +1 @@ +kyle@vade.20490:1773789991 \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..057273d --- /dev/null +++ b/.gitignore @@ -0,0 +1,19 @@ +# Binaries +/mcrsrv +/mcr-web +/mcrctl + +# Runtime data +srv/ + +# Database files +*.db +*.db-wal +*.db-shm + +# IDE +.idea/ +.vscode/ + +# OS +.DS_Store diff --git a/.golangci.yaml b/.golangci.yaml new file mode 100644 index 0000000..276b176 --- /dev/null +++ b/.golangci.yaml @@ -0,0 +1,122 @@ +# golangci-lint v2 configuration for mcr. +# Principle: fail loudly. Security and correctness issues are errors, not warnings. + +version: "2" + +run: + timeout: 5m + # Include test files so security rules apply to test helpers too. + tests: true + +linters: + default: none + enable: + # --- Correctness --- + # Unhandled errors are silent failures; in auth code they become vulnerabilities. + - errcheck + # go vet: catches printf-verb mismatches, unreachable code, suspicious constructs. + - govet + # Detects assignments whose result is never used; dead writes hide logic bugs. + - ineffassign + # Detects variables and functions that are never used. + - unused + + # --- Error handling --- + # Enforces proper error wrapping (errors.Is/As instead of == comparisons) and + # prevents accidental discard of wrapped sentinel errors. + - errorlint + + # --- Security --- + # Primary security scanner: hardcoded secrets, weak RNG, insecure crypto + # (MD5/SHA1/DES/RC4), SQL injection, insecure TLS, file permission issues, etc. + - gosec + # Deep static analysis: deprecated APIs, incorrect mutex use, unreachable code, + # incorrect string conversions, simplification suggestions, and hundreds of other checks. + - staticcheck + + # --- Style / conventions --- + # Enforces Go naming conventions and selected style rules. + - revive + + settings: + errcheck: + # Do NOT flag blank-identifier assignments: `_ = rows.Close()` in defers, + # `_ = tx.Rollback()` after errors, and `_ = fs.Parse(args)` with ExitOnError + # are all legitimate patterns where the error is genuinely unrecoverable or + # irrelevant. The default errcheck (without check-blank) still catches + # unchecked returns that have no assignment at all. + check-blank: false + # Flag discarded ok-value in type assertions: `c, _ := x.(*T)` — the ok + # value should be checked so a failed assertion is not silently treated as nil. + check-type-assertions: true + + govet: + # Enable all analyzers except shadow. The shadow analyzer flags the idiomatic + # `if err := f(); err != nil { ... }` pattern as shadowing an outer `err`, + # which is ubiquitous in Go and does not pose a security risk in this codebase. + enable-all: true + disable: + - shadow + + gosec: + # Treat all gosec findings as errors, not warnings. + severity: medium + confidence: medium + excludes: + # G104 (errors unhandled) overlaps with errcheck; let errcheck own this. + - G104 + + errorlint: + errorf: true + asserts: true + comparison: true + + revive: + rules: + # error-return and unexported-return are correctness/API-safety rules. + - name: error-return + severity: error + - name: unexported-return + severity: error + # Style rules. + - name: error-strings + severity: warning + - name: if-return + severity: warning + - name: increment-decrement + severity: warning + - name: var-naming + severity: warning + - name: range + severity: warning + - name: time-naming + severity: warning + - name: indent-error-flow + severity: warning + - name: early-return + severity: warning + # exported and package-comments are omitted: this is a personal project, + # not a public library; godoc completeness is not a CI requirement. + +formatters: + enable: + # Enforces gofmt formatting. Non-formatted code is a CI failure. + - gofmt + # Manages import grouping and formatting; catches stray debug imports. + - goimports + +issues: + # Do not cap the number of reported issues; in security code every finding matters. + max-issues-per-linter: 0 + max-same-issues: 0 + + exclusions: + paths: + - vendor + rules: + # In test files, allow hardcoded test credentials (gosec G101) since they are + # intentional fixtures, not production secrets. + - path: "_test\\.go" + linters: + - gosec + text: "G101" diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 0000000..ebd8529 --- /dev/null +++ b/ARCHITECTURE.md @@ -0,0 +1,1094 @@ +# MCR Architecture + +Metacircular Container Registry — Technical Design Document + +--- + +## 1. System Overview + +MCR is an OCI Distribution Spec-compliant container registry for Metacircular +Dynamics. It stores and serves container images for the platform's services, +with MCP directing nodes to pull images from MCR. Authentication is delegated +to MCIAS; all operations require a valid bearer token. MCR sits behind an +mc-proxy instance for TLS routing. + +### Components + +``` + ┌──────────────────────────────────────────────┐ + │ MCR Server (mcrsrv) │ + │ │ + │ ┌────────────┐ ┌──────────┐ ┌──────────┐ │ + │ │ OCI API │ │ Auth │ │ Policy │ │ + │ │ Handler │ │ (MCIAS) │ │ Engine │ │ + │ └─────┬──────┘ └────┬─────┘ └────┬─────┘ │ + │ └──────────────┼─────────────┘ │ + │ │ │ + │ ┌─────────────▼────────────┐ │ + │ │ SQLite (metadata) │ │ + │ └──────────────────────────┘ │ + │ ┌─────────────────────────┐ │ + │ │ Filesystem (blobs) │ │ + │ │ /srv/mcr/layers/ │ │ + │ └──────────────────────────┘ │ + │ │ + │ ┌─────────────────┐ ┌──────────────────┐ │ + │ │ REST listener │ │ gRPC listener │ │ + │ │ (OCI + admin) │ │ (admin) │ │ + │ │ :8443 │ │ :9443 │ │ + │ └─────────────────┘ └──────────────────┘ │ + └──────────────────────────────────────────────┘ + ▲ ▲ ▲ + │ │ │ + ┌────┴───┐ ┌─────┴─────┐ ┌───┴──────┐ + │ Docker │ │ mcrctl │ │ mcr-web │ + │ / OCI │ │ (admin │ │ (web UI) │ + │ client │ │ CLI) │ │ │ + └────────┘ └───────────┘ └──────────┘ +``` + +**mcrsrv** — The registry server. Exposes OCI Distribution endpoints and +an admin REST API over HTTPS/TLS, plus a gRPC admin API. Handles blob +storage, manifest management, and token-based authentication via MCIAS. + +**mcr-web** — The web UI. Communicates with mcrsrv via gRPC. Provides +repository/tag browsing and ACL policy management for administrators. + +**mcrctl** — The admin CLI. Communicates with mcrsrv via REST or gRPC. +Provides garbage collection, repository management, and policy management. + +--- + +## 2. OCI Distribution Spec Compliance + +MCR implements the OCI Distribution Specification for content discovery and +content management. All OCI endpoints require authentication — there is no +anonymous access. + +### Supported Operations + +| Category | Capability | +|----------|-----------| +| Content discovery | Repository catalog, tag listing | +| Pull | Manifest retrieval (by tag or digest), blob download | +| Push | Monolithic and chunked blob upload, manifest upload | +| Delete | Manifest deletion (by digest), blob deletion | + +### Not Supported (v1) + +| Feature | Rationale | +|---------|-----------| +| Multi-arch manifest lists | Not needed for single-platform deployment | +| Image signing / content trust | Deferred to future work | +| Cross-repository blob mounts | Complexity not justified at current scale | + +### Content Addressing + +All blobs and manifests are identified by their SHA-256 digest in the format +`sha256:`. Digests are verified on upload — if the computed digest does +not match the client-supplied digest, the upload is rejected. + +Tags are mutable pointers to manifest digests. Pushing a manifest with an +existing tag atomically updates the tag to point to the new digest. + +--- + +## 3. Authentication + +MCR delegates all authentication to MCIAS. No local user database. + +### MCIAS Configuration + +MCR registers with MCIAS as a service with the `env:restricted` tag. This +means MCIAS denies login to `guest` and `viewer` accounts — only `admin` +and `user` roles can authenticate to MCR. + +```toml +[mcias] +server_url = "https://mcias.metacircular.net:8443" +service_name = "mcr" +tags = ["env:restricted"] +``` + +### OCI Token Authentication + +OCI/Docker clients expect a specific auth handshake: + +``` +Client mcrsrv + │ │ + ├─ GET /v2/ ────────────────────▶│ + │◀─ 401 WWW-Authenticate: │ + │ Bearer realm="/v2/token", │ + │ service="mcr.metacircular.…" │ + │ │ + ├─ GET /v2/token ───────────────▶│ (Basic auth: username:password) + │ ├─ Forward credentials to MCIAS + │ │ POST /v1/auth/login + │ ├─ MCIAS returns JWT + │◀─ 200 {"token":"", │ + │ "expires_in": N} │ + │ │ + ├─ GET /v2//manifests/… ──▶│ (Authorization: Bearer ) + │ ├─ Validate token via MCIAS + │ ├─ Check policy engine + │◀─ 200 (manifest) │ +``` + +**Token endpoint** (`GET /v2/token`): Accepts HTTP Basic auth, forwards +credentials to MCIAS `/v1/auth/login`, and returns the MCIAS JWT in the +Docker-compatible token response format. The `scope` parameter is accepted +but not used for token scoping — authorization is enforced per-request by +the policy engine. + +**Direct bearer tokens**: MCIAS service tokens and pre-authenticated JWTs +are accepted directly on all OCI endpoints via the `Authorization: Bearer` +header. This allows system accounts and CLI tools to skip the token endpoint. + +**Token validation**: Every request validates the bearer token by calling +MCIAS `ValidateToken()`. Results are cached by SHA-256 of the token with a +30-second TTL. + +--- + +## 4. Authorization & Policy Engine + +### Role Model + +| Role | Access | +|------|--------| +| `admin` | Full access: push, pull, delete, catalog, policy management, GC | +| `user` | Full content access: push, pull, delete, catalog | +| System account | Default deny; requires explicit policy rule for any operation | + +Admin detection is based solely on the MCIAS `admin` role. Human users with +the `user` role have full content management access. System accounts have no +implicit access and must be granted specific permissions via policy rules. + +### Policy Engine + +MCR implements a local policy engine for registry-specific access control, +following the same architecture as MCIAS's policy engine (priority-based, +deny-wins, default-deny). The engine is an in-process Go package +(`internal/policy`) with no external dependencies. + +#### Actions + +| Action | Description | +|--------|-------------| +| `registry:version_check` | OCI version check (`GET /v2/`) | +| `registry:pull` | Read manifests, download blobs, list tags for a repository | +| `registry:push` | Upload blobs and push manifests/tags | +| `registry:delete` | Delete manifests and blobs | +| `registry:catalog` | List all repositories (`GET /v2/_catalog`) | +| `policy:manage` | Create, update, delete policy rules (admin only) | + +#### Policy Input + +```go +type PolicyInput struct { + Subject string // MCIAS account UUID + AccountType string // "human" or "system" + Roles []string // roles from MCIAS JWT + + Action Action + Repository string // target repository name (e.g., "myapp"); + // empty for global operations (catalog, health) +} +``` + +#### Rule Structure + +```go +type Rule struct { + ID int64 + Priority int // lower = evaluated first + Description string + Effect Effect // "allow" or "deny" + + // Principal conditions (all populated fields ANDed) + Roles []string // principal must hold at least one + AccountTypes []string // "human", "system", or both + SubjectUUID string // exact principal UUID + + // Action condition + Actions []Action + + // Resource condition + Repositories []string // repository name patterns (glob via path.Match). + // Examples: + // "myapp" — exact match + // "production/*" — any repo one level under production/ + // Empty list = wildcard (matches all repositories). +} +``` + +#### Evaluation Algorithm + +``` +1. Merge built-in defaults with operator-defined rules +2. Sort by Priority ascending (stable) +3. Collect all matching rules +4. If any match has Effect=Deny → return Deny (deny-wins) +5. If any match has Effect=Allow → return Allow +6. Return Deny (default-deny) +``` + +A rule matches when every populated field satisfies its condition: + +| Field | Match condition | +|-------|----------------| +| `Roles` | Principal holds at least one of the listed roles | +| `AccountTypes` | Principal's account type is in the list | +| `SubjectUUID` | Principal UUID equals exactly | +| `Actions` | Request action is in the list | +| `Repositories` | Request repository matches at least one pattern; empty list is a wildcard (matches all repositories) | + +Repository glob matching uses `path.Match` semantics: `*` matches any +sequence of non-`/` characters within a single path segment. For example, +`production/*` matches `production/myapp` but not `production/team/myapp`. +An empty `Repositories` field is a wildcard (matches all repositories). + +When `PolicyInput.Repository` is empty (global operations like catalog), +only rules with an empty `Repositories` field match — a rule scoped to +specific repositories does not apply to global operations. + +#### Built-in Default Rules + +``` +Priority 0, Allow: roles=[admin], actions= + — admin wildcard + +Priority 0, Allow: roles=[user], accountTypes=[human], + actions=[registry:pull, registry:push, registry:delete, registry:catalog] + — human users have full content access + +Priority 0, Allow: actions=[registry:version_check] + — /v2/ endpoint (always accessible to authenticated users) +``` + +System accounts have no built-in allow rules for catalog, push, pull, or +delete. An operator must create explicit policy rules granting them access. + +#### Example: CI System Push Access (Glob) + +Grant a CI system account permission to push and pull from all repositories +under the `production/` namespace: + +```json +{ + "effect": "allow", + "account_types": ["system"], + "subject_uuid": "", + "actions": ["registry:push", "registry:pull"], + "repositories": ["production/*"], + "priority": 50, + "description": "CI system: push/pull to all production repos" +} +``` + +#### Example: Deploy Agent Pull-Only + +Grant a deploy agent pull access to all repositories (empty `repositories` += wildcard), but deny delete globally: + +```json +{ + "effect": "allow", + "subject_uuid": "", + "actions": ["registry:pull"], + "repositories": [], + "priority": 50, + "description": "deploy-agent: pull from any repo" +} +``` + +```json +{ + "effect": "deny", + "subject_uuid": "", + "actions": ["registry:delete"], + "priority": 10, + "description": "deploy-agent may never delete images (deny-wins)" +} +``` + +#### Example: Exact Repo Access + +Grant a specific system account access to exactly two named repositories: + +```json +{ + "effect": "allow", + "subject_uuid": "", + "actions": ["registry:push", "registry:pull"], + "repositories": ["myapp", "infra-proxy"], + "priority": 50, + "description": "svc-account: push/pull to myapp and infra-proxy only" +} +``` + +### Policy Management + +Policy rules are managed via the admin REST/gRPC API and the web UI. Only +users with the `admin` role can create, update, or delete policy rules. + +--- + +## 5. Storage Design + +MCR uses a split storage model: SQLite for metadata, filesystem for blobs. + +### Blob Storage + +Blobs (image layers and config objects) are stored as content-addressed files +under `/srv/mcr/layers/`: + +``` +/srv/mcr/layers/ +└── sha256/ + ├── ab/ + │ └── abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890 + ├── cd/ + │ └── cdef... + └── ef/ + └── ef01... +``` + +The two-character hex prefix directory limits the number of files per +directory. Blobs are written atomically: data is written to a temporary file +in `/srv/mcr/uploads/`, then renamed into place after digest verification. +The `uploads/` and `layers/` directories must reside on the same filesystem +for `rename(2)` to be atomic. + +### Upload Staging + +In-progress blob uploads are stored in `/srv/mcr/uploads/`: + +``` +/srv/mcr/uploads/ +└── +``` + +Each upload UUID corresponds to a row in the `uploads` table tracking the +current byte offset. Completed uploads are renamed to the blob store; +cancelled or expired uploads are cleaned up. + +### Manifest Storage + +Manifests are small JSON documents stored directly in the SQLite database +(in the `manifests` table `content` column). This simplifies metadata queries +and avoids filesystem overhead for small objects. + +### Manifest Push Flow + +When a client calls `PUT /v2//manifests/`: + +``` +1. Parse the manifest JSON. Reject if malformed or unsupported media type. +2. Compute the SHA-256 digest of the raw manifest bytes. +3. If is a digest, verify it matches the computed digest. + Reject with DIGEST_INVALID if mismatch. +4. Parse the manifest's layer and config descriptors. +5. Verify every referenced blob exists in the `blobs` table. + Reject with MANIFEST_BLOB_UNKNOWN if any are missing. +6. Begin write transaction: + a. Create the repository row if it does not exist (implicit creation). + b. Insert or update the manifest row (repository_id, digest, content). + c. Populate `manifest_blobs` join table for all referenced blobs. + d. If is a tag name, insert or update the tag row + to point to the new manifest (atomic tag move). +7. Commit. +8. Return 201 Created with `Docker-Content-Digest: ` header. +``` + +This is the most complex write path in the system. The entire operation +executes in a single SQLite transaction so a crash at any point leaves the +database consistent. + +### Data Directory + +``` +/srv/mcr/ +├── mcr.toml Configuration +├── mcr.db SQLite database (metadata) +├── certs/ TLS certificates +├── layers/ Content-addressed blob storage +│ └── sha256/ +├── uploads/ In-progress blob uploads +└── backups/ Database snapshots +``` + +--- + +## 6. API Surface + +### Error Response Formats + +OCI and admin endpoints use different error formats: + +**OCI endpoints** (`/v2/...`) follow the OCI Distribution Spec error format: +```json +{"errors": [{"code": "MANIFEST_UNKNOWN", "message": "...", "detail": "..."}]} +``` + +Standard OCI error codes used by MCR: + +| Code | HTTP Status | Trigger | +|------|-------------|---------| +| `UNAUTHORIZED` | 401 | Missing or invalid bearer token | +| `DENIED` | 403 | Policy engine denied the request | +| `NAME_UNKNOWN` | 404 | Repository does not exist | +| `MANIFEST_UNKNOWN` | 404 | Manifest not found (by tag or digest) | +| `BLOB_UNKNOWN` | 404 | Blob not found | +| `MANIFEST_BLOB_UNKNOWN` | 400 | Manifest references a blob not yet uploaded | +| `DIGEST_INVALID` | 400 | Computed digest does not match supplied digest | +| `MANIFEST_INVALID` | 400 | Malformed or unsupported manifest | +| `BLOB_UPLOAD_UNKNOWN` | 404 | Upload UUID not found or expired | +| `BLOB_UPLOAD_INVALID` | 400 | Chunked upload byte range error | +| `UNSUPPORTED` | 405 | Operation not supported (e.g., cross-repo mount) | + +**Admin endpoints** (`/v1/...`) use the platform-standard format: +```json +{"error": "human-readable message"} +``` + +### OCI Distribution Endpoints + +All OCI endpoints are prefixed with `/v2` and require authentication. + +#### Version Check + +| Method | Path | Description | +|--------|------|-------------| +| GET | `/v2/` | API version check; returns `{}` if authenticated | + +#### Token + +| Method | Path | Auth | Description | +|--------|------|------|-------------| +| GET | `/v2/token` | Basic | Exchange credentials for bearer token via MCIAS | + +#### Content Discovery + +| Method | Path | Auth | Description | +|--------|------|------|-------------| +| GET | `/v2/_catalog` | bearer | List repositories (paginated) | +| GET | `/v2//tags/list` | bearer | List tags for a repository (paginated) | + +#### Manifests + +| Method | Path | Auth | Description | +|--------|------|------|-------------| +| HEAD | `/v2//manifests/` | bearer | Check manifest existence | +| GET | `/v2//manifests/` | bearer | Pull manifest by tag or digest | +| PUT | `/v2//manifests/` | bearer | Push manifest | +| DELETE | `/v2//manifests/` | bearer | Delete manifest (digest only) | + +`` is either a tag name or a `sha256:...` digest. + +All manifest responses (GET, HEAD, PUT) include the `Docker-Content-Digest` +header with the manifest's SHA-256 digest and a `Content-Type` header with +the manifest's media type. + +#### Blobs + +| Method | Path | Auth | Description | +|--------|------|------|-------------| +| HEAD | `/v2//blobs/` | bearer | Check blob existence | +| GET | `/v2//blobs/` | bearer | Download blob | +| DELETE | `/v2//blobs/` | bearer | Delete blob | + +#### Blob Uploads + +| Method | Path | Auth | Description | +|--------|------|------|-------------| +| POST | `/v2//blobs/uploads/` | bearer | Initiate blob upload | +| GET | `/v2//blobs/uploads/` | bearer | Check upload progress | +| PATCH | `/v2//blobs/uploads/` | bearer | Upload chunk (chunked flow) | +| PUT | `/v2//blobs/uploads/?digest=` | bearer | Complete upload with digest verification | +| DELETE | `/v2//blobs/uploads/` | bearer | Cancel upload | + +**Monolithic upload**: Client sends `POST` to initiate, then `PUT` with the +entire blob body and `digest` query parameter in a single request. + +**Chunked upload**: Client sends `POST` to initiate, then one or more `PATCH` +requests with sequential byte ranges, then `PUT` with the final digest. + +### Admin REST Endpoints + +Admin endpoints use the `/v1` prefix and follow the platform API conventions. + +#### Authentication + +| Method | Path | Auth | Description | +|--------|------|------|-------------| +| POST | `/v1/auth/login` | none | Login via MCIAS (username/password) | +| POST | `/v1/auth/logout` | bearer | Revoke current token | + +#### Health + +| Method | Path | Auth | Description | +|--------|------|------|-------------| +| GET | `/v1/health` | none | Health check | + +#### Repository Management + +| Method | Path | Auth | Description | +|--------|------|------|-------------| +| GET | `/v1/repositories` | bearer | List repositories with metadata | +| GET | `/v1/repositories/{name}` | bearer | Repository detail (tags, size, manifest count) | +| DELETE | `/v1/repositories/{name}` | admin | Delete repository and all its manifests/tags | + +Repository `{name}` may contain `/` (e.g., `production/myapp`). The chi +router must use a wildcard catch-all segment for this parameter. + +#### Garbage Collection + +| Method | Path | Auth | Description | +|--------|------|------|-------------| +| POST | `/v1/gc` | admin | Trigger garbage collection (async) | +| GET | `/v1/gc/status` | admin | Check GC status (running, last result) | + +#### Policy Management + +| Method | Path | Auth | Description | +|--------|------|------|-------------| +| GET | `/v1/policy/rules` | admin | List all policy rules | +| POST | `/v1/policy/rules` | admin | Create a policy rule | +| GET | `/v1/policy/rules/{id}` | admin | Get a single rule | +| PATCH | `/v1/policy/rules/{id}` | admin | Update rule | +| DELETE | `/v1/policy/rules/{id}` | admin | Delete rule | + +#### Audit + +| Method | Path | Auth | Description | +|--------|------|------|-------------| +| GET | `/v1/audit` | admin | List audit log events | + +--- + +## 7. gRPC Admin Interface + +The gRPC API provides the same admin capabilities as the REST admin API. OCI +Distribution endpoints are REST-only (protocol requirement — OCI clients +speak HTTP). + +### Proto Package Layout + +``` +proto/ +└── mcr/ + └── v1/ + ├── registry.proto # Repository listing, GC + ├── policy.proto # Policy rule CRUD + ├── audit.proto # Audit log queries + ├── admin.proto # Health + └── common.proto # Shared message types +gen/ +└── mcr/ + └── v1/ # Generated Go stubs (committed) +``` + +### Service Definitions + +| Service | RPCs | +|---------|------| +| `RegistryService` | `ListRepositories`, `GetRepository`, `DeleteRepository`, `GarbageCollect`, `GetGCStatus` | +| `PolicyService` | `ListPolicyRules`, `CreatePolicyRule`, `GetPolicyRule`, `UpdatePolicyRule`, `DeletePolicyRule` | +| `AuditService` | `ListAuditEvents` | +| `AdminService` | `Health` | + +Auth endpoints (`/v1/auth/login`, `/v1/auth/logout`) are REST-only. Login +requires HTTP Basic auth or form-encoded credentials, which do not map +cleanly to gRPC unary RPCs. Clients that need programmatic auth use MCIAS +directly and present the resulting bearer token to gRPC. + +### Transport Security + +Same TLS certificate and key as the REST server. TLS 1.3 minimum. + +### Authentication + +gRPC unary interceptor extracts the `authorization` metadata key, validates +the MCIAS bearer token, and injects claims into the context. Same validation +logic as the REST middleware. + +### Interceptor Chain + +``` +[Request Logger] → [Auth Interceptor] → [Admin Interceptor] → [Handler] +``` + +- **Request Logger**: Logs method, peer IP, status code, duration. +- **Auth Interceptor**: Validates bearer JWT via MCIAS. `Health` bypasses auth. +- **Admin Interceptor**: Requires admin role for GC, policy, and delete operations. + +--- + +## 8. Database Schema + +SQLite 3, WAL mode, `PRAGMA foreign_keys = ON`, `PRAGMA busy_timeout = 5000`. + +```sql +-- Schema version tracking +CREATE TABLE schema_migrations ( + version INTEGER PRIMARY KEY, + applied_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')) +); + +-- Container image repositories +CREATE TABLE repositories ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL UNIQUE, -- e.g., "myapp", "infra/proxy" + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')) +); +-- UNIQUE on name creates an implicit index; no explicit index needed. + +-- Image manifests (content stored in DB — small JSON documents) +CREATE TABLE manifests ( + id INTEGER PRIMARY KEY, + repository_id INTEGER NOT NULL REFERENCES repositories(id) ON DELETE CASCADE, + digest TEXT NOT NULL, -- "sha256:" + media_type TEXT NOT NULL, -- "application/vnd.oci.image.manifest.v1+json" + content BLOB NOT NULL, -- manifest JSON + size INTEGER NOT NULL, -- byte size of content + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')), + UNIQUE(repository_id, digest) +); + +CREATE INDEX idx_manifests_repo ON manifests (repository_id); +CREATE INDEX idx_manifests_digest ON manifests (digest); + +-- Tags: mutable pointers from name → manifest +CREATE TABLE tags ( + id INTEGER PRIMARY KEY, + repository_id INTEGER NOT NULL REFERENCES repositories(id) ON DELETE CASCADE, + name TEXT NOT NULL, -- e.g., "latest", "v1.2.3" + 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 idx_tags_repo ON tags (repository_id); +CREATE INDEX idx_tags_manifest ON tags (manifest_id); + +-- Blob metadata (actual data on filesystem at /srv/mcr/layers/) +CREATE TABLE blobs ( + id INTEGER PRIMARY KEY, + digest TEXT NOT NULL UNIQUE, -- "sha256:" + size INTEGER NOT NULL, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')) +); +-- UNIQUE on digest creates an implicit index; no explicit index needed. + +-- Many-to-many: tracks which blobs are referenced by manifests in which repos. +-- A blob may be shared across repositories (content-addressed dedup). +-- Used by garbage collection to determine unreferenced blobs. +CREATE TABLE 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 idx_manifest_blobs_blob ON manifest_blobs (blob_id); + +-- In-progress blob uploads +CREATE TABLE 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')) +); + +-- Policy rules for registry access control +CREATE TABLE policy_rules ( + id INTEGER PRIMARY KEY, + priority INTEGER NOT NULL DEFAULT 100, + description TEXT NOT NULL, + rule_json TEXT NOT NULL, -- JSON-encoded rule body + enabled INTEGER NOT NULL DEFAULT 1 CHECK (enabled IN (0,1)), + created_by TEXT, -- MCIAS account UUID + 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')) +); + +-- Audit log — append-only +CREATE TABLE 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, -- MCIAS account UUID + repository TEXT, -- repository name (if applicable) + digest TEXT, -- affected digest (if applicable) + ip_address TEXT, + details TEXT -- JSON blob; never contains secrets +); + +CREATE INDEX idx_audit_time ON audit_log (event_time); +CREATE INDEX idx_audit_actor ON audit_log (actor_id); +CREATE INDEX idx_audit_event ON audit_log (event_type); +``` + +### Schema Notes + +- Repositories are created implicitly on first push. No explicit creation + step required. +- Repository names may contain `/` for organizational grouping (e.g., + `production/myapp`) but are otherwise flat strings. No hierarchical + enforcement. +- The `manifest_blobs` join table enables content-addressed deduplication: + the same layer blob may be referenced by manifests in different + repositories. +- Manifest deletion cascades to `manifest_blobs` rows and to any tags + pointing at the deleted manifest (`ON DELETE CASCADE` on both + `manifest_blobs.manifest_id` and `tags.manifest_id`). Blob files on + the filesystem are not deleted — unreferenced blobs are reclaimed by + garbage collection. +- Tag updates are atomic: pushing a manifest with an existing tag name + updates the `manifest_id` in a single transaction. + +--- + +## 9. Garbage Collection + +Garbage collection removes unreferenced blobs — blobs that are not +referenced by any manifest. GC is a manual process triggered by an +administrator. + +### Algorithm + +GC runs in two phases to maintain consistency across a crash: + +``` +Phase 1 — Mark and sweep (database) +1. Acquire registry-wide GC lock (blocks new blob uploads). +2. Begin write transaction. +3. Find all blob rows in `blobs` with no corresponding row in + `manifest_blobs` (unreferenced blobs). Record their digests. +4. Delete those rows from `blobs`. +5. Commit. + +Phase 2 — File cleanup (filesystem) +6. For each digest recorded in step 3: + a. Delete the file from /srv/mcr/layers/sha256//. +7. Remove empty prefix directories. +8. Release GC lock. +``` + +**Crash safety**: If the process crashes after phase 1 but before phase 2 +completes, orphaned files remain on disk with no matching DB row. These are +harmless — they consume space but are never served. A subsequent GC run or +a filesystem reconciliation command (`mcrctl gc --reconcile`) cleans them +up by scanning the layers directory and deleting files with no `blobs` row. + +### Trigger Methods + +- **CLI**: `mcrctl gc` +- **REST**: `POST /v1/gc` +- **gRPC**: `RegistryService.GarbageCollect` + +GC runs asynchronously. Status can be checked via `GET /v1/gc/status` or +`mcrctl gc status`. Only one GC run may be active at a time. + +### Safety + +GC acquires a registry-wide lock that **blocks all new blob uploads** for +the duration of the mark-and-sweep phase. Ongoing uploads that started +before the lock are allowed to complete before the lock is acquired. This +is a stop-the-world approach, acceptable at the target scale (single +developer, several dozen repos). Pulls are not blocked. + +--- + +## 10. Configuration + +TOML format. Environment variable overrides via `MCR_*`. + +```toml +[server] +listen_addr = ":8443" # HTTPS (OCI + admin REST) +grpc_addr = ":9443" # gRPC admin API (optional; omit to disable) +tls_cert = "/srv/mcr/certs/cert.pem" +tls_key = "/srv/mcr/certs/key.pem" +read_timeout = "30s" # HTTP read timeout +write_timeout = "0s" # HTTP write timeout; 0 = disabled for large + # blob uploads (idle_timeout provides the safety net) +idle_timeout = "120s" # HTTP idle timeout +shutdown_timeout = "60s" # Graceful shutdown drain period + +[database] +path = "/srv/mcr/mcr.db" + +[storage] +layers_path = "/srv/mcr/layers" # Blob storage root +uploads_path = "/srv/mcr/uploads" # Upload staging directory + # Must be on the same filesystem as layers_path + +[mcias] +server_url = "https://mcias.metacircular.net:8443" +ca_cert = "" # Custom CA for MCIAS TLS +service_name = "mcr" +tags = ["env:restricted"] + +[web] +listen_addr = "127.0.0.1:8080" # Web UI listen address +grpc_addr = "127.0.0.1:9443" # mcrsrv gRPC address for the web UI to connect to +ca_cert = "" # CA cert for verifying mcrsrv gRPC TLS + +[log] +level = "info" # debug, info, warn, error +``` + +### Validation + +Required fields are validated at startup. The server refuses to start if +any are missing or if TLS certificate paths are invalid. `storage.uploads_path` +and `storage.layers_path` must resolve to the same filesystem (verified at +startup via `os.Stat` device ID comparison). + +### Timeout Notes + +The HTTP `write_timeout` is disabled (0) by default because blob uploads +can transfer hundreds of megabytes over slow connections. The `idle_timeout` +serves as the safety net for stale connections. Operators may set a non-zero +`write_timeout` if all clients are on fast local networks. + +--- + +## 11. Web UI + +### Technology + +Go `html/template` + htmx, embedded via `//go:embed`. The web UI is a +separate binary (`mcr-web`) that communicates with mcrsrv via gRPC. + +### Pages + +| Path | Description | +|------|-------------| +| `/login` | MCIAS login form | +| `/` | Dashboard (repository count, total size, recent pushes) | +| `/repositories` | Repository list with tag counts and sizes | +| `/repositories/{name}` | Repository detail: tags, manifests, layer list | +| `/repositories/{name}/manifests/{digest}` | Manifest detail: layers, config, size | +| `/policies` | Policy rule management (admin only): create, edit, delete | +| `/audit` | Audit log viewer (admin only) | + +### Security + +- CSRF protection via signed double-submit cookies on all mutating requests. +- Session cookie: `HttpOnly`, `Secure`, `SameSite=Strict`. +- All user input escaped by `html/template`. + +--- + +## 12. CLI Tools + +### mcrsrv + +The registry server. Cobra subcommands: + +| Command | Description | +|---------|-------------| +| `server` | Start the registry server | +| `init` | First-time setup (create directories, example config) | +| `snapshot` | Database backup via `VACUUM INTO` | + +### mcr-web + +The web UI server. Communicates with mcrsrv via gRPC. + +| Command | Description | +|---------|-------------| +| `server` | Start the web UI server | + +### mcrctl + +Admin CLI. Communicates with mcrsrv via REST or gRPC. + +| Command | Description | +|---------|-------------| +| `status` | Query server health | +| `repo list` | List repositories | +| `repo delete ` | Delete a repository | +| `gc` | Trigger garbage collection | +| `gc status` | Check GC status | +| `policy list` | List policy rules | +| `policy create` | Create a policy rule | +| `policy update ` | Update a policy rule | +| `policy delete ` | Delete a policy rule | +| `audit tail [--n N]` | Print recent audit events | +| `snapshot` | Trigger database backup via `VACUUM INTO` | + +--- + +## 13. Package Structure + +``` +mcr/ +├── cmd/ +│ ├── mcrsrv/ # Server binary: OCI + REST + gRPC +│ ├── mcr-web/ # Web UI binary +│ └── mcrctl/ # Admin CLI +├── internal/ +│ ├── auth/ # MCIAS integration: token validation, 30s cache +│ ├── config/ # TOML config loading and validation +│ ├── db/ # SQLite: migrations, CRUD for all tables +│ ├── oci/ # OCI Distribution Spec handler: manifests, blobs, uploads +│ ├── policy/ # Registry policy engine: rules, evaluation, defaults +│ ├── server/ # REST API: admin routes, middleware, chi router +│ ├── grpcserver/ # gRPC admin API: interceptors, service handlers +│ ├── webserver/ # Web UI: template routes, htmx handlers +│ ├── storage/ # Blob filesystem operations: write, read, delete, GC +│ └── gc/ # Garbage collection: mark, sweep, locking +├── proto/mcr/ +│ └── v1/ # Protobuf definitions +├── gen/mcr/ +│ └── v1/ # Generated gRPC code (never edit by hand) +├── web/ +│ ├── embed.go # //go:embed directive +│ ├── templates/ # HTML templates +│ └── static/ # CSS, htmx +├── deploy/ +│ ├── docker/ # Docker Compose +│ ├── examples/ # Example config files +│ ├── scripts/ # Install script +│ └── systemd/ # systemd units and timers +└── docs/ # Internal documentation +``` + +--- + +## 14. Deployment + +### Binary + +Single static binary per component, built with `CGO_ENABLED=0`. + +### Container + +Multi-stage Docker build: +1. **Builder**: `golang:1.25-alpine`, static compilation with + `-trimpath -ldflags="-s -w"`. +2. **Runtime**: `alpine:3.21`, non-root user (`mcr`), ports 8443/9443. + +The `/srv/mcr/` directory is a mounted volume containing the database, +blob storage, and configuration. + +### systemd + +| File | Purpose | +|------|---------| +| `mcr.service` | Registry server | +| `mcr-web.service` | Web UI | +| `mcr-backup.service` | Oneshot database backup | +| `mcr-backup.timer` | Daily backup timer (02:00 UTC, 5-minute jitter) | + +Standard security hardening per engineering standards +(`NoNewPrivileges=true`, `ProtectSystem=strict`, +`ReadWritePaths=/srv/mcr`, etc.). + +### Backup + +Database backup via `VACUUM INTO` captures metadata only (repositories, +manifests, tags, blobs table, policy rules, audit log). **Blob data on the +filesystem is not included.** A complete backup requires both: + +1. `mcrsrv snapshot` (or `mcrctl snapshot`) — SQLite database backup. +2. Filesystem-level copy of `/srv/mcr/layers/` — blob data. + +The database and blob directory must be backed up together. A database +backup without the corresponding blob directory is usable (metadata is +intact; missing blobs return 404 on pull) but incomplete. A blob directory +without the database is useless (no way to map digests to repositories). + +### Graceful Shutdown + +On `SIGINT` or `SIGTERM`: +1. Stop accepting new connections. +2. Drain in-flight requests (including ongoing uploads) up to + `shutdown_timeout` (default 60s). +3. Force-close remaining connections. +4. Close database. +5. Exit. + +--- + +## 15. Audit Events + +| Event | Trigger | +|-------|---------| +| `manifest_pushed` | Manifest uploaded (includes repo, tag, digest) | +| `manifest_pulled` | Manifest downloaded | +| `manifest_deleted` | Manifest deleted | +| `blob_uploaded` | Blob upload completed | +| `blob_deleted` | Blob deleted | +| `repo_deleted` | Repository deleted (admin) | +| `gc_started` | Garbage collection started | +| `gc_completed` | Garbage collection finished (includes blobs removed, bytes freed) | +| `policy_rule_created` | Policy rule created | +| `policy_rule_updated` | Policy rule updated | +| `policy_rule_deleted` | Policy rule deleted | +| `policy_deny` | Policy engine denied a request | +| `login_ok` | Successful authentication | +| `login_fail` | Failed authentication | + +The audit log is append-only. It never contains credentials or token values. + +--- + +## 16. Error Handling and Logging + +- All errors are wrapped with `fmt.Errorf("context: %w", err)`. +- Structured logging uses `log/slog` (or goutils wrapper). +- Log levels: DEBUG (dev only), INFO (normal ops), WARN (recoverable), + ERROR (unexpected failures). +- OCI operations (push, pull, delete) are logged at INFO with: + `{event, repository, reference, digest, account_uuid, ip, duration}`. +- **Never log:** bearer tokens, passwords, blob content, MCIAS credentials. +- OCI endpoint errors use the OCI error format (see §6). Admin endpoint + errors use the platform `{"error": "..."}` format. + +--- + +## 17. Security Model + +### Threat Mitigations + +| Threat | Mitigation | +|--------|------------| +| Unauthenticated access | All endpoints require MCIAS bearer token; `env:restricted` tag blocks guest/viewer login | +| Unauthorized push/delete | Policy engine enforces per-principal, per-repository access; default-deny for system accounts | +| Digest mismatch (supply chain) | All uploads verified against client-supplied digest; rejected if mismatch | +| Blob corruption | Content-addressed storage; digests verified on write. Periodic integrity scrub via `mcrctl scrub` (future) | +| Upload resource exhaustion | Stale uploads expire and are cleaned up; GC reclaims orphaned data | +| Information leakage | Error responses follow OCI spec format; no internal details exposed | + +### Security Invariants + +1. Every API request (OCI and admin) requires a valid MCIAS bearer token. +2. Token validation results are cached for at most 30 seconds. +3. System accounts have no implicit access — explicit policy rules required. +4. Blob digests are verified on upload; mismatched digests are rejected. + Reads trust the content-addressed path (digest is the filename). +5. Manifest deletion by tag is not supported — only by digest (OCI spec). +6. The audit log never contains credentials, tokens, or blob content. +7. TLS 1.3 minimum on all listeners. No fallback. + +--- + +## 18. Future Work + +| Item | Description | +|------|-------------| +| **Image signing / content trust** | Cosign or Notary v2 integration for image verification | +| **Multi-arch manifests** | OCI image index support for multi-platform images | +| **Cross-repo blob mounts** | `POST /v2//blobs/uploads/?mount=&from=` for efficient cross-repo copies | +| **MCP integration** | Wire MCR into the Metacircular Control Plane for automated image deployment | +| **Upload expiry** | Automatic cleanup of stale uploads after configurable TTL | +| **Repository size quotas** | Per-repository storage limits | +| **Webhook notifications** | Push events to external systems on manifest push/delete | +| **Integrity scrub** | `mcrctl scrub` — verify blob digests on disk match their filenames, report corruption | +| **Metrics** | Prometheus-compatible metrics: push/pull counts, storage usage, request latency | diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..85f91a1 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,95 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +MCR (Metacircular Container Registry) is a container registry service integrated with MCIAS for authentication and authorization. It is part of the Metacircular Dynamics platform. See `ARCHITECTURE.md` for the full specification (must be written before implementation begins). + +**Priorities (in order):** security, robustness, correctness. + +## Build Commands + +```bash +make all # vet → lint → test → build +make mcr # build binary with version injection +make build # compile all packages +make test # run all tests +make vet # go vet +make lint # golangci-lint +make proto # regenerate gRPC code from proto definitions +make proto-lint # buf lint + breaking change detection +make devserver # build and run locally with srv/mcr.toml +make clean # remove built binaries +make docker # build container image +``` + +Run a single test: +```bash +go test ./internal/server -run TestPushManifest +``` + +## Tech Stack + +- **Language:** Go 1.25+, `CGO_ENABLED=0`, statically linked +- **Module path:** `git.wntrmute.dev/kyle/mcr` +- **Database:** SQLite via `modernc.org/sqlite` (pure-Go, no CGo) +- **Config:** TOML via `go-toml/v2`, env overrides via `MCR_*` +- **APIs:** REST (chi) + gRPC (protobuf), kept in sync +- **Web UI:** Go `html/template` + htmx, embedded via `//go:embed` +- **Auth:** Delegated to MCIAS — no local user database +- **Linting:** golangci-lint v2 with `.golangci.yaml` +- **Testing:** stdlib `testing` only, real SQLite in `t.TempDir()` + +## Architecture + +- REST and gRPC APIs over HTTPS/TLS 1.3 (minimum, no exceptions) +- SQLite with WAL mode, `foreign_keys=ON`, `busy_timeout=5000` +- Write-through pattern: gRPC mutations write to DB first, then update in-memory state +- Every REST endpoint must have a corresponding gRPC RPC, and vice versa +- gRPC interceptor maps (`authRequiredMethods`, `adminRequiredMethods`) enforce access control — adding an RPC without adding it to the correct maps is a security defect +- Web UI runs as a separate binary (`mcr-web`) that talks to the API server via gRPC — no direct DB access +- Runtime data in `/srv/mcr/`, never committed to the repo + +## Package Structure + +- `cmd/mcr/` — CLI entry point (cobra subcommands: server, init, status, snapshot) +- `cmd/mcr-web/` — Web UI entry point +- `internal/auth/` — MCIAS integration (token validation, 30s cache by SHA-256) +- `internal/config/` — TOML config loading and validation +- `internal/db/` — SQLite setup, migrations (idempotent, tracked in `schema_migrations`) +- `internal/server/` — REST API server, chi routes, middleware +- `internal/grpcserver/` — gRPC server, interceptors, service handlers +- `internal/webserver/` — Web UI server, template routes, htmx handlers +- `proto/mcr/v1/` — Protobuf definitions (source of truth) +- `gen/mcr/v1/` — Generated gRPC code (never edit by hand) +- `web/` — Templates and static files, embedded via `//go:embed` +- `deploy/` — Docker, systemd units, install scripts, example config + +## Development Workflow + +1. Check PROGRESS.md for current state and next steps (create it if it doesn't exist) +2. Design first — write or update `ARCHITECTURE.md` before implementing +3. Implement, test, lint, commit, update PROGRESS.md +4. Pre-push: `make all` must pass clean + +## Critical Rules + +- API sync: every REST endpoint has a matching gRPC RPC, updated in the same change +- No local user database — all auth delegated to MCIAS +- Default deny — unauthenticated and unauthorized requests always rejected +- No custom crypto — use stdlib `crypto/...` or well-audited packages only +- TLS 1.3 minimum, no fallback ciphers +- Never log secrets (passwords, tokens, keys) +- Security-sensitive changes must include a `Security:` line in the commit body +- Migrations are idempotent and never modified after deployment +- Generated code in `gen/` is never edited by hand +- Verify fixes by running `go build ./...` and `go test ./...` before claiming resolution + +## Key Documents + +- `ARCHITECTURE.md` — Full system specification (required before implementation) +- `PROJECT_PLAN.md` — Implementation phases with acceptance criteria +- `PROGRESS.md` — Development progress tracking (update after each step) +- `RUNBOOK.md` — Operational procedures (to be written) +- `../engineering-standards.md` — Platform-wide standards (authoritative reference) diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..c41866a --- /dev/null +++ b/Makefile @@ -0,0 +1,46 @@ +.PHONY: build test vet lint proto proto-lint clean docker all devserver + +LDFLAGS := -trimpath -ldflags="-s -w -X main.version=$(shell git describe --tags --always --dirty)" + +mcrsrv: + CGO_ENABLED=0 go build $(LDFLAGS) -o mcrsrv ./cmd/mcrsrv + +mcr-web: + CGO_ENABLED=0 go build $(LDFLAGS) -o mcr-web ./cmd/mcr-web + +mcrctl: + CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o mcrctl ./cmd/mcrctl + +build: + go build ./... + +test: + go test ./... + +vet: + go vet ./... + +lint: + golangci-lint run ./... + +proto: + protoc --go_out=. --go_opt=module=git.wntrmute.dev/kyle/mcr \ + --go-grpc_out=. --go-grpc_opt=module=git.wntrmute.dev/kyle/mcr \ + proto/mcr/v1/*.proto + +proto-lint: + buf lint + buf breaking --against '.git#branch=master,subdir=proto' + +clean: + rm -f mcrsrv mcr-web mcrctl + +docker: + docker build --build-arg VERSION=$(shell git describe --tags --always --dirty) -t mcr -f Dockerfile . + +devserver: mcrsrv + @mkdir -p srv + @if [ ! -f srv/mcr.toml ]; then cp deploy/examples/mcr.toml srv/mcr.toml; echo "Created srv/mcr.toml from example — edit before running."; fi + ./mcrsrv server --config srv/mcr.toml + +all: vet lint test mcrsrv mcr-web mcrctl diff --git a/PROGRESS.md b/PROGRESS.md new file mode 100644 index 0000000..be5de0c --- /dev/null +++ b/PROGRESS.md @@ -0,0 +1,49 @@ +# MCR Development Progress + +Reverse-chronological log of development work. Most recent entries first. +See `PROJECT_PLAN.md` for the implementation roadmap and +`ARCHITECTURE.md` for the full design specification. + +## Current State + +**Phase:** Pre-implementation +**Last updated:** 2026-03-19 + +### Completed + +- `ARCHITECTURE.md` — Full design specification (18 sections) +- `CLAUDE.md` — AI development guidance +- `PROJECT_PLAN.md` — Implementation plan (14 phases, 40+ steps) +- `PROGRESS.md` — This file + +### Next Steps + +1. Begin Phase 0: Project scaffolding (Step 0.1: Go module and directory + structure) +2. After Phase 0 passes `make all`, proceed to Phase 1 + +--- + +## Log + +### 2026-03-19 — Project planning + +**Task:** Create design documents and implementation plan. + +**Changes:** +- `README.md`: Existing one-line description +- `ARCHITECTURE.md`: Full design specification covering OCI Distribution + Spec compliance, MCIAS authentication, policy engine, storage design, + API surface (OCI + admin REST + gRPC), database schema, garbage collection, + configuration, web UI, CLI tools, deployment, security model +- `CLAUDE.md`: Development guidance for AI-assisted implementation +- `PROJECT_PLAN.md`: 14-phase implementation plan with discrete steps, + acceptance criteria, dependency graph, and batchable work identification +- `PROGRESS.md`: This progress tracker + +**Notes:** +- No code written yet. All files are documentation/planning. +- ARCHITECTURE.md reviewed and corrected for: GC algorithm crash safety, + policy glob semantics, tag FK cascade, OCI error format, API sync + violations, timeout configuration, backup considerations, and other + consistency issues. diff --git a/PROJECT_PLAN.md b/PROJECT_PLAN.md new file mode 100644 index 0000000..9be6bfb --- /dev/null +++ b/PROJECT_PLAN.md @@ -0,0 +1,799 @@ +# MCR Project Plan + +Implementation plan for the Metacircular Container Registry. Each phase +contains discrete steps with acceptance criteria. Steps within a phase are +sequential unless noted as batchable. See `ARCHITECTURE.md` for the full +design specification. + +## Status + +| Phase | Description | Status | +|-------|-------------|--------| +| 0 | Project scaffolding | Not started | +| 1 | Configuration & database | Not started | +| 2 | Blob storage layer | Not started | +| 3 | MCIAS authentication | Not started | +| 4 | Policy engine | Not started | +| 5 | OCI API — pull path | Not started | +| 6 | OCI API — push path | Not started | +| 7 | OCI API — delete path | Not started | +| 8 | Admin REST API | Not started | +| 9 | Garbage collection | Not started | +| 10 | gRPC admin API | Not started | +| 11 | CLI tool (mcrctl) | Not started | +| 12 | Web UI | Not started | +| 13 | Deployment artifacts | Not started | + +### Dependency Graph + +``` +Phase 0 (scaffolding) + └─► Phase 1 (config + db) + ├─► Phase 2 (blob storage) ──┐ + └─► Phase 3 (MCIAS auth) │ + └─► Phase 4 (policy) │ + └─► Phase 5 (pull) ◄──┘ + └─► Phase 6 (push) + └─► Phase 7 (delete) + └─► Phase 9 (GC) + └─► Phase 8 (admin REST) ◄── Phase 1 + └─► Phase 10 (gRPC) + ├─► Phase 11 (mcrctl) + └─► Phase 12 (web UI) + +Phase 13 (deployment) depends on all above. +``` + +### Batchable Work + +The following phases are independent and can be assigned to different +engineers simultaneously: + +- **Batch A** (after Phase 1): Phase 2 (blob storage) and Phase 3 (MCIAS auth) +- **Batch B** (after Phase 4): Phase 5 (OCI pull) and Phase 8 (admin REST) +- **Batch C** (after Phase 10): Phase 11 (mcrctl) and Phase 12 (web UI) + +--- + +## Phase 0: Project Scaffolding + +Set up the Go module, build system, and binary skeletons. This phase +produces a project that builds and lints cleanly with no functionality. + +### Step 0.1: Go module and directory structure + +**Acceptance criteria:** +- `go.mod` initialized with module path `git.wntrmute.dev/kyle/mcr` +- Directory skeleton created per `ARCHITECTURE.md` §13: + `cmd/mcrsrv/`, `cmd/mcr-web/`, `cmd/mcrctl/`, `internal/`, `proto/mcr/v1/`, + `gen/mcr/v1/`, `web/`, `deploy/` +- `.gitignore` excludes: `mcrsrv`, `mcr-web`, `mcrctl`, `srv/`, `*.db`, + `*.db-wal`, `*.db-shm` + +### Step 0.2: Makefile + +**Acceptance criteria:** +- Standard targets per engineering standards: `build`, `test`, `vet`, `lint`, + `proto`, `proto-lint`, `clean`, `docker`, `all`, `devserver` +- `all` target runs: `vet` → `lint` → `test` → build binaries +- Binary targets: `mcrsrv`, `mcr-web`, `mcrctl` with version injection via + `-X main.version=$(shell git describe --tags --always --dirty)` +- `CGO_ENABLED=0` for all builds +- `make all` passes (empty binaries link successfully) + +### Step 0.3: Linter and protobuf configuration + +**Acceptance criteria:** +- `.golangci.yaml` with required linters per engineering standards: + errcheck, govet, ineffassign, unused, errorlint, gosec, staticcheck, + revive, gofmt, goimports +- `errcheck.check-type-assertions: true` +- `govet`: all analyzers except `shadow` +- `gosec`: exclude `G101` in test files +- `buf.yaml` for proto linting +- `golangci-lint run ./...` passes cleanly + +### Step 0.4: Binary entry points with cobra + +**Acceptance criteria:** +- `cmd/mcrsrv/main.go`: root command with `server`, `init`, `snapshot` + subcommands (stubs that print "not implemented" and exit 1) +- `cmd/mcr-web/main.go`: root command with `server` subcommand (stub) +- `cmd/mcrctl/main.go`: root command with `status`, `repo`, `gc`, `policy`, + `audit`, `snapshot` subcommand groups (stubs) +- All three binaries accept `--version` flag, printing the injected version +- `make all` builds all three binaries and passes lint/vet/test + +--- + +## Phase 1: Configuration & Database + +Implement config loading, SQLite database setup, and schema migrations. +Steps 1.1 and 1.2 can be **batched** (no dependency between them). + +### Step 1.1: Configuration loading (`internal/config`) + +**Acceptance criteria:** +- TOML config struct matching `ARCHITECTURE.md` §10 (all sections: + `[server]`, `[database]`, `[storage]`, `[mcias]`, `[web]`, `[log]`) +- Parsed with `go-toml/v2` +- Environment variable overrides via `MCR_` prefix + (e.g., `MCR_SERVER_LISTEN_ADDR`) +- Startup validation: refuse to start if required fields are missing + (`listen_addr`, `tls_cert`, `tls_key`, `database.path`, + `storage.layers_path`, `mcias.server_url`) +- Same-filesystem check for `layers_path` and `uploads_path` via device ID +- Default values: `uploads_path` defaults to `/../uploads`, + `read_timeout` = 30s, `write_timeout` = 0, `idle_timeout` = 120s, + `shutdown_timeout` = 60s, `log.level` = "info" +- `mcr.toml.example` created in `deploy/examples/` +- Tests: valid config, missing required fields, env override, device ID check + +### Step 1.2: Database setup and migrations (`internal/db`) + +**Acceptance criteria:** +- SQLite opened with `modernc.org/sqlite` (pure-Go, no CGo) +- Connection pragmas: `journal_mode=WAL`, `foreign_keys=ON`, + `busy_timeout=5000` +- File permissions: `0600` +- Migration framework: Go functions registered sequentially, tracked in + `schema_migrations` table, idempotent (`CREATE TABLE IF NOT EXISTS`) +- Migration 000001: `repositories`, `manifests`, `tags`, `blobs`, + `manifest_blobs`, `uploads` tables per `ARCHITECTURE.md` §8 +- Migration 000002: `policy_rules`, `audit_log` tables per §8 +- All indexes created per schema +- `db.Open(path)` → `*DB`, `db.Close()`, `db.Migrate()` public API +- Tests: open fresh DB, run migrations, verify tables exist, run migrations + again (idempotent), verify foreign key enforcement works + +### Step 1.3: Audit log helpers (`internal/db`) + +**Acceptance criteria:** +- `db.WriteAuditEvent(event_type, actor_id, repository, digest, ip, details)` + inserts into `audit_log` +- `db.ListAuditEvents(filters)` with pagination (offset/limit), filtering + by event_type, actor_id, repository, time range +- `details` parameter is `map[string]string`, serialized as JSON +- Tests: write events, list with filters, pagination + +--- + +## Phase 2: Blob Storage Layer + +Implement content-addressed filesystem operations for blob data. + +### Step 2.1: Blob writer (`internal/storage`) + +**Acceptance criteria:** +- `storage.New(layersPath, uploadsPath)` constructor +- `storage.StartUpload(uuid)` creates a temp file at + `/` and returns a `*BlobWriter` +- `BlobWriter.Write([]byte)` appends data and updates a running SHA-256 hash +- `BlobWriter.Commit(expectedDigest)`: + - Finalizes SHA-256 + - Rejects with error if computed digest != expected digest + - Creates two-char prefix directory under `/sha256//` + - Renames temp file to `/sha256//` + - Returns the verified `sha256:` digest string +- `BlobWriter.Cancel()` removes the temp file +- `BlobWriter.BytesWritten()` returns current offset +- Tests: write blob, verify file at expected path, digest mismatch rejection, + cancel cleanup, concurrent writes to different UUIDs + +### Step 2.2: Blob reader and metadata (`internal/storage`) + +**Acceptance criteria:** +- `storage.Open(digest)` returns an `io.ReadCloser` for the blob file, or + `ErrBlobNotFound` +- `storage.Stat(digest)` returns size and existence check without opening +- `storage.Delete(digest)` removes the blob file and its prefix directory + if empty +- `storage.Exists(digest)` returns bool +- Digest format validated: must match `sha256:[a-f0-9]{64}` +- Tests: read after write, stat, delete, not-found error, invalid digest + format rejected + +--- + +## Phase 3: MCIAS Authentication + +Implement token validation and the OCI token endpoint. + +### Step 3.1: MCIAS client (`internal/auth`) + +**Acceptance criteria:** +- `auth.NewClient(mciastURL, caCert, serviceName, tags)` constructor +- `client.Login(username, password)` calls MCIAS `POST /v1/auth/login` + with `service_name` and `tags` in the request body; returns JWT string + and expiry +- `client.ValidateToken(token)` calls MCIAS `POST /v1/token/validate`; + returns parsed claims (subject UUID, account type, roles) or error +- Validation results cached by `sha256(token)` with 30-second TTL; + cache entries evicted on expiry +- TLS: custom CA cert supported; TLS 1.3 minimum enforced via + `tls.Config.MinVersion` +- HTTP client timeout: 10 seconds +- Errors wrapped with `fmt.Errorf("auth: %w", err)` +- Tests: use `httptest.Server` to mock MCIAS; test login success, + login failure (401), validate success, validate with revoked token, + cache hit within TTL, cache miss after TTL + +### Step 3.2: Auth middleware (`internal/server`) + +**Acceptance criteria:** +- `middleware.RequireAuth(authClient)` extracts `Authorization: Bearer ` + header, calls `authClient.ValidateToken`, injects claims into + `context.Context` +- Missing/invalid token returns OCI error format: + `{"errors":[{"code":"UNAUTHORIZED","message":"..."}]}` with HTTP 401 +- 401 responses include `WWW-Authenticate: Bearer realm="/v2/token",service=""` + header +- Claims retrievable from context via `auth.ClaimsFromContext(ctx)` +- Tests: valid token passes, missing header returns 401 with + WWW-Authenticate, invalid token returns 401, claims accessible in handler + +### Step 3.3: Token endpoint (`GET /v2/token`) + +**Acceptance criteria:** +- Accepts HTTP Basic auth (username:password from `Authorization` header) +- Accepts `scope` and `service` query parameters (logged but not used for + scoping) +- Calls `authClient.Login(username, password)` +- On success: returns `{"token":"","expires_in":,"issued_at":""}` +- On failure: returns `{"errors":[{"code":"UNAUTHORIZED","message":"..."}]}` + with HTTP 401 +- Tests: valid credentials, invalid credentials, missing auth header + +### Step 3.4: Version check endpoint (`GET /v2/`) + +**Acceptance criteria:** +- Requires valid bearer token (via RequireAuth middleware) +- Returns `200 OK` with body `{}` +- Unauthenticated requests return 401 with WWW-Authenticate header + (this is the entry point for the OCI auth handshake) +- Tests: authenticated returns 200, unauthenticated returns 401 with + correct WWW-Authenticate header + +--- + +## Phase 4: Policy Engine + +Implement the registry-specific authorization engine. + +### Step 4.1: Core policy types and evaluation (`internal/policy`) + +**Acceptance criteria:** +- Types defined per `ARCHITECTURE.md` §4: `Action`, `Effect`, `PolicyInput`, + `Rule` +- All action constants: `registry:version_check`, `registry:pull`, + `registry:push`, `registry:delete`, `registry:catalog`, `policy:manage` +- `Evaluate(input PolicyInput, rules []Rule) (Effect, *Rule)`: + - Sorts rules by priority (stable) + - Collects all matching rules + - Deny-wins: any matching deny → return deny + - First allow → return allow + - Default deny if no match +- Rule matching: all populated fields ANDed; empty fields are wildcards +- `Repositories` glob matching via `path.Match`; empty list = match all +- When `input.Repository` is empty (global ops), only rules with empty + `Repositories` match +- Tests: admin wildcard, user allow, system account deny (no rules), + exact repo match, glob match (`production/*`), deny-wins over allow, + priority ordering, empty repository global operation, multiple + matching rules + +### Step 4.2: Built-in defaults (`internal/policy`) + +**Acceptance criteria:** +- `DefaultRules()` returns the built-in rules per `ARCHITECTURE.md` §4: + admin wildcard, human user full access, version check allow +- Default rules use negative IDs (-1, -2, -3) +- Default rules have priority 0 +- Tests: admin gets allow for all actions, user gets allow for pull/push/ + delete/catalog, system account gets deny for everything except + version_check, user gets allow for version_check + +### Step 4.3: Policy engine wrapper with DB integration (`internal/policy`) + +**Acceptance criteria:** +- `Engine` struct wraps `Evaluate` with DB-backed rule loading +- `engine.SetRules(rules)` caches rules in memory (merges with defaults) +- `engine.Evaluate(input)` calls stateless `Evaluate` with cached rules +- Thread-safe: `sync.RWMutex` protects the cached rule set +- `engine.Reload(db)` loads enabled rules from `policy_rules` table and + calls `SetRules` +- Tests: engine with only defaults, engine with custom rules, reload + picks up new rules, disabled rules excluded + +### Step 4.4: Policy middleware (`internal/server`) + +**Acceptance criteria:** +- `middleware.RequirePolicy(engine, action)` middleware: + - Extracts claims from context (set by RequireAuth) + - Extracts repository name from URL path (empty for global ops) + - Assembles `PolicyInput` + - Calls `engine.Evaluate` + - On deny: returns OCI error `{"errors":[{"code":"DENIED","message":"..."}]}` + with HTTP 403; writes `policy_deny` audit event + - On allow: proceeds to handler +- Tests: admin allowed, user allowed, system account denied (no rules), + system account with matching rule allowed, deny rule blocks access + +--- + +## Phase 5: OCI API — Pull Path + +Implement the read side of the OCI Distribution Spec. Requires Phase 2 +(storage), Phase 3 (auth), and Phase 4 (policy). + +### Step 5.1: OCI handler scaffolding (`internal/oci`) + +**Acceptance criteria:** +- `oci.NewHandler(db, storage, authClient, policyEngine)` constructor +- Chi router with `/v2/` prefix; all routes wrapped in RequireAuth middleware +- Repository name extracted from URL path; names may contain `/` + (chi wildcard catch-all) +- OCI error response helper: `writeOCIError(w, code, status, message)` + producing `{"errors":[{"code":"...","message":"..."}]}` format +- All OCI handlers share the same `*oci.Handler` receiver +- Tests: error response format matches OCI spec + +### Step 5.2: Manifest pull (`GET /v2//manifests/`) + +**Acceptance criteria:** +- Policy check: `registry:pull` action on the target repository +- If `` is a tag: look up tag → manifest in DB +- If `` is a digest (`sha256:...`): look up manifest by + digest in DB +- Returns manifest content with: + - `Content-Type` header set to manifest's `media_type` + - `Docker-Content-Digest` header set to manifest's digest + - `Content-Length` header set to manifest's size +- `HEAD` variant returns same headers but no body +- Repository not found → `NAME_UNKNOWN` (404) +- Manifest not found → `MANIFEST_UNKNOWN` (404) +- Writes `manifest_pulled` audit event +- Tests: pull by tag, pull by digest, HEAD returns headers only, + nonexistent repo, nonexistent tag, nonexistent digest + +### Step 5.3: Blob download (`GET /v2//blobs/`) + +**Acceptance criteria:** +- Policy check: `registry:pull` action on the target repository +- Verify blob exists in `blobs` table AND is referenced by a manifest + in the target repository (via `manifest_blobs`) +- Open blob from storage, stream to response with: + - `Content-Type: application/octet-stream` + - `Docker-Content-Digest` header + - `Content-Length` header +- `HEAD` variant returns headers only +- Blob not in repo → `BLOB_UNKNOWN` (404) +- Tests: download blob, HEAD blob, blob not found, blob exists + globally but not in this repo → 404 + +### Step 5.4: Tag listing (`GET /v2//tags/list`) + +**Acceptance criteria:** +- Policy check: `registry:pull` action on the target repository +- Returns `{"name":"","tags":["tag1","tag2",...]}` sorted + alphabetically +- Pagination via `n` (limit) and `last` (cursor) query parameters + per OCI spec +- If more results: `Link` header with next page URL +- Empty tag list returns `{"name":"","tags":[]}` +- Repository not found → `NAME_UNKNOWN` (404) +- Tests: list tags, pagination, empty repo, nonexistent repo + +### Step 5.5: Catalog listing (`GET /v2/_catalog`) + +**Acceptance criteria:** +- Policy check: `registry:catalog` action (no repository context) +- Returns `{"repositories":["repo1","repo2",...]}` sorted alphabetically +- Pagination via `n` and `last` query parameters +- If more results: `Link` header with next page URL +- Tests: list repos, pagination, empty registry + +--- + +## Phase 6: OCI API — Push Path + +Implement blob uploads and manifest pushes. Requires Phase 5 (shared +OCI infrastructure). + +### Step 6.1: Blob upload — initiate (`POST /v2//blobs/uploads/`) + +**Acceptance criteria:** +- Policy check: `registry:push` action on the target repository +- Creates repository if it doesn't exist (implicit creation) +- Generates upload UUID (`crypto/rand`) +- Inserts row in `uploads` table +- Creates temp file via `storage.StartUpload(uuid)` +- Returns `202 Accepted` with: + - `Location: /v2//blobs/uploads/` header + - `Docker-Upload-UUID: ` header + - `Range: 0-0` header +- Tests: initiate returns 202 with correct headers, implicit repo + creation, UUID is unique + +### Step 6.2: Blob upload — chunked and monolithic + +**Acceptance criteria:** +- `PATCH /v2//blobs/uploads/`: + - Appends request body to the upload's temp file + - Updates `byte_offset` in `uploads` table + - `Content-Range` header processed if present + - Returns `202 Accepted` with updated `Range` and `Location` headers +- `PUT /v2//blobs/uploads/?digest=`: + - If request body is non-empty, appends it first (monolithic upload) + - Calls `BlobWriter.Commit(digest)` + - On digest mismatch: `DIGEST_INVALID` (400) + - Inserts row in `blobs` table (or no-op if digest already exists) + - Deletes row from `uploads` table + - Returns `201 Created` with: + - `Location: /v2//blobs/` header + - `Docker-Content-Digest` header + - Writes `blob_uploaded` audit event +- `GET /v2//blobs/uploads/`: + - Returns `204 No Content` with `Range: 0-` header +- `DELETE /v2//blobs/uploads/`: + - Cancels upload: deletes temp file, removes `uploads` row + - Returns `204 No Content` +- Upload UUID not found → `BLOB_UPLOAD_UNKNOWN` (404) +- Tests: monolithic upload (POST then PUT with body), chunked upload + (POST → PATCH → PATCH → PUT), digest mismatch, check progress, + cancel upload, nonexistent UUID + +### Step 6.3: Manifest push (`PUT /v2//manifests/`) + +**Acceptance criteria:** +- Policy check: `registry:push` action on the target repository +- Implements the full manifest push flow per `ARCHITECTURE.md` §5: + 1. Parse manifest JSON; reject malformed → `MANIFEST_INVALID` (400) + 2. Compute SHA-256 digest of raw bytes + 3. If reference is a digest, verify match → `DIGEST_INVALID` (400) + 4. Parse layer and config descriptors from manifest + 5. Verify all referenced blobs exist → `MANIFEST_BLOB_UNKNOWN` (400) + 6. Single SQLite transaction: + a. Create repository if not exists + b. Insert/update manifest row + c. Populate `manifest_blobs` join table + d. If reference is a tag, insert/update tag row + 7. Return `201 Created` with `Docker-Content-Digest` and `Location` + headers +- Writes `manifest_pushed` audit event (includes repo, tag, digest) +- Tests: push by tag, push by digest, push updates existing tag (atomic + tag move), missing blob → 400, malformed manifest → 400, digest + mismatch → 400, re-push same manifest (idempotent) + +--- + +## Phase 7: OCI API — Delete Path + +Implement manifest and blob deletion. + +### Step 7.1: Manifest delete (`DELETE /v2//manifests/`) + +**Acceptance criteria:** +- Policy check: `registry:delete` action on the target repository +- Reference must be a digest (not a tag) → `UNSUPPORTED` (405) if tag +- Deletes manifest row; cascades to `manifest_blobs` and `tags` + (ON DELETE CASCADE) +- Returns `202 Accepted` +- Writes `manifest_deleted` audit event +- Tests: delete by digest, attempt delete by tag → 405, nonexistent + manifest → `MANIFEST_UNKNOWN` (404), cascading tag deletion verified + +### Step 7.2: Blob delete (`DELETE /v2//blobs/`) + +**Acceptance criteria:** +- Policy check: `registry:delete` action on the target repository +- Verify blob exists and is referenced in this repository +- Removes the `manifest_blobs` rows for this repo's manifests (does NOT + delete the blob row or file — that's GC's job, since other repos may + reference it) +- Returns `202 Accepted` +- Writes `blob_deleted` audit event +- Tests: delete blob, blob still on disk (not GC'd yet), blob not in + repo → `BLOB_UNKNOWN` (404) + +--- + +## Phase 8: Admin REST API + +Implement management endpoints under `/v1/`. Can be **batched** with +Phase 5 (OCI pull) since both depend on Phase 4 but not on each other. + +### Step 8.1: Auth endpoints (`/v1/auth`) + +**Acceptance criteria:** +- `POST /v1/auth/login`: accepts `{"username":"...","password":"..."}` + body, forwards to MCIAS, returns `{"token":"...","expires_at":"..."}` +- `POST /v1/auth/logout`: requires bearer token, calls MCIAS token + revocation (if supported), returns `204 No Content` +- `GET /v1/health`: returns `{"status":"ok"}` (no auth required) +- Error format: `{"error":"..."}` (platform standard) +- Tests: login success, login failure, logout, health check + +### Step 8.2: Repository management endpoints + +**Acceptance criteria:** +- `GET /v1/repositories`: list repositories with metadata + (tag count, manifest count, total size). Paginated. Requires bearer. +- `GET /v1/repositories/{name}`: repository detail (tags with digests, + manifests, total size). Requires bearer. Name may contain `/`. +- `DELETE /v1/repositories/{name}`: delete repository and all associated + manifests, tags, manifest_blobs rows. Requires admin role. Writes + `repo_deleted` audit event. +- Tests: list repos, repo detail, delete repo cascades correctly, + non-admin delete → 403 + +### Step 8.3: Policy management endpoints + +**Acceptance criteria:** +- Full CRUD per `ARCHITECTURE.md` §6: + - `GET /v1/policy/rules`: list all rules (paginated) + - `POST /v1/policy/rules`: create rule from JSON body + - `GET /v1/policy/rules/{id}`: get single rule + - `PATCH /v1/policy/rules/{id}`: update priority, enabled, description + - `DELETE /v1/policy/rules/{id}`: delete rule +- All endpoints require admin role +- Write operations trigger policy engine reload +- Audit events: `policy_rule_created`, `policy_rule_updated`, + `policy_rule_deleted` +- Input validation: priority must be >= 1 (0 reserved for built-ins), + actions must be valid constants, effect must be "allow" or "deny" +- Tests: full CRUD cycle, validation errors, non-admin → 403, + engine reload after create/update/delete + +### Step 8.4: Audit log endpoint + +**Acceptance criteria:** +- `GET /v1/audit`: list audit events. Requires admin role. +- Query parameters: `event_type`, `actor_id`, `repository`, `since`, + `until`, `n` (limit, default 50), `offset` +- Returns JSON array of audit events +- Tests: list events, filter by type, filter by repository, pagination + +### Step 8.5: Garbage collection endpoints + +**Acceptance criteria:** +- `POST /v1/gc`: trigger async GC run. Requires admin role. Returns + `202 Accepted` with `{"id":""}`. Returns `409 Conflict` + if GC is already running. +- `GET /v1/gc/status`: returns current/last GC status: + `{"running":bool,"last_run":{"started_at":"...","completed_at":"...", + "blobs_removed":N,"bytes_freed":N}}` +- Writes `gc_started` and `gc_completed` audit events +- Tests: trigger GC, check status, concurrent trigger → 409 + +--- + +## Phase 9: Garbage Collection + +Implement the two-phase GC algorithm. Requires Phase 7 (delete path +creates unreferenced blobs). + +### Step 9.1: GC engine (`internal/gc`) + +**Acceptance criteria:** +- `gc.New(db, storage)` constructor +- `gc.Run(ctx)` executes the two-phase algorithm per `ARCHITECTURE.md` §9: + - Phase 1 (DB): acquire lock, begin write tx, find unreferenced blobs, + delete blob rows, commit + - Phase 2 (filesystem): delete blob files, remove empty prefix dirs, + release lock +- Registry-wide lock (`sync.Mutex`) blocks new blob uploads during phase 1 +- Lock integration: upload initiation (Step 6.1) must check the GC lock + before creating new uploads +- Returns `GCResult{BlobsRemoved int, BytesFreed int64, Duration time.Duration}` +- `gc.Reconcile(ctx)` scans filesystem, deletes files with no `blobs` row + (crash recovery) +- Tests: GC removes unreferenced blobs, GC does not remove referenced blobs, + concurrent GC rejected, reconcile cleans orphaned files + +### Step 9.2: Wire GC into server and CLI + +**Acceptance criteria:** +- `POST /v1/gc` and gRPC `GarbageCollect` call `gc.Run` in a goroutine +- GC status tracked in memory (running flag, last result) +- `mcrctl gc` triggers via REST/gRPC +- `mcrctl gc status` fetches status +- `mcrctl gc --reconcile` runs filesystem reconciliation +- Tests: end-to-end GC via API trigger + +--- + +## Phase 10: gRPC Admin API + +Implement the protobuf definitions and gRPC server. Requires Phase 8 +(admin REST, to share business logic). + +### Step 10.1: Proto definitions + +**Acceptance criteria:** +- Proto files per `ARCHITECTURE.md` §7: + `registry.proto`, `policy.proto`, `audit.proto`, `admin.proto`, + `common.proto` +- All RPCs defined per §7 service definitions table +- `buf lint` passes +- `make proto` generates Go stubs in `gen/mcr/v1/` +- Generated code committed + +### Step 10.2: gRPC server implementation (`internal/grpcserver`) + +**Acceptance criteria:** +- `RegistryService`: `ListRepositories`, `GetRepository`, + `DeleteRepository`, `GarbageCollect`, `GetGCStatus` +- `PolicyService`: full CRUD for policy rules +- `AuditService`: `ListAuditEvents` +- `AdminService`: `Health` +- All RPCs call the same business logic as REST handlers (shared + `internal/db` and `internal/gc` packages) +- Tests: at least one RPC per service via `grpc.NewServer` + in-process + client + +### Step 10.3: Interceptor chain + +**Acceptance criteria:** +- Interceptor chain per `ARCHITECTURE.md` §7: + Request Logger → Auth Interceptor → Admin Interceptor → Handler +- Auth interceptor extracts `authorization` metadata, validates via + MCIAS, injects claims. `Health` bypasses auth. +- Admin interceptor requires admin role for GC, policy, delete, audit. +- Request logger logs method, peer IP, status code, duration. Never + logs the authorization metadata value. +- gRPC errors: `codes.Unauthenticated` for missing/invalid token, + `codes.PermissionDenied` for insufficient role +- Tests: unauthenticated → Unauthenticated, non-admin on admin + RPC → PermissionDenied, Health bypasses auth + +### Step 10.4: TLS and server startup + +**Acceptance criteria:** +- gRPC server uses same TLS cert/key as REST server +- `tls.Config.MinVersion = tls.VersionTLS13` +- Server starts on `grpc_addr` from config; disabled if `grpc_addr` + is empty +- Graceful shutdown: `grpcServer.GracefulStop()` called on SIGINT/SIGTERM +- Tests: server starts and accepts TLS connections + +--- + +## Phase 11: CLI Tool (mcrctl) + +Implement the admin CLI. Can be **batched** with Phase 12 (web UI) +since both depend on Phase 10 but not on each other. + +### Step 11.1: Client and connection setup + +**Acceptance criteria:** +- Global flags: `--server` (REST URL), `--grpc` (gRPC address), + `--token` (bearer token), `--ca-cert` (custom CA) +- Token can be loaded from `MCR_TOKEN` env var +- gRPC client with TLS, using same CA cert if provided +- REST client with TLS, `Authorization: Bearer` header +- Connection errors produce clear messages + +### Step 11.2: Status and repository commands + +**Acceptance criteria:** +- `mcrctl status` → calls `GET /v1/health`, prints status +- `mcrctl repo list` → calls `GET /v1/repositories`, prints table +- `mcrctl repo delete ` → calls `DELETE /v1/repositories/`, + confirms before deletion +- Output: human-readable by default, `--json` for machine-readable +- Tests: at minimum, flag parsing tests + +### Step 11.3: Policy, audit, GC, and snapshot commands + +**Acceptance criteria:** +- `mcrctl policy list|create|update|delete` → full CRUD via REST/gRPC +- `mcrctl policy create` accepts `--json` flag for rule body +- `mcrctl audit tail [--n N]` → calls `GET /v1/audit` +- `mcrctl gc` → calls `POST /v1/gc` +- `mcrctl gc status` → calls `GET /v1/gc/status` +- `mcrctl gc --reconcile` → calls reconciliation endpoint +- `mcrctl snapshot` → triggers database backup +- Tests: flag parsing, output formatting + +--- + +## Phase 12: Web UI + +Implement the HTMX-based web interface. Requires Phase 10 (gRPC). + +### Step 12.1: Web server scaffolding + +**Acceptance criteria:** +- `cmd/mcr-web/` binary reads `[web]` config section +- Connects to mcrsrv via gRPC at `web.grpc_addr` +- Go `html/template` with `web/templates/layout.html` base template +- Static files embedded via `//go:embed` (`web/static/`: CSS, htmx) +- CSRF protection: signed double-submit cookies on POST/PUT/PATCH/DELETE +- Session cookie: `HttpOnly`, `Secure`, `SameSite=Strict`, stores + MCIAS JWT +- Chi router with middleware chain + +### Step 12.2: Login and authentication + +**Acceptance criteria:** +- `/login` page with username/password form +- Form submission POSTs to mcr-web, which calls MCIAS login via mcrsrv + gRPC (or directly via MCIAS client) +- On success: sets session cookie, redirects to `/` +- On failure: re-renders login with error message +- Logout link clears session cookie + +### Step 12.3: Dashboard and repository browsing + +**Acceptance criteria:** +- `/` dashboard: repository count, total size, recent pushes + (last 10 `manifest_pushed` audit events) +- `/repositories` list: table with name, tag count, manifest count, + total size +- `/repositories/{name}` detail: tag list (name → digest), manifest + list (digest, media type, size, created), layer list +- `/repositories/{name}/manifests/{digest}` detail: full manifest + JSON, referenced layers with sizes +- All data fetched from mcrsrv via gRPC + +### Step 12.4: Policy management (admin only) + +**Acceptance criteria:** +- `/policies` page: list all policy rules in a table +- Create form: HTMX form that submits new rule (priority, description, + effect, actions, account types, subject UUID, repositories) +- Edit: inline HTMX toggle for enabled/disabled, edit priority/description +- Delete: confirm dialog, HTMX delete +- Non-admin users see "Access denied" or are redirected + +### Step 12.5: Audit log viewer (admin only) + +**Acceptance criteria:** +- `/audit` page: paginated table of audit events +- Filters: event type dropdown, repository name, date range +- HTMX partial page updates for filter changes +- Non-admin users see "Access denied" + +--- + +## Phase 13: Deployment Artifacts + +Package everything for production deployment. + +### Step 13.1: Dockerfile + +**Acceptance criteria:** +- Multi-stage build per `ARCHITECTURE.md` §14: + Builder `golang:1.25-alpine`, runtime `alpine:3.21` +- `CGO_ENABLED=0`, `-trimpath -ldflags="-s -w"` +- Builds all three binaries +- Runtime: non-root user `mcr` (uid 10001) +- `EXPOSE 8443 9443` +- `VOLUME /srv/mcr` +- `ENTRYPOINT ["mcrsrv"]`, `CMD ["server", "--config", "/srv/mcr/mcr.toml"]` +- `make docker` builds image with version tag + +### Step 13.2: systemd units + +**Acceptance criteria:** +- `deploy/systemd/mcr.service`: registry server unit with security + hardening per engineering standards (`NoNewPrivileges`, `ProtectSystem`, + `ReadWritePaths=/srv/mcr`, etc.) +- `deploy/systemd/mcr-web.service`: web UI unit with + `ReadOnlyPaths=/srv/mcr` +- `deploy/systemd/mcr-backup.service`: oneshot backup unit running + `mcrsrv snapshot` +- `deploy/systemd/mcr-backup.timer`: daily 02:00 UTC with 5-min jitter +- All units run as `User=mcr`, `Group=mcr` + +### Step 13.3: Install script and example configs + +**Acceptance criteria:** +- `deploy/scripts/install.sh`: idempotent script that creates system + user/group, installs binaries to `/usr/local/bin/`, creates `/srv/mcr/` + directory structure, installs example config if none exists, installs + systemd units and reloads daemon +- `deploy/examples/mcr.toml` with annotated production defaults +- `deploy/examples/mcr-dev.toml` with local development defaults +- Script tested: runs twice without error (idempotent) diff --git a/README.md b/README.md new file mode 100644 index 0000000..77f835a --- /dev/null +++ b/README.md @@ -0,0 +1,3 @@ +MCR is the Metacircular Container Registry + +This is a container registry integrated with MCIAS. \ No newline at end of file diff --git a/buf.yaml b/buf.yaml new file mode 100644 index 0000000..c7e30e3 --- /dev/null +++ b/buf.yaml @@ -0,0 +1,9 @@ +version: v2 +modules: + - path: proto +lint: + use: + - STANDARD +breaking: + use: + - FILE diff --git a/cmd/mcr-web/main.go b/cmd/mcr-web/main.go new file mode 100644 index 0000000..ae4a339 --- /dev/null +++ b/cmd/mcr-web/main.go @@ -0,0 +1,34 @@ +package main + +import ( + "fmt" + "os" + + "github.com/spf13/cobra" +) + +var version = "dev" + +func main() { + root := &cobra.Command{ + Use: "mcr-web", + Short: "Metacircular Container Registry web UI", + Version: version, + } + + root.AddCommand(serverCmd()) + + if err := root.Execute(); err != nil { + os.Exit(1) + } +} + +func serverCmd() *cobra.Command { + return &cobra.Command{ + Use: "server", + Short: "Start the web UI server", + RunE: func(_ *cobra.Command, _ []string) error { + return fmt.Errorf("not implemented") + }, + } +} diff --git a/cmd/mcrctl/main.go b/cmd/mcrctl/main.go new file mode 100644 index 0000000..a3b1119 --- /dev/null +++ b/cmd/mcrctl/main.go @@ -0,0 +1,152 @@ +package main + +import ( + "fmt" + "os" + + "github.com/spf13/cobra" +) + +func main() { + root := &cobra.Command{ + Use: "mcrctl", + Short: "Metacircular Container Registry admin CLI", + } + + root.AddCommand(statusCmd()) + root.AddCommand(repoCmd()) + root.AddCommand(gcCmd()) + root.AddCommand(policyCmd()) + root.AddCommand(auditCmd()) + root.AddCommand(snapshotCmd()) + + if err := root.Execute(); err != nil { + os.Exit(1) + } +} + +func statusCmd() *cobra.Command { + return &cobra.Command{ + Use: "status", + Short: "Query server health", + RunE: func(_ *cobra.Command, _ []string) error { + return fmt.Errorf("not implemented") + }, + } +} + +func repoCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "repo", + Short: "Repository management", + } + + cmd.AddCommand(&cobra.Command{ + Use: "list", + Short: "List repositories", + RunE: func(_ *cobra.Command, _ []string) error { + return fmt.Errorf("not implemented") + }, + }) + + cmd.AddCommand(&cobra.Command{ + Use: "delete [name]", + Short: "Delete a repository", + Args: cobra.ExactArgs(1), + RunE: func(_ *cobra.Command, _ []string) error { + return fmt.Errorf("not implemented") + }, + }) + + return cmd +} + +func gcCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "gc", + Short: "Trigger garbage collection", + RunE: func(_ *cobra.Command, _ []string) error { + return fmt.Errorf("not implemented") + }, + } + + cmd.AddCommand(&cobra.Command{ + Use: "status", + Short: "Check GC status", + RunE: func(_ *cobra.Command, _ []string) error { + return fmt.Errorf("not implemented") + }, + }) + + return cmd +} + +func policyCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "policy", + Short: "Policy rule management", + } + + cmd.AddCommand(&cobra.Command{ + Use: "list", + Short: "List policy rules", + RunE: func(_ *cobra.Command, _ []string) error { + return fmt.Errorf("not implemented") + }, + }) + + cmd.AddCommand(&cobra.Command{ + Use: "create", + Short: "Create a policy rule", + RunE: func(_ *cobra.Command, _ []string) error { + return fmt.Errorf("not implemented") + }, + }) + + cmd.AddCommand(&cobra.Command{ + Use: "update [id]", + Short: "Update a policy rule", + Args: cobra.ExactArgs(1), + RunE: func(_ *cobra.Command, _ []string) error { + return fmt.Errorf("not implemented") + }, + }) + + cmd.AddCommand(&cobra.Command{ + Use: "delete [id]", + Short: "Delete a policy rule", + Args: cobra.ExactArgs(1), + RunE: func(_ *cobra.Command, _ []string) error { + return fmt.Errorf("not implemented") + }, + }) + + return cmd +} + +func auditCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "audit", + Short: "Audit log management", + } + + cmd.AddCommand(&cobra.Command{ + Use: "tail", + Short: "Print recent audit events", + RunE: func(_ *cobra.Command, _ []string) error { + return fmt.Errorf("not implemented") + }, + }) + + return cmd +} + +func snapshotCmd() *cobra.Command { + return &cobra.Command{ + Use: "snapshot", + Short: "Trigger database backup via VACUUM INTO", + RunE: func(_ *cobra.Command, _ []string) error { + return fmt.Errorf("not implemented") + }, + } +} diff --git a/cmd/mcrsrv/main.go b/cmd/mcrsrv/main.go new file mode 100644 index 0000000..06db1be --- /dev/null +++ b/cmd/mcrsrv/main.go @@ -0,0 +1,56 @@ +package main + +import ( + "fmt" + "os" + + "github.com/spf13/cobra" +) + +var version = "dev" + +func main() { + root := &cobra.Command{ + Use: "mcrsrv", + Short: "Metacircular Container Registry server", + Version: version, + } + + root.AddCommand(serverCmd()) + root.AddCommand(initCmd()) + root.AddCommand(snapshotCmd()) + + if err := root.Execute(); err != nil { + os.Exit(1) + } +} + +func serverCmd() *cobra.Command { + return &cobra.Command{ + Use: "server", + Short: "Start the registry server", + RunE: func(_ *cobra.Command, _ []string) error { + return fmt.Errorf("not implemented") + }, + } +} + +func initCmd() *cobra.Command { + return &cobra.Command{ + Use: "init", + Short: "First-time setup (create directories, example config)", + RunE: func(_ *cobra.Command, _ []string) error { + return fmt.Errorf("not implemented") + }, + } +} + +func snapshotCmd() *cobra.Command { + return &cobra.Command{ + Use: "snapshot", + Short: "Database backup via VACUUM INTO", + RunE: func(_ *cobra.Command, _ []string) error { + return fmt.Errorf("not implemented") + }, + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..0dc4b29 --- /dev/null +++ b/go.mod @@ -0,0 +1,9 @@ +module git.wntrmute.dev/kyle/mcr + +go 1.25.7 + +require ( + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/spf13/cobra v1.10.2 // indirect + github.com/spf13/pflag v1.0.9 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..a6ee3e0 --- /dev/null +++ b/go.sum @@ -0,0 +1,10 @@ +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=