Files
mcr/internal/oci/routes.go
Kyle Isom dddc66f31b Phases 5, 6, 8: OCI pull/push paths and admin REST API
Phase 5 (OCI pull): internal/oci/ package with manifest GET/HEAD by
tag/digest, blob GET/HEAD with repo membership check, tag listing with
OCI pagination, catalog listing. Multi-segment repo names via
parseOCIPath() right-split routing. DB query layer in
internal/db/repository.go.

Phase 6 (OCI push): blob uploads (monolithic and chunked) with
uploadManager tracking in-progress BlobWriters, manifest push
implementing full ARCHITECTURE.md §5 flow in a single SQLite
transaction (create repo, upsert manifest, populate manifest_blobs,
atomic tag move). Digest verification on both blob commit and manifest
push-by-digest.

Phase 8 (admin REST): /v1 endpoints for auth (login/logout/health),
repository management (list/detail/delete), policy CRUD with engine
reload, audit log listing with filters, GC trigger/status stubs.
RequireAdmin middleware, platform-standard error format.

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

172 lines
5.3 KiB
Go

package oci
import (
"net/http"
"strings"
"github.com/go-chi/chi/v5"
)
// ociPathInfo holds the parsed components of an OCI API path.
type ociPathInfo struct {
name string // repository name (may contain slashes)
kind string // "manifests", "blobs", or "tags"
reference string // tag, digest, or "list"
}
// parseOCIPath extracts the repository name and operation from an OCI path.
// The path should NOT include the /v2/ prefix.
// Examples:
//
// "myrepo/manifests/latest" -> {name:"myrepo", kind:"manifests", reference:"latest"}
// "org/team/app/blobs/sha256:abc" -> {name:"org/team/app", kind:"blobs", reference:"sha256:abc"}
// "myrepo/tags/list" -> {name:"myrepo", kind:"tags", reference:"list"}
// "myrepo/blobs/uploads/" -> {name:"myrepo", kind:"uploads", reference:""}
// "myrepo/blobs/uploads/uuid-here" -> {name:"myrepo", kind:"uploads", reference:"uuid-here"}
func parseOCIPath(path string) (ociPathInfo, bool) {
// Check for /tags/list suffix.
if strings.HasSuffix(path, "/tags/list") {
name := path[:len(path)-len("/tags/list")]
if name == "" {
return ociPathInfo{}, false
}
return ociPathInfo{name: name, kind: "tags", reference: "list"}, true
}
// Check for /blobs/uploads/ (must come before /blobs/).
if idx := strings.LastIndex(path, "/blobs/uploads/"); idx >= 0 {
name := path[:idx]
uuid := path[idx+len("/blobs/uploads/"):]
if name == "" {
return ociPathInfo{}, false
}
return ociPathInfo{name: name, kind: "uploads", reference: uuid}, true
}
// Check for /blobs/uploads (trailing-slash-trimmed form for POST initiate).
if strings.HasSuffix(path, "/blobs/uploads") {
name := path[:len(path)-len("/blobs/uploads")]
if name == "" {
return ociPathInfo{}, false
}
return ociPathInfo{name: name, kind: "uploads", reference: ""}, true
}
// Check for /manifests/<ref>.
if idx := strings.LastIndex(path, "/manifests/"); idx >= 0 {
name := path[:idx]
ref := path[idx+len("/manifests/"):]
if name == "" || ref == "" {
return ociPathInfo{}, false
}
return ociPathInfo{name: name, kind: "manifests", reference: ref}, true
}
// Check for /blobs/<digest>.
if idx := strings.LastIndex(path, "/blobs/"); idx >= 0 {
name := path[:idx]
ref := path[idx+len("/blobs/"):]
if name == "" || ref == "" {
return ociPathInfo{}, false
}
return ociPathInfo{name: name, kind: "blobs", reference: ref}, true
}
return ociPathInfo{}, false
}
// Router returns a chi router for OCI Distribution Spec endpoints.
// It should be mounted at /v2 on the parent router.
func (h *Handler) Router() chi.Router {
r := chi.NewRouter()
// Catalog endpoint: GET /v2/_catalog
r.Get("/_catalog", h.handleCatalog)
// All other OCI endpoints use a catch-all to support multi-segment repo names.
r.HandleFunc("/*", h.dispatch)
return r
}
// dispatch routes requests to the appropriate handler based on the parsed path.
func (h *Handler) dispatch(w http.ResponseWriter, r *http.Request) {
// Get the path after /v2/
path := chi.URLParam(r, "*")
if path == "" {
writeOCIError(w, "NAME_UNKNOWN", http.StatusNotFound, "repository name required")
return
}
info, ok := parseOCIPath(path)
if !ok {
writeOCIError(w, "NAME_UNKNOWN", http.StatusNotFound, "invalid OCI path")
return
}
switch info.kind {
case "manifests":
switch r.Method {
case http.MethodGet:
h.handleManifestGet(w, r, info.name, info.reference)
case http.MethodHead:
h.handleManifestHead(w, r, info.name, info.reference)
case http.MethodPut:
h.handleManifestPut(w, r, info.name, info.reference)
default:
w.Header().Set("Allow", "GET, HEAD, PUT")
writeOCIError(w, "UNSUPPORTED", http.StatusMethodNotAllowed, "method not allowed")
}
case "blobs":
switch r.Method {
case http.MethodGet:
h.handleBlobGet(w, r, info.name, info.reference)
case http.MethodHead:
h.handleBlobHead(w, r, info.name, info.reference)
default:
w.Header().Set("Allow", "GET, HEAD")
writeOCIError(w, "UNSUPPORTED", http.StatusMethodNotAllowed, "method not allowed")
}
case "uploads":
h.dispatchUpload(w, r, info.name, info.reference)
case "tags":
if r.Method != http.MethodGet {
w.Header().Set("Allow", "GET")
writeOCIError(w, "UNSUPPORTED", http.StatusMethodNotAllowed, "method not allowed")
return
}
h.handleTagsList(w, r, info.name)
default:
writeOCIError(w, "NAME_UNKNOWN", http.StatusNotFound, "unknown operation")
}
}
// dispatchUpload routes upload requests to the appropriate handler.
func (h *Handler) dispatchUpload(w http.ResponseWriter, r *http.Request, repo, uuid string) {
if uuid == "" {
// POST /v2/<name>/blobs/uploads/ — initiate
if r.Method != http.MethodPost {
w.Header().Set("Allow", "POST")
writeOCIError(w, "UNSUPPORTED", http.StatusMethodNotAllowed, "method not allowed")
return
}
h.handleUploadInitiate(w, r, repo)
return
}
// Operations on existing upload UUID.
switch r.Method {
case http.MethodPatch:
h.handleUploadChunk(w, r, repo, uuid)
case http.MethodPut:
h.handleUploadComplete(w, r, repo, uuid)
case http.MethodGet:
h.handleUploadStatus(w, r, repo, uuid)
case http.MethodDelete:
h.handleUploadCancel(w, r, repo, uuid)
default:
w.Header().Set("Allow", "PATCH, PUT, GET, DELETE")
writeOCIError(w, "UNSUPPORTED", http.StatusMethodNotAllowed, "method not allowed")
}
}