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>
223 lines
6.3 KiB
Go
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)
|
|
}
|