diff --git a/spinifex/domains/ecr/awsapi/repository.go b/spinifex/domains/ecr/awsapi/repository.go new file mode 100644 index 000000000..2a7d55847 --- /dev/null +++ b/spinifex/domains/ecr/awsapi/repository.go @@ -0,0 +1,60 @@ +package awsapi + +import ( + "github.com/aws/aws-sdk-go/aws" + awsecr "github.com/aws/aws-sdk-go/service/ecr" + "github.com/mulgadc/spinifex/spinifex/domains/ecr" +) + +// RepositoryEndpoint is the ECR endpoint profile the composition root supplies +// to AWS action adapters. It projects a configured registry listener into the +// AWS-visible repository ARN and URI forms without depending on gateway state. +type RepositoryEndpoint struct { + Region string + ServicesDomain string + RegistryHost string + RegistryPort string +} + +// RepositoryARN returns the ECR ARN for an account-scoped repository. +func (e RepositoryEndpoint) RepositoryARN(accountID, name string) string { + return "arn:aws:ecr:" + e.Region + ":" + accountID + ":repository/" + name +} + +// RegistryURIHost returns the configured registry listener for an account. An +// explicit advertised host takes precedence; otherwise the AWS-parity ECR DNS +// name is used. Standard HTTPS omits port 443 from the URI. +func (e RepositoryEndpoint) RegistryURIHost(accountID string) string { + host := e.RegistryHost + if host == "" { + host = accountID + ".dkr.ecr." + e.Region + "." + e.ServicesDomain + } + if e.RegistryPort != "" && e.RegistryPort != "443" { + return host + ":" + e.RegistryPort + } + return host +} + +// RepositoryURI returns the pull/push URI for an account-scoped repository. +func (e RepositoryEndpoint) RepositoryURI(accountID, name string) string { + return e.RegistryURIHost(accountID) + "/" + name +} + +// RepositoryFromMeta projects domain repository metadata into the AWS ECR +// response shape shared by create, describe and delete actions. +func (e RepositoryEndpoint) RepositoryFromMeta(accountID, name string, meta ecr.RepoMeta) *awsecr.Repository { + return &awsecr.Repository{ + RegistryId: aws.String(accountID), + RepositoryName: aws.String(name), + RepositoryArn: aws.String(e.RepositoryARN(accountID, name)), + RepositoryUri: aws.String(e.RepositoryURI(accountID, name)), + CreatedAt: aws.Time(meta.CreatedAt), + ImageTagMutability: aws.String(meta.TagMutability()), + EncryptionConfiguration: &awsecr.EncryptionConfiguration{ + EncryptionType: aws.String(meta.EncryptionTypeOrDefault()), + }, + ImageScanningConfiguration: &awsecr.ImageScanningConfiguration{ + ScanOnPush: aws.Bool(meta.ScanOnPush), + }, + } +} diff --git a/spinifex/domains/ecr/awsapi/repository_test.go b/spinifex/domains/ecr/awsapi/repository_test.go new file mode 100644 index 000000000..35d14316d --- /dev/null +++ b/spinifex/domains/ecr/awsapi/repository_test.go @@ -0,0 +1,58 @@ +package awsapi + +import ( + "testing" + "time" + + "github.com/aws/aws-sdk-go/aws" + "github.com/mulgadc/spinifex/spinifex/domains/ecr" + "github.com/stretchr/testify/assert" +) + +func TestRepositoryEndpointRegistryURIHost(t *testing.T) { + accountID := "123456789012" + cases := []struct { + name string + registryHost string + port string + want string + }{ + {"parity, port-less", "", "", accountID + ".dkr.ecr.ap-southeast-2.services.internal"}, + {"parity, 443 port-less", "", "443", accountID + ".dkr.ecr.ap-southeast-2.services.internal"}, + {"parity, non-standard port", "", "9999", accountID + ".dkr.ecr.ap-southeast-2.services.internal:9999"}, + {"advertised host", "registry.example.com", "9999", "registry.example.com:9999"}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + endpoint := RepositoryEndpoint{ + Region: "ap-southeast-2", + ServicesDomain: "services.internal", + RegistryHost: tc.registryHost, + RegistryPort: tc.port, + } + assert.Equal(t, tc.want, endpoint.RegistryURIHost(accountID)) + }) + } +} + +func TestRepositoryEndpointRepositoryFromMeta(t *testing.T) { + createdAt := time.Date(2026, time.October, 1, 12, 0, 0, 0, time.UTC) + endpoint := RepositoryEndpoint{Region: "us-east-1", ServicesDomain: "services.internal"} + + got := endpoint.RepositoryFromMeta("123456789012", "team/app", ecr.RepoMeta{ + CreatedAt: createdAt, + ImageTagMutability: ecr.TagMutabilityImmutable, + EncryptionType: ecr.EncryptionTypeKMS, + ScanOnPush: true, + }) + + assert.Equal(t, "123456789012", aws.StringValue(got.RegistryId)) + assert.Equal(t, "team/app", aws.StringValue(got.RepositoryName)) + assert.Equal(t, "arn:aws:ecr:us-east-1:123456789012:repository/team/app", aws.StringValue(got.RepositoryArn)) + assert.Equal(t, "123456789012.dkr.ecr.us-east-1.services.internal/team/app", aws.StringValue(got.RepositoryUri)) + assert.Equal(t, createdAt, aws.TimeValue(got.CreatedAt)) + assert.Equal(t, ecr.TagMutabilityImmutable, aws.StringValue(got.ImageTagMutability)) + assert.Equal(t, ecr.EncryptionTypeKMS, aws.StringValue(got.EncryptionConfiguration.EncryptionType)) + assert.True(t, aws.BoolValue(got.ImageScanningConfiguration.ScanOnPush)) +} diff --git a/spinifex/gateway/ec2_describeregions_test.go b/spinifex/gateway/ec2_describeregions_test.go index 12e6c2698..f4a5233e7 100644 --- a/spinifex/gateway/ec2_describeregions_test.go +++ b/spinifex/gateway/ec2_describeregions_test.go @@ -10,7 +10,8 @@ import ( // TestAdvertisedEndpoint_ResolvesFromRegistryHost asserts DescribeRegions' // endpoint tracks this gateway's own reachable host:port (the same -// RegistryHost/RegistryPort precedence ecrRegistryHost already applies), +// RegistryHost/RegistryPort precedence the ECR repository endpoint also +// applies), // rather than a literal that never changes with deployment. func TestAdvertisedEndpoint_ResolvesFromRegistryHost(t *testing.T) { cases := []struct { diff --git a/spinifex/gateway/ecr_createrepository.go b/spinifex/gateway/ecr_createrepository.go index b8f3f946f..85d1cbf6b 100644 --- a/spinifex/gateway/ecr_createrepository.go +++ b/spinifex/gateway/ecr_createrepository.go @@ -103,7 +103,7 @@ func (gw *GatewayConfig) handleCreateRepository(w http.ResponseWriter, r *http.R } awsapi.WriteJSONResponse(w, &ecr.CreateRepositoryOutput{ - Repository: gw.buildRepository(accountID, req.RepositoryName, meta), + Repository: gw.ecrRepositoryEndpoint().RepositoryFromMeta(accountID, req.RepositoryName, meta), }) return nil } diff --git a/spinifex/gateway/ecr_deleterepository.go b/spinifex/gateway/ecr_deleterepository.go index 16cd20d4a..4adac803e 100644 --- a/spinifex/gateway/ecr_deleterepository.go +++ b/spinifex/gateway/ecr_deleterepository.go @@ -79,7 +79,7 @@ func (gw *GatewayConfig) handleDeleteRepository(w http.ResponseWriter, r *http.R } awsapi.WriteJSONResponse(w, &ecr.DeleteRepositoryOutput{ - Repository: gw.buildRepository(accountID, req.RepositoryName, meta), + Repository: gw.ecrRepositoryEndpoint().RepositoryFromMeta(accountID, req.RepositoryName, meta), }) return nil } diff --git a/spinifex/gateway/ecr_describerepositories.go b/spinifex/gateway/ecr_describerepositories.go index ffc9bf39e..4f1b2f2d2 100644 --- a/spinifex/gateway/ecr_describerepositories.go +++ b/spinifex/gateway/ecr_describerepositories.go @@ -68,35 +68,9 @@ func (gw *GatewayConfig) handleDescribeRepositories(w http.ResponseWriter, r *ht slog.ErrorContext(ctx, "DescribeRepositories: get repo failed", "repo", name, "err", err) return errors.New(awserrors.ErrorServerInternal) } - repos = append(repos, gw.buildRepository(accountID, name, meta)) + repos = append(repos, gw.ecrRepositoryEndpoint().RepositoryFromMeta(accountID, name, meta)) } awsapi.WriteJSONResponse(w, &ecr.DescribeRepositoriesOutput{Repositories: repos}) return nil } - -// ecrRepositoryArn builds the ECR repository ARN for an account-scoped repo. -func (gw *GatewayConfig) ecrRepositoryArn(accountID, name string) string { - return "arn:aws:ecr:" + gw.Region + ":" + accountID + ":repository/" + name -} - -// ecrRegistryHost builds the registry host, appending the gateway's advertised -// port so docker dials the right port (omitted for 443). When the gateway -// advertises a reachable host it is used directly — the account comes from the -// auth token, so docker needs no DNS; otherwise the per-account -// .dkr.ecr.. parity name is used. -func (gw *GatewayConfig) ecrRegistryHost(accountID string) string { - host := gw.RegistryHost - if host == "" { - host = accountID + ".dkr.ecr." + gw.Region + "." + gw.InternalSuffix - } - if gw.RegistryPort != "" && gw.RegistryPort != "443" { - return host + ":" + gw.RegistryPort - } - return host -} - -// ecrRepositoryUri builds the registry pull/push URI for an account-scoped repo. -func (gw *GatewayConfig) ecrRepositoryUri(accountID, name string) string { - return gw.ecrRegistryHost(accountID) + "/" + name -} diff --git a/spinifex/gateway/ecr_describerepositories_test.go b/spinifex/gateway/ecr_describerepositories_test.go index 59edf44b3..74e2b1e4a 100644 --- a/spinifex/gateway/ecr_describerepositories_test.go +++ b/spinifex/gateway/ecr_describerepositories_test.go @@ -128,8 +128,9 @@ func TestECRRegistryHost_AppendsAdvertisedPort(t *testing.T) { Region: ecrTestRegion, InternalSuffix: ecrTestSuffix, RegistryHost: tc.registryHost, RegistryPort: tc.port, } - assert.Equal(t, tc.want, gw.ecrRegistryHost(ecrTestAccount)) - assert.Equal(t, tc.want+"/team/app", gw.ecrRepositoryUri(ecrTestAccount, "team/app")) + endpoint := gw.ecrRepositoryEndpoint() + assert.Equal(t, tc.want, endpoint.RegistryURIHost(ecrTestAccount)) + assert.Equal(t, tc.want+"/team/app", endpoint.RepositoryURI(ecrTestAccount, "team/app")) }) } } diff --git a/spinifex/gateway/ecr_getauthorizationtoken.go b/spinifex/gateway/ecr_getauthorizationtoken.go index 59eb08181..101cdb838 100644 --- a/spinifex/gateway/ecr_getauthorizationtoken.go +++ b/spinifex/gateway/ecr_getauthorizationtoken.go @@ -54,7 +54,7 @@ func (gw *GatewayConfig) handleGetAuthorizationToken(w http.ResponseWriter, r *h } authToken := base64.StdEncoding.EncodeToString([]byte("AWS:" + token)) - proxyEndpoint := "https://" + gw.ecrRegistryHost(accountID) + proxyEndpoint := "https://" + gw.ecrRepositoryEndpoint().RegistryURIHost(accountID) awsapi.WriteJSONResponse(w, &ecr.GetAuthorizationTokenOutput{ AuthorizationData: []*ecr.AuthorizationData{{ diff --git a/spinifex/gateway/ecr_repository.go b/spinifex/gateway/ecr_repository.go index ee87bc466..2c66b09fb 100644 --- a/spinifex/gateway/ecr_repository.go +++ b/spinifex/gateway/ecr_repository.go @@ -1,27 +1,16 @@ package gateway import ( - "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/domains/ecr/awsapi" ) -// buildRepository projects a RepoMeta record into the AWS *ecr.Repository -// shape shared by CreateRepository, DescribeRepositories, and -// DeleteRepository, so a new field is added here once rather than thrice. -func (gw *GatewayConfig) buildRepository(accountID, name string, meta handlers_ecr.RepoMeta) *ecr.Repository { - return &ecr.Repository{ - RegistryId: aws.String(accountID), - RepositoryName: aws.String(name), - RepositoryArn: aws.String(gw.ecrRepositoryArn(accountID, name)), - RepositoryUri: aws.String(gw.ecrRepositoryUri(accountID, name)), - CreatedAt: aws.Time(meta.CreatedAt), - ImageTagMutability: aws.String(meta.TagMutability()), - EncryptionConfiguration: &ecr.EncryptionConfiguration{ - EncryptionType: aws.String(meta.EncryptionTypeOrDefault()), - }, - ImageScanningConfiguration: &ecr.ImageScanningConfiguration{ - ScanOnPush: aws.Bool(meta.ScanOnPush), - }, +// ecrRepositoryEndpoint adapts gateway composition settings to the ECR domain +// action adapter. AWS-visible repository projection remains domain ownership. +func (gw *GatewayConfig) ecrRepositoryEndpoint() awsapi.RepositoryEndpoint { + return awsapi.RepositoryEndpoint{ + Region: gw.Region, + ServicesDomain: gw.InternalSuffix, + RegistryHost: gw.RegistryHost, + RegistryPort: gw.RegistryPort, } }