mirror of
https://github.com/mulgadc/spinifex.git
synced 2026-10-15 09:01:31 +00:00
refactor: move ecr describe images action to domain
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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 := ""
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user