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.
This commit is contained in:
Josh Siviour
2026-06-22 09:31:01 +10:00
committed by GitHub
parent 6165b9f517
commit bb2ea2fd2d
12 changed files with 113 additions and 45 deletions
+11 -11
View File
@@ -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("", ""))
}
+2
View File
@@ -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."},
+1 -1
View File
@@ -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.
+29 -15
View File
@@ -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
+23
View File
@@ -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"`
+2 -2
View File
@@ -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)
}
+1 -1
View File
@@ -16,7 +16,7 @@ import (
const (
ecrTestRegion = "ap-southeast-2"
ecrTestSuffix = "mulga.internal"
ecrTestSuffix = "spinifex.internal"
ecrTestAudience = "ecr." + ecrTestRegion + "." + ecrTestSuffix
ecrTestAccount = "000000000001"
)
+1 -1
View File
@@ -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
+14 -14
View File
@@ -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 {
@@ -78,6 +78,14 @@ describe("RepositoryDetailPage", () => {
expect(screen.getByText("No images pushed yet.")).toBeInTheDocument()
})
it("shows scanning is unsupported on the Scan tab", () => {
renderWithClient(<RepositoryDetailPage repositoryName={REPO} />, seed([]))
fireEvent.click(screen.getByRole("tab", { name: "Scan" }))
expect(
screen.getByText(/Vulnerability scanning is not supported/),
).toBeInTheDocument()
})
it("opens the delete-image confirmation", () => {
renderWithClient(
<RepositoryDetailPage repositoryName={REPO} />,
@@ -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({
<TabsTab value="summary">Summary</TabsTab>
<TabsTab value="images">Images</TabsTab>
<TabsTab value="lifecycle">Lifecycle</TabsTab>
<TabsTab value="scan">Scan</TabsTab>
<TabsTab value="permissions">Permissions</TabsTab>
</TabsList>
@@ -154,6 +156,10 @@ export function RepositoryDetailPage({
<LifecyclePolicyEditor repositoryName={repositoryName} />
</TabsPanel>
<TabsPanel value="scan">
<ScanFindings />
</TabsPanel>
<TabsPanel value="permissions">
<RepositoryPolicyEditor repositoryName={repositoryName} />
</TabsPanel>
@@ -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 (
<div className="rounded-lg border bg-card p-4">
<h2 className="mb-2 text-sm font-medium">Image scanning</h2>
<p className="text-sm text-muted-foreground">
Vulnerability scanning is not supported by this registry. Pushed images
are stored and served without scan findings.
</p>
</div>
)
}