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:
98
internal/oci/blob.go
Normal file
98
internal/oci/blob.go
Normal file
@@ -0,0 +1,98 @@
|
||||
package oci
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"git.wntrmute.dev/kyle/mcr/internal/db"
|
||||
"git.wntrmute.dev/kyle/mcr/internal/policy"
|
||||
)
|
||||
|
||||
func (h *Handler) handleBlobGet(w http.ResponseWriter, r *http.Request, repo, digest string) {
|
||||
if !h.checkPolicy(w, r, policy.ActionPull, repo) {
|
||||
return
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
writeOCIError(w, "UNKNOWN", http.StatusInternalServerError, "internal error")
|
||||
return
|
||||
}
|
||||
|
||||
exists, err := h.db.BlobExistsInRepo(repoID, digest)
|
||||
if err != nil {
|
||||
writeOCIError(w, "UNKNOWN", http.StatusInternalServerError, "internal error")
|
||||
return
|
||||
}
|
||||
if !exists {
|
||||
writeOCIError(w, "BLOB_UNKNOWN", http.StatusNotFound,
|
||||
fmt.Sprintf("blob %q not found in repository", digest))
|
||||
return
|
||||
}
|
||||
|
||||
size, err := h.blobs.Stat(digest)
|
||||
if err != nil {
|
||||
writeOCIError(w, "BLOB_UNKNOWN", http.StatusNotFound, "blob not found in storage")
|
||||
return
|
||||
}
|
||||
|
||||
rc, err := h.blobs.Open(digest)
|
||||
if err != nil {
|
||||
writeOCIError(w, "UNKNOWN", http.StatusInternalServerError, "internal error")
|
||||
return
|
||||
}
|
||||
defer func() { _ = rc.Close() }()
|
||||
|
||||
w.Header().Set("Content-Type", "application/octet-stream")
|
||||
w.Header().Set("Docker-Content-Digest", digest)
|
||||
w.Header().Set("Content-Length", strconv.FormatInt(size, 10))
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = io.Copy(w, rc)
|
||||
}
|
||||
|
||||
func (h *Handler) handleBlobHead(w http.ResponseWriter, r *http.Request, repo, digest string) {
|
||||
if !h.checkPolicy(w, r, policy.ActionPull, repo) {
|
||||
return
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
writeOCIError(w, "UNKNOWN", http.StatusInternalServerError, "internal error")
|
||||
return
|
||||
}
|
||||
|
||||
exists, err := h.db.BlobExistsInRepo(repoID, digest)
|
||||
if err != nil {
|
||||
writeOCIError(w, "UNKNOWN", http.StatusInternalServerError, "internal error")
|
||||
return
|
||||
}
|
||||
if !exists {
|
||||
writeOCIError(w, "BLOB_UNKNOWN", http.StatusNotFound,
|
||||
fmt.Sprintf("blob %q not found in repository", digest))
|
||||
return
|
||||
}
|
||||
|
||||
size, err := h.blobs.Stat(digest)
|
||||
if err != nil {
|
||||
writeOCIError(w, "BLOB_UNKNOWN", http.StatusNotFound, "blob not found in storage")
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/octet-stream")
|
||||
w.Header().Set("Docker-Content-Digest", digest)
|
||||
w.Header().Set("Content-Length", strconv.FormatInt(size, 10))
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
144
internal/oci/blob_test.go
Normal file
144
internal/oci/blob_test.go
Normal file
@@ -0,0 +1,144 @@
|
||||
package oci
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestBlobGet(t *testing.T) {
|
||||
fdb := newFakeDB()
|
||||
fdb.addRepo("myrepo", 1)
|
||||
fdb.addBlob(1, "sha256:layerdigest")
|
||||
|
||||
blobs := newFakeBlobs()
|
||||
blobs.data["sha256:layerdigest"] = []byte("layer-content-bytes")
|
||||
|
||||
h := NewHandler(fdb, blobs, allowAll(), nil)
|
||||
router := testRouter(h)
|
||||
|
||||
req := authedRequest("GET", "/v2/myrepo/blobs/sha256:layerdigest", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
router.ServeHTTP(rr, req)
|
||||
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("status: got %d, want %d", rr.Code, http.StatusOK)
|
||||
}
|
||||
if ct := rr.Header().Get("Content-Type"); ct != "application/octet-stream" {
|
||||
t.Fatalf("Content-Type: got %q", ct)
|
||||
}
|
||||
if dcd := rr.Header().Get("Docker-Content-Digest"); dcd != "sha256:layerdigest" {
|
||||
t.Fatalf("Docker-Content-Digest: got %q", dcd)
|
||||
}
|
||||
if cl := rr.Header().Get("Content-Length"); cl != "19" {
|
||||
t.Fatalf("Content-Length: got %q, want %q", cl, "19")
|
||||
}
|
||||
if rr.Body.String() != "layer-content-bytes" {
|
||||
t.Fatalf("body: got %q", rr.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestBlobHead(t *testing.T) {
|
||||
fdb := newFakeDB()
|
||||
fdb.addRepo("myrepo", 1)
|
||||
fdb.addBlob(1, "sha256:layerdigest")
|
||||
|
||||
blobs := newFakeBlobs()
|
||||
blobs.data["sha256:layerdigest"] = []byte("layer-content-bytes")
|
||||
|
||||
h := NewHandler(fdb, blobs, allowAll(), nil)
|
||||
router := testRouter(h)
|
||||
|
||||
req := authedRequest("HEAD", "/v2/myrepo/blobs/sha256:layerdigest", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
router.ServeHTTP(rr, req)
|
||||
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("status: got %d, want %d", rr.Code, http.StatusOK)
|
||||
}
|
||||
if ct := rr.Header().Get("Content-Type"); ct != "application/octet-stream" {
|
||||
t.Fatalf("Content-Type: got %q", ct)
|
||||
}
|
||||
if dcd := rr.Header().Get("Docker-Content-Digest"); dcd != "sha256:layerdigest" {
|
||||
t.Fatalf("Docker-Content-Digest: got %q", dcd)
|
||||
}
|
||||
if cl := rr.Header().Get("Content-Length"); cl != "19" {
|
||||
t.Fatalf("Content-Length: got %q, want %q", cl, "19")
|
||||
}
|
||||
if rr.Body.Len() != 0 {
|
||||
t.Fatalf("HEAD body should be empty, got %d bytes", rr.Body.Len())
|
||||
}
|
||||
}
|
||||
|
||||
func TestBlobGetNotInRepo(t *testing.T) {
|
||||
fdb := newFakeDB()
|
||||
fdb.addRepo("myrepo", 1)
|
||||
// Blob NOT added to the repo.
|
||||
|
||||
blobs := newFakeBlobs()
|
||||
blobs.data["sha256:orphan"] = []byte("data")
|
||||
|
||||
h := NewHandler(fdb, blobs, allowAll(), nil)
|
||||
router := testRouter(h)
|
||||
|
||||
req := authedRequest("GET", "/v2/myrepo/blobs/sha256:orphan", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
router.ServeHTTP(rr, req)
|
||||
|
||||
if rr.Code != http.StatusNotFound {
|
||||
t.Fatalf("status: got %d, want %d", rr.Code, http.StatusNotFound)
|
||||
}
|
||||
|
||||
var body ociErrorResponse
|
||||
if err := json.NewDecoder(rr.Body).Decode(&body); err != nil {
|
||||
t.Fatalf("decode error body: %v", err)
|
||||
}
|
||||
if len(body.Errors) != 1 || body.Errors[0].Code != "BLOB_UNKNOWN" {
|
||||
t.Fatalf("error code: got %+v, want BLOB_UNKNOWN", body.Errors)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBlobGetRepoNotFound(t *testing.T) {
|
||||
fdb := newFakeDB()
|
||||
// No repos.
|
||||
|
||||
h := NewHandler(fdb, newFakeBlobs(), allowAll(), nil)
|
||||
router := testRouter(h)
|
||||
|
||||
req := authedRequest("GET", "/v2/nosuchrepo/blobs/sha256:abc", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
router.ServeHTTP(rr, req)
|
||||
|
||||
if rr.Code != http.StatusNotFound {
|
||||
t.Fatalf("status: got %d, want %d", rr.Code, http.StatusNotFound)
|
||||
}
|
||||
|
||||
var body ociErrorResponse
|
||||
if err := json.NewDecoder(rr.Body).Decode(&body); err != nil {
|
||||
t.Fatalf("decode error body: %v", err)
|
||||
}
|
||||
if len(body.Errors) != 1 || body.Errors[0].Code != "NAME_UNKNOWN" {
|
||||
t.Fatalf("error code: got %+v, want NAME_UNKNOWN", body.Errors)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBlobGetMultiSegmentRepo(t *testing.T) {
|
||||
fdb := newFakeDB()
|
||||
fdb.addRepo("org/team/app", 1)
|
||||
fdb.addBlob(1, "sha256:layerdigest")
|
||||
|
||||
blobs := newFakeBlobs()
|
||||
blobs.data["sha256:layerdigest"] = []byte("data")
|
||||
|
||||
h := NewHandler(fdb, blobs, allowAll(), nil)
|
||||
router := testRouter(h)
|
||||
|
||||
req := authedRequest("GET", "/v2/org/team/app/blobs/sha256:layerdigest", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
router.ServeHTTP(rr, req)
|
||||
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("status: got %d, want %d", rr.Code, http.StatusOK)
|
||||
}
|
||||
}
|
||||
68
internal/oci/catalog.go
Normal file
68
internal/oci/catalog.go
Normal file
@@ -0,0 +1,68 @@
|
||||
package oci
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"git.wntrmute.dev/kyle/mcr/internal/policy"
|
||||
)
|
||||
|
||||
type catalogResponse struct {
|
||||
Repositories []string `json:"repositories"`
|
||||
}
|
||||
|
||||
func (h *Handler) handleCatalog(w http.ResponseWriter, r *http.Request) {
|
||||
if !h.checkPolicy(w, r, policy.ActionCatalog, "") {
|
||||
return
|
||||
}
|
||||
|
||||
n := 0
|
||||
var err error
|
||||
if nStr := r.URL.Query().Get("n"); nStr != "" {
|
||||
n, err = strconv.Atoi(nStr)
|
||||
if err != nil || n < 0 {
|
||||
writeOCIError(w, "INVALID_PARAMETER", http.StatusBadRequest, "invalid 'n' parameter")
|
||||
return
|
||||
}
|
||||
}
|
||||
last := r.URL.Query().Get("last")
|
||||
|
||||
if n == 0 {
|
||||
repos, err := h.db.ListRepositoryNames(last, 10000)
|
||||
if err != nil {
|
||||
writeOCIError(w, "UNKNOWN", http.StatusInternalServerError, "internal error")
|
||||
return
|
||||
}
|
||||
if repos == nil {
|
||||
repos = []string{}
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(catalogResponse{Repositories: repos})
|
||||
return
|
||||
}
|
||||
|
||||
repos, err := h.db.ListRepositoryNames(last, n+1)
|
||||
if err != nil {
|
||||
writeOCIError(w, "UNKNOWN", http.StatusInternalServerError, "internal error")
|
||||
return
|
||||
}
|
||||
if repos == nil {
|
||||
repos = []string{}
|
||||
}
|
||||
|
||||
hasMore := len(repos) > n
|
||||
if hasMore {
|
||||
repos = repos[:n]
|
||||
}
|
||||
|
||||
if hasMore {
|
||||
lastRepo := repos[len(repos)-1]
|
||||
linkURL := fmt.Sprintf("/v2/_catalog?n=%d&last=%s", n, lastRepo)
|
||||
w.Header().Set("Link", fmt.Sprintf(`<%s>; rel="next"`, linkURL))
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(catalogResponse{Repositories: repos})
|
||||
}
|
||||
124
internal/oci/catalog_test.go
Normal file
124
internal/oci/catalog_test.go
Normal file
@@ -0,0 +1,124 @@
|
||||
package oci
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCatalog(t *testing.T) {
|
||||
fdb := newFakeDB()
|
||||
fdb.addRepo("alpha/lib", 1)
|
||||
fdb.addRepo("bravo/svc", 2)
|
||||
fdb.addRepo("charlie/app", 3)
|
||||
|
||||
h := NewHandler(fdb, newFakeBlobs(), allowAll(), nil)
|
||||
router := testRouter(h)
|
||||
|
||||
req := authedRequest("GET", "/v2/_catalog", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
router.ServeHTTP(rr, req)
|
||||
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("status: got %d, want %d", rr.Code, http.StatusOK)
|
||||
}
|
||||
|
||||
var body catalogResponse
|
||||
if err := json.NewDecoder(rr.Body).Decode(&body); err != nil {
|
||||
t.Fatalf("decode body: %v", err)
|
||||
}
|
||||
|
||||
want := []string{"alpha/lib", "bravo/svc", "charlie/app"}
|
||||
if len(body.Repositories) != len(want) {
|
||||
t.Fatalf("repos count: got %d, want %d", len(body.Repositories), len(want))
|
||||
}
|
||||
for i, r := range body.Repositories {
|
||||
if r != want[i] {
|
||||
t.Fatalf("repos[%d]: got %q, want %q", i, r, want[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCatalogPagination(t *testing.T) {
|
||||
fdb := newFakeDB()
|
||||
fdb.addRepo("alpha/lib", 1)
|
||||
fdb.addRepo("bravo/svc", 2)
|
||||
fdb.addRepo("charlie/app", 3)
|
||||
|
||||
h := NewHandler(fdb, newFakeBlobs(), allowAll(), nil)
|
||||
router := testRouter(h)
|
||||
|
||||
// First page: n=2.
|
||||
req := authedRequest("GET", "/v2/_catalog?n=2", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
router.ServeHTTP(rr, req)
|
||||
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("status: got %d, want %d", rr.Code, http.StatusOK)
|
||||
}
|
||||
|
||||
var body catalogResponse
|
||||
if err := json.NewDecoder(rr.Body).Decode(&body); err != nil {
|
||||
t.Fatalf("decode body: %v", err)
|
||||
}
|
||||
if len(body.Repositories) != 2 {
|
||||
t.Fatalf("page 1 count: got %d, want 2", len(body.Repositories))
|
||||
}
|
||||
if body.Repositories[0] != "alpha/lib" || body.Repositories[1] != "bravo/svc" {
|
||||
t.Fatalf("page 1: got %v", body.Repositories)
|
||||
}
|
||||
|
||||
// Check Link header.
|
||||
link := rr.Header().Get("Link")
|
||||
if link == "" {
|
||||
t.Fatal("expected Link header for pagination")
|
||||
}
|
||||
|
||||
// Second page: n=2, last=bravo/svc.
|
||||
req = authedRequest("GET", "/v2/_catalog?n=2&last=bravo/svc", nil)
|
||||
rr = httptest.NewRecorder()
|
||||
router.ServeHTTP(rr, req)
|
||||
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("page 2 status: got %d, want %d", rr.Code, http.StatusOK)
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(rr.Body).Decode(&body); err != nil {
|
||||
t.Fatalf("decode page 2: %v", err)
|
||||
}
|
||||
if len(body.Repositories) != 1 {
|
||||
t.Fatalf("page 2 count: got %d, want 1", len(body.Repositories))
|
||||
}
|
||||
if body.Repositories[0] != "charlie/app" {
|
||||
t.Fatalf("page 2: got %v", body.Repositories)
|
||||
}
|
||||
|
||||
// No Link header on last page.
|
||||
if link := rr.Header().Get("Link"); link != "" {
|
||||
t.Fatalf("expected no Link header on last page, got %q", link)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCatalogEmpty(t *testing.T) {
|
||||
fdb := newFakeDB()
|
||||
|
||||
h := NewHandler(fdb, newFakeBlobs(), allowAll(), nil)
|
||||
router := testRouter(h)
|
||||
|
||||
req := authedRequest("GET", "/v2/_catalog", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
router.ServeHTTP(rr, req)
|
||||
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("status: got %d, want %d", rr.Code, http.StatusOK)
|
||||
}
|
||||
|
||||
var body catalogResponse
|
||||
if err := json.NewDecoder(rr.Body).Decode(&body); err != nil {
|
||||
t.Fatalf("decode body: %v", err)
|
||||
}
|
||||
if len(body.Repositories) != 0 {
|
||||
t.Fatalf("expected empty repositories, got %v", body.Repositories)
|
||||
}
|
||||
}
|
||||
119
internal/oci/handler.go
Normal file
119
internal/oci/handler.go
Normal file
@@ -0,0 +1,119 @@
|
||||
package oci
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"git.wntrmute.dev/kyle/mcr/internal/auth"
|
||||
"git.wntrmute.dev/kyle/mcr/internal/db"
|
||||
"git.wntrmute.dev/kyle/mcr/internal/policy"
|
||||
"git.wntrmute.dev/kyle/mcr/internal/storage"
|
||||
)
|
||||
|
||||
// DBQuerier provides the database operations needed by OCI handlers.
|
||||
type DBQuerier interface {
|
||||
GetRepositoryByName(name string) (int64, error)
|
||||
GetManifestByTag(repoID int64, tag string) (*db.ManifestRow, error)
|
||||
GetManifestByDigest(repoID int64, digest string) (*db.ManifestRow, error)
|
||||
BlobExistsInRepo(repoID int64, digest string) (bool, error)
|
||||
ListTags(repoID int64, after string, limit int) ([]string, error)
|
||||
ListRepositoryNames(after string, limit int) ([]string, error)
|
||||
|
||||
// Push operations
|
||||
GetOrCreateRepository(name string) (int64, error)
|
||||
BlobExists(digest string) (bool, error)
|
||||
InsertBlob(digest string, size int64) error
|
||||
PushManifest(p db.PushManifestParams) error
|
||||
|
||||
// Upload operations
|
||||
CreateUpload(uuid string, repoID int64) error
|
||||
GetUpload(uuid string) (*db.UploadRow, error)
|
||||
UpdateUploadOffset(uuid string, offset int64) error
|
||||
DeleteUpload(uuid string) error
|
||||
}
|
||||
|
||||
// BlobStore provides read and write access to blob storage.
|
||||
type BlobStore interface {
|
||||
Open(digest string) (io.ReadCloser, error)
|
||||
Stat(digest string) (int64, error)
|
||||
StartUpload(uuid string) (*storage.BlobWriter, error)
|
||||
}
|
||||
|
||||
// PolicyEval evaluates access control policies.
|
||||
type PolicyEval interface {
|
||||
Evaluate(input policy.PolicyInput) (policy.Effect, *policy.Rule)
|
||||
}
|
||||
|
||||
// AuditFunc records audit events. Follows the same signature pattern as
|
||||
// db.WriteAuditEvent but without an error return — audit failures should
|
||||
// not block request processing.
|
||||
type AuditFunc func(eventType, actorID, repository, digest, ip string, details map[string]string)
|
||||
|
||||
// Handler serves OCI Distribution Spec endpoints.
|
||||
type Handler struct {
|
||||
db DBQuerier
|
||||
blobs BlobStore
|
||||
policy PolicyEval
|
||||
auditFn AuditFunc
|
||||
uploads *uploadManager
|
||||
}
|
||||
|
||||
// NewHandler creates a new OCI handler.
|
||||
func NewHandler(querier DBQuerier, blobs BlobStore, pol PolicyEval, auditFn AuditFunc) *Handler {
|
||||
return &Handler{
|
||||
db: querier,
|
||||
blobs: blobs,
|
||||
policy: pol,
|
||||
auditFn: auditFn,
|
||||
uploads: newUploadManager(),
|
||||
}
|
||||
}
|
||||
|
||||
// isDigest returns true if the reference looks like a digest (sha256:...).
|
||||
func isDigest(ref string) bool {
|
||||
return strings.HasPrefix(ref, "sha256:")
|
||||
}
|
||||
|
||||
// checkPolicy evaluates the policy for the given action and repository.
|
||||
// Returns true if access is allowed, false if denied (and writes the OCI error).
|
||||
func (h *Handler) checkPolicy(w http.ResponseWriter, r *http.Request, action policy.Action, repo string) bool {
|
||||
claims := auth.ClaimsFromContext(r.Context())
|
||||
if claims == nil {
|
||||
writeOCIError(w, "UNAUTHORIZED", http.StatusUnauthorized, "authentication required")
|
||||
return false
|
||||
}
|
||||
|
||||
input := policy.PolicyInput{
|
||||
Subject: claims.Subject,
|
||||
AccountType: claims.AccountType,
|
||||
Roles: claims.Roles,
|
||||
Action: action,
|
||||
Repository: repo,
|
||||
}
|
||||
|
||||
effect, _ := h.policy.Evaluate(input)
|
||||
if effect == policy.Deny {
|
||||
if h.auditFn != nil {
|
||||
h.auditFn("policy_deny", claims.Subject, repo, "", r.RemoteAddr, map[string]string{
|
||||
"action": string(action),
|
||||
})
|
||||
}
|
||||
writeOCIError(w, "DENIED", http.StatusForbidden, "access denied by policy")
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// audit records an audit event if an audit function is configured.
|
||||
func (h *Handler) audit(r *http.Request, eventType, repo, digest string) {
|
||||
if h.auditFn == nil {
|
||||
return
|
||||
}
|
||||
claims := auth.ClaimsFromContext(r.Context())
|
||||
actorID := ""
|
||||
if claims != nil {
|
||||
actorID = claims.Subject
|
||||
}
|
||||
h.auditFn(eventType, actorID, repo, digest, r.RemoteAddr, nil)
|
||||
}
|
||||
321
internal/oci/handler_test.go
Normal file
321
internal/oci/handler_test.go
Normal file
@@ -0,0 +1,321 @@
|
||||
package oci
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sort"
|
||||
"sync"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"git.wntrmute.dev/kyle/mcr/internal/auth"
|
||||
"git.wntrmute.dev/kyle/mcr/internal/db"
|
||||
"git.wntrmute.dev/kyle/mcr/internal/policy"
|
||||
"git.wntrmute.dev/kyle/mcr/internal/storage"
|
||||
)
|
||||
|
||||
// manifestKey uniquely identifies a manifest for test lookup.
|
||||
type manifestKey struct {
|
||||
repoID int64
|
||||
reference string // tag or digest
|
||||
}
|
||||
|
||||
// fakeDB implements DBQuerier for tests.
|
||||
type fakeDB struct {
|
||||
mu sync.Mutex
|
||||
repos map[string]int64 // name -> id
|
||||
manifests map[manifestKey]*db.ManifestRow // (repoID, ref) -> manifest
|
||||
blobs map[int64]map[string]bool // repoID -> set of digests
|
||||
allBlobs map[string]bool // global blob digests
|
||||
tags map[int64][]string // repoID -> sorted tag names
|
||||
repoNames []string // sorted repo names
|
||||
uploads map[string]*db.UploadRow // uuid -> upload
|
||||
nextID int64 // auto-increment counter
|
||||
pushed []db.PushManifestParams // record of pushed manifests
|
||||
}
|
||||
|
||||
func newFakeDB() *fakeDB {
|
||||
return &fakeDB{
|
||||
repos: make(map[string]int64),
|
||||
manifests: make(map[manifestKey]*db.ManifestRow),
|
||||
blobs: make(map[int64]map[string]bool),
|
||||
allBlobs: make(map[string]bool),
|
||||
tags: make(map[int64][]string),
|
||||
uploads: make(map[string]*db.UploadRow),
|
||||
nextID: 1,
|
||||
}
|
||||
}
|
||||
|
||||
func (f *fakeDB) GetRepositoryByName(name string) (int64, error) {
|
||||
id, ok := f.repos[name]
|
||||
if !ok {
|
||||
return 0, db.ErrRepoNotFound
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
func (f *fakeDB) GetManifestByTag(repoID int64, tag string) (*db.ManifestRow, error) {
|
||||
m, ok := f.manifests[manifestKey{repoID, tag}]
|
||||
if !ok {
|
||||
return nil, db.ErrManifestNotFound
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (f *fakeDB) GetManifestByDigest(repoID int64, digest string) (*db.ManifestRow, error) {
|
||||
m, ok := f.manifests[manifestKey{repoID, digest}]
|
||||
if !ok {
|
||||
return nil, db.ErrManifestNotFound
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (f *fakeDB) BlobExistsInRepo(repoID int64, digest string) (bool, error) {
|
||||
digests, ok := f.blobs[repoID]
|
||||
if !ok {
|
||||
return false, nil
|
||||
}
|
||||
return digests[digest], nil
|
||||
}
|
||||
|
||||
func (f *fakeDB) ListTags(repoID int64, after string, limit int) ([]string, error) {
|
||||
allTags := f.tags[repoID]
|
||||
var result []string
|
||||
for _, t := range allTags {
|
||||
if after != "" && t <= after {
|
||||
continue
|
||||
}
|
||||
result = append(result, t)
|
||||
if len(result) >= limit {
|
||||
break
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (f *fakeDB) ListRepositoryNames(after string, limit int) ([]string, error) {
|
||||
var result []string
|
||||
for _, n := range f.repoNames {
|
||||
if after != "" && n <= after {
|
||||
continue
|
||||
}
|
||||
result = append(result, n)
|
||||
if len(result) >= limit {
|
||||
break
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (f *fakeDB) GetOrCreateRepository(name string) (int64, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
id, ok := f.repos[name]
|
||||
if ok {
|
||||
return id, nil
|
||||
}
|
||||
id = f.nextID
|
||||
f.nextID++
|
||||
f.repos[name] = id
|
||||
f.repoNames = append(f.repoNames, name)
|
||||
sort.Strings(f.repoNames)
|
||||
return id, nil
|
||||
}
|
||||
|
||||
func (f *fakeDB) BlobExists(digest string) (bool, error) {
|
||||
return f.allBlobs[digest], nil
|
||||
}
|
||||
|
||||
func (f *fakeDB) InsertBlob(digest string, size int64) error {
|
||||
f.allBlobs[digest] = true
|
||||
_ = size
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeDB) PushManifest(p db.PushManifestParams) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.pushed = append(f.pushed, p)
|
||||
// Simulate creating the manifest in our fake data.
|
||||
repoID, ok := f.repos[p.RepoName]
|
||||
if !ok {
|
||||
repoID = f.nextID
|
||||
f.nextID++
|
||||
f.repos[p.RepoName] = repoID
|
||||
f.repoNames = append(f.repoNames, p.RepoName)
|
||||
sort.Strings(f.repoNames)
|
||||
}
|
||||
m := &db.ManifestRow{
|
||||
ID: f.nextID,
|
||||
RepositoryID: repoID,
|
||||
Digest: p.Digest,
|
||||
MediaType: p.MediaType,
|
||||
Content: p.Content,
|
||||
Size: p.Size,
|
||||
}
|
||||
f.nextID++
|
||||
f.manifests[manifestKey{repoID, p.Digest}] = m
|
||||
if p.Tag != "" {
|
||||
f.manifests[manifestKey{repoID, p.Tag}] = m
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeDB) CreateUpload(uuid string, repoID int64) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.uploads[uuid] = &db.UploadRow{
|
||||
ID: f.nextID,
|
||||
UUID: uuid,
|
||||
RepositoryID: repoID,
|
||||
ByteOffset: 0,
|
||||
}
|
||||
f.nextID++
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeDB) GetUpload(uuid string) (*db.UploadRow, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
u, ok := f.uploads[uuid]
|
||||
if !ok {
|
||||
return nil, db.ErrUploadNotFound
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
|
||||
func (f *fakeDB) UpdateUploadOffset(uuid string, offset int64) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
u, ok := f.uploads[uuid]
|
||||
if !ok {
|
||||
return db.ErrUploadNotFound
|
||||
}
|
||||
u.ByteOffset = offset
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeDB) DeleteUpload(uuid string) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
if _, ok := f.uploads[uuid]; !ok {
|
||||
return db.ErrUploadNotFound
|
||||
}
|
||||
delete(f.uploads, uuid)
|
||||
return nil
|
||||
}
|
||||
|
||||
// addRepo adds a repo to the fakeDB and returns its ID.
|
||||
func (f *fakeDB) addRepo(name string, id int64) {
|
||||
f.repos[name] = id
|
||||
f.repoNames = append(f.repoNames, name)
|
||||
sort.Strings(f.repoNames)
|
||||
if id >= f.nextID {
|
||||
f.nextID = id + 1
|
||||
}
|
||||
}
|
||||
|
||||
// addManifest adds a manifest accessible by both tag and digest.
|
||||
func (f *fakeDB) addManifest(repoID int64, tag, digest, mediaType string, content []byte) {
|
||||
m := &db.ManifestRow{
|
||||
ID: f.nextID,
|
||||
RepositoryID: repoID,
|
||||
Digest: digest,
|
||||
MediaType: mediaType,
|
||||
Content: content,
|
||||
Size: int64(len(content)),
|
||||
}
|
||||
f.nextID++
|
||||
if tag != "" {
|
||||
f.manifests[manifestKey{repoID, tag}] = m
|
||||
}
|
||||
f.manifests[manifestKey{repoID, digest}] = m
|
||||
}
|
||||
|
||||
// addBlob registers a blob digest in a repository.
|
||||
func (f *fakeDB) addBlob(repoID int64, digest string) {
|
||||
if f.blobs[repoID] == nil {
|
||||
f.blobs[repoID] = make(map[string]bool)
|
||||
}
|
||||
f.blobs[repoID][digest] = true
|
||||
}
|
||||
|
||||
// addGlobalBlob registers a blob in the global blob table.
|
||||
func (f *fakeDB) addGlobalBlob(digest string) {
|
||||
f.allBlobs[digest] = true
|
||||
}
|
||||
|
||||
// addTag adds a tag to a repository's tag list.
|
||||
func (f *fakeDB) addTag(repoID int64, tag string) {
|
||||
f.tags[repoID] = append(f.tags[repoID], tag)
|
||||
sort.Strings(f.tags[repoID])
|
||||
}
|
||||
|
||||
// fakeBlobs implements BlobStore for tests.
|
||||
type fakeBlobs struct {
|
||||
data map[string][]byte // digest -> content
|
||||
uploads map[string]*bytes.Buffer
|
||||
}
|
||||
|
||||
func newFakeBlobs() *fakeBlobs {
|
||||
return &fakeBlobs{
|
||||
data: make(map[string][]byte),
|
||||
uploads: make(map[string]*bytes.Buffer),
|
||||
}
|
||||
}
|
||||
|
||||
func (f *fakeBlobs) Open(digest string) (io.ReadCloser, error) {
|
||||
data, ok := f.data[digest]
|
||||
if !ok {
|
||||
return nil, io.ErrUnexpectedEOF
|
||||
}
|
||||
return io.NopCloser(bytes.NewReader(data)), nil
|
||||
}
|
||||
|
||||
func (f *fakeBlobs) Stat(digest string) (int64, error) {
|
||||
data, ok := f.data[digest]
|
||||
if !ok {
|
||||
return 0, io.ErrUnexpectedEOF
|
||||
}
|
||||
return int64(len(data)), nil
|
||||
}
|
||||
|
||||
func (f *fakeBlobs) StartUpload(uuid string) (*storage.BlobWriter, error) {
|
||||
// For tests that need real storage, use a real Store in t.TempDir().
|
||||
// This fake panics to catch unintended usage.
|
||||
panic("fakeBlobs.StartUpload should not be called; use a real storage.Store for upload tests")
|
||||
}
|
||||
|
||||
// fakePolicy implements PolicyEval, always returning Allow.
|
||||
type fakePolicy struct {
|
||||
effect policy.Effect
|
||||
}
|
||||
|
||||
func (f *fakePolicy) Evaluate(_ policy.PolicyInput) (policy.Effect, *policy.Rule) {
|
||||
return f.effect, nil
|
||||
}
|
||||
|
||||
// allowAll returns a fakePolicy that allows all requests.
|
||||
func allowAll() *fakePolicy {
|
||||
return &fakePolicy{effect: policy.Allow}
|
||||
}
|
||||
|
||||
// testRouter creates a chi.Mux with the OCI handler mounted at /v2.
|
||||
func testRouter(h *Handler) *chi.Mux {
|
||||
parent := chi.NewRouter()
|
||||
parent.Mount("/v2", h.Router())
|
||||
return parent
|
||||
}
|
||||
|
||||
// authedRequest creates an HTTP request with authenticated claims in the context.
|
||||
func authedRequest(method, path string, body io.Reader) *http.Request {
|
||||
req := httptest.NewRequest(method, path, body)
|
||||
claims := &auth.Claims{
|
||||
Subject: "test-user",
|
||||
AccountType: "human",
|
||||
Roles: []string{"user"},
|
||||
}
|
||||
return req.WithContext(auth.ContextWithClaims(req.Context(), claims))
|
||||
}
|
||||
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)
|
||||
}
|
||||
187
internal/oci/manifest_test.go
Normal file
187
internal/oci/manifest_test.go
Normal file
@@ -0,0 +1,187 @@
|
||||
package oci
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestManifestGetByTag(t *testing.T) {
|
||||
fdb := newFakeDB()
|
||||
fdb.addRepo("myrepo", 1)
|
||||
content := []byte(`{"schemaVersion":2}`)
|
||||
fdb.addManifest(1, "latest", "sha256:aaaa", "application/vnd.oci.image.manifest.v1+json", content)
|
||||
|
||||
h := NewHandler(fdb, newFakeBlobs(), allowAll(), nil)
|
||||
router := testRouter(h)
|
||||
|
||||
req := authedRequest("GET", "/v2/myrepo/manifests/latest", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
router.ServeHTTP(rr, req)
|
||||
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("status: got %d, want %d", rr.Code, http.StatusOK)
|
||||
}
|
||||
if ct := rr.Header().Get("Content-Type"); ct != "application/vnd.oci.image.manifest.v1+json" {
|
||||
t.Fatalf("Content-Type: got %q", ct)
|
||||
}
|
||||
if dcd := rr.Header().Get("Docker-Content-Digest"); dcd != "sha256:aaaa" {
|
||||
t.Fatalf("Docker-Content-Digest: got %q", dcd)
|
||||
}
|
||||
if cl := rr.Header().Get("Content-Length"); cl != "19" {
|
||||
t.Fatalf("Content-Length: got %q, want %q", cl, "19")
|
||||
}
|
||||
if rr.Body.String() != `{"schemaVersion":2}` {
|
||||
t.Fatalf("body: got %q", rr.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestManifestGetByDigest(t *testing.T) {
|
||||
fdb := newFakeDB()
|
||||
fdb.addRepo("myrepo", 1)
|
||||
content := []byte(`{"schemaVersion":2}`)
|
||||
fdb.addManifest(1, "latest", "sha256:aaaa", "application/vnd.oci.image.manifest.v1+json", content)
|
||||
|
||||
h := NewHandler(fdb, newFakeBlobs(), allowAll(), nil)
|
||||
router := testRouter(h)
|
||||
|
||||
req := authedRequest("GET", "/v2/myrepo/manifests/sha256:aaaa", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
router.ServeHTTP(rr, req)
|
||||
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("status: got %d, want %d", rr.Code, http.StatusOK)
|
||||
}
|
||||
if dcd := rr.Header().Get("Docker-Content-Digest"); dcd != "sha256:aaaa" {
|
||||
t.Fatalf("Docker-Content-Digest: got %q", dcd)
|
||||
}
|
||||
if rr.Body.String() != `{"schemaVersion":2}` {
|
||||
t.Fatalf("body: got %q", rr.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestManifestHead(t *testing.T) {
|
||||
fdb := newFakeDB()
|
||||
fdb.addRepo("myrepo", 1)
|
||||
content := []byte(`{"schemaVersion":2}`)
|
||||
fdb.addManifest(1, "latest", "sha256:aaaa", "application/vnd.oci.image.manifest.v1+json", content)
|
||||
|
||||
h := NewHandler(fdb, newFakeBlobs(), allowAll(), nil)
|
||||
router := testRouter(h)
|
||||
|
||||
req := authedRequest("HEAD", "/v2/myrepo/manifests/latest", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
router.ServeHTTP(rr, req)
|
||||
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("status: got %d, want %d", rr.Code, http.StatusOK)
|
||||
}
|
||||
if ct := rr.Header().Get("Content-Type"); ct != "application/vnd.oci.image.manifest.v1+json" {
|
||||
t.Fatalf("Content-Type: got %q", ct)
|
||||
}
|
||||
if dcd := rr.Header().Get("Docker-Content-Digest"); dcd != "sha256:aaaa" {
|
||||
t.Fatalf("Docker-Content-Digest: got %q", dcd)
|
||||
}
|
||||
if cl := rr.Header().Get("Content-Length"); cl != "19" {
|
||||
t.Fatalf("Content-Length: got %q, want %q", cl, "19")
|
||||
}
|
||||
if rr.Body.Len() != 0 {
|
||||
t.Fatalf("HEAD body should be empty, got %d bytes", rr.Body.Len())
|
||||
}
|
||||
}
|
||||
|
||||
func TestManifestGetRepoNotFound(t *testing.T) {
|
||||
fdb := newFakeDB()
|
||||
// No repos added.
|
||||
|
||||
h := NewHandler(fdb, newFakeBlobs(), allowAll(), nil)
|
||||
router := testRouter(h)
|
||||
|
||||
req := authedRequest("GET", "/v2/nosuchrepo/manifests/latest", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
router.ServeHTTP(rr, req)
|
||||
|
||||
if rr.Code != http.StatusNotFound {
|
||||
t.Fatalf("status: got %d, want %d", rr.Code, http.StatusNotFound)
|
||||
}
|
||||
|
||||
var body ociErrorResponse
|
||||
if err := json.NewDecoder(rr.Body).Decode(&body); err != nil {
|
||||
t.Fatalf("decode error body: %v", err)
|
||||
}
|
||||
if len(body.Errors) != 1 || body.Errors[0].Code != "NAME_UNKNOWN" {
|
||||
t.Fatalf("error code: got %+v, want NAME_UNKNOWN", body.Errors)
|
||||
}
|
||||
}
|
||||
|
||||
func TestManifestGetManifestNotFoundByTag(t *testing.T) {
|
||||
fdb := newFakeDB()
|
||||
fdb.addRepo("myrepo", 1)
|
||||
// No manifests added.
|
||||
|
||||
h := NewHandler(fdb, newFakeBlobs(), allowAll(), nil)
|
||||
router := testRouter(h)
|
||||
|
||||
req := authedRequest("GET", "/v2/myrepo/manifests/nonexistent", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
router.ServeHTTP(rr, req)
|
||||
|
||||
if rr.Code != http.StatusNotFound {
|
||||
t.Fatalf("status: got %d, want %d", rr.Code, http.StatusNotFound)
|
||||
}
|
||||
|
||||
var body ociErrorResponse
|
||||
if err := json.NewDecoder(rr.Body).Decode(&body); err != nil {
|
||||
t.Fatalf("decode error body: %v", err)
|
||||
}
|
||||
if len(body.Errors) != 1 || body.Errors[0].Code != "MANIFEST_UNKNOWN" {
|
||||
t.Fatalf("error code: got %+v, want MANIFEST_UNKNOWN", body.Errors)
|
||||
}
|
||||
}
|
||||
|
||||
func TestManifestGetManifestNotFoundByDigest(t *testing.T) {
|
||||
fdb := newFakeDB()
|
||||
fdb.addRepo("myrepo", 1)
|
||||
// No manifests added.
|
||||
|
||||
h := NewHandler(fdb, newFakeBlobs(), allowAll(), nil)
|
||||
router := testRouter(h)
|
||||
|
||||
req := authedRequest("GET", "/v2/myrepo/manifests/sha256:nonexistent", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
router.ServeHTTP(rr, req)
|
||||
|
||||
if rr.Code != http.StatusNotFound {
|
||||
t.Fatalf("status: got %d, want %d", rr.Code, http.StatusNotFound)
|
||||
}
|
||||
|
||||
var body ociErrorResponse
|
||||
if err := json.NewDecoder(rr.Body).Decode(&body); err != nil {
|
||||
t.Fatalf("decode error body: %v", err)
|
||||
}
|
||||
if len(body.Errors) != 1 || body.Errors[0].Code != "MANIFEST_UNKNOWN" {
|
||||
t.Fatalf("error code: got %+v, want MANIFEST_UNKNOWN", body.Errors)
|
||||
}
|
||||
}
|
||||
|
||||
func TestManifestGetMultiSegmentRepo(t *testing.T) {
|
||||
fdb := newFakeDB()
|
||||
fdb.addRepo("org/team/app", 1)
|
||||
content := []byte(`{"layers":[]}`)
|
||||
fdb.addManifest(1, "v1.0", "sha256:cccc", "application/vnd.oci.image.manifest.v1+json", content)
|
||||
|
||||
h := NewHandler(fdb, newFakeBlobs(), allowAll(), nil)
|
||||
router := testRouter(h)
|
||||
|
||||
req := authedRequest("GET", "/v2/org/team/app/manifests/v1.0", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
router.ServeHTTP(rr, req)
|
||||
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("status: got %d, want %d", rr.Code, http.StatusOK)
|
||||
}
|
||||
if dcd := rr.Header().Get("Docker-Content-Digest"); dcd != "sha256:cccc" {
|
||||
t.Fatalf("Docker-Content-Digest: got %q", dcd)
|
||||
}
|
||||
}
|
||||
23
internal/oci/ocierror.go
Normal file
23
internal/oci/ocierror.go
Normal file
@@ -0,0 +1,23 @@
|
||||
package oci
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
type ociErrorEntry struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
type ociErrorResponse struct {
|
||||
Errors []ociErrorEntry `json:"errors"`
|
||||
}
|
||||
|
||||
func writeOCIError(w http.ResponseWriter, code string, status int, message string) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(ociErrorResponse{
|
||||
Errors: []ociErrorEntry{{Code: code, Message: message}},
|
||||
})
|
||||
}
|
||||
266
internal/oci/push_test.go
Normal file
266
internal/oci/push_test.go
Normal file
@@ -0,0 +1,266 @@
|
||||
package oci
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func makeManifest(configDigest string, layerDigests []string) []byte {
|
||||
layers := ""
|
||||
for i, d := range layerDigests {
|
||||
if i > 0 {
|
||||
layers += ","
|
||||
}
|
||||
layers += fmt.Sprintf(`{"mediaType":"application/vnd.oci.image.layer.v1.tar+gzip","digest":%q,"size":1000}`, d)
|
||||
}
|
||||
return []byte(fmt.Sprintf(`{"schemaVersion":2,"mediaType":"application/vnd.oci.image.manifest.v1+json","config":{"mediaType":"application/vnd.oci.image.config.v1+json","digest":%q,"size":100},"layers":[%s]}`, configDigest, layers))
|
||||
}
|
||||
|
||||
func manifestDigest(content []byte) string {
|
||||
sum := sha256.Sum256(content)
|
||||
return "sha256:" + hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func TestManifestPushByTag(t *testing.T) {
|
||||
fdb := newFakeDB()
|
||||
fdb.addRepo("myrepo", 1)
|
||||
fdb.addGlobalBlob("sha256:config1")
|
||||
fdb.addGlobalBlob("sha256:layer1")
|
||||
|
||||
h := NewHandler(fdb, newFakeBlobs(), allowAll(), nil)
|
||||
router := testRouter(h)
|
||||
|
||||
content := makeManifest("sha256:config1", []string{"sha256:layer1"})
|
||||
digest := manifestDigest(content)
|
||||
|
||||
req := authedRequest("PUT", "/v2/myrepo/manifests/latest", nil)
|
||||
req.Body = http.NoBody
|
||||
// Re-create with proper body.
|
||||
req = authedPushRequest("PUT", "/v2/myrepo/manifests/latest", content)
|
||||
req.Header.Set("Content-Type", "application/vnd.oci.image.manifest.v1+json")
|
||||
rr := httptest.NewRecorder()
|
||||
router.ServeHTTP(rr, req)
|
||||
|
||||
if rr.Code != http.StatusCreated {
|
||||
t.Fatalf("status: got %d, want %d, body: %s", rr.Code, http.StatusCreated, rr.Body.String())
|
||||
}
|
||||
|
||||
dcd := rr.Header().Get("Docker-Content-Digest")
|
||||
if dcd != digest {
|
||||
t.Fatalf("Docker-Content-Digest: got %q, want %q", dcd, digest)
|
||||
}
|
||||
|
||||
loc := rr.Header().Get("Location")
|
||||
if loc == "" {
|
||||
t.Fatal("Location header missing")
|
||||
}
|
||||
|
||||
ct := rr.Header().Get("Content-Type")
|
||||
if ct != "application/vnd.oci.image.manifest.v1+json" {
|
||||
t.Fatalf("Content-Type: got %q", ct)
|
||||
}
|
||||
|
||||
// Verify manifest was stored.
|
||||
if len(fdb.pushed) != 1 {
|
||||
t.Fatalf("pushed count: got %d, want 1", len(fdb.pushed))
|
||||
}
|
||||
if fdb.pushed[0].Tag != "latest" {
|
||||
t.Fatalf("pushed tag: got %q, want %q", fdb.pushed[0].Tag, "latest")
|
||||
}
|
||||
}
|
||||
|
||||
func TestManifestPushByDigest(t *testing.T) {
|
||||
fdb := newFakeDB()
|
||||
fdb.addRepo("myrepo", 1)
|
||||
fdb.addGlobalBlob("sha256:config1")
|
||||
|
||||
h := NewHandler(fdb, newFakeBlobs(), allowAll(), nil)
|
||||
router := testRouter(h)
|
||||
|
||||
content := makeManifest("sha256:config1", nil)
|
||||
digest := manifestDigest(content)
|
||||
|
||||
req := authedPushRequest("PUT", "/v2/myrepo/manifests/"+digest, content)
|
||||
req.Header.Set("Content-Type", "application/vnd.oci.image.manifest.v1+json")
|
||||
rr := httptest.NewRecorder()
|
||||
router.ServeHTTP(rr, req)
|
||||
|
||||
if rr.Code != http.StatusCreated {
|
||||
t.Fatalf("status: got %d, body: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
|
||||
// Verify no tag was set.
|
||||
if len(fdb.pushed) != 1 {
|
||||
t.Fatalf("pushed count: got %d", len(fdb.pushed))
|
||||
}
|
||||
if fdb.pushed[0].Tag != "" {
|
||||
t.Fatalf("pushed tag: got %q, want empty", fdb.pushed[0].Tag)
|
||||
}
|
||||
}
|
||||
|
||||
func TestManifestPushDigestMismatch(t *testing.T) {
|
||||
fdb := newFakeDB()
|
||||
fdb.addRepo("myrepo", 1)
|
||||
|
||||
h := NewHandler(fdb, newFakeBlobs(), allowAll(), nil)
|
||||
router := testRouter(h)
|
||||
|
||||
content := []byte(`{"schemaVersion":2}`)
|
||||
wrongDigest := "sha256:0000000000000000000000000000000000000000000000000000000000000000"
|
||||
|
||||
req := authedPushRequest("PUT", "/v2/myrepo/manifests/"+wrongDigest, content)
|
||||
req.Header.Set("Content-Type", "application/vnd.oci.image.manifest.v1+json")
|
||||
rr := httptest.NewRecorder()
|
||||
router.ServeHTTP(rr, req)
|
||||
|
||||
if rr.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status: got %d, want %d", rr.Code, http.StatusBadRequest)
|
||||
}
|
||||
|
||||
var body ociErrorResponse
|
||||
if err := json.NewDecoder(rr.Body).Decode(&body); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if len(body.Errors) != 1 || body.Errors[0].Code != "DIGEST_INVALID" {
|
||||
t.Fatalf("error code: got %+v, want DIGEST_INVALID", body.Errors)
|
||||
}
|
||||
}
|
||||
|
||||
func TestManifestPushMissingBlob(t *testing.T) {
|
||||
fdb := newFakeDB()
|
||||
fdb.addRepo("myrepo", 1)
|
||||
// Config blob exists but layer blob does not.
|
||||
fdb.addGlobalBlob("sha256:config1")
|
||||
|
||||
h := NewHandler(fdb, newFakeBlobs(), allowAll(), nil)
|
||||
router := testRouter(h)
|
||||
|
||||
content := makeManifest("sha256:config1", []string{"sha256:missing_layer"})
|
||||
|
||||
req := authedPushRequest("PUT", "/v2/myrepo/manifests/latest", content)
|
||||
req.Header.Set("Content-Type", "application/vnd.oci.image.manifest.v1+json")
|
||||
rr := httptest.NewRecorder()
|
||||
router.ServeHTTP(rr, req)
|
||||
|
||||
if rr.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status: got %d, want %d, body: %s", rr.Code, http.StatusBadRequest, rr.Body.String())
|
||||
}
|
||||
|
||||
var body ociErrorResponse
|
||||
if err := json.NewDecoder(rr.Body).Decode(&body); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if len(body.Errors) != 1 || body.Errors[0].Code != "MANIFEST_BLOB_UNKNOWN" {
|
||||
t.Fatalf("error code: got %+v, want MANIFEST_BLOB_UNKNOWN", body.Errors)
|
||||
}
|
||||
}
|
||||
|
||||
func TestManifestPushMalformed(t *testing.T) {
|
||||
fdb := newFakeDB()
|
||||
fdb.addRepo("myrepo", 1)
|
||||
|
||||
h := NewHandler(fdb, newFakeBlobs(), allowAll(), nil)
|
||||
router := testRouter(h)
|
||||
|
||||
req := authedPushRequest("PUT", "/v2/myrepo/manifests/latest", []byte("not valid json"))
|
||||
req.Header.Set("Content-Type", "application/vnd.oci.image.manifest.v1+json")
|
||||
rr := httptest.NewRecorder()
|
||||
router.ServeHTTP(rr, req)
|
||||
|
||||
if rr.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status: got %d, want %d", rr.Code, http.StatusBadRequest)
|
||||
}
|
||||
|
||||
var body ociErrorResponse
|
||||
if err := json.NewDecoder(rr.Body).Decode(&body); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if len(body.Errors) != 1 || body.Errors[0].Code != "MANIFEST_INVALID" {
|
||||
t.Fatalf("error code: got %+v, want MANIFEST_INVALID", body.Errors)
|
||||
}
|
||||
}
|
||||
|
||||
func TestManifestPushEmpty(t *testing.T) {
|
||||
fdb := newFakeDB()
|
||||
fdb.addRepo("myrepo", 1)
|
||||
|
||||
h := NewHandler(fdb, newFakeBlobs(), allowAll(), nil)
|
||||
router := testRouter(h)
|
||||
|
||||
req := authedPushRequest("PUT", "/v2/myrepo/manifests/latest", nil)
|
||||
req.Header.Set("Content-Type", "application/vnd.oci.image.manifest.v1+json")
|
||||
rr := httptest.NewRecorder()
|
||||
router.ServeHTTP(rr, req)
|
||||
|
||||
if rr.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status: got %d, want %d", rr.Code, http.StatusBadRequest)
|
||||
}
|
||||
}
|
||||
|
||||
func TestManifestPushUpdatesTag(t *testing.T) {
|
||||
fdb := newFakeDB()
|
||||
fdb.addRepo("myrepo", 1)
|
||||
fdb.addGlobalBlob("sha256:config1")
|
||||
|
||||
h := NewHandler(fdb, newFakeBlobs(), allowAll(), nil)
|
||||
router := testRouter(h)
|
||||
|
||||
// First push with tag "latest".
|
||||
content1 := makeManifest("sha256:config1", nil)
|
||||
req := authedPushRequest("PUT", "/v2/myrepo/manifests/latest", content1)
|
||||
req.Header.Set("Content-Type", "application/vnd.oci.image.manifest.v1+json")
|
||||
rr := httptest.NewRecorder()
|
||||
router.ServeHTTP(rr, req)
|
||||
if rr.Code != http.StatusCreated {
|
||||
t.Fatalf("first push status: got %d", rr.Code)
|
||||
}
|
||||
firstDigest := rr.Header().Get("Docker-Content-Digest")
|
||||
|
||||
// Second push with same tag — different content.
|
||||
fdb.addGlobalBlob("sha256:config2")
|
||||
content2 := makeManifest("sha256:config2", nil)
|
||||
req = authedPushRequest("PUT", "/v2/myrepo/manifests/latest", content2)
|
||||
req.Header.Set("Content-Type", "application/vnd.oci.image.manifest.v1+json")
|
||||
rr = httptest.NewRecorder()
|
||||
router.ServeHTTP(rr, req)
|
||||
if rr.Code != http.StatusCreated {
|
||||
t.Fatalf("second push status: got %d", rr.Code)
|
||||
}
|
||||
secondDigest := rr.Header().Get("Docker-Content-Digest")
|
||||
|
||||
if firstDigest == secondDigest {
|
||||
t.Fatal("two pushes should produce different digests")
|
||||
}
|
||||
|
||||
// Verify that the tag was atomically moved.
|
||||
if len(fdb.pushed) != 2 {
|
||||
t.Fatalf("pushed count: got %d, want 2", len(fdb.pushed))
|
||||
}
|
||||
}
|
||||
|
||||
func TestManifestPushRepushIdempotent(t *testing.T) {
|
||||
fdb := newFakeDB()
|
||||
fdb.addRepo("myrepo", 1)
|
||||
fdb.addGlobalBlob("sha256:config1")
|
||||
|
||||
h := NewHandler(fdb, newFakeBlobs(), allowAll(), nil)
|
||||
router := testRouter(h)
|
||||
|
||||
content := makeManifest("sha256:config1", nil)
|
||||
|
||||
// Push the same manifest twice.
|
||||
for i := range 2 {
|
||||
req := authedPushRequest("PUT", "/v2/myrepo/manifests/latest", content)
|
||||
req.Header.Set("Content-Type", "application/vnd.oci.image.manifest.v1+json")
|
||||
rr := httptest.NewRecorder()
|
||||
router.ServeHTTP(rr, req)
|
||||
if rr.Code != http.StatusCreated {
|
||||
t.Fatalf("push %d status: got %d", i+1, rr.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
171
internal/oci/routes.go
Normal file
171
internal/oci/routes.go
Normal file
@@ -0,0 +1,171 @@
|
||||
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)
|
||||
default:
|
||||
w.Header().Set("Allow", "GET, HEAD, PUT")
|
||||
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)
|
||||
default:
|
||||
w.Header().Set("Allow", "GET, HEAD")
|
||||
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")
|
||||
}
|
||||
}
|
||||
141
internal/oci/routes_test.go
Normal file
141
internal/oci/routes_test.go
Normal file
@@ -0,0 +1,141 @@
|
||||
package oci
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestParseOCIPath(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
path string
|
||||
want ociPathInfo
|
||||
wantOK bool
|
||||
}{
|
||||
{
|
||||
name: "simple repo manifest by tag",
|
||||
path: "myrepo/manifests/latest",
|
||||
want: ociPathInfo{name: "myrepo", kind: "manifests", reference: "latest"},
|
||||
wantOK: true,
|
||||
},
|
||||
{
|
||||
name: "multi-segment repo manifest by tag",
|
||||
path: "org/team/app/manifests/v1.0",
|
||||
want: ociPathInfo{name: "org/team/app", kind: "manifests", reference: "v1.0"},
|
||||
wantOK: true,
|
||||
},
|
||||
{
|
||||
name: "manifest by digest",
|
||||
path: "myrepo/manifests/sha256:abc123def456",
|
||||
want: ociPathInfo{name: "myrepo", kind: "manifests", reference: "sha256:abc123def456"},
|
||||
wantOK: true,
|
||||
},
|
||||
{
|
||||
name: "simple repo blob",
|
||||
path: "myrepo/blobs/sha256:abc123",
|
||||
want: ociPathInfo{name: "myrepo", kind: "blobs", reference: "sha256:abc123"},
|
||||
wantOK: true,
|
||||
},
|
||||
{
|
||||
name: "multi-segment repo blob",
|
||||
path: "org/team/app/blobs/sha256:abc123",
|
||||
want: ociPathInfo{name: "org/team/app", kind: "blobs", reference: "sha256:abc123"},
|
||||
wantOK: true,
|
||||
},
|
||||
{
|
||||
name: "simple repo tags list",
|
||||
path: "myrepo/tags/list",
|
||||
want: ociPathInfo{name: "myrepo", kind: "tags", reference: "list"},
|
||||
wantOK: true,
|
||||
},
|
||||
{
|
||||
name: "multi-segment repo tags list",
|
||||
path: "org/app/tags/list",
|
||||
want: ociPathInfo{name: "org/app", kind: "tags", reference: "list"},
|
||||
wantOK: true,
|
||||
},
|
||||
{
|
||||
name: "empty path",
|
||||
path: "",
|
||||
wantOK: false,
|
||||
},
|
||||
{
|
||||
name: "just repo name",
|
||||
path: "myrepo",
|
||||
wantOK: false,
|
||||
},
|
||||
{
|
||||
name: "unknown operation",
|
||||
path: "myrepo/unknown/ref",
|
||||
wantOK: false,
|
||||
},
|
||||
{
|
||||
name: "manifests with no ref",
|
||||
path: "myrepo/manifests/",
|
||||
wantOK: false,
|
||||
},
|
||||
{
|
||||
name: "blobs with no digest",
|
||||
path: "myrepo/blobs/",
|
||||
wantOK: false,
|
||||
},
|
||||
{
|
||||
name: "tags without list suffix",
|
||||
path: "myrepo/tags/something",
|
||||
wantOK: false,
|
||||
},
|
||||
{
|
||||
name: "no repo name before manifests",
|
||||
path: "/manifests/latest",
|
||||
wantOK: false,
|
||||
},
|
||||
{
|
||||
name: "upload initiate (trailing slash)",
|
||||
path: "myrepo/blobs/uploads/",
|
||||
want: ociPathInfo{name: "myrepo", kind: "uploads", reference: ""},
|
||||
wantOK: true,
|
||||
},
|
||||
{
|
||||
name: "upload initiate (no trailing slash)",
|
||||
path: "myrepo/blobs/uploads",
|
||||
want: ociPathInfo{name: "myrepo", kind: "uploads", reference: ""},
|
||||
wantOK: true,
|
||||
},
|
||||
{
|
||||
name: "upload with uuid",
|
||||
path: "myrepo/blobs/uploads/abc-123-def",
|
||||
want: ociPathInfo{name: "myrepo", kind: "uploads", reference: "abc-123-def"},
|
||||
wantOK: true,
|
||||
},
|
||||
{
|
||||
name: "multi-segment repo upload",
|
||||
path: "org/team/app/blobs/uploads/uuid-456",
|
||||
want: ociPathInfo{name: "org/team/app", kind: "uploads", reference: "uuid-456"},
|
||||
wantOK: true,
|
||||
},
|
||||
{
|
||||
name: "multi-segment repo upload initiate",
|
||||
path: "org/team/app/blobs/uploads/",
|
||||
want: ociPathInfo{name: "org/team/app", kind: "uploads", reference: ""},
|
||||
wantOK: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, ok := parseOCIPath(tt.path)
|
||||
if ok != tt.wantOK {
|
||||
t.Fatalf("parseOCIPath(%q) ok = %v, want %v", tt.path, ok, tt.wantOK)
|
||||
}
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if got.name != tt.want.name {
|
||||
t.Errorf("name: got %q, want %q", got.name, tt.want.name)
|
||||
}
|
||||
if got.kind != tt.want.kind {
|
||||
t.Errorf("kind: got %q, want %q", got.kind, tt.want.kind)
|
||||
}
|
||||
if got.reference != tt.want.reference {
|
||||
t.Errorf("reference: got %q, want %q", got.reference, tt.want.reference)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
84
internal/oci/tags.go
Normal file
84
internal/oci/tags.go
Normal file
@@ -0,0 +1,84 @@
|
||||
package oci
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"git.wntrmute.dev/kyle/mcr/internal/db"
|
||||
"git.wntrmute.dev/kyle/mcr/internal/policy"
|
||||
)
|
||||
|
||||
type tagListResponse struct {
|
||||
Name string `json:"name"`
|
||||
Tags []string `json:"tags"`
|
||||
}
|
||||
|
||||
func (h *Handler) handleTagsList(w http.ResponseWriter, r *http.Request, repo string) {
|
||||
if !h.checkPolicy(w, r, policy.ActionPull, repo) {
|
||||
return
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
writeOCIError(w, "UNKNOWN", http.StatusInternalServerError, "internal error")
|
||||
return
|
||||
}
|
||||
|
||||
// Parse pagination params.
|
||||
n := 0
|
||||
if nStr := r.URL.Query().Get("n"); nStr != "" {
|
||||
n, err = strconv.Atoi(nStr)
|
||||
if err != nil || n < 0 {
|
||||
writeOCIError(w, "INVALID_PARAMETER", http.StatusBadRequest, "invalid 'n' parameter")
|
||||
return
|
||||
}
|
||||
}
|
||||
last := r.URL.Query().Get("last")
|
||||
|
||||
// Default: no limit (return all).
|
||||
if n == 0 {
|
||||
tags, err := h.db.ListTags(repoID, last, 10000)
|
||||
if err != nil {
|
||||
writeOCIError(w, "UNKNOWN", http.StatusInternalServerError, "internal error")
|
||||
return
|
||||
}
|
||||
if tags == nil {
|
||||
tags = []string{}
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(tagListResponse{Name: repo, Tags: tags})
|
||||
return
|
||||
}
|
||||
|
||||
// Request n+1 to detect if there are more results.
|
||||
tags, err := h.db.ListTags(repoID, last, n+1)
|
||||
if err != nil {
|
||||
writeOCIError(w, "UNKNOWN", http.StatusInternalServerError, "internal error")
|
||||
return
|
||||
}
|
||||
if tags == nil {
|
||||
tags = []string{}
|
||||
}
|
||||
|
||||
hasMore := len(tags) > n
|
||||
if hasMore {
|
||||
tags = tags[:n]
|
||||
}
|
||||
|
||||
if hasMore {
|
||||
lastTag := tags[len(tags)-1]
|
||||
linkURL := fmt.Sprintf("/v2/%s/tags/list?n=%d&last=%s", repo, n, lastTag)
|
||||
w.Header().Set("Link", fmt.Sprintf(`<%s>; rel="next"`, linkURL))
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(tagListResponse{Name: repo, Tags: tags})
|
||||
}
|
||||
154
internal/oci/tags_test.go
Normal file
154
internal/oci/tags_test.go
Normal file
@@ -0,0 +1,154 @@
|
||||
package oci
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestTagsList(t *testing.T) {
|
||||
fdb := newFakeDB()
|
||||
fdb.addRepo("myrepo", 1)
|
||||
fdb.addTag(1, "latest")
|
||||
fdb.addTag(1, "v1.0")
|
||||
fdb.addTag(1, "v2.0")
|
||||
|
||||
h := NewHandler(fdb, newFakeBlobs(), allowAll(), nil)
|
||||
router := testRouter(h)
|
||||
|
||||
req := authedRequest("GET", "/v2/myrepo/tags/list", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
router.ServeHTTP(rr, req)
|
||||
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("status: got %d, want %d", rr.Code, http.StatusOK)
|
||||
}
|
||||
|
||||
var body tagListResponse
|
||||
if err := json.NewDecoder(rr.Body).Decode(&body); err != nil {
|
||||
t.Fatalf("decode body: %v", err)
|
||||
}
|
||||
if body.Name != "myrepo" {
|
||||
t.Fatalf("name: got %q, want %q", body.Name, "myrepo")
|
||||
}
|
||||
want := []string{"latest", "v1.0", "v2.0"}
|
||||
if len(body.Tags) != len(want) {
|
||||
t.Fatalf("tags count: got %d, want %d", len(body.Tags), len(want))
|
||||
}
|
||||
for i, tag := range body.Tags {
|
||||
if tag != want[i] {
|
||||
t.Fatalf("tags[%d]: got %q, want %q", i, tag, want[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTagsListPagination(t *testing.T) {
|
||||
fdb := newFakeDB()
|
||||
fdb.addRepo("myrepo", 1)
|
||||
fdb.addTag(1, "alpha")
|
||||
fdb.addTag(1, "beta")
|
||||
fdb.addTag(1, "gamma")
|
||||
|
||||
h := NewHandler(fdb, newFakeBlobs(), allowAll(), nil)
|
||||
router := testRouter(h)
|
||||
|
||||
// First page: n=2.
|
||||
req := authedRequest("GET", "/v2/myrepo/tags/list?n=2", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
router.ServeHTTP(rr, req)
|
||||
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("status: got %d, want %d", rr.Code, http.StatusOK)
|
||||
}
|
||||
|
||||
var body tagListResponse
|
||||
if err := json.NewDecoder(rr.Body).Decode(&body); err != nil {
|
||||
t.Fatalf("decode body: %v", err)
|
||||
}
|
||||
if len(body.Tags) != 2 {
|
||||
t.Fatalf("page 1 tags count: got %d, want 2", len(body.Tags))
|
||||
}
|
||||
if body.Tags[0] != "alpha" || body.Tags[1] != "beta" {
|
||||
t.Fatalf("page 1 tags: got %v", body.Tags)
|
||||
}
|
||||
|
||||
// Check Link header.
|
||||
link := rr.Header().Get("Link")
|
||||
if link == "" {
|
||||
t.Fatal("expected Link header for pagination")
|
||||
}
|
||||
|
||||
// Second page: n=2, last=beta.
|
||||
req = authedRequest("GET", "/v2/myrepo/tags/list?n=2&last=beta", nil)
|
||||
rr = httptest.NewRecorder()
|
||||
router.ServeHTTP(rr, req)
|
||||
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("page 2 status: got %d, want %d", rr.Code, http.StatusOK)
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(rr.Body).Decode(&body); err != nil {
|
||||
t.Fatalf("decode page 2: %v", err)
|
||||
}
|
||||
if len(body.Tags) != 1 {
|
||||
t.Fatalf("page 2 tags count: got %d, want 1", len(body.Tags))
|
||||
}
|
||||
if body.Tags[0] != "gamma" {
|
||||
t.Fatalf("page 2 tags: got %v", body.Tags)
|
||||
}
|
||||
|
||||
// No Link header on last page.
|
||||
if link := rr.Header().Get("Link"); link != "" {
|
||||
t.Fatalf("expected no Link header on last page, got %q", link)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTagsListEmpty(t *testing.T) {
|
||||
fdb := newFakeDB()
|
||||
fdb.addRepo("myrepo", 1)
|
||||
// No tags.
|
||||
|
||||
h := NewHandler(fdb, newFakeBlobs(), allowAll(), nil)
|
||||
router := testRouter(h)
|
||||
|
||||
req := authedRequest("GET", "/v2/myrepo/tags/list", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
router.ServeHTTP(rr, req)
|
||||
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("status: got %d, want %d", rr.Code, http.StatusOK)
|
||||
}
|
||||
|
||||
var body tagListResponse
|
||||
if err := json.NewDecoder(rr.Body).Decode(&body); err != nil {
|
||||
t.Fatalf("decode body: %v", err)
|
||||
}
|
||||
if len(body.Tags) != 0 {
|
||||
t.Fatalf("expected empty tags, got %v", body.Tags)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTagsListRepoNotFound(t *testing.T) {
|
||||
fdb := newFakeDB()
|
||||
// No repos.
|
||||
|
||||
h := NewHandler(fdb, newFakeBlobs(), allowAll(), nil)
|
||||
router := testRouter(h)
|
||||
|
||||
req := authedRequest("GET", "/v2/nosuchrepo/tags/list", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
router.ServeHTTP(rr, req)
|
||||
|
||||
if rr.Code != http.StatusNotFound {
|
||||
t.Fatalf("status: got %d, want %d", rr.Code, http.StatusNotFound)
|
||||
}
|
||||
|
||||
var body ociErrorResponse
|
||||
if err := json.NewDecoder(rr.Body).Decode(&body); err != nil {
|
||||
t.Fatalf("decode error body: %v", err)
|
||||
}
|
||||
if len(body.Errors) != 1 || body.Errors[0].Code != "NAME_UNKNOWN" {
|
||||
t.Fatalf("error code: got %+v, want NAME_UNKNOWN", body.Errors)
|
||||
}
|
||||
}
|
||||
241
internal/oci/upload.go
Normal file
241
internal/oci/upload.go
Normal file
@@ -0,0 +1,241 @@
|
||||
package oci
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"sync"
|
||||
|
||||
"git.wntrmute.dev/kyle/mcr/internal/db"
|
||||
"git.wntrmute.dev/kyle/mcr/internal/policy"
|
||||
"git.wntrmute.dev/kyle/mcr/internal/storage"
|
||||
)
|
||||
|
||||
// uploadManager tracks in-progress blob writers by UUID.
|
||||
type uploadManager struct {
|
||||
mu sync.Mutex
|
||||
writers map[string]*storage.BlobWriter
|
||||
}
|
||||
|
||||
func newUploadManager() *uploadManager {
|
||||
return &uploadManager{writers: make(map[string]*storage.BlobWriter)}
|
||||
}
|
||||
|
||||
func (m *uploadManager) set(uuid string, bw *storage.BlobWriter) {
|
||||
m.mu.Lock()
|
||||
m.writers[uuid] = bw
|
||||
m.mu.Unlock()
|
||||
}
|
||||
|
||||
func (m *uploadManager) get(uuid string) (*storage.BlobWriter, bool) {
|
||||
m.mu.Lock()
|
||||
bw, ok := m.writers[uuid]
|
||||
m.mu.Unlock()
|
||||
return bw, ok
|
||||
}
|
||||
|
||||
func (m *uploadManager) remove(uuid string) {
|
||||
m.mu.Lock()
|
||||
delete(m.writers, uuid)
|
||||
m.mu.Unlock()
|
||||
}
|
||||
|
||||
// generateUUID creates a random UUID (v4) string.
|
||||
func generateUUID() (string, error) {
|
||||
var buf [16]byte
|
||||
if _, err := rand.Read(buf[:]); err != nil {
|
||||
return "", fmt.Errorf("oci: generate uuid: %w", err)
|
||||
}
|
||||
// Set version 4 and variant bits.
|
||||
buf[6] = (buf[6] & 0x0f) | 0x40
|
||||
buf[8] = (buf[8] & 0x3f) | 0x80
|
||||
return fmt.Sprintf("%08x-%04x-%04x-%04x-%012x",
|
||||
buf[0:4], buf[4:6], buf[6:8], buf[8:10], buf[10:16]), nil
|
||||
}
|
||||
|
||||
// handleUploadInitiate handles POST /v2/<name>/blobs/uploads/
|
||||
func (h *Handler) handleUploadInitiate(w http.ResponseWriter, r *http.Request, repo string) {
|
||||
if !h.checkPolicy(w, r, policy.ActionPush, repo) {
|
||||
return
|
||||
}
|
||||
|
||||
// Create repository if it doesn't exist (implicit creation).
|
||||
repoID, err := h.db.GetOrCreateRepository(repo)
|
||||
if err != nil {
|
||||
writeOCIError(w, "UNKNOWN", http.StatusInternalServerError, "internal error")
|
||||
return
|
||||
}
|
||||
|
||||
uuid, err := generateUUID()
|
||||
if err != nil {
|
||||
writeOCIError(w, "UNKNOWN", http.StatusInternalServerError, "internal error")
|
||||
return
|
||||
}
|
||||
|
||||
// Insert upload row in DB.
|
||||
if err := h.db.CreateUpload(uuid, repoID); err != nil {
|
||||
writeOCIError(w, "UNKNOWN", http.StatusInternalServerError, "internal error")
|
||||
return
|
||||
}
|
||||
|
||||
// Create temp file via storage.
|
||||
bw, err := h.blobs.StartUpload(uuid)
|
||||
if err != nil {
|
||||
// Clean up DB row on storage failure.
|
||||
_ = h.db.DeleteUpload(uuid)
|
||||
writeOCIError(w, "UNKNOWN", http.StatusInternalServerError, "internal error")
|
||||
return
|
||||
}
|
||||
|
||||
h.uploads.set(uuid, bw)
|
||||
|
||||
w.Header().Set("Location", fmt.Sprintf("/v2/%s/blobs/uploads/%s", repo, uuid))
|
||||
w.Header().Set("Docker-Upload-UUID", uuid)
|
||||
w.Header().Set("Range", "0-0")
|
||||
w.WriteHeader(http.StatusAccepted)
|
||||
}
|
||||
|
||||
// handleUploadChunk handles PATCH /v2/<name>/blobs/uploads/<uuid>
|
||||
func (h *Handler) handleUploadChunk(w http.ResponseWriter, r *http.Request, repo, uuid string) {
|
||||
if !h.checkPolicy(w, r, policy.ActionPush, repo) {
|
||||
return
|
||||
}
|
||||
|
||||
bw, ok := h.uploads.get(uuid)
|
||||
if !ok {
|
||||
writeOCIError(w, "BLOB_UPLOAD_UNKNOWN", http.StatusNotFound, "upload not found")
|
||||
return
|
||||
}
|
||||
|
||||
// Append request body to upload file.
|
||||
n, err := io.Copy(bw, r.Body)
|
||||
if err != nil {
|
||||
writeOCIError(w, "UNKNOWN", http.StatusInternalServerError, "write failed")
|
||||
return
|
||||
}
|
||||
|
||||
// Update offset in DB.
|
||||
newOffset := bw.BytesWritten()
|
||||
if err := h.db.UpdateUploadOffset(uuid, newOffset); err != nil {
|
||||
writeOCIError(w, "UNKNOWN", http.StatusInternalServerError, "internal error")
|
||||
return
|
||||
}
|
||||
|
||||
_ = n // bytes written this chunk
|
||||
|
||||
w.Header().Set("Location", fmt.Sprintf("/v2/%s/blobs/uploads/%s", repo, uuid))
|
||||
w.Header().Set("Docker-Upload-UUID", uuid)
|
||||
w.Header().Set("Range", fmt.Sprintf("0-%d", newOffset))
|
||||
w.WriteHeader(http.StatusAccepted)
|
||||
}
|
||||
|
||||
// handleUploadComplete handles PUT /v2/<name>/blobs/uploads/<uuid>?digest=<digest>
|
||||
func (h *Handler) handleUploadComplete(w http.ResponseWriter, r *http.Request, repo, uuid string) {
|
||||
if !h.checkPolicy(w, r, policy.ActionPush, repo) {
|
||||
return
|
||||
}
|
||||
|
||||
digest := r.URL.Query().Get("digest")
|
||||
if digest == "" {
|
||||
writeOCIError(w, "DIGEST_INVALID", http.StatusBadRequest, "digest parameter required")
|
||||
return
|
||||
}
|
||||
|
||||
bw, ok := h.uploads.get(uuid)
|
||||
if !ok {
|
||||
writeOCIError(w, "BLOB_UPLOAD_UNKNOWN", http.StatusNotFound, "upload not found")
|
||||
return
|
||||
}
|
||||
|
||||
// If request body is non-empty, append it first (monolithic upload).
|
||||
if r.ContentLength != 0 {
|
||||
if _, err := io.Copy(bw, r.Body); err != nil {
|
||||
writeOCIError(w, "UNKNOWN", http.StatusInternalServerError, "write failed")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Commit the blob: verify digest, move to final location.
|
||||
size := bw.BytesWritten()
|
||||
_, err := bw.Commit(digest)
|
||||
if err != nil {
|
||||
h.uploads.remove(uuid)
|
||||
if errors.Is(err, storage.ErrDigestMismatch) {
|
||||
_ = h.db.DeleteUpload(uuid)
|
||||
writeOCIError(w, "DIGEST_INVALID", http.StatusBadRequest, "digest mismatch")
|
||||
return
|
||||
}
|
||||
if errors.Is(err, storage.ErrInvalidDigest) {
|
||||
_ = h.db.DeleteUpload(uuid)
|
||||
writeOCIError(w, "DIGEST_INVALID", http.StatusBadRequest, "invalid digest format")
|
||||
return
|
||||
}
|
||||
writeOCIError(w, "UNKNOWN", http.StatusInternalServerError, "commit failed")
|
||||
return
|
||||
}
|
||||
|
||||
h.uploads.remove(uuid)
|
||||
|
||||
// Insert blob row (no-op if already exists — content-addressed dedup).
|
||||
if err := h.db.InsertBlob(digest, size); err != nil {
|
||||
writeOCIError(w, "UNKNOWN", http.StatusInternalServerError, "internal error")
|
||||
return
|
||||
}
|
||||
|
||||
// Delete upload row.
|
||||
_ = h.db.DeleteUpload(uuid)
|
||||
|
||||
h.audit(r, "blob_uploaded", repo, digest)
|
||||
|
||||
w.Header().Set("Location", fmt.Sprintf("/v2/%s/blobs/%s", repo, digest))
|
||||
w.Header().Set("Docker-Content-Digest", digest)
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
}
|
||||
|
||||
// handleUploadStatus handles GET /v2/<name>/blobs/uploads/<uuid>
|
||||
func (h *Handler) handleUploadStatus(w http.ResponseWriter, r *http.Request, repo, uuid string) {
|
||||
if !h.checkPolicy(w, r, policy.ActionPush, repo) {
|
||||
return
|
||||
}
|
||||
|
||||
upload, err := h.db.GetUpload(uuid)
|
||||
if err != nil {
|
||||
if errors.Is(err, db.ErrUploadNotFound) {
|
||||
writeOCIError(w, "BLOB_UPLOAD_UNKNOWN", http.StatusNotFound, "upload not found")
|
||||
return
|
||||
}
|
||||
writeOCIError(w, "UNKNOWN", http.StatusInternalServerError, "internal error")
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Location", fmt.Sprintf("/v2/%s/blobs/uploads/%s", repo, uuid))
|
||||
w.Header().Set("Docker-Upload-UUID", uuid)
|
||||
w.Header().Set("Range", fmt.Sprintf("0-%d", upload.ByteOffset))
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// handleUploadCancel handles DELETE /v2/<name>/blobs/uploads/<uuid>
|
||||
func (h *Handler) handleUploadCancel(w http.ResponseWriter, r *http.Request, repo, uuid string) {
|
||||
if !h.checkPolicy(w, r, policy.ActionPush, repo) {
|
||||
return
|
||||
}
|
||||
|
||||
bw, ok := h.uploads.get(uuid)
|
||||
if ok {
|
||||
_ = bw.Cancel()
|
||||
h.uploads.remove(uuid)
|
||||
}
|
||||
|
||||
if err := h.db.DeleteUpload(uuid); err != nil {
|
||||
if errors.Is(err, db.ErrUploadNotFound) {
|
||||
writeOCIError(w, "BLOB_UPLOAD_UNKNOWN", http.StatusNotFound, "upload not found")
|
||||
return
|
||||
}
|
||||
writeOCIError(w, "UNKNOWN", http.StatusInternalServerError, "internal error")
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
291
internal/oci/upload_test.go
Normal file
291
internal/oci/upload_test.go
Normal file
@@ -0,0 +1,291 @@
|
||||
package oci
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"git.wntrmute.dev/kyle/mcr/internal/auth"
|
||||
"git.wntrmute.dev/kyle/mcr/internal/storage"
|
||||
)
|
||||
|
||||
// testHandlerWithStorage creates a handler with real storage in t.TempDir().
|
||||
func testHandlerWithStorage(t *testing.T, fdb *fakeDB) (*Handler, *chi.Mux) {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
store := storage.New(dir+"/layers", dir+"/uploads")
|
||||
h := NewHandler(fdb, store, allowAll(), nil)
|
||||
router := chi.NewRouter()
|
||||
router.Mount("/v2", h.Router())
|
||||
return h, router
|
||||
}
|
||||
|
||||
func authedPushRequest(method, path string, body []byte) *http.Request {
|
||||
var reader *bytes.Reader
|
||||
if body != nil {
|
||||
reader = bytes.NewReader(body)
|
||||
} else {
|
||||
reader = bytes.NewReader(nil)
|
||||
}
|
||||
req := httptest.NewRequest(method, path, reader)
|
||||
claims := &auth.Claims{
|
||||
Subject: "pusher",
|
||||
AccountType: "human",
|
||||
Roles: []string{"user"},
|
||||
}
|
||||
return req.WithContext(auth.ContextWithClaims(req.Context(), claims))
|
||||
}
|
||||
|
||||
func TestUploadInitiate(t *testing.T) {
|
||||
fdb := newFakeDB()
|
||||
_, router := testHandlerWithStorage(t, fdb)
|
||||
|
||||
req := authedPushRequest("POST", "/v2/myrepo/blobs/uploads/", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
router.ServeHTTP(rr, req)
|
||||
|
||||
if rr.Code != http.StatusAccepted {
|
||||
t.Fatalf("status: got %d, want %d", rr.Code, http.StatusAccepted)
|
||||
}
|
||||
|
||||
loc := rr.Header().Get("Location")
|
||||
if !strings.HasPrefix(loc, "/v2/myrepo/blobs/uploads/") {
|
||||
t.Fatalf("Location: got %q", loc)
|
||||
}
|
||||
|
||||
uuid := rr.Header().Get("Docker-Upload-UUID")
|
||||
if uuid == "" {
|
||||
t.Fatal("Docker-Upload-UUID header missing")
|
||||
}
|
||||
|
||||
rng := rr.Header().Get("Range")
|
||||
if rng != "0-0" {
|
||||
t.Fatalf("Range: got %q, want %q", rng, "0-0")
|
||||
}
|
||||
|
||||
// Verify repo was implicitly created.
|
||||
if _, ok := fdb.repos["myrepo"]; !ok {
|
||||
t.Fatal("repository should have been implicitly created")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadInitiateUniqueUUIDs(t *testing.T) {
|
||||
fdb := newFakeDB()
|
||||
_, router := testHandlerWithStorage(t, fdb)
|
||||
|
||||
uuids := make(map[string]bool)
|
||||
for range 5 {
|
||||
req := authedPushRequest("POST", "/v2/myrepo/blobs/uploads/", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
router.ServeHTTP(rr, req)
|
||||
|
||||
if rr.Code != http.StatusAccepted {
|
||||
t.Fatalf("status: got %d", rr.Code)
|
||||
}
|
||||
|
||||
uuid := rr.Header().Get("Docker-Upload-UUID")
|
||||
if uuids[uuid] {
|
||||
t.Fatalf("duplicate UUID: %s", uuid)
|
||||
}
|
||||
uuids[uuid] = true
|
||||
}
|
||||
}
|
||||
|
||||
func TestMonolithicUpload(t *testing.T) {
|
||||
fdb := newFakeDB()
|
||||
_, router := testHandlerWithStorage(t, fdb)
|
||||
|
||||
// Step 1: Initiate upload.
|
||||
req := authedPushRequest("POST", "/v2/myrepo/blobs/uploads/", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
router.ServeHTTP(rr, req)
|
||||
|
||||
if rr.Code != http.StatusAccepted {
|
||||
t.Fatalf("initiate status: got %d", rr.Code)
|
||||
}
|
||||
uuid := rr.Header().Get("Docker-Upload-UUID")
|
||||
|
||||
// Step 2: Complete upload with body and digest in a single PUT.
|
||||
blobData := []byte("hello world blob data")
|
||||
sum := sha256.Sum256(blobData)
|
||||
digest := "sha256:" + hex.EncodeToString(sum[:])
|
||||
|
||||
putURL := "/v2/myrepo/blobs/uploads/" + uuid + "?digest=" + digest
|
||||
req = authedPushRequest("PUT", putURL, blobData)
|
||||
rr = httptest.NewRecorder()
|
||||
router.ServeHTTP(rr, req)
|
||||
|
||||
if rr.Code != http.StatusCreated {
|
||||
t.Fatalf("complete status: got %d, body: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
|
||||
loc := rr.Header().Get("Location")
|
||||
if !strings.Contains(loc, digest) {
|
||||
t.Fatalf("Location should contain digest: got %q", loc)
|
||||
}
|
||||
|
||||
dcd := rr.Header().Get("Docker-Content-Digest")
|
||||
if dcd != digest {
|
||||
t.Fatalf("Docker-Content-Digest: got %q, want %q", dcd, digest)
|
||||
}
|
||||
|
||||
// Verify blob was inserted in fake DB.
|
||||
if !fdb.allBlobs[digest] {
|
||||
t.Fatal("blob should exist in DB after upload")
|
||||
}
|
||||
}
|
||||
|
||||
func TestChunkedUpload(t *testing.T) {
|
||||
fdb := newFakeDB()
|
||||
_, router := testHandlerWithStorage(t, fdb)
|
||||
|
||||
// Step 1: Initiate.
|
||||
req := authedPushRequest("POST", "/v2/myrepo/blobs/uploads/", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
router.ServeHTTP(rr, req)
|
||||
uuid := rr.Header().Get("Docker-Upload-UUID")
|
||||
|
||||
// Step 2: PATCH chunk 1.
|
||||
chunk1 := []byte("chunk-one-data-")
|
||||
req = authedPushRequest("PATCH", "/v2/myrepo/blobs/uploads/"+uuid, chunk1)
|
||||
rr = httptest.NewRecorder()
|
||||
router.ServeHTTP(rr, req)
|
||||
|
||||
if rr.Code != http.StatusAccepted {
|
||||
t.Fatalf("patch 1 status: got %d", rr.Code)
|
||||
}
|
||||
|
||||
// Step 3: PATCH chunk 2.
|
||||
chunk2 := []byte("chunk-two-data")
|
||||
req = authedPushRequest("PATCH", "/v2/myrepo/blobs/uploads/"+uuid, chunk2)
|
||||
rr = httptest.NewRecorder()
|
||||
router.ServeHTTP(rr, req)
|
||||
|
||||
if rr.Code != http.StatusAccepted {
|
||||
t.Fatalf("patch 2 status: got %d", rr.Code)
|
||||
}
|
||||
|
||||
// Step 4: Complete with PUT.
|
||||
allData := append(chunk1, chunk2...)
|
||||
sum := sha256.Sum256(allData)
|
||||
digest := "sha256:" + hex.EncodeToString(sum[:])
|
||||
|
||||
req = authedPushRequest("PUT", "/v2/myrepo/blobs/uploads/"+uuid+"?digest="+digest, nil)
|
||||
rr = httptest.NewRecorder()
|
||||
router.ServeHTTP(rr, req)
|
||||
|
||||
if rr.Code != http.StatusCreated {
|
||||
t.Fatalf("complete status: got %d, body: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
if rr.Header().Get("Docker-Content-Digest") != digest {
|
||||
t.Fatalf("Docker-Content-Digest: got %q, want %q", rr.Header().Get("Docker-Content-Digest"), digest)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadDigestMismatch(t *testing.T) {
|
||||
fdb := newFakeDB()
|
||||
_, router := testHandlerWithStorage(t, fdb)
|
||||
|
||||
// Initiate.
|
||||
req := authedPushRequest("POST", "/v2/myrepo/blobs/uploads/", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
router.ServeHTTP(rr, req)
|
||||
uuid := rr.Header().Get("Docker-Upload-UUID")
|
||||
|
||||
// Complete with wrong digest.
|
||||
blobData := []byte("some data")
|
||||
wrongDigest := "sha256:0000000000000000000000000000000000000000000000000000000000000000"
|
||||
|
||||
req = authedPushRequest("PUT", "/v2/myrepo/blobs/uploads/"+uuid+"?digest="+wrongDigest, blobData)
|
||||
rr = httptest.NewRecorder()
|
||||
router.ServeHTTP(rr, req)
|
||||
|
||||
if rr.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status: got %d, want %d", rr.Code, http.StatusBadRequest)
|
||||
}
|
||||
|
||||
var body ociErrorResponse
|
||||
if err := json.NewDecoder(rr.Body).Decode(&body); err != nil {
|
||||
t.Fatalf("decode error: %v", err)
|
||||
}
|
||||
if len(body.Errors) != 1 || body.Errors[0].Code != "DIGEST_INVALID" {
|
||||
t.Fatalf("error code: got %+v, want DIGEST_INVALID", body.Errors)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadStatus(t *testing.T) {
|
||||
fdb := newFakeDB()
|
||||
_, router := testHandlerWithStorage(t, fdb)
|
||||
|
||||
// Initiate.
|
||||
req := authedPushRequest("POST", "/v2/myrepo/blobs/uploads/", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
router.ServeHTTP(rr, req)
|
||||
uuid := rr.Header().Get("Docker-Upload-UUID")
|
||||
|
||||
// Check status.
|
||||
req = authedPushRequest("GET", "/v2/myrepo/blobs/uploads/"+uuid, nil)
|
||||
rr = httptest.NewRecorder()
|
||||
router.ServeHTTP(rr, req)
|
||||
|
||||
if rr.Code != http.StatusNoContent {
|
||||
t.Fatalf("status: got %d, want %d", rr.Code, http.StatusNoContent)
|
||||
}
|
||||
|
||||
if rr.Header().Get("Docker-Upload-UUID") != uuid {
|
||||
t.Fatalf("Docker-Upload-UUID: got %q", rr.Header().Get("Docker-Upload-UUID"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadCancel(t *testing.T) {
|
||||
fdb := newFakeDB()
|
||||
_, router := testHandlerWithStorage(t, fdb)
|
||||
|
||||
// Initiate.
|
||||
req := authedPushRequest("POST", "/v2/myrepo/blobs/uploads/", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
router.ServeHTTP(rr, req)
|
||||
uuid := rr.Header().Get("Docker-Upload-UUID")
|
||||
|
||||
// Cancel.
|
||||
req = authedPushRequest("DELETE", "/v2/myrepo/blobs/uploads/"+uuid, nil)
|
||||
rr = httptest.NewRecorder()
|
||||
router.ServeHTTP(rr, req)
|
||||
|
||||
if rr.Code != http.StatusNoContent {
|
||||
t.Fatalf("status: got %d, want %d", rr.Code, http.StatusNoContent)
|
||||
}
|
||||
|
||||
// Verify upload was removed from DB.
|
||||
if _, ok := fdb.uploads[uuid]; ok {
|
||||
t.Fatal("upload should have been deleted from DB")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadNonexistentUUID(t *testing.T) {
|
||||
fdb := newFakeDB()
|
||||
_, router := testHandlerWithStorage(t, fdb)
|
||||
|
||||
req := authedPushRequest("PATCH", "/v2/myrepo/blobs/uploads/nonexistent-uuid", []byte("data"))
|
||||
rr := httptest.NewRecorder()
|
||||
router.ServeHTTP(rr, req)
|
||||
|
||||
if rr.Code != http.StatusNotFound {
|
||||
t.Fatalf("status: got %d, want %d", rr.Code, http.StatusNotFound)
|
||||
}
|
||||
|
||||
var body ociErrorResponse
|
||||
if err := json.NewDecoder(rr.Body).Decode(&body); err != nil {
|
||||
t.Fatalf("decode error: %v", err)
|
||||
}
|
||||
if len(body.Errors) != 1 || body.Errors[0].Code != "BLOB_UPLOAD_UNKNOWN" {
|
||||
t.Fatalf("error code: got %+v, want BLOB_UPLOAD_UNKNOWN", body.Errors)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user