Files
mcr/internal/oci/manifest.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

223 lines
6.3 KiB
Go

package oci
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strconv"
"git.wntrmute.dev/kyle/mcr/internal/auth"
"git.wntrmute.dev/kyle/mcr/internal/db"
"git.wntrmute.dev/kyle/mcr/internal/policy"
)
func (h *Handler) handleManifestGet(w http.ResponseWriter, r *http.Request, repo, reference string) {
if !h.checkPolicy(w, r, policy.ActionPull, repo) {
return
}
m, ok := h.resolveManifest(w, repo, reference)
if !ok {
return
}
h.audit(r, "manifest_pulled", repo, m.Digest)
w.Header().Set("Content-Type", m.MediaType)
w.Header().Set("Docker-Content-Digest", m.Digest)
w.Header().Set("Content-Length", strconv.FormatInt(m.Size, 10))
w.WriteHeader(http.StatusOK)
_, _ = w.Write(m.Content)
}
func (h *Handler) handleManifestHead(w http.ResponseWriter, r *http.Request, repo, reference string) {
if !h.checkPolicy(w, r, policy.ActionPull, repo) {
return
}
m, ok := h.resolveManifest(w, repo, reference)
if !ok {
return
}
w.Header().Set("Content-Type", m.MediaType)
w.Header().Set("Docker-Content-Digest", m.Digest)
w.Header().Set("Content-Length", strconv.FormatInt(m.Size, 10))
w.WriteHeader(http.StatusOK)
}
// resolveManifest looks up a manifest by tag or digest, writing OCI errors on failure.
func (h *Handler) resolveManifest(w http.ResponseWriter, repo, reference string) (*db.ManifestRow, bool) {
repoID, err := h.db.GetRepositoryByName(repo)
if err != nil {
if errors.Is(err, db.ErrRepoNotFound) {
writeOCIError(w, "NAME_UNKNOWN", http.StatusNotFound,
fmt.Sprintf("repository %q not found", repo))
return nil, false
}
writeOCIError(w, "UNKNOWN", http.StatusInternalServerError, "internal error")
return nil, false
}
var m *db.ManifestRow
if isDigest(reference) {
m, err = h.db.GetManifestByDigest(repoID, reference)
} else {
m, err = h.db.GetManifestByTag(repoID, reference)
}
if err != nil {
if errors.Is(err, db.ErrManifestNotFound) {
writeOCIError(w, "MANIFEST_UNKNOWN", http.StatusNotFound,
fmt.Sprintf("manifest %q not found", reference))
return nil, false
}
writeOCIError(w, "UNKNOWN", http.StatusInternalServerError, "internal error")
return nil, false
}
return m, true
}
// ociManifest is the minimal structure for parsing an OCI image manifest.
type ociManifest struct {
SchemaVersion int `json:"schemaVersion"`
MediaType string `json:"mediaType,omitempty"`
Config ociDescriptor `json:"config"`
Layers []ociDescriptor `json:"layers"`
}
// ociDescriptor is a content-addressed reference within a manifest.
type ociDescriptor struct {
MediaType string `json:"mediaType"`
Digest string `json:"digest"`
Size int64 `json:"size"`
}
// supportedManifestMediaTypes lists the manifest media types MCR accepts.
var supportedManifestMediaTypes = map[string]bool{
"application/vnd.oci.image.manifest.v1+json": true,
"application/vnd.docker.distribution.manifest.v2+json": true,
}
// handleManifestPut handles PUT /v2/<name>/manifests/<reference>
func (h *Handler) handleManifestPut(w http.ResponseWriter, r *http.Request, repo, reference string) {
if !h.checkPolicy(w, r, policy.ActionPush, repo) {
return
}
// Step 1: Read and parse manifest JSON.
body, err := io.ReadAll(r.Body)
if err != nil {
writeOCIError(w, "MANIFEST_INVALID", http.StatusBadRequest, "failed to read request body")
return
}
if len(body) == 0 {
writeOCIError(w, "MANIFEST_INVALID", http.StatusBadRequest, "empty manifest")
return
}
var manifest ociManifest
if err := json.Unmarshal(body, &manifest); err != nil {
writeOCIError(w, "MANIFEST_INVALID", http.StatusBadRequest, "malformed manifest JSON")
return
}
if manifest.SchemaVersion != 2 {
writeOCIError(w, "MANIFEST_INVALID", http.StatusBadRequest, "unsupported schema version")
return
}
// Determine media type from Content-Type header, falling back to manifest body.
mediaType := r.Header.Get("Content-Type")
if mediaType == "" {
mediaType = manifest.MediaType
}
if mediaType == "" {
mediaType = "application/vnd.oci.image.manifest.v1+json"
}
if !supportedManifestMediaTypes[mediaType] {
writeOCIError(w, "MANIFEST_INVALID", http.StatusBadRequest,
fmt.Sprintf("unsupported media type: %s", mediaType))
return
}
// Step 2: Compute SHA-256 digest.
sum := sha256.Sum256(body)
computedDigest := "sha256:" + hex.EncodeToString(sum[:])
// Step 3: If reference is a digest, verify it matches.
tag := ""
if isDigest(reference) {
if reference != computedDigest {
writeOCIError(w, "DIGEST_INVALID", http.StatusBadRequest,
fmt.Sprintf("digest mismatch: computed %s, got %s", computedDigest, reference))
return
}
} else {
tag = reference
}
// Step 4: Collect all referenced blob digests.
var blobDigests []string
if manifest.Config.Digest != "" {
blobDigests = append(blobDigests, manifest.Config.Digest)
}
for _, layer := range manifest.Layers {
if layer.Digest != "" {
blobDigests = append(blobDigests, layer.Digest)
}
}
// Step 5: Verify all referenced blobs exist.
for _, bd := range blobDigests {
exists, err := h.db.BlobExists(bd)
if err != nil {
writeOCIError(w, "UNKNOWN", http.StatusInternalServerError, "internal error")
return
}
if !exists {
writeOCIError(w, "MANIFEST_BLOB_UNKNOWN", http.StatusBadRequest,
fmt.Sprintf("blob %s not found", bd))
return
}
}
// Step 6: Single transaction — create repo, insert manifest, populate
// manifest_blobs, upsert tag.
params := db.PushManifestParams{
RepoName: repo,
Digest: computedDigest,
MediaType: mediaType,
Content: body,
Size: int64(len(body)),
Tag: tag,
BlobDigests: blobDigests,
}
if err := h.db.PushManifest(params); err != nil {
writeOCIError(w, "UNKNOWN", http.StatusInternalServerError, "internal error")
return
}
// Step 7: Audit and respond.
details := map[string]string{}
if tag != "" {
details["tag"] = tag
}
if h.auditFn != nil {
claims := auth.ClaimsFromContext(r.Context())
actorID := ""
if claims != nil {
actorID = claims.Subject
}
h.auditFn("manifest_pushed", actorID, repo, computedDigest, r.RemoteAddr, details)
}
w.Header().Set("Location", fmt.Sprintf("/v2/%s/manifests/%s", repo, computedDigest))
w.Header().Set("Docker-Content-Digest", computedDigest)
w.Header().Set("Content-Type", mediaType)
w.WriteHeader(http.StatusCreated)
}