refactor: compose ecr repository actions

This commit is contained in:
joshsiv-mulga
2026-10-02 09:12:26 +10:00
parent 0c48268edf
commit 54f96ad787
18 changed files with 186 additions and 152 deletions
@@ -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) {
@@ -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))
})
@@ -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) {
@@ -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))
})
@@ -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
@@ -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))
})
@@ -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)
}
}
@@ -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())
}
-35
View File
@@ -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
}
-35
View File
@@ -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
}
@@ -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
}
@@ -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())
}
+4 -3
View File
@@ -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,
+12 -3
View File
@@ -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
@@ -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)
}
}
+3
View File
@@ -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).
+4 -1
View File
@@ -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{
+7
View File
@@ -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,