mirror of
https://github.com/mulgadc/spinifex.git
synced 2026-10-15 17:11:30 +00:00
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:
@@ -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("", ""))
|
||||
}
|
||||
|
||||
@@ -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."},
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ import (
|
||||
|
||||
const (
|
||||
ecrTestRegion = "ap-southeast-2"
|
||||
ecrTestSuffix = "mulga.internal"
|
||||
ecrTestSuffix = "spinifex.internal"
|
||||
ecrTestAudience = "ecr." + ecrTestRegion + "." + ecrTestSuffix
|
||||
ecrTestAccount = "000000000001"
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+8
@@ -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} />,
|
||||
|
||||
+6
@@ -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>
|
||||
|
||||
+15
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user