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:
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user