diff --git a/spinifex/handlers/rds/launch.go b/spinifex/handlers/rds/launch.go index 8fe209d24..797ee373e 100644 --- a/spinifex/handlers/rds/launch.go +++ b/spinifex/handlers/rds/launch.go @@ -52,6 +52,24 @@ const ( // validation failure rather than launching something else. var ErrEngineAMINotFound = errors.New("rds: no AMI found for engine") +// The catalog registers every engine's system image under this prefix, so the +// error can name the image an operator has to import. +const engineImageNamePrefix = "spinifex-rds-" + +// An engine image no deployment ever imported is a gap no retry closes, so the +// failure carries a code: a bare error resolves to ServerInternal, which the +// caller reads as a transient fault and retries. +func engineAMINotFound(engine, version string) error { + requested := "an engine image for " + engine + if version != "" { + requested = "version " + version + " for " + engine + } + return fmt.Errorf("%w: %w", ErrEngineAMINotFound, awserrors.Errorf( + awserrors.ErrorInvalidParameterCombination, + "Cannot find %s; this deployment has no %s%s system image installed", + requested, engineImageNamePrefix, engine)) +} + type launchVPCProvisioner interface { CreateNetworkInterface(ctx context.Context, input *ec2.CreateNetworkInterfaceInput, accountID string) (*ec2.CreateNetworkInterfaceOutput, error) DeleteNetworkInterface(ctx context.Context, input *ec2.DeleteNetworkInterfaceInput, accountID string) (*ec2.DeleteNetworkInterfaceOutput, error) @@ -460,7 +478,7 @@ func resolveEngineAMI(ctx context.Context, amiSvc launchAMIResolver, engine, ver return "", fmt.Errorf("rds: describe %s AMI: %w", engine, err) } if out == nil { - return "", fmt.Errorf("%w: %s %s", ErrEngineAMINotFound, engine, version) + return "", engineAMINotFound(engine, version) } // Several builds of one engine version can be registered; skip malformed @@ -485,7 +503,7 @@ func resolveEngineAMI(ctx context.Context, amiSvc launchAMIResolver, engine, ver } } if newestID == "" { - return "", fmt.Errorf("%w: %s %s", ErrEngineAMINotFound, engine, version) + return "", engineAMINotFound(engine, version) } if matches > 1 { slog.WarnContext(ctx, "rds: multiple AMIs match the requested engine; using newest", diff --git a/spinifex/handlers/rds/launch_test.go b/spinifex/handlers/rds/launch_test.go index ef9257db2..107a3db06 100644 --- a/spinifex/handlers/rds/launch_test.go +++ b/spinifex/handlers/rds/launch_test.go @@ -928,5 +928,41 @@ func TestLaunchDBInstanceVMFailsWithoutAnEngineAMI(t *testing.T) { _, err := LaunchDBInstanceVM(t.Context(), h.deps(), testLaunchInput()) require.ErrorIs(t, err, ErrEngineAMINotFound, "a missing engine image must be a named failure, never a fallback to some other AMI") + code, _, ok := awserrors.ResolveErrorDetail(err) + require.True(t, ok, "the launch must not swallow the code on its way out") + assert.Equal(t, awserrors.ErrorInvalidParameterCombination, code) assert.Empty(t, h.enis.created) } + +func TestResolveEngineAMINotFoundCarriesAClientCode(t *testing.T) { + t.Parallel() + h := newLaunchHarness() + h.images.images = nil + + _, err := resolveEngineAMI(t.Context(), h.images, "postgres", "18") + require.ErrorIs(t, err, ErrEngineAMINotFound) + + code, message, ok := awserrors.ResolveErrorDetail(err) + require.True(t, ok, + "an uncoded error reaches the caller as a retried ServerInternal, which says nothing about what is missing") + assert.Equal(t, awserrors.ErrorInvalidParameterCombination, code) + assert.Contains(t, message, "version 18 for postgres") + assert.Contains(t, message, "spinifex-rds-postgres", + "the message names the image an operator has to import") +} + +func TestResolveEngineAMINotFoundWithoutARequestedVersion(t *testing.T) { + t.Parallel() + h := newLaunchHarness() + h.images.images = nil + + _, err := resolveEngineAMI(t.Context(), h.images, "mariadb", "") + require.ErrorIs(t, err, ErrEngineAMINotFound) + + code, message, ok := awserrors.ResolveErrorDetail(err) + require.True(t, ok) + assert.Equal(t, awserrors.ErrorInvalidParameterCombination, code) + assert.Contains(t, message, "an engine image for mariadb", + "an unset version must not read back as an empty one") + assert.Contains(t, message, "spinifex-rds-mariadb") +}