diff --git a/spinifex/domains/ecr/awsapi/describe_images.go b/spinifex/domains/ecr/awsapi/describe_images.go new file mode 100644 index 000000000..d1130da30 --- /dev/null +++ b/spinifex/domains/ecr/awsapi/describe_images.go @@ -0,0 +1,91 @@ +package awsapi + +import ( + "context" + "encoding/json" + "errors" + "slices" + + "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/service/ecr" + ecrregistry "github.com/mulgadc/spinifex/spinifex/domains/ecr/registry" + "github.com/mulgadc/spinifex/spinifex/foundation/aws/errors" +) + +// imageIdentifier is the AWS JSON 1.1 {imageDigest, imageTag} pair. +type imageIdentifier struct { + ImageDigest string `json:"imageDigest"` + ImageTag string `json:"imageTag"` +} + +type describeImagesRequest struct { + RepositoryName string `json:"repositoryName"` + RegistryID string `json:"registryId"` + ImageIDs []imageIdentifier `json:"imageIds"` + // Filter is accepted but not yet implemented. Preserve its current wire + // behavior during the structural extraction; its semantics are a separate + // ECR compatibility decision. + Filter *tagStatusFilter `json:"filter"` +} + +// DescribeImages returns detailed metadata for repository images, optionally +// narrowed to requested digests or tags. The current implementation preserves +// the existing behavior of accepting, but not applying, the filter field. +func DescribeImages(ctx context.Context, catalog ImageCatalog, accountID string, body []byte) (*ecr.DescribeImagesOutput, error) { + var req describeImagesRequest + 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) + } + + records, err := listImageRecords(ctx, catalog, accountID, req.RepositoryName) + if err != nil { + return nil, err + } + + wanted := func(record ecrregistry.ImageRecord) bool { + if len(req.ImageIDs) == 0 { + return true + } + for _, image := range req.ImageIDs { + if image.ImageDigest != "" && image.ImageDigest == record.Digest { + return true + } + if image.ImageTag != "" && slices.Contains(record.Tags, image.ImageTag) { + return true + } + } + return false + } + + details := make([]*ecr.ImageDetail, 0, len(records)) + for _, record := range records { + if !wanted(record) { + continue + } + detail := &ecr.ImageDetail{ + RegistryId: aws.String(accountID), + RepositoryName: aws.String(req.RepositoryName), + ImageDigest: aws.String(record.Digest), + ImageSizeInBytes: aws.Int64(record.Size), + ImageManifestMediaType: aws.String(record.MediaType), + } + if !record.PushedAt.IsZero() { + detail.ImagePushedAt = aws.Time(record.PushedAt) + } + for _, tag := range record.Tags { + detail.ImageTags = append(detail.ImageTags, aws.String(tag)) + } + details = append(details, detail) + } + if len(req.ImageIDs) > 0 && len(details) == 0 { + return nil, errors.New(awserrors.ErrorImageNotFound) + } + + return &ecr.DescribeImagesOutput{ImageDetails: details}, nil +} diff --git a/spinifex/domains/ecr/awsapi/describe_images_test.go b/spinifex/domains/ecr/awsapi/describe_images_test.go new file mode 100644 index 000000000..009689ef7 --- /dev/null +++ b/spinifex/domains/ecr/awsapi/describe_images_test.go @@ -0,0 +1,36 @@ +package awsapi + +import ( + "context" + "testing" + "time" + + ecrregistry "github.com/mulgadc/spinifex/spinifex/domains/ecr/registry" + "github.com/mulgadc/spinifex/spinifex/foundation/aws/errors" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestDescribeImages_ProjectsAndSelectsImageDetails(t *testing.T) { + pushedAt := time.Date(2026, 10, 1, 1, 2, 3, 0, time.UTC) + catalog := fakeImageCatalog{records: []ecrregistry.ImageRecord{ + {Digest: "sha256:v1", Tags: []string{"v1"}, Size: 17, PushedAt: pushedAt, MediaType: "application/vnd.oci.image.manifest.v1+json"}, + {Digest: "sha256:v2", Tags: []string{"v2"}, Size: 19, MediaType: "application/vnd.oci.image.manifest.v1+json"}, + }} + + out, err := DescribeImages(context.Background(), catalog, "123456789012", []byte(`{"repositoryName":"team/app","imageIds":[{"imageTag":"v1"}]}`)) + require.NoError(t, err) + require.Len(t, out.ImageDetails, 1) + detail := out.ImageDetails[0] + assert.Equal(t, "sha256:v1", *detail.ImageDigest) + assert.Equal(t, []string{"v1"}, []string{*detail.ImageTags[0]}) + assert.Equal(t, int64(17), *detail.ImageSizeInBytes) + assert.Equal(t, pushedAt, *detail.ImagePushedAt) +} + +func TestDescribeImages_ReportsMissingRequestedImage(t *testing.T) { + catalog := fakeImageCatalog{records: []ecrregistry.ImageRecord{{Digest: "sha256:present", Tags: []string{"present"}}}} + _, err := DescribeImages(context.Background(), catalog, "123456789012", []byte(`{"repositoryName":"team/app","imageIds":[{"imageTag":"missing"}]}`)) + require.Error(t, err) + assert.Equal(t, awserrors.ErrorImageNotFound, awserrors.ValidErrorCodeFromError(err)) +} diff --git a/spinifex/domains/ecr/awsapi/image_catalog.go b/spinifex/domains/ecr/awsapi/image_catalog.go new file mode 100644 index 000000000..097e2c3b5 --- /dev/null +++ b/spinifex/domains/ecr/awsapi/image_catalog.go @@ -0,0 +1,33 @@ +package awsapi + +import ( + "context" + "errors" + "log/slog" + + handlers_ecr "github.com/mulgadc/spinifex/spinifex/domains/ecr" + ecrregistry "github.com/mulgadc/spinifex/spinifex/domains/ecr/registry" + "github.com/mulgadc/spinifex/spinifex/foundation/aws/errors" +) + +// ImageCatalog is the read-only ECR registry capability image-listing actions +// need. The OCI Distribution adapter owns the concrete registry and storage +// wiring; the AWS adapter owns the ECR JSON request and response shapes. +type ImageCatalog interface { + ListImages(ctx context.Context, account, repository string) ([]ecrregistry.ImageRecord, error) +} + +func listImageRecords(ctx context.Context, catalog ImageCatalog, accountID, repository string) ([]ecrregistry.ImageRecord, error) { + if catalog == nil { + return nil, errors.New(awserrors.ErrorServerInternal) + } + records, err := catalog.ListImages(ctx, accountID, repository) + if errors.Is(err, handlers_ecr.ErrNotFound) { + return nil, errors.New(awserrors.ErrorRepositoryNotFound) + } + if err != nil { + slog.ErrorContext(ctx, "ECR image action: list images failed", "repository", repository, "err", err) + return nil, errors.New(awserrors.ErrorServerInternal) + } + return records, nil +} diff --git a/spinifex/domains/ecr/awsapi/list_images.go b/spinifex/domains/ecr/awsapi/list_images.go index d447c4507..62cfb54a7 100644 --- a/spinifex/domains/ecr/awsapi/list_images.go +++ b/spinifex/domains/ecr/awsapi/list_images.go @@ -4,22 +4,12 @@ import ( "context" "encoding/json" "errors" - "log/slog" "github.com/aws/aws-sdk-go/aws" "github.com/aws/aws-sdk-go/service/ecr" - handlers_ecr "github.com/mulgadc/spinifex/spinifex/domains/ecr" - ecrregistry "github.com/mulgadc/spinifex/spinifex/domains/ecr/registry" "github.com/mulgadc/spinifex/spinifex/foundation/aws/errors" ) -// ImageCatalog is the read-only ECR registry capability ListImages needs. The -// OCI Distribution adapter owns the concrete registry and storage wiring; the -// AWS adapter owns the ECR JSON request and response shapes. -type ImageCatalog interface { - ListImages(ctx context.Context, account, repository string) ([]ecrregistry.ImageRecord, error) -} - type listImagesRequest struct { RepositoryName string `json:"repositoryName"` RegistryID string `json:"registryId"` @@ -34,9 +24,6 @@ type tagStatusFilter struct { // optionally filtering tagged or untagged manifests. Each tag is an AWS image // identifier; an untagged manifest is represented by its digest only. func ListImages(ctx context.Context, catalog ImageCatalog, accountID string, body []byte) (*ecr.ListImagesOutput, error) { - if catalog == nil { - return nil, errors.New(awserrors.ErrorServerInternal) - } var req listImagesRequest if err := json.Unmarshal(body, &req); err != nil { return nil, MalformedBodyError() @@ -48,13 +35,9 @@ func ListImages(ctx context.Context, catalog ImageCatalog, accountID string, bod return nil, errors.New(awserrors.ErrorAccessDenied) } - records, err := catalog.ListImages(ctx, accountID, req.RepositoryName) - if errors.Is(err, handlers_ecr.ErrNotFound) { - return nil, errors.New(awserrors.ErrorRepositoryNotFound) - } + records, err := listImageRecords(ctx, catalog, accountID, req.RepositoryName) if err != nil { - slog.ErrorContext(ctx, "ECR ListImages: list images failed", "repository", req.RepositoryName, "err", err) - return nil, errors.New(awserrors.ErrorServerInternal) + return nil, err } status := "" diff --git a/spinifex/gateway/ecr_image.go b/spinifex/gateway/ecr_image.go index 578b905f7..787b7fb05 100644 --- a/spinifex/gateway/ecr_image.go +++ b/spinifex/gateway/ecr_image.go @@ -7,11 +7,9 @@ import ( "io" "log/slog" "net/http" - "slices" "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" ecrregistry "github.com/mulgadc/spinifex/spinifex/domains/ecr/registry" "github.com/mulgadc/spinifex/spinifex/foundation/aws/errors" @@ -20,23 +18,14 @@ import ( // maxImageBatch is the per-call cap on imageIds for the batch image actions. const maxImageBatch = 100 -// imageIdentifier is the AWS JSON 1.1 {imageDigest, imageTag} pair. +// imageIdentifier is the AWS JSON 1.1 {imageDigest, imageTag} pair used by +// the remaining gateway image-action adapters. It will leave with those +// actions; DescribeImages has its domain-local input shape already. type imageIdentifier struct { ImageDigest string `json:"imageDigest"` ImageTag string `json:"imageTag"` } -type tagStatusFilter struct { - TagStatus string `json:"tagStatus"` -} - -type describeImagesRequest struct { - RepositoryName string `json:"repositoryName"` - RegistryID string `json:"registryId"` - ImageIds []imageIdentifier `json:"imageIds"` - Filter *tagStatusFilter `json:"filter"` -} - type batchGetImageRequest struct { RepositoryName string `json:"repositoryName"` RegistryID string `json:"registryId"` @@ -119,68 +108,24 @@ func (gw *GatewayConfig) handleListImages(w http.ResponseWriter, r *http.Request return nil } -// handleDescribeImages returns detailed metadata for the repo's images, -// optionally narrowed to the requested imageIds. +// handleDescribeImages adapts the authenticated HTTP request to the ECR +// DescribeImages action. The ECR AWS adapter owns request and response +// semantics; gateway supplies the configured OCI registry capability. func (gw *GatewayConfig) handleDescribeImages(w http.ResponseWriter, r *http.Request) error { ctx := r.Context() accountID, err := gw.ecrImageAccount(r) if err != nil { return err } - var req describeImagesRequest - if err := decodeJSONBody(r, &req); err != nil { - return err + body, err := io.ReadAll(r.Body) + if err != nil { + return awsapi.MalformedBodyError() } - if err := validateRepoAndRegistry(req.RepositoryName, req.RegistryID, accountID); err != nil { - return err - } - - records, err := gw.ecrListImages(ctx, accountID, req.RepositoryName) + output, err := awsapi.DescribeImages(ctx, gw.ECRRegistry, accountID, body) if err != nil { return err } - - wanted := func(rec ecrregistry.ImageRecord) bool { - if len(req.ImageIds) == 0 { - return true - } - for _, id := range req.ImageIds { - if id.ImageDigest != "" && id.ImageDigest == rec.Digest { - return true - } - if id.ImageTag != "" && slices.Contains(rec.Tags, id.ImageTag) { - return true - } - } - return false - } - - details := make([]*ecr.ImageDetail, 0, len(records)) - for _, rec := range records { - if !wanted(rec) { - continue - } - detail := &ecr.ImageDetail{ - RegistryId: aws.String(accountID), - RepositoryName: aws.String(req.RepositoryName), - ImageDigest: aws.String(rec.Digest), - ImageSizeInBytes: aws.Int64(rec.Size), - ImageManifestMediaType: aws.String(rec.MediaType), - } - if !rec.PushedAt.IsZero() { - detail.ImagePushedAt = aws.Time(rec.PushedAt) - } - for _, tag := range rec.Tags { - detail.ImageTags = append(detail.ImageTags, aws.String(tag)) - } - details = append(details, detail) - } - - if len(req.ImageIds) > 0 && len(details) == 0 { - return errors.New(awserrors.ErrorImageNotFound) - } - - awsapi.WriteJSONResponse(w, &ecr.DescribeImagesOutput{ImageDetails: details}) + awsapi.WriteJSONResponse(w, output) return nil } @@ -336,20 +281,6 @@ func (gw *GatewayConfig) handleBatchDeleteImage(w http.ResponseWriter, r *http.R return nil } -// ecrListImages resolves the repo's image records, mapping a missing repo to -// RepositoryNotFound and any other backend fault to ServerInternal. -func (gw *GatewayConfig) ecrListImages(ctx context.Context, accountID, repo string) ([]ecrregistry.ImageRecord, error) { - records, err := gw.ECRRegistry.ListImages(ctx, accountID, repo) - if errors.Is(err, handlers_ecr.ErrNotFound) { - return nil, errors.New(awserrors.ErrorRepositoryNotFound) - } - if err != nil { - slog.ErrorContext(ctx, "ECR image action: list images failed", "repo", repo, "err", err) - return nil, errors.New(awserrors.ErrorServerInternal) - } - return records, nil -} - // mapStoreManifestError translates the OCI manifest-store error codes into AWS // PutImage error codes. func mapStoreManifestError(ctx context.Context, err error, repo string) error {