diff --git a/spinifex/domains/ecr/awsapi/create_repository.go b/spinifex/domains/ecr/awsapi/create_repository.go index 919c92309..e4db93a45 100644 --- a/spinifex/domains/ecr/awsapi/create_repository.go +++ b/spinifex/domains/ecr/awsapi/create_repository.go @@ -11,7 +11,6 @@ import ( "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" ) // createRepositoryRequest is the camelCase AWS JSON 1.1 input shape. ecr.Tag @@ -40,7 +39,10 @@ type encryptionConfigurationInput struct { // // The advertised endpoint is deployment composition rather than ECR resource // state, so the caller supplies it to project the AWS response. -func CreateRepository(ctx context.Context, nc *nats.Conn, endpoint RepositoryEndpoint, accountID string, body []byte) (*ecr.CreateRepositoryOutput, error) { +func CreateRepository(ctx context.Context, store RepositoryStore, endpoint RepositoryEndpoint, accountID string, body []byte) (*ecr.CreateRepositoryOutput, error) { + if store == nil { + return nil, errors.New(awserrors.ErrorServerInternal) + } var req createRepositoryRequest if err := json.Unmarshal(body, &req); err != nil { return nil, MalformedBodyError() @@ -64,7 +66,6 @@ func CreateRepository(ctx context.Context, nc *nats.Conn, endpoint RepositoryEnd return nil, err } - store := handlers_ecr.NewNATSMetaStore(nc) if _, err := store.GetRepo(ctx, accountID, req.RepositoryName); err == nil { return nil, errors.New(awserrors.ErrorRepositoryAlreadyExists) } else if !errors.Is(err, handlers_ecr.ErrNotFound) { diff --git a/spinifex/domains/ecr/awsapi/create_repository_test.go b/spinifex/domains/ecr/awsapi/create_repository_test.go index 7a556750a..a809ee868 100644 --- a/spinifex/domains/ecr/awsapi/create_repository_test.go +++ b/spinifex/domains/ecr/awsapi/create_repository_test.go @@ -12,16 +12,17 @@ import ( func TestCreateRepository_PersistsConfiguredMetadata(t *testing.T) { nc := newRepositoryActionTestConn(t) + store := handlers_ecr.NewNATSMetaStore(nc) body := []byte(`{"repositoryName":"team/app","imageTagMutability":"IMMUTABLE","tags":[{"Key":"environment","Value":"test"}],"imageScanningConfiguration":{"scanOnPush":true}}`) - out, err := CreateRepository(context.Background(), nc, repositoryActionEndpoint(), repositoryActionTestAccount, body) + out, err := CreateRepository(context.Background(), store, repositoryActionEndpoint(), repositoryActionTestAccount, body) require.NoError(t, err) require.NotNil(t, out.Repository) assert.Equal(t, "team/app", *out.Repository.RepositoryName) assert.Equal(t, "IMMUTABLE", *out.Repository.ImageTagMutability) assert.True(t, *out.Repository.ImageScanningConfiguration.ScanOnPush) - meta, err := handlers_ecr.NewNATSMetaStore(nc).GetRepo(context.Background(), repositoryActionTestAccount, "team/app") + meta, err := store.GetRepo(context.Background(), repositoryActionTestAccount, "team/app") require.NoError(t, err) assert.Equal(t, handlers_ecr.TagMutabilityImmutable, meta.ImageTagMutability) assert.Equal(t, map[string]string{"environment": "test"}, meta.Tags) @@ -30,7 +31,8 @@ func TestCreateRepository_PersistsConfiguredMetadata(t *testing.T) { func TestCreateRepository_RejectsInvalidRequestsAndDuplicates(t *testing.T) { nc := newRepositoryActionTestConn(t) - _, err := CreateRepository(context.Background(), nc, repositoryActionEndpoint(), repositoryActionTestAccount, []byte(`{"repositoryName":"team/app"}`)) + store := handlers_ecr.NewNATSMetaStore(nc) + _, err := CreateRepository(context.Background(), store, repositoryActionEndpoint(), repositoryActionTestAccount, []byte(`{"repositoryName":"team/app"}`)) require.NoError(t, err) cases := []struct { @@ -46,7 +48,7 @@ func TestCreateRepository_RejectsInvalidRequestsAndDuplicates(t *testing.T) { } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { - _, err := CreateRepository(context.Background(), nc, repositoryActionEndpoint(), repositoryActionTestAccount, []byte(tc.body)) + _, err := CreateRepository(context.Background(), store, repositoryActionEndpoint(), repositoryActionTestAccount, []byte(tc.body)) require.Error(t, err) assert.Equal(t, tc.code, awserrors.ValidErrorCodeFromError(err)) }) diff --git a/spinifex/domains/ecr/awsapi/delete_repository.go b/spinifex/domains/ecr/awsapi/delete_repository.go index c9b5c0585..e8cd62856 100644 --- a/spinifex/domains/ecr/awsapi/delete_repository.go +++ b/spinifex/domains/ecr/awsapi/delete_repository.go @@ -9,7 +9,6 @@ import ( "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 @@ -24,7 +23,10 @@ type deleteRepositoryRequest struct { // 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) { +func DeleteRepository(ctx context.Context, store RepositoryStore, endpoint RepositoryEndpoint, accountID string, body []byte) (*ecr.DeleteRepositoryOutput, error) { + if store == nil { + return nil, errors.New(awserrors.ErrorServerInternal) + } var req deleteRepositoryRequest if err := json.Unmarshal(body, &req); err != nil { return nil, MalformedBodyError() @@ -36,7 +38,6 @@ func DeleteRepository(ctx context.Context, nc *nats.Conn, endpoint RepositoryEnd 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) { diff --git a/spinifex/domains/ecr/awsapi/delete_repository_test.go b/spinifex/domains/ecr/awsapi/delete_repository_test.go index 8da4bc30e..7f2c8ccf6 100644 --- a/spinifex/domains/ecr/awsapi/delete_repository_test.go +++ b/spinifex/domains/ecr/awsapi/delete_repository_test.go @@ -14,14 +14,15 @@ import ( func TestDeleteRepository_RemovesEmptyRepository(t *testing.T) { nc := newRepositoryActionTestConn(t) + store := handlers_ecr.NewNATSMetaStore(nc) seedRepositoryForAction(t, nc, "team/app") - out, err := DeleteRepository(context.Background(), nc, repositoryActionEndpoint(), repositoryActionTestAccount, []byte(`{"repositoryName":"team/app"}`)) + out, err := DeleteRepository(context.Background(), store, 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") + _, err = store.GetRepo(context.Background(), repositoryActionTestAccount, "team/app") require.ErrorIs(t, err, handlers_ecr.ErrNotFound) } @@ -33,17 +34,18 @@ func TestDeleteRepository_ProtectsNonEmptyRepositoryUnlessForced(t *testing.T) { 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"}`)) + _, err := DeleteRepository(context.Background(), store, 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}`)) + out, err := DeleteRepository(context.Background(), store, 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) + store := handlers_ecr.NewNATSMetaStore(nc) cases := []struct { name string body string @@ -56,7 +58,7 @@ func TestDeleteRepository_ValidatesRequest(t *testing.T) { } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { - _, err := DeleteRepository(context.Background(), nc, repositoryActionEndpoint(), repositoryActionTestAccount, []byte(tc.body)) + _, err := DeleteRepository(context.Background(), store, repositoryActionEndpoint(), repositoryActionTestAccount, []byte(tc.body)) require.Error(t, err) assert.Equal(t, tc.code, awserrors.ValidErrorCodeFromError(err)) }) diff --git a/spinifex/domains/ecr/awsapi/describe_repositories.go b/spinifex/domains/ecr/awsapi/describe_repositories.go index cf7d2872d..4b1e919e8 100644 --- a/spinifex/domains/ecr/awsapi/describe_repositories.go +++ b/spinifex/domains/ecr/awsapi/describe_repositories.go @@ -9,7 +9,6 @@ import ( "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" ) // describeRepositoriesRequest is the camelCase AWS JSON 1.1 input shape. The @@ -27,7 +26,10 @@ type describeRepositoriesRequest struct { // // The caller supplies the advertised endpoint profile because it is deployment // composition, rather than ECR resource state. -func DescribeRepositories(ctx context.Context, nc *nats.Conn, endpoint RepositoryEndpoint, accountID string, body []byte) (*ecr.DescribeRepositoriesOutput, error) { +func DescribeRepositories(ctx context.Context, store RepositoryStore, endpoint RepositoryEndpoint, accountID string, body []byte) (*ecr.DescribeRepositoriesOutput, error) { + if store == nil { + return nil, errors.New(awserrors.ErrorServerInternal) + } var req describeRepositoriesRequest if len(body) > 0 { if err := json.Unmarshal(body, &req); err != nil { @@ -38,7 +40,6 @@ func DescribeRepositories(ctx context.Context, nc *nats.Conn, endpoint Repositor return nil, errors.New(awserrors.ErrorAccessDenied) } - store := handlers_ecr.NewNATSMetaStore(nc) names := req.RepositoryNames if len(names) == 0 { var err error diff --git a/spinifex/domains/ecr/awsapi/describe_repositories_test.go b/spinifex/domains/ecr/awsapi/describe_repositories_test.go index 4fb426fab..8ac6206e1 100644 --- a/spinifex/domains/ecr/awsapi/describe_repositories_test.go +++ b/spinifex/domains/ecr/awsapi/describe_repositories_test.go @@ -4,6 +4,7 @@ import ( "context" "testing" + 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" @@ -11,10 +12,11 @@ import ( func TestDescribeRepositories_ListsAccountScoped(t *testing.T) { nc := newRepositoryActionTestConn(t) + store := handlers_ecr.NewNATSMetaStore(nc) seedRepositoryForAction(t, nc, "team/app") seedRepositoryForAction(t, nc, "team/web") - out, err := DescribeRepositories(context.Background(), nc, repositoryActionEndpoint(), repositoryActionTestAccount, []byte(`{}`)) + out, err := DescribeRepositories(context.Background(), store, repositoryActionEndpoint(), repositoryActionTestAccount, []byte(`{}`)) require.NoError(t, err) require.Len(t, out.Repositories, 2) @@ -30,6 +32,7 @@ func TestDescribeRepositories_ListsAccountScoped(t *testing.T) { func TestDescribeRepositories_ValidatesRequestAndRepository(t *testing.T) { nc := newRepositoryActionTestConn(t) + store := handlers_ecr.NewNATSMetaStore(nc) seedRepositoryForAction(t, nc, "team/app") cases := []struct { @@ -43,7 +46,7 @@ func TestDescribeRepositories_ValidatesRequestAndRepository(t *testing.T) { } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { - _, err := DescribeRepositories(context.Background(), nc, repositoryActionEndpoint(), repositoryActionTestAccount, []byte(tc.body)) + _, err := DescribeRepositories(context.Background(), store, repositoryActionEndpoint(), repositoryActionTestAccount, []byte(tc.body)) require.Error(t, err) assert.Equal(t, tc.code, awserrors.ValidErrorCodeFromError(err)) }) diff --git a/spinifex/domains/ecr/awsapi/repository_actions.go b/spinifex/domains/ecr/awsapi/repository_actions.go new file mode 100644 index 000000000..ff7d5347b --- /dev/null +++ b/spinifex/domains/ecr/awsapi/repository_actions.go @@ -0,0 +1,79 @@ +package awsapi + +import ( + "context" + "errors" + + handlers_ecr "github.com/mulgadc/spinifex/spinifex/domains/ecr" + "github.com/mulgadc/spinifex/spinifex/foundation/aws/errors" +) + +// RepositoryStore is the account-scoped metadata capability consumed by the +// repository action group. It deliberately omits policy, tag and upload +// operations: this control-plane surface needs only repository records and +// their manifest inventory. +type RepositoryStore interface { + GetRepo(ctx context.Context, accountID, repo string) (handlers_ecr.RepoMeta, error) + ListRepos(ctx context.Context, accountID string) ([]string, error) + PutRepo(ctx context.Context, accountID string, meta handlers_ecr.RepoMeta) error + ListManifests(ctx context.Context, accountID, repo string) ([]string, error) + DeleteRepo(ctx context.Context, accountID, repo string) error +} + +// RepositoryActionService composes the metadata store and advertised endpoint +// profile used by the ECR repository action group. The profile remains +// deployment composition, while AWS request validation and response projection +// remain in the individual action adapters. +type RepositoryActionService struct { + store RepositoryStore + endpoint RepositoryEndpoint +} + +// NewRepositoryActionService binds the repository metadata capability and the +// configured ECR endpoint profile for repository action dispatch. +func NewRepositoryActionService(store RepositoryStore, endpoint RepositoryEndpoint) *RepositoryActionService { + return &RepositoryActionService{store: store, endpoint: endpoint} +} + +var repositoryActionNames = []string{ + "CreateRepository", + "DeleteRepository", + "DescribeRepositories", +} + +// RepositoryActionNames returns the repository actions served by +// RepositoryActionService. The copy keeps coverage consumers from changing the +// service's dispatch set. +func RepositoryActionNames() []string { + return append([]string(nil), repositoryActionNames...) +} + +// IsRepositoryAction reports whether action is handled by the composed +// repository-metadata capability. +func IsRepositoryAction(action string) bool { + for _, name := range repositoryActionNames { + if action == name { + return true + } + } + return false +} + +// Execute handles a repository ECR JSON action using the caller account +// established by gateway authentication and policy enforcement. +func (s *RepositoryActionService) Execute(ctx context.Context, action, accountID string, body []byte) (any, error) { + if s == nil { + return nil, errors.New(awserrors.ErrorServerInternal) + } + + switch action { + case "CreateRepository": + return CreateRepository(ctx, s.store, s.endpoint, accountID, body) + case "DeleteRepository": + return DeleteRepository(ctx, s.store, s.endpoint, accountID, body) + case "DescribeRepositories": + return DescribeRepositories(ctx, s.store, s.endpoint, accountID, body) + default: + return nil, errors.New(awserrors.ErrorInvalidAction) + } +} diff --git a/spinifex/gateway/ecr_createdeleterepository_test.go b/spinifex/gateway/ecr_createdeleterepository_test.go index 143c6469b..31302c63b 100644 --- a/spinifex/gateway/ecr_createdeleterepository_test.go +++ b/spinifex/gateway/ecr_createdeleterepository_test.go @@ -11,6 +11,7 @@ import ( "github.com/mulgadc/spinifex/internal/testkit" 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" "github.com/nats-io/nats.go" "github.com/stretchr/testify/assert" @@ -32,24 +33,27 @@ func newRepoLifecycleGateway(t *testing.T) (*GatewayConfig, *nats.Conn) { gw := &GatewayConfig{ NATSConn: nc, Region: ecrTestRegion, InternalSuffix: ecrTestSuffix, DisableLogging: true, IAMService: allowAllIAMService(), + ECRRepositoryActions: awsapi.NewRepositoryActionService(handlers_ecr.NewNATSMetaStore(nc), awsapi.RepositoryEndpoint{ + Region: ecrTestRegion, ServicesDomain: ecrTestSuffix, + }), } return gw, nc } -func ecrLifecycleRequest(t *testing.T, gw *GatewayConfig, handler func(*GatewayConfig, http.ResponseWriter, *http.Request) error, body string) (*httptest.ResponseRecorder, error) { +func ecrRepositoryRequest(t *testing.T, gw *GatewayConfig, action, body string) (*httptest.ResponseRecorder, error) { t.Helper() - req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(body)) + req := setupECRRequest(awsapi.TargetPrefix+"."+action, body) ctx := context.WithValue(req.Context(), ctxAccountID, ecrTestAccount) w := httptest.NewRecorder() - return w, handler(gw, w, req.WithContext(ctx)) + return w, gw.ECR_Request(w, req.WithContext(ctx)) } func createRepo(t *testing.T, gw *GatewayConfig, body string) (*httptest.ResponseRecorder, error) { - return ecrLifecycleRequest(t, gw, (*GatewayConfig).handleCreateRepository, body) + return ecrRepositoryRequest(t, gw, "CreateRepository", body) } func deleteRepo(t *testing.T, gw *GatewayConfig, body string) (*httptest.ResponseRecorder, error) { - return ecrLifecycleRequest(t, gw, (*GatewayConfig).handleDeleteRepository, body) + return ecrRepositoryRequest(t, gw, "DeleteRepository", body) } type repoOut struct { @@ -177,7 +181,7 @@ func TestCreateRepository_BadNameCarriesAWSMessage(t *testing.T) { } // noAccountRequest builds a request without the auth-context account ID, which -// both handlers reject with ServerInternal before touching the store. +// generic ECR dispatch rejects with InternalError before touching the store. func noAccountRequest(body string) *http.Request { return httptest.NewRequest(http.MethodPost, "/", strings.NewReader(body)) } @@ -185,9 +189,11 @@ func noAccountRequest(body string) *http.Request { func TestCreateRepository_NoAccountAndMalformed(t *testing.T) { gw, _ := newRepoLifecycleGateway(t) - err := gw.handleCreateRepository(httptest.NewRecorder(), noAccountRequest(`{"repositoryName":"team/app"}`)) + req := noAccountRequest(`{"repositoryName":"team/app"}`) + req.Header.Set("X-Amz-Target", awsapi.TargetPrefix+".CreateRepository") + err := gw.ECR_Request(httptest.NewRecorder(), req) require.Error(t, err) - assert.Equal(t, "ServerInternal", err.Error()) + assert.Equal(t, "InternalError", err.Error()) _, err = createRepo(t, gw, `{`) require.Error(t, err) @@ -197,9 +203,11 @@ func TestCreateRepository_NoAccountAndMalformed(t *testing.T) { func TestDeleteRepository_NoAccountAndMalformed(t *testing.T) { gw, _ := newRepoLifecycleGateway(t) - err := gw.handleDeleteRepository(httptest.NewRecorder(), noAccountRequest(`{"repositoryName":"team/app"}`)) + req := noAccountRequest(`{"repositoryName":"team/app"}`) + req.Header.Set("X-Amz-Target", awsapi.TargetPrefix+".DeleteRepository") + err := gw.ECR_Request(httptest.NewRecorder(), req) require.Error(t, err) - assert.Equal(t, "ServerInternal", err.Error()) + assert.Equal(t, "InternalError", err.Error()) _, err = deleteRepo(t, gw, `{`) require.Error(t, err) @@ -271,3 +279,15 @@ func TestECRRequest_CreateDeleteDispatched(t *testing.T) { require.NoError(t, gw.ECR_Request(wd, del)) assert.Equal(t, http.StatusOK, wd.Code) } + +func TestRepositoryActions_MissingComposition(t *testing.T) { + gw := &GatewayConfig{ + Region: ecrTestRegion, + DisableLogging: true, + IAMService: allowAllIAMService(), + } + + _, err := ecrRepositoryRequest(t, gw, "CreateRepository", `{"repositoryName":"team/app"}`) + require.Error(t, err) + assert.Equal(t, "ServerInternal", err.Error()) +} diff --git a/spinifex/gateway/ecr_createrepository.go b/spinifex/gateway/ecr_createrepository.go deleted file mode 100644 index 9aba01936..000000000 --- a/spinifex/gateway/ecr_createrepository.go +++ /dev/null @@ -1,35 +0,0 @@ -package gateway - -import ( - "errors" - "io" - "log/slog" - "net/http" - - awsapi "github.com/mulgadc/spinifex/spinifex/domains/ecr/awsapi" - "github.com/mulgadc/spinifex/spinifex/foundation/aws/errors" -) - -// handleCreateRepository adapts the authenticated HTTP request to the ECR AWS -// action. ECR request semantics and resource state are domain-adapter -// responsibilities; gateway retains HTTP and auth-context handling. -func (gw *GatewayConfig) handleCreateRepository(w http.ResponseWriter, r *http.Request) error { - ctx := r.Context() - accountID, _ := ctx.Value(ctxAccountID).(string) - if accountID == "" { - slog.ErrorContext(ctx, "CreateRepository: no account ID in auth context") - return errors.New(awserrors.ErrorServerInternal) - } - - body, err := io.ReadAll(r.Body) - if err != nil { - slog.ErrorContext(ctx, "CreateRepository: failed to read body", "err", err) - return awsapi.MalformedBodyError() - } - output, err := awsapi.CreateRepository(ctx, gw.NATSConn, gw.ecrRepositoryEndpoint(), accountID, body) - if err != nil { - return err - } - awsapi.WriteJSONResponse(w, output) - return nil -} diff --git a/spinifex/gateway/ecr_deleterepository.go b/spinifex/gateway/ecr_deleterepository.go deleted file mode 100644 index f6fd4b9f4..000000000 --- a/spinifex/gateway/ecr_deleterepository.go +++ /dev/null @@ -1,35 +0,0 @@ -package gateway - -import ( - "errors" - "io" - "log/slog" - "net/http" - - awsapi "github.com/mulgadc/spinifex/spinifex/domains/ecr/awsapi" - "github.com/mulgadc/spinifex/spinifex/foundation/aws/errors" -) - -// 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) - if accountID == "" { - slog.ErrorContext(ctx, "DeleteRepository: no account ID in auth context") - return errors.New(awserrors.ErrorServerInternal) - } - - body, err := io.ReadAll(r.Body) - if err != nil { - slog.ErrorContext(ctx, "DeleteRepository: failed to read body", "err", err) - return awsapi.MalformedBodyError() - } - output, err := awsapi.DeleteRepository(ctx, gw.NATSConn, gw.ecrRepositoryEndpoint(), accountID, body) - if err != nil { - return err - } - awsapi.WriteJSONResponse(w, output) - return nil -} diff --git a/spinifex/gateway/ecr_describerepositories.go b/spinifex/gateway/ecr_describerepositories.go deleted file mode 100644 index 5093e7628..000000000 --- a/spinifex/gateway/ecr_describerepositories.go +++ /dev/null @@ -1,35 +0,0 @@ -package gateway - -import ( - "errors" - "io" - "log/slog" - "net/http" - - awsapi "github.com/mulgadc/spinifex/spinifex/domains/ecr/awsapi" - "github.com/mulgadc/spinifex/spinifex/foundation/aws/errors" -) - -// handleDescribeRepositories adapts the authenticated HTTP request to the ECR -// AWS action. Request semantics, storage and AWS response construction live in -// the ECR domain adapter; gateway retains only HTTP and auth-context concerns. -func (gw *GatewayConfig) handleDescribeRepositories(w http.ResponseWriter, r *http.Request) error { - ctx := r.Context() - accountID, _ := ctx.Value(ctxAccountID).(string) - if accountID == "" { - slog.ErrorContext(ctx, "DescribeRepositories: no account ID in auth context") - return errors.New(awserrors.ErrorServerInternal) - } - - body, err := io.ReadAll(r.Body) - if err != nil { - slog.ErrorContext(ctx, "DescribeRepositories: failed to read body", "err", err) - return awsapi.MalformedBodyError() - } - output, err := awsapi.DescribeRepositories(ctx, gw.NATSConn, gw.ecrRepositoryEndpoint(), accountID, body) - if err != nil { - return err - } - awsapi.WriteJSONResponse(w, output) - return nil -} diff --git a/spinifex/gateway/ecr_describerepositories_test.go b/spinifex/gateway/ecr_describerepositories_test.go index 74e2b1e4a..7befca1f2 100644 --- a/spinifex/gateway/ecr_describerepositories_test.go +++ b/spinifex/gateway/ecr_describerepositories_test.go @@ -5,12 +5,12 @@ import ( "encoding/json" "net/http" "net/http/httptest" - "strings" "testing" "time" "github.com/mulgadc/spinifex/internal/testkit" handlers_ecr "github.com/mulgadc/spinifex/spinifex/domains/ecr" + awsapi "github.com/mulgadc/spinifex/spinifex/domains/ecr/awsapi" "github.com/mulgadc/spinifex/spinifex/utils" "github.com/nats-io/nats.go" "github.com/stretchr/testify/assert" @@ -56,15 +56,18 @@ func newDescribeReposGateway(t *testing.T, repos ...string) *GatewayConfig { return &GatewayConfig{ NATSConn: nc, Region: ecrTestRegion, InternalSuffix: ecrTestSuffix, DisableLogging: true, IAMService: allowAllIAMService(), + ECRRepositoryActions: awsapi.NewRepositoryActionService(store, awsapi.RepositoryEndpoint{ + Region: ecrTestRegion, ServicesDomain: ecrTestSuffix, + }), } } func describeReposRequest(t *testing.T, gw *GatewayConfig, body string) *httptest.ResponseRecorder { t.Helper() - req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(body)) + req := setupECRRequest(awsapi.TargetPrefix+".DescribeRepositories", body) ctx := context.WithValue(req.Context(), ctxAccountID, ecrTestAccount) w := httptest.NewRecorder() - require.NoError(t, gw.handleDescribeRepositories(w, req.WithContext(ctx))) + require.NoError(t, gw.ECR_Request(w, req.WithContext(ctx))) return w } @@ -124,11 +127,10 @@ func TestECRRegistryHost_AppendsAdvertisedPort(t *testing.T) { } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { - gw := &GatewayConfig{ - Region: ecrTestRegion, InternalSuffix: ecrTestSuffix, + endpoint := awsapi.RepositoryEndpoint{ + Region: ecrTestRegion, ServicesDomain: ecrTestSuffix, RegistryHost: tc.registryHost, RegistryPort: tc.port, } - endpoint := gw.ecrRepositoryEndpoint() assert.Equal(t, tc.want, endpoint.RegistryURIHost(ecrTestAccount)) assert.Equal(t, tc.want+"/team/app", endpoint.RepositoryURI(ecrTestAccount, "team/app")) }) @@ -146,18 +148,18 @@ func TestDescribeRepositories_NameFilter(t *testing.T) { func TestDescribeRepositories_MissingNamedRepo(t *testing.T) { gw := newDescribeReposGateway(t, "team/app") - req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(`{"repositoryNames":["team/ghost"]}`)) + req := setupECRRequest(awsapi.TargetPrefix+".DescribeRepositories", `{"repositoryNames":["team/ghost"]}`) ctx := context.WithValue(req.Context(), ctxAccountID, ecrTestAccount) - err := gw.handleDescribeRepositories(httptest.NewRecorder(), req.WithContext(ctx)) + err := gw.ECR_Request(httptest.NewRecorder(), req.WithContext(ctx)) require.Error(t, err) assert.Equal(t, "RepositoryNotFoundException", err.Error()) } func TestDescribeRepositories_CrossAccountDenied(t *testing.T) { gw := newDescribeReposGateway(t, "team/app") - req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(`{"registryId":"999999999999"}`)) + req := setupECRRequest(awsapi.TargetPrefix+".DescribeRepositories", `{"registryId":"999999999999"}`) ctx := context.WithValue(req.Context(), ctxAccountID, ecrTestAccount) - err := gw.handleDescribeRepositories(httptest.NewRecorder(), req.WithContext(ctx)) + err := gw.ECR_Request(httptest.NewRecorder(), req.WithContext(ctx)) require.Error(t, err) assert.Equal(t, "AccessDenied", err.Error()) } diff --git a/spinifex/gateway/ecr_repository.go b/spinifex/gateway/ecr_repository.go index 2c66b09fb..a41b1aa93 100644 --- a/spinifex/gateway/ecr_repository.go +++ b/spinifex/gateway/ecr_repository.go @@ -1,11 +1,12 @@ package gateway import ( - "github.com/mulgadc/spinifex/spinifex/domains/ecr/awsapi" + awsapi "github.com/mulgadc/spinifex/spinifex/domains/ecr/awsapi" ) -// ecrRepositoryEndpoint adapts gateway composition settings to the ECR domain -// action adapter. AWS-visible repository projection remains domain ownership. +// ecrRepositoryEndpoint adapts gateway composition settings to the ECR token +// action, which still constructs its canonical caller principal in gateway. +// Repository metadata actions receive this profile from their composed service. func (gw *GatewayConfig) ecrRepositoryEndpoint() awsapi.RepositoryEndpoint { return awsapi.RepositoryEndpoint{ Region: gw.Region, diff --git a/spinifex/gateway/ecrapi.go b/spinifex/gateway/ecrapi.go index f51265d21..d37114af1 100644 --- a/spinifex/gateway/ecrapi.go +++ b/spinifex/gateway/ecrapi.go @@ -20,9 +20,6 @@ type ecrInlineHandler func(*GatewayConfig, http.ResponseWriter, *http.Request) e // operation report from classifying an inline implementation as a stub. var ecrInlineActions = map[string]ecrInlineHandler{ "GetAuthorizationToken": (*GatewayConfig).handleGetAuthorizationToken, - "DescribeRepositories": (*GatewayConfig).handleDescribeRepositories, - "CreateRepository": (*GatewayConfig).handleCreateRepository, - "DeleteRepository": (*GatewayConfig).handleDeleteRepository, } // ecrActionFromTarget extracts the action suffix from an X-Amz-Target header. @@ -97,6 +94,18 @@ func (gw *GatewayConfig) ECR_Request(w http.ResponseWriter, r *http.Request) err awsapi.WriteJSONResponse(w, output) return nil } + if awsapi.IsRepositoryAction(action) { + if gw.ECRRepositoryActions == nil { + slog.Error("ECR repository action: capabilities not configured", "action", action) + return errors.New(awserrors.ErrorServerInternal) + } + output, err := gw.ECRRepositoryActions.Execute(r.Context(), action, accountID, body) + if err != nil { + return err + } + awsapi.WriteJSONResponse(w, output) + return nil + } if inline, ok := ecrInlineActions[action]; ok { // The inline handlers read r.Body themselves, so it is rewound over the diff --git a/spinifex/gateway/ecrapi_authz_completeness_test.go b/spinifex/gateway/ecrapi_authz_completeness_test.go index 813a4b770..e249de823 100644 --- a/spinifex/gateway/ecrapi_authz_completeness_test.go +++ b/spinifex/gateway/ecrapi_authz_completeness_test.go @@ -46,4 +46,9 @@ func TestECRComposedActionsAreInTheNamespace(t *testing.T) { assert.True(t, ok, "composed ECR lifecycle-preview action %q is not in awsapi.Actions, so ECR_Request rejects it as InvalidAction", action) } + for _, action := range awsapi.RepositoryActionNames() { + _, ok := awsapi.Actions[action] + assert.True(t, ok, + "composed ECR repository action %q is not in awsapi.Actions, so ECR_Request rejects it as InvalidAction", action) + } } diff --git a/spinifex/gateway/gateway.go b/spinifex/gateway/gateway.go index 5eed1d5fb..62df1a776 100644 --- a/spinifex/gateway/gateway.go +++ b/spinifex/gateway/gateway.go @@ -168,6 +168,9 @@ type GatewayConfig struct { // policy-store and image-catalog composition prevents the OCI registry from // becoming a broad ECR control-plane service. ECRLifecyclePreview *awsapi.LifecyclePreviewActionService + // ECRRepositoryActions serves repository metadata actions from the composed + // metadata store and advertised endpoint profile. + ECRRepositoryActions *awsapi.RepositoryActionService // ECRTokenIssuer mints GetAuthorizationToken JWTs; ECRTokenVerifier validates // them on /v2/*. Both nil disables the auth bridge (registry mounts open, as // in unit tests of unrelated routes). diff --git a/spinifex/gateway/operations.go b/spinifex/gateway/operations.go index 6f1e68492..48a4a8f55 100644 --- a/spinifex/gateway/operations.go +++ b/spinifex/gateway/operations.go @@ -23,7 +23,10 @@ type ServiceOperationInventory struct { // dispatch S3 operations and delegates that REST surface to Predastore. func AWSOperationInventory() map[string]ServiceOperationInventory { ecrInline := mapKeys(ecrInlineActions) - ecrComposed := union(union(ecrInline, awsapi.RegistryActionNames()), awsapi.LifecyclePreviewActionNames()) + ecrComposed := union( + union(union(ecrInline, awsapi.RegistryActionNames()), awsapi.LifecyclePreviewActionNames()), + awsapi.RepositoryActionNames(), + ) ecrRegistered := union(mapKeys(awsapi.Actions), ecrComposed) ecrStubbed := without(awsapi.StubbedActionNames(), ecrComposed) return map[string]ServiceOperationInventory{ diff --git a/spinifex/services/awsgw/awsgw.go b/spinifex/services/awsgw/awsgw.go index 2d2ca4be3..47161eada 100644 --- a/spinifex/services/awsgw/awsgw.go +++ b/spinifex/services/awsgw/awsgw.go @@ -312,6 +312,12 @@ func launchService(config *config.ClusterConfig) error { if registryHost == "" && isConcreteRegistryHost(nodeConfig.AdvertiseIP) { registryHost = nodeConfig.AdvertiseIP } + ecrEndpoint := awsapi.RepositoryEndpoint{ + Region: nodeConfig.Region, + ServicesDomain: config.AWS.ServicesDomain, + RegistryHost: registryHost, + RegistryPort: registryPort, + } // Bedrock provider credentials: per-account keys live in the // bedrock-credentials KV bucket; OCHRE_ANTHROPIC_API_KEY seeds an optional @@ -456,6 +462,7 @@ func launchService(config *config.ClusterConfig) error { ECRRegistry: ecrRegistry, ECRRegistryActions: awsapi.NewRegistryActionService(ecrRegistry, ecrRegistry, ecrRegistry, ecrRegistry), ECRLifecyclePreview: awsapi.NewLifecyclePreviewActionService(ecrMeta, ecrRegistry), + ECRRepositoryActions: awsapi.NewRepositoryActionService(ecrMeta, ecrEndpoint), ECRTokenIssuer: ecrauth.NewIssuer(signingKey, ecrAudience), ECRTokenVerifier: ecrauth.NewVerifier(verifyKeys, ecrAudience), BedrockCredentials: bedrockCredentials,