Files
mcr/internal/oci/manifest.go
Kyle Isom ef39152f4e Add structured error logging to OCI handlers
Every 500 response in the OCI package silently discarded the actual
error, making production debugging impossible. Add slog.Error before
each 500 response with the error and relevant context (repo, digest,
tag, uuid). Add slog.Info for state-mutating successes (manifest push,
blob upload complete, deletions).

Logger is injected into the OCI Handler via constructor, falling back
to slog.Default() if nil.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 12:47:44 -07:00

229 lines
6.8 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
}
h.log.Error("manifest resolve: lookup repository", "error", err, "repo", repo)
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
}
h.log.Error("manifest resolve: lookup manifest", "error", err, "repo", repo, "reference", reference)
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 {
h.log.Error("manifest push: check blob exists", "error", err, "repo", repo, "blob_digest", bd)
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 {
h.log.Error("manifest push: write to database", "error", err, "repo", repo, "digest", computedDigest, "tag", tag)
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)
}
h.log.Info("manifest pushed", "repo", repo, "digest", computedDigest, "tag", tag, "size", len(body))
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)
}