refactor: compose ecr lifecycle preview actions

This commit is contained in:
joshsiv-mulga
2026-10-02 08:53:09 +10:00
parent 95dece05de
commit 40e7b14adb
9 changed files with 114 additions and 83 deletions
@@ -0,0 +1,64 @@
package awsapi
import (
"context"
"errors"
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
)
// LifecyclePreviewActionService composes the policy-store and image-catalog
// capabilities used by the ECR lifecycle-preview actions. It deliberately does
// not share RegistryActionService: lifecycle previews have a different storage
// dependency and should not make the OCI registry a broad ECR control-plane
// service.
type LifecyclePreviewActionService struct {
policies LifecyclePolicyStore
catalog ImageCatalog
}
// NewLifecyclePreviewActionService binds the policy and image capabilities
// consumed by the synchronous lifecycle-preview action pair.
func NewLifecyclePreviewActionService(policies LifecyclePolicyStore, catalog ImageCatalog) *LifecyclePreviewActionService {
return &LifecyclePreviewActionService{policies: policies, catalog: catalog}
}
var lifecyclePreviewActionNames = []string{
"GetLifecyclePolicyPreview",
"StartLifecyclePolicyPreview",
}
// LifecyclePreviewActionNames returns the lifecycle-preview actions served by
// LifecyclePreviewActionService. The copy keeps the coverage inventory from
// changing the service's dispatch set.
func LifecyclePreviewActionNames() []string {
return append([]string(nil), lifecyclePreviewActionNames...)
}
// IsLifecyclePreviewAction reports whether action is handled by the composed
// lifecycle-preview capability.
func IsLifecyclePreviewAction(action string) bool {
for _, name := range lifecyclePreviewActionNames {
if action == name {
return true
}
}
return false
}
// Execute handles a lifecycle-preview ECR JSON action using the caller account
// established by gateway authentication and policy enforcement.
func (s *LifecyclePreviewActionService) Execute(ctx context.Context, action, accountID string, body []byte) (any, error) {
if s == nil {
return nil, errors.New(awserrors.ErrorServerInternal)
}
switch action {
case "StartLifecyclePolicyPreview":
return StartLifecyclePolicyPreview(ctx, s.policies, s.catalog, accountID, body)
case "GetLifecyclePolicyPreview":
return GetLifecyclePolicyPreview(ctx, s.policies, s.catalog, accountID, body)
default:
return nil, errors.New(awserrors.ErrorInvalidAction)
}
}
+9 -7
View File
@@ -23,14 +23,16 @@ import (
// object store + memory meta) and its composed ECR JSON image-action service.
func newImageGateway(t *testing.T) *GatewayConfig {
t.Helper()
reg := ecrregistry.NewRegistry(objectstore.NewMemoryObjectStore(), handlers_ecr.NewMemoryMetaStore(), ecrTestAccount)
meta := handlers_ecr.NewMemoryMetaStore()
reg := ecrregistry.NewRegistry(objectstore.NewMemoryObjectStore(), meta, ecrTestAccount)
return &GatewayConfig{
ECRRegistry: reg,
ECRRegistryActions: awsapi.NewRegistryActionService(reg, reg, reg, reg),
Region: ecrTestRegion,
InternalSuffix: ecrTestSuffix,
DisableLogging: true,
IAMService: allowAllIAMService(),
ECRRegistry: reg,
ECRRegistryActions: awsapi.NewRegistryActionService(reg, reg, reg, reg),
ECRLifecyclePreview: awsapi.NewLifecyclePreviewActionService(meta, reg),
Region: ecrTestRegion,
InternalSuffix: ecrTestSuffix,
DisableLogging: true,
IAMService: allowAllIAMService(),
}
}
-68
View File
@@ -1,68 +0,0 @@
package gateway
import (
"errors"
"io"
"log/slog"
"net/http"
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"
)
// ecrLifecyclePreviewAccount obtains the authenticated account and the
// registry capability still required by the transitional lifecycle-preview
// adapters. Registry-backed image actions use RegistryActionService instead.
func (gw *GatewayConfig) ecrLifecyclePreviewAccount(r *http.Request) (string, error) {
accountID, _ := r.Context().Value(ctxAccountID).(string)
if accountID == "" {
slog.ErrorContext(r.Context(), "ECR lifecycle preview: no account ID in auth context")
return "", errors.New(awserrors.ErrorServerInternal)
}
if gw.ECRRegistry == nil {
slog.ErrorContext(r.Context(), "ECR lifecycle preview: OCI registry not configured")
return "", errors.New(awserrors.ErrorServerInternal)
}
return accountID, nil
}
// handleStartLifecyclePolicyPreview adapts the authenticated HTTP request to
// the ECR action. Lifecycle evaluation and AWS response semantics live in the
// ECR adapter; gateway supplies the policy store and image catalog.
func (gw *GatewayConfig) handleStartLifecyclePolicyPreview(w http.ResponseWriter, r *http.Request) error {
accountID, err := gw.ecrLifecyclePreviewAccount(r)
if err != nil {
return err
}
body, err := io.ReadAll(r.Body)
if err != nil {
return awsapi.MalformedBodyError()
}
output, err := awsapi.StartLifecyclePolicyPreview(r.Context(), handlers_ecr.NewNATSMetaStore(gw.NATSConn), gw.ECRRegistry, accountID, body)
if err != nil {
return err
}
awsapi.WriteJSONResponse(w, output)
return nil
}
// handleGetLifecyclePolicyPreview adapts the authenticated HTTP request to the
// ECR action. Lifecycle evaluation and AWS response projection live in the
// ECR adapter; gateway supplies the policy store and image catalog.
func (gw *GatewayConfig) handleGetLifecyclePolicyPreview(w http.ResponseWriter, r *http.Request) error {
accountID, err := gw.ecrLifecyclePreviewAccount(r)
if err != nil {
return err
}
body, err := io.ReadAll(r.Body)
if err != nil {
return awsapi.MalformedBodyError()
}
output, err := awsapi.GetLifecyclePolicyPreview(r.Context(), handlers_ecr.NewNATSMetaStore(gw.NATSConn), gw.ECRRegistry, accountID, body)
if err != nil {
return err
}
awsapi.WriteJSONResponse(w, output)
return nil
}
@@ -89,6 +89,18 @@ func TestLifecyclePreview_Errors(t *testing.T) {
assert.Equal(t, "AccessDenied", err.Error())
}
func TestLifecyclePreview_MissingComposition(t *testing.T) {
gw := &GatewayConfig{
Region: ecrTestRegion,
DisableLogging: true,
IAMService: allowAllIAMService(),
}
_, err := callImage(t, gw, "StartLifecyclePolicyPreview", previewBody("team/app", previewExpireOldest))
require.Error(t, err)
assert.Equal(t, "ServerInternal", err.Error())
}
func strconvQuotePreview(s string) string {
b, _ := json.Marshal(s)
return string(b)
+16 -6
View File
@@ -19,12 +19,10 @@ type ecrInlineHandler func(*GatewayConfig, http.ResponseWriter, *http.Request) e
// Keeping dispatch and coverage on the same map prevents the generated
// 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,
"StartLifecyclePolicyPreview": (*GatewayConfig).handleStartLifecyclePolicyPreview,
"GetLifecyclePolicyPreview": (*GatewayConfig).handleGetLifecyclePolicyPreview,
"GetAuthorizationToken": (*GatewayConfig).handleGetAuthorizationToken,
"DescribeRepositories": (*GatewayConfig).handleDescribeRepositories,
"CreateRepository": (*GatewayConfig).handleCreateRepository,
"DeleteRepository": (*GatewayConfig).handleDeleteRepository,
}
// ecrActionFromTarget extracts the action suffix from an X-Amz-Target header.
@@ -87,6 +85,18 @@ func (gw *GatewayConfig) ECR_Request(w http.ResponseWriter, r *http.Request) err
awsapi.WriteJSONResponse(w, output)
return nil
}
if awsapi.IsLifecyclePreviewAction(action) {
if gw.ECRLifecyclePreview == nil {
slog.Error("ECR lifecycle-preview action: capabilities not configured", "action", action)
return errors.New(awserrors.ErrorServerInternal)
}
output, err := gw.ECRLifecyclePreview.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
@@ -41,4 +41,9 @@ func TestECRComposedActionsAreInTheNamespace(t *testing.T) {
assert.True(t, ok,
"composed ECR registry action %q is not in awsapi.Actions, so ECR_Request rejects it as InvalidAction", action)
}
for _, action := range awsapi.LifecyclePreviewActionNames() {
_, ok := awsapi.Actions[action]
assert.True(t, ok,
"composed ECR lifecycle-preview action %q is not in awsapi.Actions, so ECR_Request rejects it as InvalidAction", action)
}
}
+4
View File
@@ -164,6 +164,10 @@ type GatewayConfig struct {
// composed from the OCI registry at startup; nil is a composition fault for
// those actions rather than a fallback to the generic NotImplemented table.
ECRRegistryActions *awsapi.RegistryActionService
// ECRLifecyclePreview serves the lifecycle-preview pair. Its distinct
// policy-store and image-catalog composition prevents the OCI registry from
// becoming a broad ECR control-plane service.
ECRLifecyclePreview *awsapi.LifecyclePreviewActionService
// 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).
+1 -1
View File
@@ -23,7 +23,7 @@ type ServiceOperationInventory struct {
// dispatch S3 operations and delegates that REST surface to Predastore.
func AWSOperationInventory() map[string]ServiceOperationInventory {
ecrInline := mapKeys(ecrInlineActions)
ecrComposed := union(ecrInline, awsapi.RegistryActionNames())
ecrComposed := union(union(ecrInline, awsapi.RegistryActionNames()), awsapi.LifecyclePreviewActionNames())
ecrRegistered := union(mapKeys(awsapi.Actions), ecrComposed)
ecrStubbed := without(awsapi.StubbedActionNames(), ecrComposed)
return map[string]ServiceOperationInventory{
+3 -1
View File
@@ -271,7 +271,8 @@ func launchService(config *config.ClusterConfig) error {
nodeConfig.Predastore.AccessKey,
nodeConfig.Predastore.SecretKey,
)
ecrRegistry := ecrregistry.NewRegistry(objStore, ecr.NewNATSMetaStore(natsConn), config.Bootstrap.AccountID)
ecrMeta := ecr.NewNATSMetaStore(natsConn)
ecrRegistry := ecrregistry.NewRegistry(objStore, ecrMeta, config.Bootstrap.AccountID)
// Lifecycle expiry sweep applies each repo's stored lifecycle policy and
// deletes the expired set via the registry GC path. It runs here (not the
@@ -454,6 +455,7 @@ func launchService(config *config.ClusterConfig) error {
Commit: commit,
ECRRegistry: ecrRegistry,
ECRRegistryActions: awsapi.NewRegistryActionService(ecrRegistry, ecrRegistry, ecrRegistry, ecrRegistry),
ECRLifecyclePreview: awsapi.NewLifecyclePreviewActionService(ecrMeta, ecrRegistry),
ECRTokenIssuer: ecrauth.NewIssuer(signingKey, ecrAudience),
ECRTokenVerifier: ecrauth.NewVerifier(verifyKeys, ecrAudience),
BedrockCredentials: bedrockCredentials,