refactor: move ecr describe images action to domain

This commit is contained in:
joshsiv-mulga
2026-10-01 15:53:03 +10:00
parent 44568f85f5
commit b22b723a44
5 changed files with 173 additions and 99 deletions
@@ -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
}
@@ -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))
}
@@ -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
}
+2 -19
View File
@@ -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 := ""
+11 -80
View File
@@ -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 {