mirror of
https://github.com/mulgadc/spinifex.git
synced 2026-10-22 20:26:44 +00:00
refactor: move ecr delete action to domain
This commit is contained in:
@@ -0,0 +1,71 @@
|
||||
package awsapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log/slog"
|
||||
|
||||
"github.com/aws/aws-sdk-go/service/ecr"
|
||||
handlers_ecr "github.com/mulgadc/spinifex/spinifex/domains/ecr"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
|
||||
"github.com/nats-io/nats.go"
|
||||
)
|
||||
|
||||
// deleteRepositoryRequest is the camelCase AWS JSON 1.1 input shape. force
|
||||
// permits deletion when a repository still contains images.
|
||||
type deleteRepositoryRequest struct {
|
||||
RepositoryName string `json:"repositoryName"`
|
||||
RegistryID string `json:"registryId"`
|
||||
Force bool `json:"force"`
|
||||
}
|
||||
|
||||
// DeleteRepository removes an account-scoped ECR repository. Without force,
|
||||
// a repository with image manifests returns RepositoryNotEmptyException. The
|
||||
// metadata service cascades repository records; object blob reclamation is
|
||||
// intentionally deferred to the separate garbage-collection path.
|
||||
func DeleteRepository(ctx context.Context, nc *nats.Conn, endpoint RepositoryEndpoint, accountID string, body []byte) (*ecr.DeleteRepositoryOutput, error) {
|
||||
var req deleteRepositoryRequest
|
||||
if err := json.Unmarshal(body, &req); err != nil {
|
||||
return nil, MalformedBodyError()
|
||||
}
|
||||
if err := ValidateRepositoryName(req.RepositoryName); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if req.RegistryID != "" && req.RegistryID != accountID {
|
||||
return nil, errors.New(awserrors.ErrorAccessDenied)
|
||||
}
|
||||
|
||||
store := handlers_ecr.NewNATSMetaStore(nc)
|
||||
meta, err := store.GetRepo(ctx, accountID, req.RepositoryName)
|
||||
if err != nil {
|
||||
if errors.Is(err, handlers_ecr.ErrNotFound) {
|
||||
return nil, errors.New(awserrors.ErrorRepositoryNotFound)
|
||||
}
|
||||
slog.ErrorContext(ctx, "ECR DeleteRepository: get repository failed", "repository", req.RepositoryName, "err", err)
|
||||
return nil, errors.New(awserrors.ErrorServerInternal)
|
||||
}
|
||||
|
||||
if !req.Force {
|
||||
manifests, err := store.ListManifests(ctx, accountID, req.RepositoryName)
|
||||
if err != nil {
|
||||
slog.ErrorContext(ctx, "ECR DeleteRepository: list manifests failed", "repository", req.RepositoryName, "err", err)
|
||||
return nil, errors.New(awserrors.ErrorServerInternal)
|
||||
}
|
||||
if len(manifests) > 0 {
|
||||
return nil, errors.New(awserrors.ErrorRepositoryNotEmpty)
|
||||
}
|
||||
}
|
||||
|
||||
if err := store.DeleteRepo(ctx, accountID, req.RepositoryName); err != nil {
|
||||
if errors.Is(err, handlers_ecr.ErrNotFound) {
|
||||
return nil, errors.New(awserrors.ErrorRepositoryNotFound)
|
||||
}
|
||||
slog.ErrorContext(ctx, "ECR DeleteRepository: delete repository failed", "repository", req.RepositoryName, "err", err)
|
||||
return nil, errors.New(awserrors.ErrorServerInternal)
|
||||
}
|
||||
|
||||
return &ecr.DeleteRepositoryOutput{
|
||||
Repository: endpoint.RepositoryFromMeta(accountID, req.RepositoryName, meta),
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package awsapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
handlers_ecr "github.com/mulgadc/spinifex/spinifex/domains/ecr"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestDeleteRepository_RemovesEmptyRepository(t *testing.T) {
|
||||
nc := newRepositoryActionTestConn(t)
|
||||
seedRepositoryForAction(t, nc, "team/app")
|
||||
|
||||
out, err := DeleteRepository(context.Background(), nc, repositoryActionEndpoint(), repositoryActionTestAccount, []byte(`{"repositoryName":"team/app"}`))
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, out.Repository)
|
||||
assert.Equal(t, "team/app", *out.Repository.RepositoryName)
|
||||
|
||||
_, err = handlers_ecr.NewNATSMetaStore(nc).GetRepo(context.Background(), repositoryActionTestAccount, "team/app")
|
||||
require.ErrorIs(t, err, handlers_ecr.ErrNotFound)
|
||||
}
|
||||
|
||||
func TestDeleteRepository_ProtectsNonEmptyRepositoryUnlessForced(t *testing.T) {
|
||||
nc := newRepositoryActionTestConn(t)
|
||||
seedRepositoryForAction(t, nc, "team/app")
|
||||
store := handlers_ecr.NewNATSMetaStore(nc)
|
||||
require.NoError(t, store.PutManifestMeta(context.Background(), repositoryActionTestAccount, "team/app", handlers_ecr.ManifestMeta{
|
||||
Digest: "sha256:" + strings.Repeat("a", 64), MediaType: "application/json", Size: 7, PushedAt: time.Now(),
|
||||
}))
|
||||
|
||||
_, err := DeleteRepository(context.Background(), nc, repositoryActionEndpoint(), repositoryActionTestAccount, []byte(`{"repositoryName":"team/app"}`))
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, awserrors.ErrorRepositoryNotEmpty, awserrors.ValidErrorCodeFromError(err))
|
||||
|
||||
out, err := DeleteRepository(context.Background(), nc, repositoryActionEndpoint(), repositoryActionTestAccount, []byte(`{"repositoryName":"team/app","force":true}`))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "team/app", *out.Repository.RepositoryName)
|
||||
}
|
||||
|
||||
func TestDeleteRepository_ValidatesRequest(t *testing.T) {
|
||||
nc := newRepositoryActionTestConn(t)
|
||||
cases := []struct {
|
||||
name string
|
||||
body string
|
||||
code string
|
||||
}{
|
||||
{"malformed", `{`, awserrors.ErrorInvalidParameterValue},
|
||||
{"missing name", `{}`, awserrors.ErrorInvalidParameterValue},
|
||||
{"cross account", `{"repositoryName":"team/app","registryId":"999999999999"}`, awserrors.ErrorAccessDenied},
|
||||
{"missing repository", `{"repositoryName":"team/missing"}`, awserrors.ErrorRepositoryNotFound},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
_, err := DeleteRepository(context.Background(), nc, repositoryActionEndpoint(), repositoryActionTestAccount, []byte(tc.body))
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, tc.code, awserrors.ValidErrorCodeFromError(err))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -27,7 +27,10 @@ func newRepositoryActionTestConn(t *testing.T) *nats.Conn {
|
||||
svc := handlers_ecr.NewKVMetaService(js)
|
||||
serveRepositoryActionMeta(t, nc, handlers_ecr.SubjectRepoCreate, svc.RepoCreate)
|
||||
serveRepositoryActionMeta(t, nc, handlers_ecr.SubjectRepoDescribe, svc.RepoDescribe)
|
||||
serveRepositoryActionMeta(t, nc, handlers_ecr.SubjectRepoDelete, svc.RepoDelete)
|
||||
serveRepositoryActionMeta(t, nc, handlers_ecr.SubjectRepoList, svc.RepoList)
|
||||
serveRepositoryActionMeta(t, nc, handlers_ecr.SubjectManifestPut, svc.ManifestPut)
|
||||
serveRepositoryActionMeta(t, nc, handlers_ecr.SubjectManifestList, svc.ManifestList)
|
||||
return nc
|
||||
}
|
||||
|
||||
|
||||
@@ -1,30 +1,18 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"github.com/aws/aws-sdk-go/service/ecr"
|
||||
handlers_ecr "github.com/mulgadc/spinifex/spinifex/domains/ecr"
|
||||
awsapi "github.com/mulgadc/spinifex/spinifex/domains/ecr/awsapi"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
|
||||
)
|
||||
|
||||
// deleteRepositoryRequest is the camelCase AWS JSON 1.1 input shape. force=true
|
||||
// allows deleting a repository that still contains images.
|
||||
type deleteRepositoryRequest struct {
|
||||
RepositoryName string `json:"repositoryName"`
|
||||
RegistryID string `json:"registryId"`
|
||||
Force bool `json:"force"`
|
||||
}
|
||||
|
||||
// handleDeleteRepository removes a repository in the caller account. Without
|
||||
// force, a repository that still holds images is rejected with
|
||||
// RepositoryNotEmptyException. The KV metadata (meta, policy, tags, manifests)
|
||||
// is cascaded; predastore blob reclamation is deferred to a separate GC pass.
|
||||
// handleDeleteRepository adapts the authenticated HTTP request to the ECR AWS
|
||||
// action. ECR lifecycle rules and metadata operations belong to the domain
|
||||
// adapter; gateway retains HTTP and auth-context handling.
|
||||
func (gw *GatewayConfig) handleDeleteRepository(w http.ResponseWriter, r *http.Request) error {
|
||||
ctx := r.Context()
|
||||
accountID, _ := ctx.Value(ctxAccountID).(string)
|
||||
@@ -38,48 +26,10 @@ func (gw *GatewayConfig) handleDeleteRepository(w http.ResponseWriter, r *http.R
|
||||
slog.ErrorContext(ctx, "DeleteRepository: failed to read body", "err", err)
|
||||
return awsapi.MalformedBodyError()
|
||||
}
|
||||
var req deleteRepositoryRequest
|
||||
if err := json.Unmarshal(body, &req); err != nil {
|
||||
return awsapi.MalformedBodyError()
|
||||
}
|
||||
if err := awsapi.ValidateRepositoryName(req.RepositoryName); err != nil {
|
||||
output, err := awsapi.DeleteRepository(ctx, gw.NATSConn, gw.ecrRepositoryEndpoint(), accountID, body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if req.RegistryID != "" && req.RegistryID != accountID {
|
||||
return errors.New(awserrors.ErrorAccessDenied)
|
||||
}
|
||||
|
||||
store := handlers_ecr.NewNATSMetaStore(gw.NATSConn)
|
||||
meta, err := store.GetRepo(ctx, accountID, req.RepositoryName)
|
||||
if err != nil {
|
||||
if errors.Is(err, handlers_ecr.ErrNotFound) {
|
||||
return errors.New(awserrors.ErrorRepositoryNotFound)
|
||||
}
|
||||
slog.ErrorContext(ctx, "DeleteRepository: get repo failed", "repo", req.RepositoryName, "err", err)
|
||||
return errors.New(awserrors.ErrorServerInternal)
|
||||
}
|
||||
|
||||
if !req.Force {
|
||||
manifests, err := store.ListManifests(ctx, accountID, req.RepositoryName)
|
||||
if err != nil {
|
||||
slog.ErrorContext(ctx, "DeleteRepository: list manifests failed", "repo", req.RepositoryName, "err", err)
|
||||
return errors.New(awserrors.ErrorServerInternal)
|
||||
}
|
||||
if len(manifests) > 0 {
|
||||
return errors.New(awserrors.ErrorRepositoryNotEmpty)
|
||||
}
|
||||
}
|
||||
|
||||
if err := store.DeleteRepo(ctx, accountID, req.RepositoryName); err != nil {
|
||||
if errors.Is(err, handlers_ecr.ErrNotFound) {
|
||||
return errors.New(awserrors.ErrorRepositoryNotFound)
|
||||
}
|
||||
slog.ErrorContext(ctx, "DeleteRepository: delete repo failed", "repo", req.RepositoryName, "err", err)
|
||||
return errors.New(awserrors.ErrorServerInternal)
|
||||
}
|
||||
|
||||
awsapi.WriteJSONResponse(w, &ecr.DeleteRepositoryOutput{
|
||||
Repository: gw.ecrRepositoryEndpoint().RepositoryFromMeta(accountID, req.RepositoryName, meta),
|
||||
})
|
||||
awsapi.WriteJSONResponse(w, output)
|
||||
return nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user