feat(ecr): require repository to exist before push (#400)

The OCI /v2 registry auto-created a repository on first push (ensureRepo).
AWS ECR does not: a push or PutImage to a repository that was never created
with CreateRepository is rejected. Replace ensureRepo with requireRepo, which
returns OCI NAME_UNKNOWN on the data plane and RepositoryNotFoundException on
the JSON PutImage path. Enforcement sits at the single push choke point
(startUpload + StoreManifest).
This commit is contained in:
Josh Siviour
2026-06-19 12:38:15 +10:00
committed by GitHub
parent 2cde4ed21e
commit 57737af09e
8 changed files with 103 additions and 8 deletions
+18 -7
View File
@@ -237,8 +237,13 @@ func (reg *Registry) getBlob(w http.ResponseWriter, _, digest string) {
// to 201 when the digest already lives in the account pool; otherwise it opens a
// new chunked upload (202) with a fresh running sha256 state.
func (reg *Registry) startUpload(w http.ResponseWriter, r *http.Request, name string) {
if err := reg.ensureRepo(name); err != nil {
reg.internal(w, "ensure repo", err)
if err := reg.requireRepo(name); err != nil {
var mErr *ManifestStoreError
if errors.As(err, &mErr) {
WriteError(w, mErr.Status, mErr.Code, mErr.Msg)
return
}
reg.internal(w, "require repo", err)
return
}
@@ -731,13 +736,19 @@ func (reg *Registry) ensureBucket() error {
return nil
}
func (reg *Registry) ensureRepo(name string) error {
if _, err := reg.Meta.GetRepo(reg.AccountID, name); err == nil {
// requireRepo enforces that the repository exists before a push. ECR does not
// auto-create repositories: a push to a repo that was never created with
// CreateRepository is rejected with OCI NAME_UNKNOWN (the JSON PutImage path
// maps this to RepositoryNotFoundException).
func (reg *Registry) requireRepo(name string) error {
_, err := reg.Meta.GetRepo(reg.AccountID, name)
if err == nil {
return nil
} else if !errors.Is(err, ecr.ErrNotFound) {
return err
}
return reg.Meta.PutRepo(reg.AccountID, ecr.RepoMeta{Name: name, CreatedAt: time.Now().UTC()})
if errors.Is(err, ecr.ErrNotFound) {
return &ManifestStoreError{http.StatusNotFound, "NAME_UNKNOWN", fmt.Sprintf("repository %q does not exist", name)}
}
return err
}
func (reg *Registry) blobExists(digest string) bool {
+1 -1
View File
@@ -52,7 +52,7 @@ type ImageRecord struct {
// *ManifestStoreError carrying the OCI code.
func (reg *Registry) StoreManifest(account, repo, ref, contentType string, body []byte) (string, error) {
scoped := reg.forAccount(account)
if err := scoped.ensureRepo(repo); err != nil {
if err := scoped.requireRepo(repo); err != nil {
return "", err
}
if len(body) > maxManifestBytes {
@@ -159,6 +159,7 @@ func TestListImages_KVDigestForm(t *testing.T) {
_, _, js := testutil.StartTestJetStream(t)
reg := NewRegistry(objectstore.NewMemoryObjectStore(), ecr.NewKVMetaStore(js), testAccount)
repo := "team/app"
require.NoError(t, reg.Meta.PutRepo(testAccount, ecr.RepoMeta{Name: repo}))
manifest := []byte(`{"schemaVersion":2,"mediaType":"application/vnd.docker.distribution.manifest.v2+json","layers":[]}`)
digest, err := reg.StoreManifest(testAccount, repo, "v1", mediaTypeDockerManifest, manifest)
+33
View File
@@ -43,9 +43,17 @@ func do(reg *Registry, method, path string, body []byte, hdr map[string]string)
return w
}
// seedRepo creates the repository metadata so push handlers — which require an
// existing repository — accept writes. Idempotent.
func seedRepo(t *testing.T, reg *Registry, repo string) {
t.Helper()
require.NoError(t, reg.Meta.PutRepo(testAccount, ecr.RepoMeta{Name: repo}))
}
// pushBlob runs the monolithic upload-start + PUT?digest round-trip.
func pushBlob(t *testing.T, reg *Registry, repo string, data []byte) string {
t.Helper()
seedRepo(t, reg, repo)
w := do(reg, http.MethodPost, "/v2/"+repo+"/blobs/uploads/", nil, nil)
require.Equal(t, http.StatusAccepted, w.Code)
loc := w.Header().Get("Location")
@@ -88,6 +96,7 @@ func TestBlobGet_MalformedDigest(t *testing.T) {
func TestBlobUpload_Chunked(t *testing.T) {
reg := newTestRegistry()
seedRepo(t, reg, "r/repo")
c1 := []byte("first-chunk-")
c2 := []byte("second-chunk")
full := append(append([]byte{}, c1...), c2...)
@@ -114,6 +123,7 @@ func TestBlobUpload_Chunked(t *testing.T) {
func TestBlobUpload_DigestMismatch(t *testing.T) {
reg := newTestRegistry()
seedRepo(t, reg, "r/repo")
w := do(reg, http.MethodPost, "/v2/r/repo/blobs/uploads/", nil, nil)
loc := w.Header().Get("Location")
w = do(reg, http.MethodPut, loc+"?digest="+digestOf([]byte("wrong")), []byte("actual"), nil)
@@ -123,6 +133,7 @@ func TestBlobUpload_DigestMismatch(t *testing.T) {
func TestBlobUpload_Cancel(t *testing.T) {
reg := newTestRegistry()
seedRepo(t, reg, "r/repo")
w := do(reg, http.MethodPost, "/v2/r/repo/blobs/uploads/", nil, nil)
loc := w.Header().Get("Location")
@@ -138,6 +149,7 @@ func TestBlobMount_HitAndMiss(t *testing.T) {
reg := newTestRegistry()
data := []byte("shared-layer")
dg := pushBlob(t, reg, "team/source", data)
seedRepo(t, reg, "team/dest")
// Mount hit: blob already in account pool -> 201 without upload.
w := do(reg, http.MethodPost,
@@ -185,6 +197,7 @@ func TestManifest_PutGetHead_ImageManifest(t *testing.T) {
func TestManifest_Put_MissingBlob(t *testing.T) {
reg := newTestRegistry()
repo := "team/app"
seedRepo(t, reg, repo)
manifest := fmt.Appendf(nil,
`{"mediaType":"%s","config":{"digest":"%s"},"layers":[]}`,
mediaTypeDockerManifest, digestOf([]byte("nope")))
@@ -238,6 +251,26 @@ func TestManifest_Get_Unknown(t *testing.T) {
assertCode(t, w, "MANIFEST_UNKNOWN")
}
// TestStartUpload_RepoNotCreated asserts a blob upload to a repository that was
// never created is rejected up front — ECR does not auto-create on push.
func TestStartUpload_RepoNotCreated(t *testing.T) {
reg := newTestRegistry()
w := do(reg, http.MethodPost, "/v2/team/ghost/blobs/uploads/", nil, nil)
assert.Equal(t, http.StatusNotFound, w.Code)
assertCode(t, w, "NAME_UNKNOWN")
}
// TestPutManifest_RepoNotCreated asserts a manifest PUT to an uncreated
// repository is rejected with NAME_UNKNOWN.
func TestPutManifest_RepoNotCreated(t *testing.T) {
reg := newTestRegistry()
manifest := fmt.Appendf(nil, `{"mediaType":"%s","layers":[]}`, mediaTypeDockerManifest)
w := do(reg, http.MethodPut, "/v2/team/ghost/manifests/v1", manifest,
map[string]string{"Content-Type": mediaTypeDockerManifest})
assert.Equal(t, http.StatusNotFound, w.Code)
assertCode(t, w, "NAME_UNKNOWN")
}
func TestManifest_ImageIndex_ChildValidation(t *testing.T) {
reg := newTestRegistry()
repo := "team/app"
@@ -35,6 +35,7 @@ func TestFinishUpload_StoreFailure_CleansUp(t *testing.T) {
store := &blobPutFailStore{MemoryObjectStore: mem}
meta := ecr.NewMemoryMetaStore()
reg := NewRegistry(store, meta, testAccount)
seedRepo(t, reg, "team/app")
w := do(reg, http.MethodPost, "/v2/team/app/blobs/uploads/", nil, nil)
require.Equal(t, http.StatusAccepted, w.Code)
@@ -115,6 +116,7 @@ func TestPatchUpload_ConcurrentNoCorruption(t *testing.T) {
for iter := range 25 {
reg := newTestRegistry()
repo := "team/race"
seedRepo(t, reg, repo)
w := do(reg, http.MethodPost, "/v2/"+repo+"/blobs/uploads/", nil, nil)
require.Equal(t, http.StatusAccepted, w.Code)
+2
View File
@@ -388,6 +388,8 @@ func mapStoreManifestError(err error, repo string) error {
return errors.New(awserrors.ErrorLayersNotFound)
case "TAG_IMMUTABLE":
return errors.New(awserrors.ErrorImageTagAlreadyExists)
case "NAME_UNKNOWN":
return errors.New(awserrors.ErrorRepositoryNotFound)
default:
return errors.New(awserrors.ErrorInvalidParameterValue)
}
+24
View File
@@ -26,10 +26,18 @@ func newImageGateway(t *testing.T) *GatewayConfig {
return &GatewayConfig{ECRRegistry: reg, Region: ecrTestRegion, InternalSuffix: ecrTestSuffix, DisableLogging: true}
}
// seedGatewayRepo creates the repository metadata so push/PutImage handlers —
// which require an existing repository — accept writes. Idempotent.
func seedGatewayRepo(t *testing.T, gw *GatewayConfig, repo string) {
t.Helper()
require.NoError(t, gw.ECRRegistry.Meta.PutRepo(ecrTestAccount, handlers_ecr.RepoMeta{Name: repo}))
}
// seedTaggedImage stores a layerless manifest under repo:tag, returning its
// digest. A unique manifest per tag yields a unique digest.
func seedTaggedImage(t *testing.T, gw *GatewayConfig, repo, tag string) string {
t.Helper()
seedGatewayRepo(t, gw, repo)
manifest := fmt.Appendf(nil,
`{"schemaVersion":2,"mediaType":"application/vnd.docker.distribution.manifest.v2+json","layers":[],"annotations":{"tag":"%s"}}`, tag)
digest, err := gw.ECRRegistry.StoreManifest(ecrTestAccount, repo, tag, "application/vnd.docker.distribution.manifest.v2+json", manifest)
@@ -143,6 +151,7 @@ func TestBatchGetImage_CapExceeded(t *testing.T) {
func TestPutImage_HappyAndMissingManifest(t *testing.T) {
gw := newImageGateway(t)
seedGatewayRepo(t, gw, "team/app")
manifest := `{"schemaVersion":2,"mediaType":"application/vnd.docker.distribution.manifest.v2+json","layers":[]}`
body, _ := json.Marshal(map[string]string{
"repositoryName": "team/app",
@@ -171,6 +180,21 @@ func TestPutImage_HappyAndMissingManifest(t *testing.T) {
assert.Equal(t, "InvalidParameterValue", err.Error())
}
// TestPutImage_RepoNotCreated asserts the JSON PutImage path rejects an uncreated
// repository with RepositoryNotFoundException (the AWS twin of OCI NAME_UNKNOWN).
func TestPutImage_RepoNotCreated(t *testing.T) {
gw := newImageGateway(t)
body, _ := json.Marshal(map[string]string{
"repositoryName": "team/ghost",
"imageManifest": `{"schemaVersion":2,"mediaType":"application/vnd.docker.distribution.manifest.v2+json","layers":[]}`,
"imageManifestMediaType": "application/vnd.docker.distribution.manifest.v2+json",
"imageTag": "v1",
})
_, err := callImage(t, gw, (*GatewayConfig).handlePutImage, string(body))
require.Error(t, err)
assert.Equal(t, "RepositoryNotFoundException", err.Error())
}
func TestBatchDeleteImage_Partial(t *testing.T) {
gw := newImageGateway(t)
digest := seedTaggedImage(t, gw, "team/app", "v1")
+22
View File
@@ -79,6 +79,28 @@ func TestCranePushPull(t *testing.T) {
require.NoErrorf(t, err, "crane pull: %s", out)
}
// TestPushToUncreatedRepoRejected asserts the registry does not auto-create a
// repository on push: a crane append to a repo that was never created with
// CreateRepository must fail with NAME_UNKNOWN.
func TestPushToUncreatedRepoRejected(t *testing.T) {
f := requireECRFixture(t)
host := harness.ECRRegistryHost(f.Account)
harness.RequireRegistryResolves(t, host)
dockerConfig := filepath.Join(f.TmpDir, "uncreated")
require.NoError(t, os.MkdirAll(dockerConfig, 0o700))
crane := harness.NewCrane(t, dockerConfig)
craneLogin(t, crane, host, harness.ECRGetLoginPassword(t, f.AWS))
// Deliberately NOT created: no CreateECRRepository call.
ref := harness.ECRRepositoryURI(f.Account, uniqueRepo("uncreated")) + ":v1"
layer := writeLayerTar(t, f.TmpDir)
out, err := crane.Run(ociCmdTimeout, "append", "-f", layer, "-t", ref)
require.Errorf(t, err, "push to uncreated repo should fail; got success: %s", out)
assert.Contains(t, out, "NAME_UNKNOWN", "expected NAME_UNKNOWN rejection, got: %s", out)
}
// TestSkopeoCopy seeds an image with crane, then `skopeo copy` retags it within
// the registry — exercising skopeo's Bearer auth on both read and write.
func TestSkopeoCopy(t *testing.T) {