From bb2ea2fd2d5b43e4f3d682d82da319ba60c47776 Mon Sep 17 00:00:00 2001 From: Josh Siviour Date: Mon, 22 Jun 2026 09:31:01 +1000 Subject: [PATCH] feat(ecr): OperationNotSupportedException for image-scanning actions (#403) * feat(ecr): return OperationNotSupportedException for image-scanning actions The ECR image-scan control-plane actions resolved to the generic NotImplemented 501 stub, indistinguishable from unbuilt features and surfaced as a server fault. mulga runs no vulnerability scanner, so the scan surface now returns OperationNotSupportedException (400): - awserrors: add OperationNotSupportedException (400) ECR code. - ecrapi: ScanningNotSupported handler; repoint the four image-scan actions and register the three registry-scanning actions at it. - spinifexui: Scan tab on the repository detail page renders a static 'scanning not supported' panel rather than a request that errors. * refactor(config): default internal DNS suffix to spinifex.internal Rename the AWS-parity internal DNS suffix from mulga.internal to spinifex.internal across the config default, cert SAN builder, ECR endpoint/audience construction, and their tests. Single runtime knob is config.DefaultAWSInternalSuffix; no DNS backend exists yet, so this is a naming change with no functional impact. --- spinifex/admin/admin_test.go | 22 +++++----- spinifex/awserrors/awserrors.go | 2 + spinifex/config/config.go | 2 +- spinifex/gateway/ecrapi/handler.go | 44 ++++++++++++------- spinifex/gateway/ecrapi/handler_test.go | 23 ++++++++++ spinifex/gateway/ecrauth/ecrauth_test.go | 4 +- spinifex/gateway/ecrauth_middleware_test.go | 2 +- spinifex/gateway/gateway.go | 2 +- spinifex/gateway/hostmatch_test.go | 28 ++++++------ .../repository-detail-page.test.tsx | 8 ++++ .../-components/repository-detail-page.tsx | 6 +++ .../-components/scan-findings.tsx | 15 +++++++ 12 files changed, 113 insertions(+), 45 deletions(-) create mode 100644 spinifex/services/spinifexui/frontend/src/routes/_auth/ecr/(repositories)/-components/scan-findings.tsx diff --git a/spinifex/admin/admin_test.go b/spinifex/admin/admin_test.go index e43668417..14c99fc51 100644 --- a/spinifex/admin/admin_test.go +++ b/spinifex/admin/admin_test.go @@ -621,7 +621,7 @@ func TestGenerateCertificatesIfNeeded(t *testing.T) { dir := t.TempDir() // First call generates all certs - caCertPath := GenerateCertificatesIfNeeded(dir, false, "", "us-east-1", "mulga.internal") + caCertPath := GenerateCertificatesIfNeeded(dir, false, "", "us-east-1", "spinifex.internal") assert.Equal(t, filepath.Join(dir, "ca.pem"), caCertPath) assert.True(t, FileExists(filepath.Join(dir, "ca.pem"))) assert.True(t, FileExists(filepath.Join(dir, "ca.key"))) @@ -632,7 +632,7 @@ func TestGenerateCertificatesIfNeeded(t *testing.T) { caInfo, _ := os.Stat(filepath.Join(dir, "ca.pem")) origModTime := caInfo.ModTime() - GenerateCertificatesIfNeeded(dir, false, "", "us-east-1", "mulga.internal") + GenerateCertificatesIfNeeded(dir, false, "", "us-east-1", "spinifex.internal") caInfo2, _ := os.Stat(filepath.Join(dir, "ca.pem")) assert.Equal(t, origModTime, caInfo2.ModTime()) }) @@ -640,7 +640,7 @@ func TestGenerateCertificatesIfNeeded(t *testing.T) { t.Run("ForceRegenerates", func(t *testing.T) { origCA, _ := os.ReadFile(filepath.Join(dir, "ca.pem")) - GenerateCertificatesIfNeeded(dir, true, "", "us-east-1", "mulga.internal") + GenerateCertificatesIfNeeded(dir, true, "", "us-east-1", "spinifex.internal") newCA, _ := os.ReadFile(filepath.Join(dir, "ca.pem")) assert.NotEqual(t, origCA, newCA) }) @@ -660,7 +660,7 @@ func TestGenerateServerCertOnly(t *testing.T) { require.NoError(t, os.WriteFile(filepath.Join(dir, "ca.pem"), caCert, 0600)) require.NoError(t, os.WriteFile(filepath.Join(dir, "ca.key"), caKey, 0600)) - err := GenerateServerCertOnly(dir, "10.0.0.5", "us-east-1", "mulga.internal") + err := GenerateServerCertOnly(dir, "10.0.0.5", "us-east-1", "spinifex.internal") require.NoError(t, err) assert.True(t, FileExists(filepath.Join(dir, "server.pem"))) assert.True(t, FileExists(filepath.Join(dir, "server.key"))) @@ -678,13 +678,13 @@ func TestGenerateServerCertOnly(t *testing.T) { assert.True(t, hasBindIP) // AWS-parity ECR SANs must be present. - assert.Contains(t, cert.DNSNames, "ecr.us-east-1.mulga.internal") - assert.Contains(t, cert.DNSNames, "*.dkr.ecr.us-east-1.mulga.internal") + assert.Contains(t, cert.DNSNames, "ecr.us-east-1.spinifex.internal") + assert.Contains(t, cert.DNSNames, "*.dkr.ecr.us-east-1.spinifex.internal") }) t.Run("MissingCA", func(t *testing.T) { dir := t.TempDir() - err := GenerateServerCertOnly(dir, "10.0.0.5", "us-east-1", "mulga.internal") + err := GenerateServerCertOnly(dir, "10.0.0.5", "us-east-1", "spinifex.internal") assert.Error(t, err) assert.Contains(t, err.Error(), "CA files not found") }) @@ -693,13 +693,13 @@ func TestGenerateServerCertOnly(t *testing.T) { func TestAWSGWServiceDNSNames(t *testing.T) { t.Parallel() - got := AWSGWServiceDNSNames("us-east-1", "mulga.internal") + got := AWSGWServiceDNSNames("us-east-1", "spinifex.internal") assert.Equal(t, []string{ - "ecr.us-east-1.mulga.internal", - "*.dkr.ecr.us-east-1.mulga.internal", + "ecr.us-east-1.spinifex.internal", + "*.dkr.ecr.us-east-1.spinifex.internal", }, got) - assert.Nil(t, AWSGWServiceDNSNames("", "mulga.internal")) + assert.Nil(t, AWSGWServiceDNSNames("", "spinifex.internal")) assert.Nil(t, AWSGWServiceDNSNames("us-east-1", "")) assert.Nil(t, AWSGWServiceDNSNames("", "")) } diff --git a/spinifex/awserrors/awserrors.go b/spinifex/awserrors/awserrors.go index fd635ad49..b3144b040 100644 --- a/spinifex/awserrors/awserrors.go +++ b/spinifex/awserrors/awserrors.go @@ -466,6 +466,7 @@ var ( ErrorLayersNotFound = "LayersNotFoundException" ErrorReferencedImagesNotFound = "ReferencedImagesNotFoundException" ErrorTooManyTags = "TooManyTagsException" + ErrorOperationNotSupported = "OperationNotSupportedException" // ELBv2-specific error codes ErrorELBv2LoadBalancerNotFound = "LoadBalancerNotFound" @@ -984,6 +985,7 @@ var ErrorLookup = map[string]ErrorMessage{ ErrorLayersNotFound: {HTTPCode: 400, Message: "The specified layers could not be found, or the specified layer is not valid for this repository."}, ErrorReferencedImagesNotFound: {HTTPCode: 400, Message: "The manifest list is referencing an image that does not exist."}, ErrorTooManyTags: {HTTPCode: 400, Message: "The list of tags on the repository is over the limit. The maximum number of tags that can be applied to a repository is 50."}, + ErrorOperationNotSupported: {HTTPCode: 400, Message: "The specified operation is not supported in this registry."}, // ELBv2 error codes ErrorELBv2LoadBalancerNotFound: {HTTPCode: 400, Message: "One or more load balancers not found."}, diff --git a/spinifex/config/config.go b/spinifex/config/config.go index ed20b4e9a..0b9b03517 100644 --- a/spinifex/config/config.go +++ b/spinifex/config/config.go @@ -24,7 +24,7 @@ type ClusterConfig struct { // Defaults for cluster-wide AWS-parity settings. const ( DefaultAWSRegion = "us-east-1" - DefaultAWSInternalSuffix = "mulga.internal" + DefaultAWSInternalSuffix = "spinifex.internal" ) // AWSConfig holds cluster-wide AWS-parity settings shared across services. diff --git a/spinifex/gateway/ecrapi/handler.go b/spinifex/gateway/ecrapi/handler.go index b7deafd0a..4155352dd 100644 --- a/spinifex/gateway/ecrapi/handler.go +++ b/spinifex/gateway/ecrapi/handler.go @@ -36,6 +36,14 @@ func NotImplemented(_ *nats.Conn, _ string, _ []byte) (any, error) { return nil, errors.New(awserrors.ErrorNotImplemented) } +// ScanningNotSupported is the handler for the ECR image-scanning surface. mulga +// runs no vulnerability scanner, so these actions return the AWS +// OperationNotSupportedException (400) rather than NotImplemented (501): clients +// and the console read it as a deliberately-off feature, not a server fault. +func ScanningNotSupported(_ *nats.Conn, _ string, _ []byte) (any, error) { + return nil, errors.New(awserrors.ErrorOperationNotSupported) +} + // Actions is the authoritative ECR control-plane action namespace. Real // implementations replace the NotImplemented value as each action lands; the // key set is the v1 API contract. @@ -70,21 +78,27 @@ var Actions = map[string]Handler{ "PutRegistryPolicy": NotImplemented, "DescribeRegistry": NotImplemented, - // Deferred-feature surface (image scan, lifecycle, replication, tagging). - "PutImageScanningConfiguration": NotImplemented, - "GetImageScanningConfiguration": NotImplemented, - "StartImageScan": NotImplemented, - "DescribeImageScanFindings": NotImplemented, - "PutLifecyclePolicy": PutLifecyclePolicy, - "GetLifecyclePolicy": GetLifecyclePolicy, - "DeleteLifecyclePolicy": DeleteLifecyclePolicy, - "StartLifecyclePolicyPreview": NotImplemented, - "GetLifecyclePolicyPreview": NotImplemented, - "PutReplicationConfiguration": NotImplemented, - "ReplicateImage": NotImplemented, - "TagResource": NotImplemented, - "UntagResource": NotImplemented, - "ListTagsForResource": NotImplemented, + // Image scanning is unsupported (no scanner backend): the whole scan surface + // returns OperationNotSupportedException rather than NotImplemented. + "PutImageScanningConfiguration": ScanningNotSupported, + "GetImageScanningConfiguration": ScanningNotSupported, + "StartImageScan": ScanningNotSupported, + "DescribeImageScanFindings": ScanningNotSupported, + "GetRegistryScanningConfiguration": ScanningNotSupported, + "PutRegistryScanningConfiguration": ScanningNotSupported, + "BatchGetRepositoryScanningConfiguration": ScanningNotSupported, + + // Deferred-feature surface (lifecycle, replication, tagging). + "PutLifecyclePolicy": PutLifecyclePolicy, + "GetLifecyclePolicy": GetLifecyclePolicy, + "DeleteLifecyclePolicy": DeleteLifecyclePolicy, + "StartLifecyclePolicyPreview": NotImplemented, + "GetLifecyclePolicyPreview": NotImplemented, + "PutReplicationConfiguration": NotImplemented, + "ReplicateImage": NotImplemented, + "TagResource": NotImplemented, + "UntagResource": NotImplemented, + "ListTagsForResource": NotImplemented, } // WriteJSONResponse serialises obj as a 200 AWS JSON 1.1 response using the diff --git a/spinifex/gateway/ecrapi/handler_test.go b/spinifex/gateway/ecrapi/handler_test.go index cf55bb348..e52d9c25c 100644 --- a/spinifex/gateway/ecrapi/handler_test.go +++ b/spinifex/gateway/ecrapi/handler_test.go @@ -34,6 +34,29 @@ func TestActions_CoreRegisteredAsStub(t *testing.T) { } } +func TestScanningNotSupported(t *testing.T) { + out, err := ScanningNotSupported(nil, "123456789012", []byte("{}")) + assert.Nil(t, out) + require.Error(t, err) + assert.Equal(t, awserrors.ErrorOperationNotSupported, err.Error()) +} + +func TestActions_ScanSurfaceUnsupported(t *testing.T) { + scan := []string{ + "PutImageScanningConfiguration", "GetImageScanningConfiguration", + "StartImageScan", "DescribeImageScanFindings", + "GetRegistryScanningConfiguration", "PutRegistryScanningConfiguration", + "BatchGetRepositoryScanningConfiguration", + } + for _, action := range scan { + h, ok := Actions[action] + require.True(t, ok, "action %q should be registered", action) + _, err := h(nil, "123456789012", nil) + assert.Equal(t, awserrors.ErrorOperationNotSupported, err.Error(), + "action %q should resolve to the OperationNotSupported stub", action) + } +} + func TestWriteJSONResponse(t *testing.T) { type repo struct { RepositoryName *string `locationName:"repositoryName" type:"string"` diff --git a/spinifex/gateway/ecrauth/ecrauth_test.go b/spinifex/gateway/ecrauth/ecrauth_test.go index c21e78217..d34a0aa4c 100644 --- a/spinifex/gateway/ecrauth/ecrauth_test.go +++ b/spinifex/gateway/ecrauth/ecrauth_test.go @@ -13,7 +13,7 @@ import ( "github.com/stretchr/testify/require" ) -const testAudience = "ecr.ap-southeast-2.mulga.internal" +const testAudience = "ecr.ap-southeast-2.spinifex.internal" var testMasterKey = func() []byte { k := make([]byte, 32) @@ -102,7 +102,7 @@ func TestVerifier_RejectsWrongAudience(t *testing.T) { tok, _, err := NewIssuer(key, testAudience).Mint(samplePrincipal()) require.NoError(t, err) - _, err = NewVerifier(verify, "ecr.us-east-1.mulga.internal").Verify(tok) + _, err = NewVerifier(verify, "ecr.us-east-1.spinifex.internal").Verify(tok) require.Error(t, err) } diff --git a/spinifex/gateway/ecrauth_middleware_test.go b/spinifex/gateway/ecrauth_middleware_test.go index ca15ad3d1..baa445dc3 100644 --- a/spinifex/gateway/ecrauth_middleware_test.go +++ b/spinifex/gateway/ecrauth_middleware_test.go @@ -16,7 +16,7 @@ import ( const ( ecrTestRegion = "ap-southeast-2" - ecrTestSuffix = "mulga.internal" + ecrTestSuffix = "spinifex.internal" ecrTestAudience = "ecr." + ecrTestRegion + "." + ecrTestSuffix ecrTestAccount = "000000000001" ) diff --git a/spinifex/gateway/gateway.go b/spinifex/gateway/gateway.go index d6abadb9b..50f484da0 100644 --- a/spinifex/gateway/gateway.go +++ b/spinifex/gateway/gateway.go @@ -76,7 +76,7 @@ type GatewayConfig struct { Config string // Shared AWS Gateway config for S3 auth ExpectedNodes int // Number of expected spinifex nodes for multi-node operations Region string // Region this gateway is running in - InternalSuffix string // Internal DNS suffix for AWS-parity endpoints (e.g. mulga.internal) + InternalSuffix string // Internal DNS suffix for AWS-parity endpoints (e.g. spinifex.internal) AZ string // Availability zone this gateway is running in IAMService handlers_iam.IAMService STSService handlers_sts.STSService diff --git a/spinifex/gateway/hostmatch_test.go b/spinifex/gateway/hostmatch_test.go index c9fee8cbf..afa46f357 100644 --- a/spinifex/gateway/hostmatch_test.go +++ b/spinifex/gateway/hostmatch_test.go @@ -19,16 +19,16 @@ func TestParseRegistryHost(t *testing.T) { }{ { name: "match with suffix", - host: "123456789012.dkr.ecr.us-east-1.mulga.internal", - suffix: "mulga.internal", + host: "123456789012.dkr.ecr.us-east-1.spinifex.internal", + suffix: "spinifex.internal", wantAcct: "123456789012", wantRegn: "us-east-1", wantOK: true, }, { name: "match strips port", - host: "123456789012.dkr.ecr.us-east-1.mulga.internal:9999", - suffix: "mulga.internal", + host: "123456789012.dkr.ecr.us-east-1.spinifex.internal:9999", + suffix: "spinifex.internal", wantAcct: "123456789012", wantRegn: "us-east-1", wantOK: true, @@ -43,26 +43,26 @@ func TestParseRegistryHost(t *testing.T) { }, { name: "control plane host is not a registry host", - host: "ecr.us-east-1.mulga.internal", - suffix: "mulga.internal", + host: "ecr.us-east-1.spinifex.internal", + suffix: "spinifex.internal", wantOK: false, }, { name: "wrong suffix does not match", host: "123456789012.dkr.ecr.us-east-1.other.internal", - suffix: "mulga.internal", + suffix: "spinifex.internal", wantOK: false, }, { name: "missing dkr.ecr labels", - host: "123456789012.foo.bar.us-east-1.mulga.internal", - suffix: "mulga.internal", + host: "123456789012.foo.bar.us-east-1.spinifex.internal", + suffix: "spinifex.internal", wantOK: false, }, { name: "empty host", host: "", - suffix: "mulga.internal", + suffix: "spinifex.internal", wantOK: false, }, { @@ -95,7 +95,7 @@ func TestParseRegistryHost(t *testing.T) { func TestHostMatch_PopulatesContextOnRegistryHost(t *testing.T) { t.Parallel() - gw := &GatewayConfig{InternalSuffix: "mulga.internal"} + gw := &GatewayConfig{InternalSuffix: "spinifex.internal"} var gotAcct, gotRegn string var acctOK, regnOK bool @@ -105,7 +105,7 @@ func TestHostMatch_PopulatesContextOnRegistryHost(t *testing.T) { }) req := httptest.NewRequest(http.MethodGet, "/v2/", nil) - req.Host = "123456789012.dkr.ecr.us-east-1.mulga.internal" + req.Host = "123456789012.dkr.ecr.us-east-1.spinifex.internal" gw.hostMatch(next).ServeHTTP(httptest.NewRecorder(), req) if !acctOK || gotAcct != "123456789012" { @@ -119,7 +119,7 @@ func TestHostMatch_PopulatesContextOnRegistryHost(t *testing.T) { func TestHostMatch_PassesThroughNonRegistryHost(t *testing.T) { t.Parallel() - gw := &GatewayConfig{InternalSuffix: "mulga.internal"} + gw := &GatewayConfig{InternalSuffix: "spinifex.internal"} var hadAccount bool next := http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) { @@ -127,7 +127,7 @@ func TestHostMatch_PassesThroughNonRegistryHost(t *testing.T) { }) req := httptest.NewRequest(http.MethodGet, "/v2/", nil) - req.Host = "ecr.us-east-1.mulga.internal" + req.Host = "ecr.us-east-1.spinifex.internal" gw.hostMatch(next).ServeHTTP(httptest.NewRecorder(), req) if hadAccount { diff --git a/spinifex/services/spinifexui/frontend/src/routes/_auth/ecr/(repositories)/-components/repository-detail-page.test.tsx b/spinifex/services/spinifexui/frontend/src/routes/_auth/ecr/(repositories)/-components/repository-detail-page.test.tsx index d55e8cf45..4f47343c4 100644 --- a/spinifex/services/spinifexui/frontend/src/routes/_auth/ecr/(repositories)/-components/repository-detail-page.test.tsx +++ b/spinifex/services/spinifexui/frontend/src/routes/_auth/ecr/(repositories)/-components/repository-detail-page.test.tsx @@ -78,6 +78,14 @@ describe("RepositoryDetailPage", () => { expect(screen.getByText("No images pushed yet.")).toBeInTheDocument() }) + it("shows scanning is unsupported on the Scan tab", () => { + renderWithClient(, seed([])) + fireEvent.click(screen.getByRole("tab", { name: "Scan" })) + expect( + screen.getByText(/Vulnerability scanning is not supported/), + ).toBeInTheDocument() + }) + it("opens the delete-image confirmation", () => { renderWithClient( , diff --git a/spinifex/services/spinifexui/frontend/src/routes/_auth/ecr/(repositories)/-components/repository-detail-page.tsx b/spinifex/services/spinifexui/frontend/src/routes/_auth/ecr/(repositories)/-components/repository-detail-page.tsx index a77a37b78..33f4e14e8 100644 --- a/spinifex/services/spinifexui/frontend/src/routes/_auth/ecr/(repositories)/-components/repository-detail-page.tsx +++ b/spinifex/services/spinifexui/frontend/src/routes/_auth/ecr/(repositories)/-components/repository-detail-page.tsx @@ -26,6 +26,7 @@ import { import { LifecyclePolicyEditor } from "./lifecycle-policy-editor" import { RepositoryPolicyEditor } from "./repository-policy-editor" import { RepositorySummary } from "./repository-summary" +import { ScanFindings } from "./scan-findings" interface RepositoryDetailPageProps { repositoryName: string @@ -82,6 +83,7 @@ export function RepositoryDetailPage({ Summary Images Lifecycle + Scan Permissions @@ -154,6 +156,10 @@ export function RepositoryDetailPage({ + + + + diff --git a/spinifex/services/spinifexui/frontend/src/routes/_auth/ecr/(repositories)/-components/scan-findings.tsx b/spinifex/services/spinifexui/frontend/src/routes/_auth/ecr/(repositories)/-components/scan-findings.tsx new file mode 100644 index 000000000..bf0f9c40b --- /dev/null +++ b/spinifex/services/spinifexui/frontend/src/routes/_auth/ecr/(repositories)/-components/scan-findings.tsx @@ -0,0 +1,15 @@ +// ScanFindings is the Scan tab of the repository detail page. mulga's registry +// runs no vulnerability scanner, so the ECR scan API returns +// OperationNotSupportedException. Rather than fire a request that surfaces a red +// error, the tab states plainly that scanning is unavailable. +export function ScanFindings() { + return ( +
+

Image scanning

+

+ Vulnerability scanning is not supported by this registry. Pushed images + are stored and served without scan findings. +

+
+ ) +}