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>
This commit is contained in:
222
internal/oci/manifest.go
Normal file
222
internal/oci/manifest.go
Normal file
@@ -0,0 +1,222 @@
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user