internal/policy/: Priority-based policy engine per ARCHITECTURE.md §4. Stateless Evaluate() sorts rules by priority, collects all matches, deny-wins over allow, default-deny if no match. Rule matching: all populated fields ANDed, empty fields are wildcards, repository glob via path.Match. Built-in defaults: admin wildcard (all actions), human user content access (pull/push/delete/catalog), version check (always accessible). Engine wrapper with sync.RWMutex-protected cache, SetRules merges with defaults, Reload loads from RuleStore. internal/db/: LoadEnabledPolicyRules() parses rule_json column from policy_rules table into []policy.Rule, filtered by enabled=1, ordered by priority. internal/server/: RequirePolicy middleware extracts claims from context, repo from chi URL param, evaluates policy, returns OCI DENIED (403) on deny with optional audit callback. 69 tests passing across all packages. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
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 | 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.modinitialized with module pathgit.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/ .gitignoreexcludes: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 alltarget runs:vet→lint→test→ build binaries- Binary targets:
mcrsrv,mcr-web,mcrctlwith version injection via-X main.version=$(shell git describe --tags --always --dirty) CGO_ENABLED=0for all buildsmake allpasses (empty binaries link successfully)
Step 0.3: Linter and protobuf configuration
Acceptance criteria:
.golangci.yamlwith required linters per engineering standards: errcheck, govet, ineffassign, unused, errorlint, gosec, staticcheck, revive, gofmt, goimportserrcheck.check-type-assertions: truegovet: all analyzers exceptshadowgosec: excludeG101in test filesbuf.yamlfor proto lintinggolangci-lint run ./...passes cleanly
Step 0.4: Binary entry points with cobra
Acceptance criteria:
cmd/mcrsrv/main.go: root command withserver,init,snapshotsubcommands (stubs that print "not implemented" and exit 1)cmd/mcr-web/main.go: root command withserversubcommand (stub)cmd/mcrctl/main.go: root command withstatus,repo,gc,policy,audit,snapshotsubcommand groups (stubs)- All three binaries accept
--versionflag, printing the injected version make allbuilds 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_pathanduploads_pathvia device ID - Default values:
uploads_pathdefaults to<layers_path>/../uploads,read_timeout= 30s,write_timeout= 0,idle_timeout= 120s,shutdown_timeout= 60s,log.level= "info" mcr.toml.examplecreated indeploy/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_migrationstable, idempotent (CREATE TABLE IF NOT EXISTS) - Migration 000001:
repositories,manifests,tags,blobs,manifest_blobs,uploadstables perARCHITECTURE.md§8 - Migration 000002:
policy_rules,audit_logtables 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 intoaudit_logdb.ListAuditEvents(filters)with pagination (offset/limit), filtering by event_type, actor_id, repository, time rangedetailsparameter ismap[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)constructorstorage.StartUpload(uuid)creates a temp file at<uploadsPath>/<uuid>and returns a*BlobWriterBlobWriter.Write([]byte)appends data and updates a running SHA-256 hashBlobWriter.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 fileBlobWriter.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 anio.ReadCloserfor the blob file, orErrBlobNotFoundstorage.Stat(digest)returns size and existence check without openingstorage.Delete(digest)removes the blob file and its prefix directory if emptystorage.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)constructorclient.Login(username, password)calls MCIASPOST /v1/auth/loginwithservice_nameandtagsin the request body; returns JWT string and expiryclient.ValidateToken(token)calls MCIASPOST /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.Serverto 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)extractsAuthorization: Bearer <token>header, callsauthClient.ValidateToken, injects claims intocontext.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
Authorizationheader) - Accepts
scopeandservicequery 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 OKwith 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
Repositoriesglob matching viapath.Match; empty list = match all- When
input.Repositoryis empty (global ops), only rules with emptyRepositoriesmatch - 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 perARCHITECTURE.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:
Enginestruct wrapsEvaluatewith DB-backed rule loadingengine.SetRules(rules)caches rules in memory (merges with defaults)engine.Evaluate(input)calls statelessEvaluatewith cached rules- Thread-safe:
sync.RWMutexprotects the cached rule set engine.Reload(db)loads enabled rules frompolicy_rulestable and callsSetRules- 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; writespolicy_denyaudit 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.Handlerreceiver - Tests: error response format matches OCI spec
Step 5.2: Manifest pull (GET /v2/<name>/manifests/<reference>)
Acceptance criteria:
- Policy check:
registry:pullaction 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-Typeheader set to manifest'smedia_typeDocker-Content-Digestheader set to manifest's digestContent-Lengthheader set to manifest's size
HEADvariant returns same headers but no body- Repository not found →
NAME_UNKNOWN(404) - Manifest not found →
MANIFEST_UNKNOWN(404) - Writes
manifest_pulledaudit 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:pullaction on the target repository - Verify blob exists in
blobstable AND is referenced by a manifest in the target repository (viamanifest_blobs) - Open blob from storage, stream to response with:
Content-Type: application/octet-streamDocker-Content-DigestheaderContent-Lengthheader
HEADvariant 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:pullaction on the target repository - Returns
{"name":"<repo>","tags":["tag1","tag2",...]}sorted alphabetically - Pagination via
n(limit) andlast(cursor) query parameters per OCI spec - If more results:
Linkheader 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:catalogaction (no repository context) - Returns
{"repositories":["repo1","repo2",...]}sorted alphabetically - Pagination via
nandlastquery parameters - If more results:
Linkheader 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:pushaction on the target repository - Creates repository if it doesn't exist (implicit creation)
- Generates upload UUID (
crypto/rand) - Inserts row in
uploadstable - Creates temp file via
storage.StartUpload(uuid) - Returns
202 Acceptedwith:Location: /v2/<name>/blobs/uploads/<uuid>headerDocker-Upload-UUID: <uuid>headerRange: 0-0header
- 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_offsetinuploadstable Content-Rangeheader processed if present- Returns
202 Acceptedwith updatedRangeandLocationheaders
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
blobstable (or no-op if digest already exists) - Deletes row from
uploadstable - Returns
201 Createdwith:Location: /v2/<name>/blobs/<digest>headerDocker-Content-Digestheader
- Writes
blob_uploadedaudit event
GET /v2/<name>/blobs/uploads/<uuid>:- Returns
204 No ContentwithRange: 0-<offset>header
- Returns
DELETE /v2/<name>/blobs/uploads/<uuid>:- Cancels upload: deletes temp file, removes
uploadsrow - Returns
204 No Content
- Cancels upload: deletes temp file, removes
- 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:pushaction on the target repository - Implements the full manifest push flow per
ARCHITECTURE.md§5:- Parse manifest JSON; reject malformed →
MANIFEST_INVALID(400) - Compute SHA-256 digest of raw bytes
- If reference is a digest, verify match →
DIGEST_INVALID(400) - Parse layer and config descriptors from manifest
- Verify all referenced blobs exist →
MANIFEST_BLOB_UNKNOWN(400) - Single SQLite transaction:
a. Create repository if not exists
b. Insert/update manifest row
c. Populate
manifest_blobsjoin table d. If reference is a tag, insert/update tag row - Return
201 CreatedwithDocker-Content-DigestandLocationheaders
- Parse manifest JSON; reject malformed →
- Writes
manifest_pushedaudit 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:deleteaction on the target repository - Reference must be a digest (not a tag) →
UNSUPPORTED(405) if tag - Deletes manifest row; cascades to
manifest_blobsandtags(ON DELETE CASCADE) - Returns
202 Accepted - Writes
manifest_deletedaudit 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:deleteaction on the target repository - Verify blob exists and is referenced in this repository
- Removes the
manifest_blobsrows 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_deletedaudit 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), returns204 No ContentGET /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. Writesrepo_deletedaudit 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 bodyGET /v1/policy/rules/{id}: get single rulePATCH /v1/policy/rules/{id}: update priority, enabled, descriptionDELETE /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. Returns202 Acceptedwith{"id":"<gc-run-id>"}. Returns409 Conflictif 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_startedandgc_completedaudit 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)constructorgc.Run(ctx)executes the two-phase algorithm perARCHITECTURE.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 noblobsrow (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/gcand gRPCGarbageCollectcallgc.Runin a goroutine- GC status tracked in memory (running flag, last result)
mcrctl gctriggers via REST/gRPCmcrctl gc statusfetches statusmcrctl gc --reconcileruns 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 lintpassesmake protogenerates Go stubs ingen/mcr/v1/- Generated code committed
Step 10.2: gRPC server implementation (internal/grpcserver)
Acceptance criteria:
RegistryService:ListRepositories,GetRepository,DeleteRepository,GarbageCollect,GetGCStatusPolicyService: full CRUD for policy rulesAuditService:ListAuditEventsAdminService:Health- All RPCs call the same business logic as REST handlers (shared
internal/dbandinternal/gcpackages) - 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
authorizationmetadata, validates via MCIAS, injects claims.Healthbypasses 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.Unauthenticatedfor missing/invalid token,codes.PermissionDeniedfor 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_addrfrom config; disabled ifgrpc_addris 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_TOKENenv var - gRPC client with TLS, using same CA cert if provided
- REST client with TLS,
Authorization: Bearerheader - Connection errors produce clear messages
Step 11.2: Status and repository commands
Acceptance criteria:
mcrctl status→ callsGET /v1/health, prints statusmcrctl repo list→ callsGET /v1/repositories, prints tablemcrctl repo delete <name>→ callsDELETE /v1/repositories/<name>, confirms before deletion- Output: human-readable by default,
--jsonfor 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/gRPCmcrctl policy createaccepts--jsonflag for rule bodymcrctl audit tail [--n N]→ callsGET /v1/auditmcrctl gc→ callsPOST /v1/gcmcrctl gc status→ callsGET /v1/gc/statusmcrctl gc --reconcile→ calls reconciliation endpointmcrctl 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/templatewithweb/templates/layout.htmlbase 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:
/loginpage 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 10manifest_pushedaudit events)/repositorieslist: 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:
/policiespage: 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:
/auditpage: 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: Buildergolang:1.25-alpine, runtimealpine:3.21 CGO_ENABLED=0,-trimpath -ldflags="-s -w"- Builds all three binaries
- Runtime: non-root user
mcr(uid 10001) EXPOSE 8443 9443VOLUME /srv/mcrENTRYPOINT ["mcrsrv"],CMD ["server", "--config", "/srv/mcr/mcr.toml"]make dockerbuilds 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 withReadOnlyPaths=/srv/mcrdeploy/systemd/mcr-backup.service: oneshot backup unit runningmcrsrv snapshotdeploy/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 daemondeploy/examples/mcr.tomlwith annotated production defaultsdeploy/examples/mcr-dev.tomlwith local development defaults- Script tested: runs twice without error (idempotent)