Files
mcr/PROJECT_PLAN.md
Kyle Isom 185b68ff6d Phase 10: gRPC admin API with interceptor chain
Proto definitions for 4 services (RegistryService, PolicyService,
AuditService, AdminService) with hand-written Go stubs using JSON
codec until protobuf tooling is available.

Interceptor chain: logging (method, peer IP, duration, never logs
auth metadata) → auth (bearer token via MCIAS, Health bypasses) →
admin (role check for GC, policy, delete, audit RPCs).

All RPCs share business logic with REST handlers via internal/db
and internal/gc packages. TLS 1.3 minimum on gRPC listener.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 20:46:21 -07:00

31 KiB

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 Complete
1 Configuration & database Complete
2 Blob storage layer Complete
3 MCIAS authentication Complete
4 Policy engine Complete
5 OCI API — pull path Complete
6 OCI API — push path Complete
7 OCI API — delete path Complete
8 Admin REST API Complete
9 Garbage collection Complete
10 gRPC admin API Complete
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: vetlinttest → 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 <layers_path>/../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 <uploadsPath>/<uuid> 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 <layersPath>/sha256/<prefix>/
    • Renames temp file to <layersPath>/sha256/<prefix>/<hex-digest>
    • Returns the verified sha256:<hex> 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 <token> 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="<service_name>" 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":"<jwt>","expires_in":<seconds>,"issued_at":"<rfc3339>"}
  • 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/<name>/manifests/<reference>)

Acceptance criteria:

  • Policy check: registry:pull action on the target repository
  • If <reference> is a tag: look up tag → manifest in DB
  • If <reference> 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/<name>/blobs/<digest>)

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/<name>/tags/list)

Acceptance criteria:

  • Policy check: registry:pull action on the target repository
  • Returns {"name":"<repo>","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":"<repo>","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/<name>/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/<name>/blobs/uploads/<uuid> header
    • Docker-Upload-UUID: <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/<name>/blobs/uploads/<uuid>:
    • 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/<name>/blobs/uploads/<uuid>?digest=<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/<name>/blobs/<digest> header
      • Docker-Content-Digest header
    • Writes blob_uploaded audit event
  • GET /v2/<name>/blobs/uploads/<uuid>:
    • Returns 204 No Content with Range: 0-<offset> header
  • DELETE /v2/<name>/blobs/uploads/<uuid>:
    • 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/<name>/manifests/<reference>)

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/<name>/manifests/<digest>)

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/<name>/blobs/<digest>)

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":"<gc-run-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 <name> → calls DELETE /v1/repositories/<name>, 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)