feat: DescribeDBEngineVersions and DescribeOrderableDBInstanceOptions (#807)

This commit is contained in:
Julian Sommer
2026-08-17 15:04:12 +10:00
committed by GitHub
parent dd398b3a93
commit 95911cbfae
15 changed files with 1314 additions and 42 deletions
+16 -2
View File
@@ -906,7 +906,7 @@ Everything below applies to both engines unless it names one. `Engine` is fixed
**`mysql` is not an accepted engine and is not an alias for `mariadb`.** MariaDB is offered under its own AWS engine name, exactly as AWS RDS offers it. Naming `mysql` fails with `InvalidParameterValue`; a client — including Terraform's `aws_db_instance` — must set `engine = "mariadb"`. Aliasing would report an engine and a version the instance is not running, and the discrepancy would propagate into `DescribeDBInstances`, parameter-group families and snapshot metadata.
- **Engine version:** pinned per engine, and an `EngineVersion` naming anything but the pin is rejected. A minor version such as `18.4` or `11.8.8` is rejected as well: the AMI does not promise any particular minor, so accepting one would be a promise the platform cannot keep. There is no in-place upgrade.
- **Instance classes:** `db.t3.{micro,small,medium,large}` and `db.m5.{large,xlarge}` — a naming facade over the platform's EC2 sizing table. Any other class is rejected at create.
- **Instance classes:** `db.t3.{micro,small,medium,large}` and `db.m5.{large,xlarge}` — a naming facade over the platform's EC2 sizing table. Any other class is rejected at create. **`db.X` is the EC2 instance type `X`**, so a client reads a class's vCPU count and memory from `ec2:describe-instance-types` for the identically named type; an orderable option carries neither, because AWS's own shape has no field for them. `describe-orderable-db-instance-options` reports only the classes the cluster's nodes can actually run, which on a Graviton-only cluster is none of the six — a class that would fail at launch is not offered.
- **Storage:** gp3 only, 20–65536 GiB, always encrypted with the cluster key. Grow-only, and a grow is **stop/start with downtime** — the volume cannot be resized while attached.
- **`DBName`:** optional; an omitted name creates no initial database. When supplied it must begin with a letter and hold only letters, digits and underscores, within the per-engine limit above. The rule is narrower than either engine's own, because the guest interpolates the name into a `CREATE DATABASE`.
- **TLS v1.3:** **required by default on both engines** — `rds.force_ssl` on `postgres18` and `require_secure_transport` on `mariadb11.8`, both boolean, modifiable and dynamic, both defaulting to `1`. Setting either to `0` in a parameter group restores plaintext without a reboot. Defaulting on is a **deliberate divergence** for MariaDB, which AWS leaves off.
@@ -988,6 +988,20 @@ A group takes effect on an instance when `modify-db-instance --db-parameter-grou
|---------|-------------------|---------------|--------|
| `describe-events` | `--source-type` (db-instance, db-snapshot), `--source-identifier`, `--event-categories`, `--duration`, `--start-time`, `--end-time`, `--max-records` | `--marker` | **DONE** — 100-event ring per resource, 14-day retention, one-hour default window |
### RDS — Engine Versions & Orderable Options
| Command | Implemented Flags | Missing Flags | Status |
|---------|-------------------|---------------|--------|
| `describe-db-engine-versions` | `--engine`, `--engine-version`, `--db-parameter-group-family`, `--filters` (`engine`, `engine-version`, `db-parameter-group-family`, `status`) | `--default-only`, `--include-all`, `--list-supported-character-sets`, `--list-supported-timezones` (accepted, provably no-ops), `--max-records` (accepted, no effect), `--marker` (rejected) | **DONE** — one row per engine |
| `describe-orderable-db-instance-options` | `--engine` (required), `--engine-version`, `--db-instance-class`, `--license-model`, `--vpc`, `--filters` (`engine`, `engine-version`, `db-instance-class`, `license-model`, `vpc`) | `--max-records` (accepted, no effect), `--marker`, `--availability-zone-group` (both rejected) | **DONE** — the engines crossed with the classes the cluster's nodes can run |
Both are read from the tables the create path validates against, so an engine pin bump or a class-map change moves these answers with it. Neither paginates — at most 2 engine versions and 12 orderable options — so no `Marker` is ever returned, and one supplied is rejected rather than answered as page one, where the other RDS describes parse and ignore it.
- **`AvailabilityZones` and `AvailableProcessorFeatures` are always empty**, and are gaps only in appearance: `--availability-zone` is rejected on create and `--processor-features` is ignored, so naming either here would advertise a knob that does nothing.
- **Only the classes the cluster's nodes can run are offered.** A cluster that answers nothing is `ServerInternal`, never the full class list; one that answers and runs none of them is an empty list. Free capacity is deliberately not consulted — exhausted capacity is a create-time failure, and filtering on it would make a Terraform data source return a different answer on every plan.
Two further deliberate divergences: the four `describe-db-engine-versions` booleans are accepted as **provable identities** rather than the "parsed, not applied" the other describes document, because one version per engine and two empty lists leave them nothing to change; and `--filters` on `describe-orderable-db-instance-options` is **applied** where AWS documents it unsupported. Both actions reject an unrecognised filter name, and a non-boolean `vpc` value, with `InvalidParameterValue` rather than ignoring it — stricter than AWS rather than looser.
### RDS — Rejected Parameters
Policy: a parameter whose omission would create a false safety, security or availability guarantee is rejected with `InvalidParameterValue` rather than silently dropped. Parameters that are merely inert — `AutoMinorVersionUpgrade`, `CopyTagsToSnapshot`, `DeleteAutomatedBackups`, Performance Insights and Enhanced Monitoring fields — are accepted as no-ops.
@@ -1001,6 +1015,7 @@ Policy: a parameter whose omission would create a false safety, security or avai
| `Iops`, `StorageThroughput`, `StorageType` ≠ `gp3` | Provisioned performance classes are not implemented |
| `KmsKeyId`, `TdeCredentialArn` | Storage is encrypted with the cluster key, not a customer-managed one |
| `AvailabilityZone` | The platform exposes a single zone |
| `AvailabilityZoneGroup` (orderable options) | It selects a zone or local-zone group, and naming a zone is already refused |
| `DBSecurityGroups` | EC2-Classic security groups — use `VpcSecurityGroupIds` |
| `DBClusterIdentifier`, `DBClusterSnapshotIdentifier` | Clustered engines are not offered |
| `EnableCloudwatchLogsExports` | Log export is not implemented |
@@ -1030,7 +1045,6 @@ Recognised actions below return `OperationNotSupported`, so a client sees "not o
| Read replicas | `create-db-instance-read-replica`, `promote-read-replica` | Medium | **NOT STARTED** |
| Aurora / DB clusters | `create-db-cluster`, `modify-db-cluster`, `delete-db-cluster`, `describe-db-clusters`, `failover-db-cluster` | Low | **NOT STARTED** |
| Option groups | `create-option-group`, `modify-option-group`, `delete-option-group`, `describe-option-groups` | Low | **NOT STARTED** |
| Engine-version / orderable-option data sources | `describe-db-engine-versions`, `describe-orderable-db-instance-options` | Low | **NOT STARTED** (`InvalidAction`) — not required by `aws_db_instance`; pin the class and version literally in Terraform |
| Multi-AZ standby, online (no-downtime) storage grow, storage autoscaling, IAM database auth, Performance Insights, Enhanced Monitoring, log exports, per-tenant private DNS zones, enforced TLS, cross-engine snapshot restore, in-place migration between the two engines | — | — | **NOT STARTED** |
IAM: `AmazonRDSFullAccess` and `AmazonRDSReadOnlyAccess` are available as managed policies. They grant `rds:` verb prefixes rather than `rds:*`, because `rds:*` would also appear to grant the internal agent actions the gateway reserves for a DB VM's own role.
+18 -16
View File
@@ -12,7 +12,7 @@ Implemented means a modelled operation is registered to a real handler. Stub and
| ecs | 2014-11-13 | 56 | 39 | 31 | 5 | 0 | 20 | 3 |
| elasticloadbalancingv2 | 2015-12-01 | 46 | 37 | 33 | 0 | 0 | 13 | 4 |
| iam | 2010-05-08 | 159 | 75 | 75 | 0 | 0 | 84 | 0 |
| rds | 2014-10-31 | 162 | 41 | 24 | 0 | 12 | 126 | 5 |
| rds | 2014-10-31 | 162 | 43 | 26 | 0 | 12 | 124 | 5 |
| s3 | 2006-03-01 | 99 | — | — | — | — | — | — |
| sts | 2011-06-15 | 8 | 4 | 4 | 0 | 0 | 4 | 0 |
@@ -462,20 +462,22 @@ None.
## rds
Implements **24 of 162** modelled operations (14.8%).
Implements **26 of 162** modelled operations (16.0%).
<details><summary>Implemented (24)</summary>
<details><summary>Implemented (26)</summary>
`AddTagsToResource`, `CreateDBInstance`, `CreateDBParameterGroup`, `CreateDBSnapshot`, `CreateDBSubnetGroup`, `DeleteDBInstance`, `DeleteDBParameterGroup`, `DeleteDBSnapshot`
`DeleteDBSubnetGroup`, `DescribeDBInstanceAutomatedBackups`, `DescribeDBInstances`, `DescribeDBParameterGroups`, `DescribeDBParameters`, `DescribeDBSnapshots`, `DescribeDBSubnetGroups`, `DescribeEvents`
`DeleteDBSubnetGroup`, `DescribeDBEngineVersions`, `DescribeDBInstanceAutomatedBackups`, `DescribeDBInstances`, `DescribeDBParameterGroups`, `DescribeDBParameters`, `DescribeDBSnapshots`, `DescribeDBSubnetGroups`
`ListTagsForResource`, `ModifyDBInstance`, `ModifyDBParameterGroup`, `RebootDBInstance`, `RemoveTagsFromResource`, `RestoreDBInstanceFromDBSnapshot`, `StartDBInstance`, `StopDBInstance`
`DescribeEvents`, `DescribeOrderableDBInstanceOptions`, `ListTagsForResource`, `ModifyDBInstance`, `ModifyDBParameterGroup`, `RebootDBInstance`, `RemoveTagsFromResource`, `RestoreDBInstanceFromDBSnapshot`
`StartDBInstance`, `StopDBInstance`
</details>
<details><summary>Missing from dispatch (126)</summary>
<details><summary>Missing from dispatch (124)</summary>
`AddRoleToDBCluster`, `AddRoleToDBInstance`, `AddSourceIdentifierToSubscription`, `ApplyPendingMaintenanceAction`, `AuthorizeDBSecurityGroupIngress`, `BacktrackDBCluster`, `CancelExportTask`, `CopyDBClusterParameterGroup`
@@ -489,25 +491,25 @@ Implements **24 of 162** modelled operations (14.8%).
`DeregisterDBProxyTargets`, `DescribeAccountAttributes`, `DescribeBlueGreenDeployments`, `DescribeCertificates`, `DescribeDBClusterAutomatedBackups`, `DescribeDBClusterBacktracks`, `DescribeDBClusterEndpoints`, `DescribeDBClusterParameterGroups`
`DescribeDBClusterParameters`, `DescribeDBClusterSnapshotAttributes`, `DescribeDBClusterSnapshots`, `DescribeDBEngineVersions`, `DescribeDBLogFiles`, `DescribeDBProxies`, `DescribeDBProxyEndpoints`, `DescribeDBProxyTargetGroups`
`DescribeDBClusterParameters`, `DescribeDBClusterSnapshotAttributes`, `DescribeDBClusterSnapshots`, `DescribeDBLogFiles`, `DescribeDBProxies`, `DescribeDBProxyEndpoints`, `DescribeDBProxyTargetGroups`, `DescribeDBProxyTargets`
`DescribeDBProxyTargets`, `DescribeDBRecommendations`, `DescribeDBSecurityGroups`, `DescribeDBShardGroups`, `DescribeDBSnapshotAttributes`, `DescribeDBSnapshotTenantDatabases`, `DescribeEngineDefaultClusterParameters`, `DescribeEngineDefaultParameters`
`DescribeDBRecommendations`, `DescribeDBSecurityGroups`, `DescribeDBShardGroups`, `DescribeDBSnapshotAttributes`, `DescribeDBSnapshotTenantDatabases`, `DescribeEngineDefaultClusterParameters`, `DescribeEngineDefaultParameters`, `DescribeEventCategories`
`DescribeEventCategories`, `DescribeEventSubscriptions`, `DescribeExportTasks`, `DescribeGlobalClusters`, `DescribeIntegrations`, `DescribeOptionGroupOptions`, `DescribeOrderableDBInstanceOptions`, `DescribePendingMaintenanceActions`
`DescribeEventSubscriptions`, `DescribeExportTasks`, `DescribeGlobalClusters`, `DescribeIntegrations`, `DescribeOptionGroupOptions`, `DescribePendingMaintenanceActions`, `DescribeReservedDBInstances`, `DescribeReservedDBInstancesOfferings`
`DescribeReservedDBInstances`, `DescribeReservedDBInstancesOfferings`, `DescribeSourceRegions`, `DescribeTenantDatabases`, `DescribeValidDBInstanceModifications`, `DisableHttpEndpoint`, `DownloadDBLogFilePortion`, `EnableHttpEndpoint`
`DescribeSourceRegions`, `DescribeTenantDatabases`, `DescribeValidDBInstanceModifications`, `DisableHttpEndpoint`, `DownloadDBLogFilePortion`, `EnableHttpEndpoint`, `FailoverGlobalCluster`, `ModifyActivityStream`
`FailoverGlobalCluster`, `ModifyActivityStream`, `ModifyCertificates`, `ModifyCurrentDBClusterCapacity`, `ModifyCustomDBEngineVersion`, `ModifyDBClusterEndpoint`, `ModifyDBClusterParameterGroup`, `ModifyDBClusterSnapshotAttribute`
`ModifyCertificates`, `ModifyCurrentDBClusterCapacity`, `ModifyCustomDBEngineVersion`, `ModifyDBClusterEndpoint`, `ModifyDBClusterParameterGroup`, `ModifyDBClusterSnapshotAttribute`, `ModifyDBProxy`, `ModifyDBProxyEndpoint`
`ModifyDBProxy`, `ModifyDBProxyEndpoint`, `ModifyDBProxyTargetGroup`, `ModifyDBRecommendation`, `ModifyDBShardGroup`, `ModifyDBSnapshot`, `ModifyDBSnapshotAttribute`, `ModifyDBSubnetGroup`
`ModifyDBProxyTargetGroup`, `ModifyDBRecommendation`, `ModifyDBShardGroup`, `ModifyDBSnapshot`, `ModifyDBSnapshotAttribute`, `ModifyDBSubnetGroup`, `ModifyEventSubscription`, `ModifyGlobalCluster`
`ModifyEventSubscription`, `ModifyGlobalCluster`, `ModifyIntegration`, `ModifyTenantDatabase`, `PromoteReadReplicaDBCluster`, `PurchaseReservedDBInstancesOffering`, `RebootDBCluster`, `RebootDBShardGroup`
`ModifyIntegration`, `ModifyTenantDatabase`, `PromoteReadReplicaDBCluster`, `PurchaseReservedDBInstancesOffering`, `RebootDBCluster`, `RebootDBShardGroup`, `RegisterDBProxyTargets`, `RemoveFromGlobalCluster`
`RegisterDBProxyTargets`, `RemoveFromGlobalCluster`, `RemoveRoleFromDBCluster`, `RemoveRoleFromDBInstance`, `RemoveSourceIdentifierFromSubscription`, `ResetDBClusterParameterGroup`, `ResetDBParameterGroup`, `RestoreDBClusterFromS3`
`RemoveRoleFromDBCluster`, `RemoveRoleFromDBInstance`, `RemoveSourceIdentifierFromSubscription`, `ResetDBClusterParameterGroup`, `ResetDBParameterGroup`, `RestoreDBClusterFromS3`, `RestoreDBClusterFromSnapshot`, `RestoreDBClusterToPointInTime`
`RestoreDBClusterFromSnapshot`, `RestoreDBClusterToPointInTime`, `RestoreDBInstanceFromS3`, `RevokeDBSecurityGroupIngress`, `StartActivityStream`, `StartDBCluster`, `StartDBInstanceAutomatedBackupsReplication`, `StartExportTask`
`RestoreDBInstanceFromS3`, `RevokeDBSecurityGroupIngress`, `StartActivityStream`, `StartDBCluster`, `StartDBInstanceAutomatedBackupsReplication`, `StartExportTask`, `StopActivityStream`, `StopDBCluster`
`StopActivityStream`, `StopDBCluster`, `StopDBInstanceAutomatedBackupsReplication`, `SwitchoverBlueGreenDeployment`, `SwitchoverGlobalCluster`, `SwitchoverReadReplica`
`StopDBInstanceAutomatedBackupsReplication`, `SwitchoverBlueGreenDeployment`, `SwitchoverGlobalCluster`, `SwitchoverReadReplica`
</details>
+2 -1
View File
@@ -54,7 +54,8 @@ func (gw *GatewayConfig) RDS_Request(w http.ResponseWriter, r *http.Request) err
return errors.New(awserrors.ErrorServerInternal)
}
xmlOutput, err := gateway_rds.Dispatch(r.Context(), action, queryArgs, gw.NATSConn, caller)
xmlOutput, err := gateway_rds.Dispatch(r.Context(), action, queryArgs, gw.NATSConn, caller,
gateway_rds.Env{ExpectedNodes: gw.ExpectedNodes})
if err != nil {
return err
}
+1 -1
View File
@@ -121,7 +121,7 @@ func TestAuthorizeAgent_RejectsWhenNoInstancesExist(t *testing.T) {
func TestInternalActions_GateBeforeDispatch(t *testing.T) {
for _, action := range []string{"RegisterDBInstance", "SubmitDBStateChange", "PollDBCommands", "GetDBBootstrapConfig"} {
t.Run(action, func(t *testing.T) {
_, err := Dispatch(t.Context(), action, map[string]string{"Action": action}, nil, testCaller)
_, err := Dispatch(t.Context(), action, map[string]string{"Action": action}, nil, testCaller, testEnv)
require.Error(t, err)
assert.Equal(t, awserrors.ErrorAccessDenied, err.Error())
})
+220
View File
@@ -0,0 +1,220 @@
package gateway_rds
import (
"context"
"errors"
"log/slog"
"strconv"
"strings"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/service/ec2"
"github.com/aws/aws-sdk-go/service/rds"
"github.com/mulgadc/spinifex/spinifex/awserrors"
gateway_ec2_instance "github.com/mulgadc/spinifex/spinifex/gateway/ec2/instance"
handlers_rds "github.com/mulgadc/spinifex/spinifex/handlers/rds"
"github.com/mulgadc/spinifex/spinifex/utils"
"github.com/nats-io/nats.go"
)
// The filter names each action recognises. Rejecting an unknown name is only
// implementable against a closed vocabulary, and the two actions take disjoint
// typed parameters, so each keeps its own list rather than sharing one.
const (
filterNameEngine = "engine"
filterNameEngineVersion = "engine-version"
filterNameParameterGroupFamily = "db-parameter-group-family"
filterNameStatus = "status"
filterNameDBInstanceClass = "db-instance-class"
filterNameLicenseModel = "license-model"
filterNameVpc = "vpc"
)
var (
engineVersionFilterNames = []string{
filterNameEngine, filterNameEngineVersion, filterNameParameterGroupFamily, filterNameStatus,
}
orderableFilterNames = []string{
filterNameEngine, filterNameEngineVersion, filterNameDBInstanceClass, filterNameLicenseModel, filterNameVpc,
}
)
// The engine catalog is static, so this reads it with no I/O at all: Engine is a
// filter here rather than a required parameter, and an unknown one is an empty
// list rather than the rejection create-db-instance gives it.
func DescribeDBEngineVersions(ctx context.Context, input *rds.DescribeDBEngineVersionsInput, _ *nats.Conn, _ Caller) (any, error) {
if err := rejectMarker(input.Marker, "DescribeDBEngineVersions"); err != nil {
return nil, err
}
filter := handlers_rds.EngineVersionFilter{}
filter.Engine.AddParam(aws.StringValue(input.Engine))
filter.EngineVersion.AddParam(aws.StringValue(input.EngineVersion))
filter.ParameterGroupFamily.AddParam(aws.StringValue(input.DBParameterGroupFamily))
for _, entry := range input.Filters {
name, values, err := filterEntry(entry)
if err != nil {
return nil, err
}
switch name {
case filterNameEngine:
filter.Engine.AddFilter(values)
case filterNameEngineVersion:
filter.EngineVersion.AddFilter(values)
case filterNameParameterGroupFamily:
filter.ParameterGroupFamily.AddFilter(values)
case filterNameStatus:
filter.Status.AddFilter(values)
default:
return nil, unknownFilterName(name, engineVersionFilterNames)
}
}
// DefaultOnly, IncludeAll, ListSupportedCharacterSets and ListSupportedTimezones
// are accepted and not read: each is an identity on a catalog of one available
// version per engine with no character-set or timezone list to populate.
return &rds.DescribeDBEngineVersionsOutput{DBEngineVersions: handlers_rds.EngineVersions(filter)}, nil
}
// Engine is required, so an absent one is MissingParameter and an unknown one is
// the InvalidParameterValue LookupEngine already words. Everything else narrows.
func DescribeOrderableDBInstanceOptions(ctx context.Context, input *rds.DescribeOrderableDBInstanceOptionsInput, nc *nats.Conn, _ Caller, env Env) (any, error) {
if err := rejectMarker(input.Marker, "DescribeOrderableDBInstanceOptions"); err != nil {
return nil, err
}
if aws.StringValue(input.AvailabilityZoneGroup) != "" {
return nil, awserrors.Errorf(awserrors.ErrorInvalidParameterValue,
"AvailabilityZoneGroup is not supported: this platform exposes a single zone and names none")
}
if strings.TrimSpace(aws.StringValue(input.Engine)) == "" {
return nil, awserrors.Errorf(awserrors.ErrorMissingParameter, "Engine is required")
}
engine, err := handlers_rds.LookupEngine(aws.StringValue(input.Engine))
if err != nil {
return nil, err
}
filter := handlers_rds.OrderableFilter{}
filter.Engine.AddParam(engine.Name)
filter.EngineVersion.AddParam(aws.StringValue(input.EngineVersion))
filter.DBInstanceClass.AddParam(aws.StringValue(input.DBInstanceClass))
filter.LicenseModel.AddParam(aws.StringValue(input.LicenseModel))
if input.Vpc != nil {
filter.Vpc.AddParam(strconv.FormatBool(aws.BoolValue(input.Vpc)))
}
for _, entry := range input.Filters {
name, values, err := filterEntry(entry)
if err != nil {
return nil, err
}
switch name {
case filterNameEngine:
filter.Engine.AddFilter(values)
case filterNameEngineVersion:
filter.EngineVersion.AddFilter(values)
case filterNameDBInstanceClass:
filter.DBInstanceClass.AddFilter(values)
case filterNameLicenseModel:
filter.LicenseModel.AddFilter(values)
case filterNameVpc:
parsed, perr := boolFilterValues(name, values)
if perr != nil {
return nil, perr
}
filter.Vpc.AddFilter(parsed)
default:
return nil, unknownFilterName(name, orderableFilterNames)
}
}
runnable, err := clusterRunnableTypes(ctx, nc, env)
if err != nil {
return nil, err
}
return &rds.DescribeOrderableDBInstanceOptionsOutput{
OrderableDBInstanceOptions: handlers_rds.OrderableOptions(filter, runnable),
}, nil
}
// Which EC2 instance types the cluster's nodes report they can run, as a
// membership test. Capacity is deliberately not consulted: an exhausted cluster
// still offers the class, and a create that cannot be placed is a create-time
// failure rather than a class the region does not have.
//
// The probe names no instance type, because the union is what tells a cluster
// that answered nothing apart from one whose nodes run none of the db.* classes:
// DescribeInstanceTypes filters its reply by the requested types and reports a
// timed-out gather as an empty list with no error, so asking for the six would
// collapse both onto the same answer.
func clusterRunnableTypes(ctx context.Context, nc *nats.Conn, env Env) (func(string) bool, error) {
out, err := gateway_ec2_instance.DescribeInstanceTypes(ctx, &ec2.DescribeInstanceTypesInput{},
nc, env.ExpectedNodes, utils.GlobalAccountID)
if err != nil {
slog.ErrorContext(ctx, "RDS: instance-type capability probe failed", "err", err)
return nil, errors.New(awserrors.ErrorServerInternal)
}
supported := make(map[string]bool, len(out.InstanceTypes))
for _, info := range out.InstanceTypes {
if info != nil && info.InstanceType != nil {
supported[*info.InstanceType] = true
}
}
// No node answered. Falling back to the full class list here would offer
// classes that cannot launch, which is the failure this probe exists to stop.
if len(supported) == 0 {
slog.ErrorContext(ctx, "RDS: no node reported any instance type")
return nil, errors.New(awserrors.ErrorServerInternal)
}
return func(instanceType string) bool { return supported[instanceType] }, nil
}
// Neither action ever issues a Marker, so one in a request can only have been
// fabricated. Answering it as page one would report the whole catalog as if it
// were a later page.
func rejectMarker(marker *string, action string) error {
if aws.StringValue(marker) == "" {
return nil
}
return awserrors.Errorf(awserrors.ErrorInvalidParameterValue,
"Marker is not supported: %s returns every row in a single page", action)
}
// Both members are required by the shape, and treating either as absent would
// silently widen the result rather than narrow it.
func filterEntry(entry *rds.Filter) (string, []string, error) {
if entry == nil || strings.TrimSpace(aws.StringValue(entry.Name)) == "" {
return "", nil, awserrors.Errorf(awserrors.ErrorInvalidParameterValue, "each filter must carry a name")
}
name := strings.ToLower(strings.TrimSpace(aws.StringValue(entry.Name)))
if len(entry.Values) == 0 {
return "", nil, awserrors.Errorf(awserrors.ErrorInvalidParameterValue,
"filter %q must carry at least one value", name)
}
return name, aws.StringValueSlice(entry.Values), nil
}
// A bool-shaped filter is parsed rather than compared as text, so it accepts
// every spelling the typed parameter does. Matching only "true" and "false"
// would answer "1" with an empty catalog the caller cannot tell from a real one.
func boolFilterValues(name string, values []string) ([]string, error) {
parsed := make([]string, 0, len(values))
for _, value := range values {
b, err := strconv.ParseBool(strings.TrimSpace(value))
if err != nil {
return nil, awserrors.Errorf(awserrors.ErrorInvalidParameterValue,
"filter %q takes a boolean, not %q", name, value)
}
parsed = append(parsed, strconv.FormatBool(b))
}
return parsed, nil
}
// Rejected rather than ignored: these two actions exist only to be filtered, so
// a dropped filter returns rows the caller asked not to see and cannot detect.
func unknownFilterName(name string, recognised []string) error {
return awserrors.Errorf(awserrors.ErrorInvalidParameterValue,
"filter %q is not recognised; supported filters are %s", name, strings.Join(recognised, ", "))
}
+355
View File
@@ -0,0 +1,355 @@
package gateway_rds
//test:in-package — shares newStubbedNATS, testCaller and testEnv with
// handler_test.go, which is in-package for the same reason: the stubbed NATS
// scaffolding every dispatch test needs is not reachable from outside.
import (
"bytes"
"encoding/xml"
"testing"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/private/protocol/xml/xmlutil"
"github.com/aws/aws-sdk-go/service/ec2"
"github.com/aws/aws-sdk-go/service/rds"
"github.com/mulgadc/spinifex/spinifex/awserrors"
handlers_rds "github.com/mulgadc/spinifex/spinifex/handlers/rds"
"github.com/mulgadc/spinifex/spinifex/testutil"
"github.com/nats-io/nats.go"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func describeEngineVersions(t *testing.T, query map[string]string) []*rds.DBEngineVersion {
t.Helper()
query["Action"] = "DescribeDBEngineVersions"
body, err := Dispatch(t.Context(), "DescribeDBEngineVersions", query, nil, testCaller, testEnv)
require.NoError(t, err)
var out rds.DescribeDBEngineVersionsOutput
require.NoError(t, xmlutil.UnmarshalXML(&out, xml.NewDecoder(bytes.NewReader(body)),
"DescribeDBEngineVersionsResult"))
return out.DBEngineVersions
}
func describeOrderable(t *testing.T, nc *nats.Conn, query map[string]string) []*rds.OrderableDBInstanceOption {
t.Helper()
query["Action"] = "DescribeOrderableDBInstanceOptions"
body, err := Dispatch(t.Context(), "DescribeOrderableDBInstanceOptions", query, nc, testCaller, testEnv)
require.NoError(t, err)
var out rds.DescribeOrderableDBInstanceOptionsOutput
require.NoError(t, xmlutil.UnmarshalXML(&out, xml.NewDecoder(bytes.NewReader(body)),
"DescribeOrderableDBInstanceOptionsResult"))
return out.OrderableDBInstanceOptions
}
func engineNames(rows []*rds.DBEngineVersion) []string {
names := make([]string, 0, len(rows))
for _, row := range rows {
names = append(names, aws.StringValue(row.Engine))
}
return names
}
func classNames(options []*rds.OrderableDBInstanceOption) []string {
names := make([]string, 0, len(options))
for _, option := range options {
names = append(names, aws.StringValue(option.DBInstanceClass))
}
return names
}
// AWS nests each row inside its list, and the SDK's own unmarshaler will not
// find one written any other way.
func TestDispatch_DescribeDBEngineVersionsRendersTheNestedList(t *testing.T) {
rows := describeEngineVersions(t, map[string]string{})
require.Len(t, rows, len(handlers_rds.SupportedEngines()))
assert.Equal(t, handlers_rds.SupportedEngines(), engineNames(rows))
for _, row := range rows {
assert.NotEmpty(t, aws.StringValue(row.EngineVersion))
assert.NotEmpty(t, aws.StringValue(row.DBParameterGroupFamily))
assert.Equal(t, "available", aws.StringValue(row.Status))
}
}
func TestDispatch_DescribeOrderableDBInstanceOptionsRendersTheNestedList(t *testing.T) {
options := describeOrderable(t, newStubbedNATS(t), map[string]string{"Engine": "postgres"})
require.Len(t, options, len(handlers_rds.SupportedInstanceClasses()))
assert.Equal(t, handlers_rds.SupportedInstanceClasses(), classNames(options))
for _, option := range options {
assert.Equal(t, "postgres", aws.StringValue(option.Engine))
assert.Equal(t, "gp3", aws.StringValue(option.StorageType))
assert.True(t, aws.BoolValue(option.Vpc))
assert.Equal(t, []string{"IPV4"}, aws.StringValueSlice(option.SupportedNetworkTypes))
}
}
func TestDescribeDBEngineVersions_AppliesTypedParametersAndFilters(t *testing.T) {
cases := []struct {
name string
query map[string]string
want []string
}{
{"engine parameter", map[string]string{"Engine": "postgres"}, []string{"postgres"}},
{"engine filter", map[string]string{
"Filters.Filter.1.Name": "engine", "Filters.Filter.1.Values.Value.1": "mariadb",
}, []string{"mariadb"}},
{"engine filter with two values", map[string]string{
"Filters.Filter.1.Name": "engine",
"Filters.Filter.1.Values.Value.1": "postgres",
"Filters.Filter.1.Values.Value.2": "mariadb",
}, handlers_rds.SupportedEngines()},
{"engine version parameter", map[string]string{"EngineVersion": "18"}, []string{"postgres"}},
{"parameter group family parameter",
map[string]string{"DBParameterGroupFamily": "mariadb11.8"}, []string{"mariadb"}},
{"parameter group family filter", map[string]string{
"Filters.Filter.1.Name": "db-parameter-group-family", "Filters.Filter.1.Values.Value.1": "postgres18",
}, []string{"postgres"}},
{"status filter", map[string]string{
"Filters.Filter.1.Name": "status", "Filters.Filter.1.Values.Value.1": "available",
}, handlers_rds.SupportedEngines()},
// A query for a row that does not exist is empty; only a request to build
// something that cannot exist is refused.
{"unpinned version", map[string]string{"EngineVersion": "17"}, []string{}},
{"unknown engine", map[string]string{"Engine": "oracle-ee"}, []string{}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
assert.Equal(t, tc.want, engineNames(describeEngineVersions(t, tc.query)))
})
}
}
func TestDescribeOrderableDBInstanceOptions_AppliesTypedParametersAndFilters(t *testing.T) {
nc := newStubbedNATS(t)
all := handlers_rds.SupportedInstanceClasses()
cases := []struct {
name string
query map[string]string
want []string
}{
{"class parameter", map[string]string{"DBInstanceClass": "db.t3.micro"}, []string{"db.t3.micro"}},
{"class filter", map[string]string{
"Filters.Filter.1.Name": "db-instance-class", "Filters.Filter.1.Values.Value.1": "db.m5.large",
}, []string{"db.m5.large"}},
{"engine version parameter", map[string]string{"EngineVersion": "18"}, all},
{"license model parameter", map[string]string{"LicenseModel": "postgresql-license"}, all},
{"license model filter", map[string]string{
"Filters.Filter.1.Name": "license-model", "Filters.Filter.1.Values.Value.1": "general-public-license",
}, []string{}},
{"vpc true", map[string]string{"Vpc": "true"}, all},
// Every endpoint is a private VPC address, so there is no non-VPC option.
{"vpc false", map[string]string{"Vpc": "false"}, []string{}},
{"vpc filter false", map[string]string{
"Filters.Filter.1.Name": "vpc", "Filters.Filter.1.Values.Value.1": "false",
}, []string{}},
// The typed parameter takes every spelling ParseBool does, so the filter
// has to agree with it: "1" narrowing to nothing would be indistinguishable
// from a cluster that runs none of the classes.
{"vpc numeric parameter", map[string]string{"Vpc": "1"}, all},
{"vpc numeric filter", map[string]string{
"Filters.Filter.1.Name": "vpc", "Filters.Filter.1.Values.Value.1": "1",
}, all},
{"vpc numeric filter false", map[string]string{
"Filters.Filter.1.Name": "vpc", "Filters.Filter.1.Values.Value.1": "0",
}, []string{}},
{"vpc shouted filter", map[string]string{
"Filters.Filter.1.Name": "vpc", "Filters.Filter.1.Values.Value.1": "TRUE",
}, all},
{"unpinned version", map[string]string{"EngineVersion": "17"}, []string{}},
{"unknown class", map[string]string{"DBInstanceClass": "db.r5.24xlarge"}, []string{}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
tc.query["Engine"] = "postgres"
assert.Equal(t, tc.want, classNames(describeOrderable(t, nc, tc.query)))
})
}
}
// The two actions take disjoint typed parameters, so each has its own closed
// filter vocabulary and a name valid on the other one is still rejected.
func TestDescribeCatalogs_RejectAnUnrecognisedFilterName(t *testing.T) {
cases := []struct {
action string
filter string
}{
{"DescribeDBEngineVersions", "not-a-filter"},
{"DescribeDBEngineVersions", "db-instance-class"},
{"DescribeDBEngineVersions", "license-model"},
{"DescribeDBEngineVersions", "vpc"},
{"DescribeOrderableDBInstanceOptions", "not-a-filter"},
{"DescribeOrderableDBInstanceOptions", "db-parameter-group-family"},
{"DescribeOrderableDBInstanceOptions", "status"},
}
nc := newStubbedNATS(t)
for _, tc := range cases {
t.Run(tc.action+"/"+tc.filter, func(t *testing.T) {
_, err := Dispatch(t.Context(), tc.action, map[string]string{
"Action": tc.action, "Engine": "postgres",
"Filters.Filter.1.Name": tc.filter, "Filters.Filter.1.Values.Value.1": "anything",
}, nc, testCaller, testEnv)
require.Error(t, err)
assert.Contains(t, err.Error(), awserrors.ErrorInvalidParameterValue)
assert.Contains(t, err.Error(), tc.filter)
})
}
}
// A value the typed parameter would have refused outright is refused here too,
// rather than narrowing the catalog to nothing and reporting that as the answer.
func TestDescribeOrderableDBInstanceOptions_RejectANonBooleanVpcFilter(t *testing.T) {
for _, value := range []string{"yes", "", "2"} {
t.Run(value, func(t *testing.T) {
_, err := Dispatch(t.Context(), "DescribeOrderableDBInstanceOptions", map[string]string{
"Action": "DescribeOrderableDBInstanceOptions", "Engine": "postgres",
"Filters.Filter.1.Name": "vpc", "Filters.Filter.1.Values.Value.1": value,
}, newStubbedNATS(t), testCaller, testEnv)
require.Error(t, err)
assert.Contains(t, err.Error(), awserrors.ErrorInvalidParameterValue)
})
}
}
func TestDescribeCatalogs_RejectMalformedFilters(t *testing.T) {
cases := []struct {
name string
query map[string]string
}{
{"no values", map[string]string{"Filters.Filter.1.Name": "engine"}},
{"no name", map[string]string{"Filters.Filter.1.Values.Value.1": "postgres"}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
tc.query["Action"] = "DescribeDBEngineVersions"
_, err := Dispatch(t.Context(), "DescribeDBEngineVersions", tc.query, nil, testCaller, testEnv)
require.Error(t, err)
assert.Contains(t, err.Error(), awserrors.ErrorInvalidParameterValue)
})
}
}
// Neither action ever issues a Marker, so one in a request was fabricated by the
// caller. This diverges from the other RDS describes, which parse and ignore it,
// because those may one day paginate and these two never will.
func TestDescribeCatalogs_RejectAMarker(t *testing.T) {
nc := newStubbedNATS(t)
for _, action := range []string{"DescribeDBEngineVersions", "DescribeOrderableDBInstanceOptions"} {
t.Run(action, func(t *testing.T) {
_, err := Dispatch(t.Context(), action, map[string]string{
"Action": action, "Engine": "postgres", "Marker": "page-two",
}, nc, testCaller, testEnv)
require.Error(t, err)
assert.Contains(t, err.Error(), awserrors.ErrorInvalidParameterValue)
assert.Contains(t, err.Error(), "Marker")
})
}
}
func TestDescribeOrderableDBInstanceOptions_RequiresAKnownEngine(t *testing.T) {
cases := []struct {
name string
query map[string]string
want string
}{
{"absent", map[string]string{}, awserrors.ErrorMissingParameter},
{"blank", map[string]string{"Engine": " "}, awserrors.ErrorMissingParameter},
{"unknown", map[string]string{"Engine": "oracle-ee"}, awserrors.ErrorInvalidParameterValue},
{"mysql is not an alias for mariadb", map[string]string{"Engine": "mysql"},
awserrors.ErrorInvalidParameterValue},
{"availability zone group", map[string]string{"Engine": "postgres", "AvailabilityZoneGroup": "az-group-1"},
awserrors.ErrorInvalidParameterValue},
}
nc := newStubbedNATS(t)
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
tc.query["Action"] = "DescribeOrderableDBInstanceOptions"
_, err := Dispatch(t.Context(), "DescribeOrderableDBInstanceOptions", tc.query, nc, testCaller, testEnv)
require.Error(t, err)
assert.Contains(t, err.Error(), tc.want)
})
}
}
// The four booleans are accepted as provable identities on this catalog rather
// than parsed and dropped, and this is what keeps the claim provable: adding a
// second version per engine, or populating either list, fails here and forces
// the parameter to be implemented rather than left inert.
func TestDescribeDBEngineVersions_TheFourBooleansAreProvableNoOps(t *testing.T) {
// Compared as decoded responses rather than byte-wise: BuildXML emits sibling
// elements in map order, so a row's own members reorder between two calls that
// returned exactly the same rows.
baseline := describeEngineVersions(t, map[string]string{})
booleans := []string{"DefaultOnly", "IncludeAll", "ListSupportedCharacterSets", "ListSupportedTimezones"}
for _, name := range booleans {
for _, value := range []string{"true", "false"} {
t.Run(name+"="+value, func(t *testing.T) {
assert.Equal(t, baseline, describeEngineVersions(t, map[string]string{name: value}))
})
}
}
// The conditions that make each identity hold, so the assertion above cannot
// stay true by accident once the catalog grows.
rows := baseline
byEngine := map[string]int{}
for _, row := range rows {
byEngine[aws.StringValue(row.Engine)]++
assert.Equal(t, "available", aws.StringValue(row.Status),
"IncludeAll cannot widen a set only while every version is available")
assert.Empty(t, row.SupportedCharacterSets,
"ListSupportedCharacterSets is an identity only while the list is empty")
assert.Empty(t, row.SupportedTimezones,
"ListSupportedTimezones is an identity only while the list is empty")
}
for engine, count := range byEngine {
assert.Equal(t, 1, count,
"DefaultOnly cannot narrow a set only while %s has exactly one version", engine)
}
}
// A stubbed cluster that answers nothing must not fall back to the static class
// list: reporting classes that cannot launch is the failure this probe prevents.
func TestDescribeOrderableDBInstanceOptions_ASilentClusterIsServerInternal(t *testing.T) {
_, nc, _ := testutil.StartTestJetStream(t)
_, err := Dispatch(t.Context(), "DescribeOrderableDBInstanceOptions", map[string]string{
"Action": "DescribeOrderableDBInstanceOptions", "Engine": "postgres",
}, nc, testCaller, testEnv)
require.Error(t, err)
assert.Equal(t, awserrors.ErrorServerInternal, err.Error())
}
// A cluster whose nodes answered but run none of the db.* types is an empty
// option list, which is the truthful answer and not the same case as above.
func TestDescribeOrderableDBInstanceOptions_AnArmOnlyClusterOffersNothing(t *testing.T) {
_, nc, _ := testutil.StartTestJetStream(t)
respondWith(t, nc, "ec2.DescribeInstanceTypes", &ec2.DescribeInstanceTypesOutput{
InstanceTypes: instanceTypeInfo("t4g.micro", "t4g.small", "m6g.large"),
})
assert.Empty(t, describeOrderable(t, nc, map[string]string{"Engine": "postgres"}))
}
// Only the classes the nodes report are offered, so a class that would fail at
// launch is never in the answer.
func TestDescribeOrderableDBInstanceOptions_OffersOnlyWhatTheNodesRun(t *testing.T) {
_, nc, _ := testutil.StartTestJetStream(t)
respondWith(t, nc, "ec2.DescribeInstanceTypes", &ec2.DescribeInstanceTypesOutput{
InstanceTypes: instanceTypeInfo("t3.micro", "t3.small", "t4g.micro", "m6g.large"),
})
assert.Equal(t, []string{"db.t3.micro", "db.t3.small"},
classNames(describeOrderable(t, nc, map[string]string{"Engine": "postgres"})))
}
+28 -6
View File
@@ -29,12 +29,21 @@ type Caller struct {
SessionName string
}
type Handler func(ctx context.Context, action string, q map[string]string, nc *nats.Conn, caller Caller) ([]byte, error)
// Cluster facts a handler needs that the request cannot carry. It holds the
// ingredient rather than a ready-made probe, so the next action needing a
// fan-out gets it without another parameter.
type Env struct {
// How many nodes a fan-out waits for before it stops waiting. Without it a
// gather burns its full timeout on every call instead of early-exiting.
ExpectedNodes int
}
type Handler func(ctx context.Context, action string, q map[string]string, nc *nats.Conn, caller Caller, env Env) ([]byte, error)
// Allocates the input struct, parses the query params into it, calls handler and
// marshals the output into the IAM-style <ActionResponse><ActionResult> envelope.
func typed[In any](handler func(context.Context, *In, *nats.Conn, Caller) (any, error)) Handler {
return func(ctx context.Context, action string, q map[string]string, nc *nats.Conn, caller Caller) ([]byte, error) {
func typedEnv[In any](handler func(context.Context, *In, *nats.Conn, Caller, Env) (any, error)) Handler {
return func(ctx context.Context, action string, q map[string]string, nc *nats.Conn, caller Caller, env Env) ([]byte, error) {
input := new(In)
if err := awsec2query.QueryParamsToStruct(q, input); err != nil {
// An over-long indexed list is a client-side malformation, not an
@@ -44,7 +53,7 @@ func typed[In any](handler func(context.Context, *In, *nats.Conn, Caller) (any,
}
return nil, errors.New(awserrors.ErrorInvalidParameterValue)
}
output, err := handler(ctx, input, nc, caller)
output, err := handler(ctx, input, nc, caller, env)
if err != nil {
return nil, err
}
@@ -57,6 +66,14 @@ func typed[In any](handler func(context.Context, *In, *nats.Conn, Caller) (any,
}
}
// The same adapter for the majority of actions, which address one account's own
// state and need nothing from the cluster beyond the NATS connection.
func typed[In any](handler func(context.Context, *In, *nats.Conn, Caller) (any, error)) Handler {
return typedEnv(func(ctx context.Context, input *In, nc *nats.Conn, caller Caller, _ Env) (any, error) {
return handler(ctx, input, nc, caller)
})
}
// One entry per action: what serves it, whether it is agent-only, and which
// resource its policy check evaluates against. Both authorization facts live on
// the table so a new action cannot be added without deciding either.
@@ -118,6 +135,11 @@ var actions = map[string]actionDef{
// Events. The ring is per-account and a filter names no single resource.
"DescribeEvents": {handler: typed(DescribeEvents)},
// Catalogs. Neither reads per-account state, so both evaluate against "*";
// the orderable one reads cluster capability, which is not tenant data either.
"DescribeDBEngineVersions": {handler: typed(DescribeDBEngineVersions)},
"DescribeOrderableDBInstanceOptions": {handler: typedEnv(DescribeOrderableDBInstanceOptions)},
// Internal agent actions, callable only by the in-guest agent's system role.
// They share the namespace because the agent reaches the control plane over
// SigV4-on-awsgw like every other in-guest agent.
@@ -154,7 +176,7 @@ func HasAction(action string) bool {
// Callers are expected to have authorized the action already; the unknown-action
// check here is a backstop, not the enforcement point. The internal actions
// re-run their own gate, so skipping AuthorizeCaller still cannot reach one.
func Dispatch(ctx context.Context, action string, q map[string]string, nc *nats.Conn, caller Caller) ([]byte, error) {
func Dispatch(ctx context.Context, action string, q map[string]string, nc *nats.Conn, caller Caller, env Env) ([]byte, error) {
def, ok := actions[action]
if !ok {
return nil, errors.New(awserrors.ErrorInvalidAction)
@@ -163,7 +185,7 @@ func Dispatch(ctx context.Context, action string, q map[string]string, nc *nats.
slog.DebugContext(ctx, "RDS: action not available", "action", action)
return nil, errors.New(awserrors.ErrorOperationNotSupported)
}
return def.handler(ctx, action, q, nc, caller)
return def.handler(ctx, action, q, nc, caller, env)
}
// ActionNames returns every action the gateway recognises, in stable order.
+47 -11
View File
@@ -4,11 +4,13 @@ import (
"bytes"
"encoding/json"
"encoding/xml"
"maps"
"strconv"
"testing"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/private/protocol/xml/xmlutil"
"github.com/aws/aws-sdk-go/service/ec2"
"github.com/aws/aws-sdk-go/service/rds"
"github.com/mulgadc/spinifex/spinifex/awserrors"
handlers_rds "github.com/mulgadc/spinifex/spinifex/handlers/rds"
@@ -59,6 +61,9 @@ var v1Actions = []string{
"DescribeEvents",
"DescribeDBEngineVersions",
"DescribeOrderableDBInstanceOptions",
"RegisterDBInstance",
"SubmitDBStateChange",
"PollDBCommands",
@@ -100,7 +105,7 @@ func TestHasAction_UnknownAction(t *testing.T) {
}
func TestDispatch_UnknownAction(t *testing.T) {
_, err := Dispatch(t.Context(), "NotAnRDSAction", nil, nil, testCaller)
_, err := Dispatch(t.Context(), "NotAnRDSAction", nil, nil, testCaller, testEnv)
require.Error(t, err)
assert.Equal(t, awserrors.ErrorInvalidAction, err.Error())
}
@@ -118,7 +123,7 @@ func TestDispatch_MalformedScalarIsInvalidParameterValue(t *testing.T) {
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
_, err := Dispatch(t.Context(), tc.action, tc.query, nil, testCaller)
_, err := Dispatch(t.Context(), tc.action, tc.query, nil, testCaller, testEnv)
require.Error(t, err)
assert.Equal(t, awserrors.ErrorInvalidParameterValue, err.Error())
})
@@ -131,7 +136,7 @@ func TestDispatch_OversizedListIsMalformedQueryString(t *testing.T) {
query["Tags.Tag."+strconv.Itoa(i)+".Key"] = "key"
}
_, err := Dispatch(t.Context(), "CreateDBInstance", query, nil, testCaller)
_, err := Dispatch(t.Context(), "CreateDBInstance", query, nil, testCaller, testEnv)
require.Error(t, err)
assert.Equal(t, awserrors.ErrorMalformedQueryString, err.Error())
}
@@ -149,8 +154,25 @@ var liveActions = []string{
"CreateDBSnapshot", "DescribeDBSnapshots", "DeleteDBSnapshot",
"RestoreDBInstanceFromDBSnapshot",
"DescribeDBInstanceAutomatedBackups",
"DescribeDBEngineVersions", "DescribeOrderableDBInstanceOptions",
}
// The parameters an action refuses to run without, so a table-driven dispatch
// reaches the handler rather than its own required-parameter check.
var requiredParams = map[string]map[string]string{
"DescribeOrderableDBInstanceOptions": {"Engine": "postgres"},
}
func dispatchQuery(action string) map[string]string {
q := map[string]string{"Action": action}
maps.Copy(q, requiredParams[action])
return q
}
// One node, so the orderable action's capability probe early-exits on the first
// frame instead of paying the gather's full three-second timeout.
var testEnv = Env{ExpectedNodes: 1}
// What is under test in this file is the action table and the XML envelope, not
// the orchestration behind the subject, so the responder returns a fixed output.
func newStubbedNATS(t *testing.T) *nats.Conn {
@@ -188,9 +210,23 @@ func newStubbedNATS(t *testing.T) *nats.Conn {
&rds.RestoreDBInstanceFromDBSnapshotOutput{DBInstance: &rds.DBInstance{DBInstanceIdentifier: aws.String("orders-db-restored")}})
respondWith(t, nc, handlers_rds.SubjectDescribeDBInstanceAutomatedBackups,
&rds.DescribeDBInstanceAutomatedBackupsOutput{})
// The orderable catalog's capability probe. Unstubbed it would get no
// ErrNoResponders — Gather publishes and reads an inbox rather than
// requesting — so every dispatch over the table would pay its full timeout.
respondWith(t, nc, "ec2.DescribeInstanceTypes", &ec2.DescribeInstanceTypesOutput{
InstanceTypes: instanceTypeInfo("t3.micro", "t3.small", "t3.medium", "t3.large", "m5.large", "m5.xlarge"),
})
return nc
}
func instanceTypeInfo(names ...string) []*ec2.InstanceTypeInfo {
out := make([]*ec2.InstanceTypeInfo, 0, len(names))
for _, name := range names {
out = append(out, &ec2.InstanceTypeInfo{InstanceType: aws.String(name)})
}
return out
}
func respondWith(t *testing.T, nc *nats.Conn, subject string, output any) {
t.Helper()
payload, err := json.Marshal(output)
@@ -211,7 +247,7 @@ func TestDispatch_EveryActionResolves(t *testing.T) {
nc := newStubbedNATS(t)
for action := range actions {
t.Run(action, func(t *testing.T) {
body, err := Dispatch(t.Context(), action, map[string]string{"Action": action}, nc, testCaller)
body, err := Dispatch(t.Context(), action, dispatchQuery(action), nc, testCaller, testEnv)
if err == nil {
assert.NotEmpty(t, body, "a successful action must return an XML body")
return
@@ -230,7 +266,7 @@ func TestDispatch_EveryActionResolves(t *testing.T) {
func TestDispatch_LiveActionsAreNotPending(t *testing.T) {
nc := newStubbedNATS(t)
for _, action := range liveActions {
body, err := Dispatch(t.Context(), action, map[string]string{"Action": action}, nc, testCaller)
body, err := Dispatch(t.Context(), action, dispatchQuery(action), nc, testCaller, testEnv)
require.NoError(t, err, "action %q", action)
assert.Contains(t, string(body), "<"+action+"Result", "action %q", action)
}
@@ -238,7 +274,7 @@ func TestDispatch_LiveActionsAreNotPending(t *testing.T) {
func TestDispatch_OutOfScopeActionIsNotSupported(t *testing.T) {
for _, action := range outOfScopeActions {
_, err := Dispatch(t.Context(), action, map[string]string{"Action": action}, nil, testCaller)
_, err := Dispatch(t.Context(), action, map[string]string{"Action": action}, nil, testCaller, testEnv)
require.Error(t, err, "action %q", action)
assert.Equal(t, awserrors.ErrorOperationNotSupported, err.Error(), "action %q", action)
}
@@ -246,7 +282,7 @@ func TestDispatch_OutOfScopeActionIsNotSupported(t *testing.T) {
func TestDispatch_DescribeDBInstancesReturnsEmptyResultSet(t *testing.T) {
body, err := Dispatch(t.Context(), "DescribeDBInstances",
map[string]string{"Action": "DescribeDBInstances", "Version": "2014-10-31"}, newStubbedNATS(t), testCaller)
map[string]string{"Action": "DescribeDBInstances", "Version": "2014-10-31"}, newStubbedNATS(t), testCaller, testEnv)
require.NoError(t, err)
// The IAM-style envelope the aws-sdk-go query unmarshaler expects, carrying
@@ -263,7 +299,7 @@ func TestDispatch_ListTagsForResourceRendersTheNestedTagList(t *testing.T) {
body, err := Dispatch(t.Context(), "ListTagsForResource", map[string]string{
"Action": "ListTagsForResource",
"ResourceName": handlers_rds.DBInstanceARN("ap-southeast-2", testAccountID, "orders-db"),
}, newStubbedNATS(t), testCaller)
}, newStubbedNATS(t), testCaller, testEnv)
require.NoError(t, err)
// Decoded the way a real client decodes it, rather than compared byte-wise:
@@ -301,7 +337,7 @@ func TestDispatch_AddTagsToResourceParsesTheTagList(t *testing.T) {
"ResourceName": arn,
"Tags.Tag.1.Key": "env", "Tags.Tag.1.Value": "prod",
"Tags.Tag.2.Key": "team", "Tags.Tag.2.Value": "platform",
}, nc, testCaller)
}, nc, testCaller, testEnv)
require.NoError(t, err)
got := <-requests
@@ -337,7 +373,7 @@ func TestDispatch_RemoveTagsFromResourceParsesMemberTagKeys(t *testing.T) {
"ResourceName": handlers_rds.DBInstanceARN("ap-southeast-2", testAccountID, "orders-db"),
"TagKeys.member.1": "env",
"TagKeys.member.2": "team",
}, nc, testCaller)
}, nc, testCaller, testEnv)
require.NoError(t, err)
assert.Equal(t, []string{"env", "team"}, aws.StringValueSlice((<-requests).TagKeys))
@@ -350,7 +386,7 @@ func TestDispatch_DescribeDBInstancesWithFilters(t *testing.T) {
"Action": "DescribeDBInstances",
"DBInstanceIdentifier": "orders-db",
"MaxRecords": "20",
}, newStubbedNATS(t), testCaller)
}, newStubbedNATS(t), testCaller, testEnv)
require.NoError(t, err)
assert.Contains(t, string(body), "<DescribeDBInstancesResult")
}
+214
View File
@@ -0,0 +1,214 @@
package handlers_rds
import (
"slices"
"strconv"
"strings"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/service/rds"
)
// The status every offered version carries. One version per engine and it is
// the one the AMI ships, so nothing is ever deprecated, pending or beta.
const engineVersionStatusAvailable = "available"
// The only network type offered. Advertising DUAL would promise an address
// family no subnet hands out.
const networkTypeIPv4 = "IPV4"
// Every endpoint is a private VPC address, so a vpc=false filter matches
// nothing. Named once so the filter and the reported field cannot drift apart.
const orderableVpc = true
// ValueSets is a conjunction of accepted-value sets, one per source: the typed
// parameter and each Filters entry that names the same field. A row must satisfy
// every set, so naming two different engines matches nothing rather than both.
type ValueSets [][]string
// AddParam constrains by a typed parameter. An omitted one narrows nothing,
// which is why an empty value is not recorded as a set that matches nothing.
func (v *ValueSets) AddParam(value string) {
if strings.TrimSpace(value) == "" {
return
}
*v = append(*v, []string{normaliseFilterValue(value)})
}
// AddFilter constrains by one Filters entry. Unlike AddParam an empty or
// unmatchable value is kept, because the caller wrote the filter deliberately.
func (v *ValueSets) AddFilter(values []string) {
set := make([]string, 0, len(values))
for _, value := range values {
set = append(set, normaliseFilterValue(value))
}
*v = append(*v, set)
}
// A free function rather than a method, so ValueSets keeps the pointer receivers
// its two mutators need without mixing the two receiver kinds on one type.
func accepts(sets ValueSets, value string) bool {
value = normaliseFilterValue(value)
for _, set := range sets {
if !slices.Contains(set, value) {
return false
}
}
return true
}
// Every value either side of the comparison is a lowercase identifier already,
// so folding here only makes a shouted filter work rather than widening a match.
func normaliseFilterValue(value string) string {
return strings.ToLower(strings.TrimSpace(value))
}
// EngineVersionFilter narrows the engine-version catalog. Every field is
// optional; a zero filter returns every row.
type EngineVersionFilter struct {
Engine ValueSets
EngineVersion ValueSets
ParameterGroupFamily ValueSets
Status ValueSets
}
func (f EngineVersionFilter) matches(e Engine) bool {
return accepts(f.Engine, e.Name) &&
accepts(f.EngineVersion, e.EngineVersion()) &&
accepts(f.ParameterGroupFamily, e.ParameterGroupFamily()) &&
accepts(f.Status, engineVersionStatusAvailable)
}
// OrderableFilter narrows the orderable-option catalog. Vpc is a value set like
// the rest, holding "true" or "false", so an unset filter stays distinct from
// one asking for non-VPC options.
type OrderableFilter struct {
Engine ValueSets
EngineVersion ValueSets
DBInstanceClass ValueSets
LicenseModel ValueSets
Vpc ValueSets
}
func (f OrderableFilter) matchesEngine(e Engine) bool {
return accepts(f.Engine, e.Name) &&
accepts(f.EngineVersion, e.EngineVersion()) &&
accepts(f.LicenseModel, e.licenseModel) &&
accepts(f.Vpc, strconv.FormatBool(orderableVpc))
}
// EngineVersions is the engine half of the catalog: one row per engine, since
// v1 pins a single major each and that pin is the only version an AMI serves.
func EngineVersions(filter EngineVersionFilter) []*rds.DBEngineVersion {
out := make([]*rds.DBEngineVersion, 0, len(engines))
for _, name := range SupportedEngines() {
engine := engines[name]
if !filter.matches(engine) {
continue
}
out = append(out, engine.describeVersion())
}
return out
}
// OrderableOptions is the cross product of the engines, their pinned version and
// the db.* classes, minus every class whose EC2 instance type runnable rejects.
// runnable is the cluster's own answer to "can a node run this", which is the
// difference between a class that validates and one that can actually launch.
func OrderableOptions(filter OrderableFilter, runnable func(instanceType string) bool) []*rds.OrderableDBInstanceOption {
out := make([]*rds.OrderableDBInstanceOption, 0, len(engines)*len(dbInstanceClasses))
for _, name := range SupportedEngines() {
engine := engines[name]
if !filter.matchesEngine(engine) {
continue
}
for _, class := range SupportedInstanceClasses() {
if !accepts(filter.DBInstanceClass, class) {
continue
}
instanceType, err := InstanceTypeForClass(class)
if err != nil || !runnable(instanceType) {
continue
}
out = append(out, engine.orderableOption(class))
}
}
return out
}
// Everything the platform has a truth for. Fields the SDK struct carries that
// nothing here answers — the custom-engine manifest, the CA identifiers, the
// installation-file locations — are left nil rather than guessed at.
func (e Engine) describeVersion() *rds.DBEngineVersion {
return &rds.DBEngineVersion{
Engine: aws.String(e.Name),
EngineVersion: aws.String(e.EngineVersion()),
MajorEngineVersion: aws.String(e.MajorVersion),
DBParameterGroupFamily: aws.String(e.ParameterGroupFamily()),
DBEngineDescription: aws.String(e.description),
DBEngineVersionDescription: aws.String(e.description + " " + e.MajorVersion),
Status: aws.String(engineVersionStatusAvailable),
// Empty rather than absent: there is no in-place upgrade to target, no log
// type to export, no engine mode to select, and no engine-selectable
// character set or timezone — the timezone knob lives in the parameter
// catalog, keyed by family, not here.
ValidUpgradeTarget: []*rds.UpgradeTarget{},
SupportedFeatureNames: []*string{},
ExportableLogTypes: []*string{},
SupportedEngineModes: []*string{},
SupportedCharacterSets: []*rds.CharacterSet{},
SupportedTimezones: []*rds.Timezone{},
SupportsReadReplica: aws.Bool(false),
SupportsGlobalDatabases: aws.Bool(false),
SupportsLogExportsToCloudwatchLogs: aws.Bool(false),
SupportsParallelQuery: aws.Bool(false),
SupportsBabelfish: aws.Bool(false),
SupportsIntegrations: aws.Bool(false),
SupportsLimitlessDatabase: aws.Bool(false),
SupportsLocalWriteForwarding: aws.Bool(false),
SupportsCertificateRotationWithoutRestart: aws.Bool(false),
}
}
// Every false below restates a line of rejectUnimplemented or of the
// accepted-but-inert set, so an option cannot advertise a capability the create
// path refuses.
func (e Engine) orderableOption(class string) *rds.OrderableDBInstanceOption {
return &rds.OrderableDBInstanceOption{
Engine: aws.String(e.Name),
EngineVersion: aws.String(e.EngineVersion()),
DBInstanceClass: aws.String(class),
LicenseModel: aws.String(e.licenseModel),
StorageType: aws.String(storageTypeGP3),
MinStorageSize: aws.Int64(minAllocatedStorageGiB),
MaxStorageSize: aws.Int64(maxAllocatedStorageGiB),
SupportsStorageEncryption: aws.Bool(true),
Vpc: aws.Bool(orderableVpc),
SupportedNetworkTypes: []*string{aws.String(networkTypeIPv4)},
// Both are empty by design. AvailabilityZone is a rejected create
// parameter, so naming a zone would invite a request that fails; and
// ProcessorFeatures is accepted and ignored, so advertising one would
// promise a knob that does nothing.
AvailabilityZones: []*rds.AvailabilityZone{},
AvailableProcessorFeatures: []*rds.AvailableProcessorFeature{},
MultiAZCapable: aws.Bool(false),
ReadReplicaCapable: aws.Bool(false),
SupportsIops: aws.Bool(false),
SupportsStorageThroughput: aws.Bool(false),
SupportsStorageAutoscaling: aws.Bool(false),
SupportsIAMDatabaseAuthentication: aws.Bool(false),
SupportsEnhancedMonitoring: aws.Bool(false),
SupportsPerformanceInsights: aws.Bool(false),
SupportsKerberosAuthentication: aws.Bool(false),
SupportsGlobalDatabases: aws.Bool(false),
SupportsClusters: aws.Bool(false),
OutpostCapable: aws.Bool(false),
SupportsDedicatedLogVolume: aws.Bool(false),
}
}
+311
View File
@@ -0,0 +1,311 @@
package handlers_rds
//test:in-package — asserts the catalog against the unexported rejectUnimplemented
// and against storageTypeGP3 and the allocated-storage bounds, which are package
// constants deliberately, so the option cannot claim what create refuses.
import (
"strings"
"testing"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/service/rds"
"github.com/mulgadc/spinifex/spinifex/instancetypes"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func runsEverything(string) bool { return true }
func runsNothing(string) bool { return false }
// Both are reported verbatim by the two catalogs, and the licence model is
// filterable, so an engine registering neither would answer a --license-model
// filter with an empty row set rather than with its own licence.
func TestEngines_RegisterADescriptionAndALicenceModel(t *testing.T) {
t.Parallel()
for _, name := range SupportedEngines() {
t.Run(name, func(t *testing.T) {
t.Parallel()
engine, err := LookupEngine(name)
require.NoError(t, err)
assert.NotEmpty(t, engine.Description())
assert.NotEmpty(t, engine.LicenseModel())
})
}
}
// The catalog is a read of engine.go, so a pin bump has to fail here rather than
// in a client that hardcoded the old one.
func TestEngineVersions_ReportWhatTheEngineTableSays(t *testing.T) {
t.Parallel()
byEngine := map[string]*rds.DBEngineVersion{}
for _, row := range EngineVersions(EngineVersionFilter{}) {
byEngine[aws.StringValue(row.Engine)] = row
}
require.Len(t, byEngine, len(SupportedEngines()))
for _, name := range SupportedEngines() {
t.Run(name, func(t *testing.T) {
t.Parallel()
engine, err := LookupEngine(name)
require.NoError(t, err)
row := byEngine[name]
require.NotNil(t, row)
assert.Equal(t, engine.EngineVersion(), aws.StringValue(row.EngineVersion))
assert.Equal(t, engine.MajorVersion, aws.StringValue(row.MajorEngineVersion))
assert.Equal(t, engine.ParameterGroupFamily(), aws.StringValue(row.DBParameterGroupFamily))
assert.Equal(t, engine.Description(), aws.StringValue(row.DBEngineDescription))
assert.Equal(t, engineVersionStatusAvailable, aws.StringValue(row.Status))
// The family the row names has to be the one the implicit default
// parameter group is built from, or a console reading both disagrees.
assert.Equal(t, defaultParameterGroupPrefix+aws.StringValue(row.DBParameterGroupFamily),
engine.DefaultParameterGroupName())
})
}
}
// v1 offers no upgrade path, no log export and no engine-selectable character
// set or timezone, so every one of these is empty rather than unpopulated.
func TestEngineVersions_ReportNoCapabilityThePlatformLacks(t *testing.T) {
t.Parallel()
for _, row := range EngineVersions(EngineVersionFilter{}) {
name := aws.StringValue(row.Engine)
assert.Empty(t, row.ValidUpgradeTarget, "%s should offer no upgrade target", name)
assert.Empty(t, row.ExportableLogTypes, "%s should export no log type", name)
assert.Empty(t, row.SupportedEngineModes, "%s should offer no engine mode", name)
assert.Empty(t, row.SupportedCharacterSets, "%s should offer no character set", name)
assert.Empty(t, row.SupportedTimezones, "%s should offer no timezone", name)
assert.False(t, aws.BoolValue(row.SupportsReadReplica), "%s should not claim read replicas", name)
assert.False(t, aws.BoolValue(row.SupportsGlobalDatabases), "%s should not claim global databases", name)
assert.False(t, aws.BoolValue(row.SupportsLogExportsToCloudwatchLogs), "%s should not claim log export", name)
}
}
func TestOrderableOptions_CoverEveryEngineAndClass(t *testing.T) {
t.Parallel()
options := OrderableOptions(OrderableFilter{}, runsEverything)
require.Len(t, options, len(SupportedEngines())*len(SupportedInstanceClasses()))
for _, option := range options {
label := aws.StringValue(option.Engine) + "/" + aws.StringValue(option.DBInstanceClass)
engine, err := LookupEngine(aws.StringValue(option.Engine))
require.NoError(t, err, label)
assert.Equal(t, engine.EngineVersion(), aws.StringValue(option.EngineVersion), label)
assert.Equal(t, engine.LicenseModel(), aws.StringValue(option.LicenseModel), label)
assert.Contains(t, SupportedInstanceClasses(), aws.StringValue(option.DBInstanceClass), label)
assert.Equal(t, storageTypeGP3, aws.StringValue(option.StorageType), label)
assert.Equal(t, int64(minAllocatedStorageGiB), aws.Int64Value(option.MinStorageSize), label)
assert.Equal(t, int64(maxAllocatedStorageGiB), aws.Int64Value(option.MaxStorageSize), label)
assert.True(t, aws.BoolValue(option.SupportsStorageEncryption), label)
assert.True(t, aws.BoolValue(option.Vpc), label)
assert.Equal(t, []string{networkTypeIPv4}, aws.StringValueSlice(option.SupportedNetworkTypes), label)
// A zone here would invite a create naming it, which validate.go refuses;
// a processor feature would advertise a knob create accepts and ignores.
assert.Empty(t, option.AvailabilityZones, label)
assert.Empty(t, option.AvailableProcessorFeatures, label)
}
}
// An option claiming a capability the create path refuses is a contradiction the
// catalog should not be able to hold quietly.
func TestOrderableOptions_AgreeWithTheRejectedCreateParameters(t *testing.T) {
t.Parallel()
option := OrderableOptions(OrderableFilter{}, runsEverything)[0]
cases := []struct {
capability string
claimed *bool
create *rds.CreateDBInstanceInput
}{
{"MultiAZCapable", option.MultiAZCapable, &rds.CreateDBInstanceInput{MultiAZ: aws.Bool(true)}},
{"SupportsIops", option.SupportsIops, &rds.CreateDBInstanceInput{Iops: aws.Int64(3000)}},
{"SupportsStorageThroughput", option.SupportsStorageThroughput,
&rds.CreateDBInstanceInput{StorageThroughput: aws.Int64(125)}},
{"SupportsStorageAutoscaling", option.SupportsStorageAutoscaling,
&rds.CreateDBInstanceInput{MaxAllocatedStorage: aws.Int64(100)}},
{"SupportsIAMDatabaseAuthentication", option.SupportsIAMDatabaseAuthentication,
&rds.CreateDBInstanceInput{EnableIAMDatabaseAuthentication: aws.Bool(true)}},
{"SupportsClusters", option.SupportsClusters,
&rds.CreateDBInstanceInput{DBClusterIdentifier: aws.String("orders-cluster")}},
}
for _, tc := range cases {
t.Run(tc.capability, func(t *testing.T) {
t.Parallel()
assert.False(t, aws.BoolValue(tc.claimed), "the option should not claim %s", tc.capability)
assert.Error(t, rejectUnimplemented(tc.create),
"create should refuse the parameter %s corresponds to", tc.capability)
})
}
// The one true claim, checked the same way: encrypted storage is not merely
// offered, unencrypted storage is refused.
assert.True(t, aws.BoolValue(option.SupportsStorageEncryption))
assert.Error(t, rejectUnimplemented(&rds.CreateDBInstanceInput{StorageEncrypted: aws.Bool(false)}))
}
// A class the cluster's nodes cannot run is never offered, and a cluster that
// runs none of them is an empty list rather than an error.
func TestOrderableOptions_FilterOnClusterCapability(t *testing.T) {
t.Parallel()
onlyMicro := OrderableOptions(OrderableFilter{}, func(instanceType string) bool {
return instanceType == "t3.micro"
})
require.Len(t, onlyMicro, len(SupportedEngines()))
for _, option := range onlyMicro {
assert.Equal(t, "db.t3.micro", aws.StringValue(option.DBInstanceClass))
}
assert.Empty(t, OrderableOptions(OrderableFilter{}, runsNothing))
}
// db.X is the EC2 instance type X, which is what lets a client read vCPU and
// memory from ec2:DescribeInstanceTypes for the identically named type. Asserted
// by name rather than by footprint: t3.large and m5.large are both 2 vCPU and
// 8 GiB, so repointing db.t3.large at m5.large would break the documented
// identity and pass a footprint comparison unchanged.
func TestInstanceTypeForClass_IsTheClassNameWithoutItsPrefix(t *testing.T) {
t.Parallel()
for _, class := range SupportedInstanceClasses() {
t.Run(class, func(t *testing.T) {
t.Parallel()
instanceType, err := InstanceTypeForClass(class)
require.NoError(t, err)
assert.Equal(t, strings.TrimPrefix(class, "db."), instanceType)
// Both lookups prove the name is known to instancetypes, which is what
// makes SmallestInstanceClass safe: it silently skips a class whose
// footprint will not resolve.
_, knownVCPUs := instancetypes.DefaultVCPUs(instanceType)
assert.True(t, knownVCPUs, "instancetypes should know the vCPU count of %s", instanceType)
_, knownMemory := instancetypes.DefaultMemoryMiB(instanceType)
assert.True(t, knownMemory, "instancetypes should know the memory of %s", instanceType)
})
}
}
func TestEngineVersionFilter_NarrowsByEveryRecognisedField(t *testing.T) {
t.Parallel()
postgres, err := LookupEngine("postgres")
require.NoError(t, err)
cases := []struct {
name string
filter func() EngineVersionFilter
want []string
}{
{"unfiltered", func() EngineVersionFilter { return EngineVersionFilter{} }, SupportedEngines()},
{"engine", func() (f EngineVersionFilter) {
f.Engine.AddParam("postgres")
return f
}, []string{"postgres"}},
{"engine is case insensitive", func() (f EngineVersionFilter) {
f.Engine.AddFilter([]string{"PostgreSQL", "POSTGRES"})
return f
}, []string{"postgres"}},
{"engine version", func() (f EngineVersionFilter) {
f.EngineVersion.AddParam(postgres.MajorVersion)
return f
}, []string{"postgres"}},
{"parameter group family", func() (f EngineVersionFilter) {
f.ParameterGroupFamily.AddParam(postgres.ParameterGroupFamily())
return f
}, []string{"postgres"}},
{"status", func() (f EngineVersionFilter) {
f.Status.AddFilter([]string{engineVersionStatusAvailable})
return f
}, SupportedEngines()},
{"deprecated status matches nothing", func() (f EngineVersionFilter) {
f.Status.AddFilter([]string{"deprecated"})
return f
}, nil},
{"unknown engine matches nothing", func() (f EngineVersionFilter) {
f.Engine.AddParam("oracle-ee")
return f
}, nil},
{"unpinned version matches nothing", func() (f EngineVersionFilter) {
f.EngineVersion.AddParam("17")
return f
}, nil},
// Two sources naming different engines is a conjunction, not a union: a
// row has to satisfy both, and no row is two engines.
{"typed parameter and filter are both applied", func() (f EngineVersionFilter) {
f.Engine.AddParam("postgres")
f.Engine.AddFilter([]string{"mariadb"})
return f
}, nil},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
var got []string
for _, row := range EngineVersions(tc.filter()) {
got = append(got, aws.StringValue(row.Engine))
}
assert.Equal(t, tc.want, got)
})
}
}
func TestOrderableFilter_NarrowsByEveryRecognisedField(t *testing.T) {
t.Parallel()
postgres, err := LookupEngine("postgres")
require.NoError(t, err)
classes := len(SupportedInstanceClasses())
cases := []struct {
name string
filter func() OrderableFilter
want int
}{
{"unfiltered", func() OrderableFilter { return OrderableFilter{} }, len(SupportedEngines()) * classes},
{"engine", func() (f OrderableFilter) {
f.Engine.AddParam("postgres")
return f
}, classes},
{"engine version", func() (f OrderableFilter) {
f.EngineVersion.AddParam(postgres.MajorVersion)
return f
}, classes},
{"instance class", func() (f OrderableFilter) {
f.DBInstanceClass.AddParam("db.t3.micro")
return f
}, len(SupportedEngines())},
{"license model", func() (f OrderableFilter) {
f.LicenseModel.AddParam(postgres.LicenseModel())
return f
}, classes},
{"vpc true", func() (f OrderableFilter) {
f.Vpc.AddParam("true")
return f
}, len(SupportedEngines()) * classes},
// Every endpoint is a private VPC address, so asking for a non-VPC option
// is an empty list rather than the whole catalog.
{"vpc false", func() (f OrderableFilter) {
f.Vpc.AddParam("false")
return f
}, 0},
{"unknown class matches nothing", func() (f OrderableFilter) {
f.DBInstanceClass.AddParam("db.r5.24xlarge")
return f
}, 0},
{"unpinned version matches nothing", func() (f OrderableFilter) {
f.EngineVersion.AddParam("17")
return f
}, 0},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
assert.Len(t, OrderableOptions(tc.filter(), runsEverything), tc.want)
})
}
}
+17
View File
@@ -18,6 +18,12 @@ type Engine struct {
// served by an image that is not the one asked for.
MajorVersion string
DefaultPort int64
// How the engine names itself in a catalog listing, which is not its API
// identifier: a console renders this rather than "postgres".
description string
// The AWS licence model the engine is offered under, which is a property of
// the engine's own licence rather than of this platform.
licenseModel string
// Identifiers the engine reserves for itself, which a master role may not
// take. Matched case-insensitively.
reservedUsernames []string
@@ -99,6 +105,17 @@ func (e Engine) EngineVersion() string {
return e.MajorVersion
}
// The engine's own name for itself, as a describe reports it.
func (e Engine) Description() string {
return e.description
}
// The AWS licence model name, which an orderable option carries and which a
// client may filter on.
func (e Engine) LicenseModel() string {
return e.licenseModel
}
// The parameter-group name AWS clients expect when none is named. The group is
// implicit: it is resolvable and reportable without ever having been created,
// and is neither modifiable nor deletable.
+2
View File
@@ -16,6 +16,8 @@ var engineMariaDB = Engine{
Name: "mariadb",
MajorVersion: "11.8",
DefaultPort: 3306,
description: "MariaDB Community Edition",
licenseModel: "general-public-license",
// root is the superuser mariadb-install-db creates and rds-init keeps,
// mariadb.sys owns the sys schema views, mysql is created under unix_socket
// auth, rdsadmin is AWS's management role, PUBLIC the server refuses outright.
+2
View File
@@ -17,6 +17,8 @@ var enginePostgres = Engine{
Name: "postgres",
MajorVersion: "18",
DefaultPort: 5432,
description: "PostgreSQL",
licenseModel: "postgresql-license",
// rdsadmin is the management role AWS reserves; postgres is the cluster
// superuser initdb creates, which the master must not collide with;
// rds_superuser is the group the bootstrap grants the master through.
@@ -28,6 +28,8 @@ var engineUnderTest = Engine{
Name: testEngineName,
MajorVersion: "1",
DefaultPort: 3306,
description: "Test Engine",
licenseModel: "test-license",
reservedUsernames: []string{"root"},
reservedUsernamePrefixes: []string{"testdb_"},
maxUsernameLen: 80,
+79 -5
View File
@@ -44,12 +44,9 @@ var outOfScopeRDSActions = []string{
"RestoreDBInstanceToPointInTime",
}
// The two catalog describes a Terraform module may reach for are unimplemented,
// so today they are unknown actions rather than empty results. Pinned here so a
// provider that needs one produces a clear failure instead of a silent success.
// An action the gateway has never heard of, as distinct from one it recognises
// and does not offer. Kept so the two stay distinguishable to a client.
var unregisteredRDSActions = []string{
"DescribeDBEngineVersions",
"DescribeOrderableDBInstanceOptions",
"NotAnRDSAction",
}
@@ -218,6 +215,83 @@ func TestAPIMatrix(t *testing.T) {
}
})
// The catalogs are read from the same tables the create path validates
// against, so these assertions are the only place a version pin or a class
// map can be checked against what the deployment will actually accept.
t.Run("CatalogDescribesAnswerFromTheLiveTables", func(t *testing.T) {
t.Run("EveryEngineVersionIsAvailable", func(t *testing.T) {
out, err := f.AWS.RDS.DescribeDBEngineVersions(&rds.DescribeDBEngineVersionsInput{})
require.NoError(t, err)
require.NotEmpty(t, out.DBEngineVersions)
for _, row := range out.DBEngineVersions {
assert.NotEmpty(t, aws.StringValue(row.EngineVersion))
assert.NotEmpty(t, aws.StringValue(row.DBParameterGroupFamily))
assert.Equal(t, "available", aws.StringValue(row.Status))
}
})
t.Run("EngineNarrowsToOneRow", func(t *testing.T) {
out, err := f.AWS.RDS.DescribeDBEngineVersions(&rds.DescribeDBEngineVersionsInput{
Engine: aws.String(dbEngine),
})
require.NoError(t, err)
require.Len(t, out.DBEngineVersions, 1)
assert.Equal(t, dbEngine, aws.StringValue(out.DBEngineVersions[0].Engine))
})
// The suite boots instances of dbClass, so a cluster that runs this suite
// runs that class: a catalog that omits it is under-reporting capability.
t.Run("TheClassThisSuiteLaunchesIsOrderable", func(t *testing.T) {
out, err := f.AWS.RDS.DescribeOrderableDBInstanceOptions(&rds.DescribeOrderableDBInstanceOptionsInput{
Engine: aws.String(dbEngine),
})
require.NoError(t, err)
classes := make([]string, 0, len(out.OrderableDBInstanceOptions))
for _, option := range out.OrderableDBInstanceOptions {
assert.Equal(t, dbEngine, aws.StringValue(option.Engine))
assert.Equal(t, "gp3", aws.StringValue(option.StorageType))
assert.True(t, aws.BoolValue(option.Vpc))
classes = append(classes, aws.StringValue(option.DBInstanceClass))
}
assert.Contains(t, classes, dbClass)
assert.NotContains(t, classes, unmappedDBClass)
})
// Raw, because the SDK marks Engine required and rejects an absent one
// client-side as InvalidParameter: the gateway's answer is only observable
// on a request the SDK never gets to validate.
t.Run("OrderableOptionsRequireAnEngine", func(t *testing.T) {
status, body, code := harness.PostRDSAction(t, f.Env, f.AWS, "DescribeOrderableDBInstanceOptions", nil)
assert.Equal(t, 400, status, "body: %s", body)
assert.Equal(t, "MissingParameter", code, "body: %s", body)
})
// These two exist only to be filtered, so an unrecognised filter name is
// refused rather than dropped: a dropped one returns rows the caller
// asked not to see and cannot detect.
t.Run("AnUnrecognisedFilterNameIsRefused", func(t *testing.T) {
harness.ExpectError(t, "InvalidParameterValue", func() error {
_, err := f.AWS.RDS.DescribeOrderableDBInstanceOptions(
&rds.DescribeOrderableDBInstanceOptionsInput{
Engine: aws.String(dbEngine),
Filters: []*rds.Filter{{Name: aws.String("status"), Values: aws.StringSlice([]string{"available"})}},
})
return err
})
})
// Neither action ever issues a Marker, so one in a request can only have
// been fabricated and answering it as page one would be a silent lie.
t.Run("AMarkerIsRefused", func(t *testing.T) {
harness.ExpectError(t, "InvalidParameterValue", func() error {
_, err := f.AWS.RDS.DescribeDBEngineVersions(&rds.DescribeDBEngineVersionsInput{
Marker: aws.String("fabricated"),
})
return err
})
})
})
t.Run("ARejectedCreateLeavesNoRecord", func(t *testing.T) {
harness.ExpectError(t, "DBInstanceNotFound", func() error {
_, err := harness.DescribeDBInstance(f.AWS, rejectedID)