refactor: localize ecr repository projection

This commit is contained in:
joshsiv-mulga
2026-10-01 14:55:41 +10:00
parent 452005583c
commit 5ff0fa452c
9 changed files with 136 additions and 53 deletions
+60
View File
@@ -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),
},
}
}
@@ -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))
}
+2 -1
View File
@@ -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 {
+1 -1
View File
@@ -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
}
+1 -1
View File
@@ -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
}
+1 -27
View File
@@ -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
// <acct>.dkr.ecr.<region>.<suffix> 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
}
@@ -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"))
})
}
}
@@ -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{{
+9 -20
View File
@@ -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,
}
}