refactor: move ecr create action to domain

This commit is contained in:
joshsiv-mulga
2026-10-01 15:20:49 +10:00
parent 87f0718e77
commit c558872e8b
5 changed files with 275 additions and 191 deletions
@@ -0,0 +1,141 @@
package awsapi
import (
"context"
"encoding/json"
"errors"
"log/slog"
"time"
"github.com/aws/aws-sdk-go/aws"
"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
// carries no locationName, so its wire keys are Key/Value (capitalised); using
// the SDK type preserves that spelling rather than reimplementing it.
type createRepositoryRequest struct {
RepositoryName string `json:"repositoryName"`
RegistryID string `json:"registryId"`
ImageTagMutability string `json:"imageTagMutability"`
Tags []*ecr.Tag `json:"tags"`
EncryptionConfiguration *encryptionConfigurationInput `json:"encryptionConfiguration"`
ImageScanningConfiguration *imageScanningConfigurationInput `json:"imageScanningConfiguration"`
}
// encryptionConfigurationInput is the camelCase input shape for
// encryptionConfiguration. kmsKey is not decoded: KMS is rejected outright,
// so any key supplied alongside it is never used.
type encryptionConfigurationInput struct {
EncryptionType string `json:"encryptionType"`
}
// CreateRepository creates only the account-scoped ECR metadata record. The
// Predastore bucket remains lazy until the first image push. An omitted
// mutability value defaults to MUTABLE and an omitted encryption type defaults
// to AES256.
//
// 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) {
var req createRepositoryRequest
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)
}
mutability, err := normalizeTagMutability(req.ImageTagMutability)
if err != nil {
return nil, err
}
encryptionType, err := normalizeEncryptionType(req.EncryptionConfiguration)
if err != nil {
return nil, err
}
tags, err := tagMapFromInput(req.Tags)
if err != nil {
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) {
slog.ErrorContext(ctx, "ECR CreateRepository: get repository failed", "repository", req.RepositoryName, "err", err)
return nil, errors.New(awserrors.ErrorServerInternal)
}
meta := handlers_ecr.RepoMeta{
Name: req.RepositoryName,
CreatedAt: time.Now().UTC(),
ImageTagMutability: mutability,
EncryptionType: encryptionType,
ScanOnPush: req.ImageScanningConfiguration != nil && req.ImageScanningConfiguration.ScanOnPush,
Tags: tags,
}
if err := store.PutRepo(ctx, accountID, meta); err != nil {
slog.ErrorContext(ctx, "ECR CreateRepository: put repository failed", "repository", req.RepositoryName, "err", err)
return nil, errors.New(awserrors.ErrorServerInternal)
}
return &ecr.CreateRepositoryOutput{
Repository: endpoint.RepositoryFromMeta(accountID, req.RepositoryName, meta),
}, nil
}
// tagMapFromInput folds the create-time tag list into RepoMeta's map, rejecting
// an empty key exactly as TagResource does. An absent list is nil so the record
// round-trips identically to one created before tags were supported.
func tagMapFromInput(in []*ecr.Tag) (map[string]string, error) {
if len(in) == 0 {
return nil, nil
}
if err := ValidateTags(in); err != nil {
return nil, err
}
out := make(map[string]string, len(in))
for _, tag := range in {
out[aws.StringValue(tag.Key)] = aws.StringValue(tag.Value)
}
return out, nil
}
// normalizeEncryptionType validates the requested encryption configuration,
// defaulting an absent one to AES256. KMS is rejected because no
// customer-managed key material is implemented.
func normalizeEncryptionType(cfg *encryptionConfigurationInput) (string, error) {
if cfg == nil || cfg.EncryptionType == "" {
return handlers_ecr.EncryptionTypeAES256, nil
}
switch cfg.EncryptionType {
case handlers_ecr.EncryptionTypeAES256:
return cfg.EncryptionType, nil
case handlers_ecr.EncryptionTypeKMS:
return "", awserrors.Errorf(awserrors.ErrorInvalidParameterValue,
"encryptionType KMS is not supported: no customer-managed key is used, and repositories are already encrypted at rest under a server-managed AES-256 key")
default:
return "", EnumValueError("encryptionConfiguration.encryptionType", cfg.EncryptionType,
handlers_ecr.EncryptionTypeAES256, handlers_ecr.EncryptionTypeKMS)
}
}
// normalizeTagMutability validates the create-time mutability, defaulting an
// empty value to MUTABLE.
func normalizeTagMutability(value string) (string, error) {
switch value {
case "":
return handlers_ecr.TagMutabilityMutable, nil
case handlers_ecr.TagMutabilityMutable, handlers_ecr.TagMutabilityImmutable:
return value, nil
default:
return "", EnumValueError("imageTagMutability", value,
handlers_ecr.TagMutabilityMutable, handlers_ecr.TagMutabilityImmutable)
}
}
@@ -0,0 +1,54 @@
package awsapi
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"
)
func TestCreateRepository_PersistsConfiguredMetadata(t *testing.T) {
nc := newRepositoryActionTestConn(t)
body := []byte(`{"repositoryName":"team/app","imageTagMutability":"IMMUTABLE","tags":[{"Key":"environment","Value":"test"}],"imageScanningConfiguration":{"scanOnPush":true}}`)
out, err := CreateRepository(context.Background(), nc, 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")
require.NoError(t, err)
assert.Equal(t, handlers_ecr.TagMutabilityImmutable, meta.ImageTagMutability)
assert.Equal(t, map[string]string{"environment": "test"}, meta.Tags)
assert.True(t, meta.ScanOnPush)
}
func TestCreateRepository_RejectsInvalidRequestsAndDuplicates(t *testing.T) {
nc := newRepositoryActionTestConn(t)
_, err := CreateRepository(context.Background(), nc, repositoryActionEndpoint(), repositoryActionTestAccount, []byte(`{"repositoryName":"team/app"}`))
require.NoError(t, err)
cases := []struct {
name string
body string
code string
}{
{"duplicate", `{"repositoryName":"team/app"}`, awserrors.ErrorRepositoryAlreadyExists},
{"malformed", `{`, awserrors.ErrorInvalidParameterValue},
{"missing name", `{}`, awserrors.ErrorInvalidParameterValue},
{"cross account", `{"repositoryName":"team/other","registryId":"999999999999"}`, awserrors.ErrorAccessDenied},
{"unsupported KMS", `{"repositoryName":"team/other","encryptionConfiguration":{"encryptionType":"KMS"}}`, awserrors.ErrorInvalidParameterValue},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
_, err := CreateRepository(context.Background(), nc, repositoryActionEndpoint(), repositoryActionTestAccount, []byte(tc.body))
require.Error(t, err)
assert.Equal(t, tc.code, awserrors.ValidErrorCodeFromError(err))
})
}
}
@@ -2,71 +2,19 @@ package awsapi
import (
"context"
"encoding/json"
"testing"
"time"
"github.com/mulgadc/spinifex/internal/testkit"
handlers_ecr "github.com/mulgadc/spinifex/spinifex/domains/ecr"
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
"github.com/mulgadc/spinifex/spinifex/utils"
"github.com/nats-io/nats.go"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
const describeRepositoriesTestAccount = "000000000000"
func newDescribeRepositoriesTestConn(t *testing.T) *nats.Conn {
t.Helper()
_, nc, _ := testutil.StartTestJetStream(t)
js := testutil.NewJetStream(t, nc)
svc := handlers_ecr.NewKVMetaService(js)
serveDescribeRepositoriesMeta(t, nc, handlers_ecr.SubjectRepoCreate, svc.RepoCreate)
serveDescribeRepositoriesMeta(t, nc, handlers_ecr.SubjectRepoDescribe, svc.RepoDescribe)
serveDescribeRepositoriesMeta(t, nc, handlers_ecr.SubjectRepoList, svc.RepoList)
return nc
}
func serveDescribeRepositoriesMeta[I any, O any](t *testing.T, nc *nats.Conn, subject string, fn func(context.Context, *I, string) (*O, error)) {
t.Helper()
sub, err := nc.Subscribe(subject, func(msg *nats.Msg) {
accountID := utils.AccountIDFromMsg(msg)
in := new(I)
if errResp := utils.UnmarshalJsonPayload(in, msg.Data); errResp != nil {
_ = msg.Respond(errResp)
return
}
out, err := fn(context.Background(), in, accountID)
if err != nil {
_ = msg.Respond(utils.GenerateErrorPayload("ServerInternal"))
return
}
data, _ := json.Marshal(out)
_ = msg.Respond(data)
})
require.NoError(t, err)
t.Cleanup(func() { _ = sub.Unsubscribe() })
}
func seedDescribeRepository(t *testing.T, nc *nats.Conn, name string) {
t.Helper()
store := handlers_ecr.NewNATSMetaStore(nc)
require.NoError(t, store.PutRepo(context.Background(), describeRepositoriesTestAccount, handlers_ecr.RepoMeta{
Name: name, CreatedAt: time.Now(),
}))
}
func describeRepositoriesEndpoint() RepositoryEndpoint {
return RepositoryEndpoint{Region: "ap-southeast-2", ServicesDomain: "spinifex.test"}
}
func TestDescribeRepositories_ListsAccountScoped(t *testing.T) {
nc := newDescribeRepositoriesTestConn(t)
seedDescribeRepository(t, nc, "team/app")
seedDescribeRepository(t, nc, "team/web")
nc := newRepositoryActionTestConn(t)
seedRepositoryForAction(t, nc, "team/app")
seedRepositoryForAction(t, nc, "team/web")
out, err := DescribeRepositories(context.Background(), nc, describeRepositoriesEndpoint(), describeRepositoriesTestAccount, []byte(`{}`))
out, err := DescribeRepositories(context.Background(), nc, repositoryActionEndpoint(), repositoryActionTestAccount, []byte(`{}`))
require.NoError(t, err)
require.Len(t, out.Repositories, 2)
@@ -75,14 +23,14 @@ func TestDescribeRepositories_ListsAccountScoped(t *testing.T) {
byName[*repository.RepositoryName] = i
}
app := out.Repositories[byName["team/app"]]
assert.Equal(t, describeRepositoriesTestAccount, *app.RegistryId)
assert.Equal(t, "arn:aws:ecr:ap-southeast-2:"+describeRepositoriesTestAccount+":repository/team/app", *app.RepositoryArn)
assert.Equal(t, describeRepositoriesTestAccount+".dkr.ecr.ap-southeast-2.spinifex.test/team/app", *app.RepositoryUri)
assert.Equal(t, repositoryActionTestAccount, *app.RegistryId)
assert.Equal(t, "arn:aws:ecr:ap-southeast-2:"+repositoryActionTestAccount+":repository/team/app", *app.RepositoryArn)
assert.Equal(t, repositoryActionTestAccount+".dkr.ecr.ap-southeast-2.spinifex.test/team/app", *app.RepositoryUri)
}
func TestDescribeRepositories_ValidatesRequestAndRepository(t *testing.T) {
nc := newDescribeRepositoriesTestConn(t)
seedDescribeRepository(t, nc, "team/app")
nc := newRepositoryActionTestConn(t)
seedRepositoryForAction(t, nc, "team/app")
cases := []struct {
name string
@@ -95,7 +43,7 @@ func TestDescribeRepositories_ValidatesRequestAndRepository(t *testing.T) {
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
_, err := DescribeRepositories(context.Background(), nc, describeRepositoriesEndpoint(), describeRepositoriesTestAccount, []byte(tc.body))
_, err := DescribeRepositories(context.Background(), nc, repositoryActionEndpoint(), repositoryActionTestAccount, []byte(tc.body))
require.Error(t, err)
assert.Equal(t, tc.code, awserrors.ValidErrorCodeFromError(err))
})
@@ -0,0 +1,65 @@
package awsapi
import (
"context"
"encoding/json"
"testing"
"time"
"github.com/mulgadc/spinifex/internal/testkit"
handlers_ecr "github.com/mulgadc/spinifex/spinifex/domains/ecr"
"github.com/mulgadc/spinifex/spinifex/utils"
"github.com/nats-io/nats.go"
"github.com/stretchr/testify/require"
)
// repositoryActionTestAccount is the account served by the direct repository
// AWS-action tests. It is intentionally separate from gateway auth tests.
const repositoryActionTestAccount = "000000000000"
// newRepositoryActionTestConn provides only the metadata subjects needed by
// repository actions. It keeps those action tests independent of gateway HTTP
// construction while exercising the deployed NATS request contract.
func newRepositoryActionTestConn(t *testing.T) *nats.Conn {
t.Helper()
_, nc, _ := testutil.StartTestJetStream(t)
js := testutil.NewJetStream(t, nc)
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.SubjectRepoList, svc.RepoList)
return nc
}
func serveRepositoryActionMeta[I any, O any](t *testing.T, nc *nats.Conn, subject string, fn func(context.Context, *I, string) (*O, error)) {
t.Helper()
sub, err := nc.Subscribe(subject, func(msg *nats.Msg) {
accountID := utils.AccountIDFromMsg(msg)
in := new(I)
if errResp := utils.UnmarshalJsonPayload(in, msg.Data); errResp != nil {
_ = msg.Respond(errResp)
return
}
out, err := fn(context.Background(), in, accountID)
if err != nil {
_ = msg.Respond(utils.GenerateErrorPayload("ServerInternal"))
return
}
data, _ := json.Marshal(out)
_ = msg.Respond(data)
})
require.NoError(t, err)
t.Cleanup(func() { _ = sub.Unsubscribe() })
}
func seedRepositoryForAction(t *testing.T, nc *nats.Conn, name string) {
t.Helper()
store := handlers_ecr.NewNATSMetaStore(nc)
require.NoError(t, store.PutRepo(context.Background(), repositoryActionTestAccount, handlers_ecr.RepoMeta{
Name: name, CreatedAt: time.Now(),
}))
}
func repositoryActionEndpoint() RepositoryEndpoint {
return RepositoryEndpoint{Region: "ap-southeast-2", ServicesDomain: "spinifex.test"}
}
+5 -129
View File
@@ -1,49 +1,18 @@
package gateway
import (
"encoding/json"
"errors"
"io"
"log/slog"
"net/http"
"time"
"github.com/aws/aws-sdk-go/aws"
"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"
)
// createRepositoryRequest is the camelCase AWS JSON 1.1 input shape. ecr.Tag
// carries no locationName, so its wire keys are Key/Value (capitalized);
// reusing the SDK type keeps that spelling rather than a lowercase hand-roll.
type createRepositoryRequest struct {
RepositoryName string `json:"repositoryName"`
RegistryID string `json:"registryId"`
ImageTagMutability string `json:"imageTagMutability"`
Tags []*ecr.Tag `json:"tags"`
EncryptionConfiguration *encryptionConfigurationInput `json:"encryptionConfiguration"`
ImageScanningConfiguration *imageScanningConfigurationInput `json:"imageScanningConfiguration"`
}
// encryptionConfigurationInput is the camelCase input shape for
// encryptionConfiguration. kmsKey is not decoded: KMS is rejected outright, so
// any key material supplied alongside it is never read.
type encryptionConfigurationInput struct {
EncryptionType string `json:"encryptionType"`
}
// imageScanningConfigurationInput is the camelCase input shape for
// imageScanningConfiguration.
type imageScanningConfigurationInput struct {
ScanOnPush bool `json:"scanOnPush"`
}
// handleCreateRepository provisions an empty repository in the caller account.
// The predastore object bucket stays lazily created on first push; this writes
// only the per-account KV meta record. imageTagMutability (MUTABLE/IMMUTABLE)
// is validated, persisted, and echoed; an unset value defaults to MUTABLE.
// 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)
@@ -57,103 +26,10 @@ func (gw *GatewayConfig) handleCreateRepository(w http.ResponseWriter, r *http.R
slog.ErrorContext(ctx, "CreateRepository: failed to read body", "err", err)
return awsapi.MalformedBodyError()
}
var req createRepositoryRequest
if err := json.Unmarshal(body, &req); err != nil {
return awsapi.MalformedBodyError()
}
if err := awsapi.ValidateRepositoryName(req.RepositoryName); err != nil {
return err
}
if req.RegistryID != "" && req.RegistryID != accountID {
return errors.New(awserrors.ErrorAccessDenied)
}
mutability, err := normalizeTagMutability(req.ImageTagMutability)
output, err := awsapi.CreateRepository(ctx, gw.NATSConn, gw.ecrRepositoryEndpoint(), accountID, body)
if err != nil {
return err
}
encryptionType, err := normalizeEncryptionType(req.EncryptionConfiguration)
if err != nil {
return err
}
scanOnPush := req.ImageScanningConfiguration != nil && req.ImageScanningConfiguration.ScanOnPush
tags, err := tagMapFromInput(req.Tags)
if err != nil {
return err
}
store := handlers_ecr.NewNATSMetaStore(gw.NATSConn)
if _, err := store.GetRepo(ctx, accountID, req.RepositoryName); err == nil {
return errors.New(awserrors.ErrorRepositoryAlreadyExists)
} else if !errors.Is(err, handlers_ecr.ErrNotFound) {
slog.ErrorContext(ctx, "CreateRepository: get repo failed", "repo", req.RepositoryName, "err", err)
return errors.New(awserrors.ErrorServerInternal)
}
meta := handlers_ecr.RepoMeta{
Name: req.RepositoryName,
CreatedAt: time.Now().UTC(),
ImageTagMutability: mutability,
EncryptionType: encryptionType,
ScanOnPush: scanOnPush,
Tags: tags,
}
if err := store.PutRepo(ctx, accountID, meta); err != nil {
slog.ErrorContext(ctx, "CreateRepository: put repo failed", "repo", req.RepositoryName, "err", err)
return errors.New(awserrors.ErrorServerInternal)
}
awsapi.WriteJSONResponse(w, &ecr.CreateRepositoryOutput{
Repository: gw.ecrRepositoryEndpoint().RepositoryFromMeta(accountID, req.RepositoryName, meta),
})
awsapi.WriteJSONResponse(w, output)
return nil
}
// tagMapFromInput folds the create-time tag list into the map RepoMeta stores,
// rejecting an empty key exactly as TagResource does. An absent list yields a
// nil map so the record round-trips identically to one created without tags.
func tagMapFromInput(in []*ecr.Tag) (map[string]string, error) {
if len(in) == 0 {
return nil, nil
}
if err := awsapi.ValidateTags(in); err != nil {
return nil, err
}
out := make(map[string]string, len(in))
for _, t := range in {
out[aws.StringValue(t.Key)] = aws.StringValue(t.Value)
}
return out, nil
}
// normalizeEncryptionType validates the requested encryption configuration,
// defaulting an absent one to AES256. KMS is rejected: no customer key
// material is plumbed anywhere for it to honor.
func normalizeEncryptionType(cfg *encryptionConfigurationInput) (string, error) {
if cfg == nil || cfg.EncryptionType == "" {
return handlers_ecr.EncryptionTypeAES256, nil
}
switch cfg.EncryptionType {
case handlers_ecr.EncryptionTypeAES256:
return cfg.EncryptionType, nil
case handlers_ecr.EncryptionTypeKMS:
return "", awserrors.Errorf(awserrors.ErrorInvalidParameterValue,
"encryptionType KMS is not supported: no customer-managed key is used, and repositories are already encrypted at rest under a server-managed AES-256 key")
default:
return "", awsapi.EnumValueError("encryptionConfiguration.encryptionType", cfg.EncryptionType,
handlers_ecr.EncryptionTypeAES256, handlers_ecr.EncryptionTypeKMS)
}
}
// normalizeTagMutability validates the requested mutability, defaulting an empty
// value to MUTABLE. An unknown value is rejected with InvalidParameterValue.
func normalizeTagMutability(v string) (string, error) {
switch v {
case "":
return handlers_ecr.TagMutabilityMutable, nil
case handlers_ecr.TagMutabilityMutable, handlers_ecr.TagMutabilityImmutable:
return v, nil
default:
return "", awsapi.EnumValueError("imageTagMutability", v,
handlers_ecr.TagMutabilityMutable, handlers_ecr.TagMutabilityImmutable)
}
}