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>
229 lines
6.8 KiB
Go
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)
|
|
}
|