mirror of
https://github.com/mulgadc/spinifex.git
synced 2026-10-15 17:11:30 +00:00
feat: DescribeDBEngineVersions and DescribeOrderableDBInstanceOptions (#807)
This commit is contained in:
+16
-2
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
})
|
||||
|
||||
@@ -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, ", "))
|
||||
}
|
||||
@@ -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"})))
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user