Manifest delete (DELETE /v2/<name>/manifests/<digest>): rejects tag references with 405 UNSUPPORTED per OCI spec, cascades to tags and manifest_blobs via ON DELETE CASCADE, returns 202 Accepted. Blob delete (DELETE /v2/<name>/blobs/<digest>): removes manifest_blobs associations only — blob row and file are preserved for GC to handle, since other repos may reference the same content-addressed blob. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
176 lines
5.5 KiB
Go
176 lines
5.5 KiB
Go
package oci
|
|
|
|
import (
|
|
"net/http"
|
|
"strings"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
)
|
|
|
|
// ociPathInfo holds the parsed components of an OCI API path.
|
|
type ociPathInfo struct {
|
|
name string // repository name (may contain slashes)
|
|
kind string // "manifests", "blobs", or "tags"
|
|
reference string // tag, digest, or "list"
|
|
}
|
|
|
|
// parseOCIPath extracts the repository name and operation from an OCI path.
|
|
// The path should NOT include the /v2/ prefix.
|
|
// Examples:
|
|
//
|
|
// "myrepo/manifests/latest" -> {name:"myrepo", kind:"manifests", reference:"latest"}
|
|
// "org/team/app/blobs/sha256:abc" -> {name:"org/team/app", kind:"blobs", reference:"sha256:abc"}
|
|
// "myrepo/tags/list" -> {name:"myrepo", kind:"tags", reference:"list"}
|
|
// "myrepo/blobs/uploads/" -> {name:"myrepo", kind:"uploads", reference:""}
|
|
// "myrepo/blobs/uploads/uuid-here" -> {name:"myrepo", kind:"uploads", reference:"uuid-here"}
|
|
func parseOCIPath(path string) (ociPathInfo, bool) {
|
|
// Check for /tags/list suffix.
|
|
if strings.HasSuffix(path, "/tags/list") {
|
|
name := path[:len(path)-len("/tags/list")]
|
|
if name == "" {
|
|
return ociPathInfo{}, false
|
|
}
|
|
return ociPathInfo{name: name, kind: "tags", reference: "list"}, true
|
|
}
|
|
|
|
// Check for /blobs/uploads/ (must come before /blobs/).
|
|
if idx := strings.LastIndex(path, "/blobs/uploads/"); idx >= 0 {
|
|
name := path[:idx]
|
|
uuid := path[idx+len("/blobs/uploads/"):]
|
|
if name == "" {
|
|
return ociPathInfo{}, false
|
|
}
|
|
return ociPathInfo{name: name, kind: "uploads", reference: uuid}, true
|
|
}
|
|
|
|
// Check for /blobs/uploads (trailing-slash-trimmed form for POST initiate).
|
|
if strings.HasSuffix(path, "/blobs/uploads") {
|
|
name := path[:len(path)-len("/blobs/uploads")]
|
|
if name == "" {
|
|
return ociPathInfo{}, false
|
|
}
|
|
return ociPathInfo{name: name, kind: "uploads", reference: ""}, true
|
|
}
|
|
|
|
// Check for /manifests/<ref>.
|
|
if idx := strings.LastIndex(path, "/manifests/"); idx >= 0 {
|
|
name := path[:idx]
|
|
ref := path[idx+len("/manifests/"):]
|
|
if name == "" || ref == "" {
|
|
return ociPathInfo{}, false
|
|
}
|
|
return ociPathInfo{name: name, kind: "manifests", reference: ref}, true
|
|
}
|
|
|
|
// Check for /blobs/<digest>.
|
|
if idx := strings.LastIndex(path, "/blobs/"); idx >= 0 {
|
|
name := path[:idx]
|
|
ref := path[idx+len("/blobs/"):]
|
|
if name == "" || ref == "" {
|
|
return ociPathInfo{}, false
|
|
}
|
|
return ociPathInfo{name: name, kind: "blobs", reference: ref}, true
|
|
}
|
|
|
|
return ociPathInfo{}, false
|
|
}
|
|
|
|
// Router returns a chi router for OCI Distribution Spec endpoints.
|
|
// It should be mounted at /v2 on the parent router.
|
|
func (h *Handler) Router() chi.Router {
|
|
r := chi.NewRouter()
|
|
|
|
// Catalog endpoint: GET /v2/_catalog
|
|
r.Get("/_catalog", h.handleCatalog)
|
|
|
|
// All other OCI endpoints use a catch-all to support multi-segment repo names.
|
|
r.HandleFunc("/*", h.dispatch)
|
|
|
|
return r
|
|
}
|
|
|
|
// dispatch routes requests to the appropriate handler based on the parsed path.
|
|
func (h *Handler) dispatch(w http.ResponseWriter, r *http.Request) {
|
|
// Get the path after /v2/
|
|
path := chi.URLParam(r, "*")
|
|
if path == "" {
|
|
writeOCIError(w, "NAME_UNKNOWN", http.StatusNotFound, "repository name required")
|
|
return
|
|
}
|
|
|
|
info, ok := parseOCIPath(path)
|
|
if !ok {
|
|
writeOCIError(w, "NAME_UNKNOWN", http.StatusNotFound, "invalid OCI path")
|
|
return
|
|
}
|
|
|
|
switch info.kind {
|
|
case "manifests":
|
|
switch r.Method {
|
|
case http.MethodGet:
|
|
h.handleManifestGet(w, r, info.name, info.reference)
|
|
case http.MethodHead:
|
|
h.handleManifestHead(w, r, info.name, info.reference)
|
|
case http.MethodPut:
|
|
h.handleManifestPut(w, r, info.name, info.reference)
|
|
case http.MethodDelete:
|
|
h.handleManifestDelete(w, r, info.name, info.reference)
|
|
default:
|
|
w.Header().Set("Allow", "GET, HEAD, PUT, DELETE")
|
|
writeOCIError(w, "UNSUPPORTED", http.StatusMethodNotAllowed, "method not allowed")
|
|
}
|
|
case "blobs":
|
|
switch r.Method {
|
|
case http.MethodGet:
|
|
h.handleBlobGet(w, r, info.name, info.reference)
|
|
case http.MethodHead:
|
|
h.handleBlobHead(w, r, info.name, info.reference)
|
|
case http.MethodDelete:
|
|
h.handleBlobDelete(w, r, info.name, info.reference)
|
|
default:
|
|
w.Header().Set("Allow", "GET, HEAD, DELETE")
|
|
writeOCIError(w, "UNSUPPORTED", http.StatusMethodNotAllowed, "method not allowed")
|
|
}
|
|
case "uploads":
|
|
h.dispatchUpload(w, r, info.name, info.reference)
|
|
case "tags":
|
|
if r.Method != http.MethodGet {
|
|
w.Header().Set("Allow", "GET")
|
|
writeOCIError(w, "UNSUPPORTED", http.StatusMethodNotAllowed, "method not allowed")
|
|
return
|
|
}
|
|
h.handleTagsList(w, r, info.name)
|
|
default:
|
|
writeOCIError(w, "NAME_UNKNOWN", http.StatusNotFound, "unknown operation")
|
|
}
|
|
}
|
|
|
|
// dispatchUpload routes upload requests to the appropriate handler.
|
|
func (h *Handler) dispatchUpload(w http.ResponseWriter, r *http.Request, repo, uuid string) {
|
|
if uuid == "" {
|
|
// POST /v2/<name>/blobs/uploads/ — initiate
|
|
if r.Method != http.MethodPost {
|
|
w.Header().Set("Allow", "POST")
|
|
writeOCIError(w, "UNSUPPORTED", http.StatusMethodNotAllowed, "method not allowed")
|
|
return
|
|
}
|
|
h.handleUploadInitiate(w, r, repo)
|
|
return
|
|
}
|
|
|
|
// Operations on existing upload UUID.
|
|
switch r.Method {
|
|
case http.MethodPatch:
|
|
h.handleUploadChunk(w, r, repo, uuid)
|
|
case http.MethodPut:
|
|
h.handleUploadComplete(w, r, repo, uuid)
|
|
case http.MethodGet:
|
|
h.handleUploadStatus(w, r, repo, uuid)
|
|
case http.MethodDelete:
|
|
h.handleUploadCancel(w, r, repo, uuid)
|
|
default:
|
|
w.Header().Set("Allow", "PATCH, PUT, GET, DELETE")
|
|
writeOCIError(w, "UNSUPPORTED", http.StatusMethodNotAllowed, "method not allowed")
|
|
}
|
|
}
|