From 456a60717bf4d77e372c4fbc0b956de378cbc496 Mon Sep 17 00:00:00 2001 From: Julian Sommer <115516918+juliansommer@users.noreply.github.com> Date: Thu, 6 Aug 2026 12:00:02 +1000 Subject: [PATCH] feat: add RDS to awsmodel coverage --- docs/COMMANDS.md | 54 +++++++++++++++++++- docs/aws-model-operation-coverage.md | 75 ++++++++++++++++++++++++++++ internal/awsmodel/embed.go | 1 + internal/awsmodel/model.go | 1 + internal/awsmodel/model_test.go | 3 +- spinifex/gateway/operations.go | 5 ++ spinifex/gateway/operations_test.go | 4 ++ spinifex/gateway/rds/handler.go | 67 +++++++++++++++---------- 8 files changed, 182 insertions(+), 28 deletions(-) diff --git a/docs/COMMANDS.md b/docs/COMMANDS.md index 6c363cd78..6ad5985f3 100644 --- a/docs/COMMANDS.md +++ b/docs/COMMANDS.md @@ -155,6 +155,7 @@ Operator surface over the daemon's `bedrock.endpoint.*` subjects. The gateway do | `describe-instance-types` | `--filters` (capacity only) | `--instance-types`, `--max-results`, `--next-token`, `--dry-run`, other filters | **DONE** | | `modify-instance-attribute` | `--instance-id`, `--instance-type`, `--user-data`, `--disable-api-termination` | `--ebs-optimized`, `--source-dest-check`, `--instance-initiated-shutdown-behavior`, `--block-device-mappings`, `--groups`, `--ena-support`, `--sriov-net-support` | **DONE** | | `get-console-output` | `--instance-id` | `--latest`, `--dry-run` | **DONE** | +| `get-password-data` | `--instance-id` | `--dry-run` | **DONE** | | `describe-instance-attribute` | `--instance-id`, `--attribute` (instanceType, userData, disableApiTermination, instanceInitiatedShutdownBehavior, disableApiStop, ebsOptimized, enaSupport, sourceDestCheck, rootDeviceName, kernel, ramdisk) | `--dry-run` | **DONE** | | `modify-instance-metadata-options` | `--instance-id`, `--http-put-response-hop-limit` (1–64), `--http-tokens` (`required`/`optional`), `--http-endpoint` (`enabled`), `--http-protocol-ipv6`/`--instance-metadata-tags` (`disabled`) — unmodelled values return `UnsupportedOperation` | `--dry-run` | **DONE** | | `describe-instance-credit-specifications` | `--instance-ids` | `--filters`, `--max-results`, `--dry-run` | **DONE** (stub — always returns `standard`) | @@ -338,8 +339,8 @@ KV CRUD only — no OVN/OVS integration. EIGWs are stored but have no effect on | `delete-network-interface` | `--network-interface-id` | `--dry-run` | **DONE** | | `describe-network-interfaces` | `--network-interface-ids`, `--filters` (subnet-id, vpc-id, attachment.instance-id) | `--max-results`, `--dry-run` | **DONE** | | `modify-network-interface-attribute` | — | `--network-interface-id`, `--description`, `--groups` | **DONE** | -| `attach-network-interface` | — | `--network-interface-id`, `--instance-id`, `--device-index` | **NOT STARTED** (internal only) | -| `detach-network-interface` | — | `--attachment-id`, `--force` | **NOT STARTED** (internal only) | +| `attach-network-interface` | `--network-interface-id`, `--instance-id`, `--device-index` (all required) | `--dry-run`, `--network-card-index`, `--ena-srd-specification` | **DONE** — hot-plugged into a running instance | +| `detach-network-interface` | `--attachment-id` (required), `--force` | `--dry-run` | **DONE** | | `assign-private-ip-addresses` | — | `--network-interface-id`, `--private-ip-addresses`, `--secondary-private-ip-address-count` | **NOT STARTED** | | `unassign-private-ip-addresses` | — | `--network-interface-id`, `--private-ip-addresses` | **NOT STARTED** | @@ -797,6 +798,8 @@ The default certificate cannot be added/removed via these calls — set it on th | Command | Implemented Flags | Missing Flags | Status | |---------|-------------------|---------------|--------| | `describe-tags` | `--resource-arns` (loadbalancer, targetgroup, listener) | — | **DONE** | +| `add-tags` | `--resource-arns`, `--tags` | — | **DONE** | +| `remove-tags` | `--resource-arns`, `--tag-keys` | — | **DONE** | ### ELBv2 — Not Yet Implemented @@ -832,6 +835,7 @@ Spinifex both stores externally-issued certificates (`import-certificate`) and i | `import-certificate` | `--certificate`, `--private-key`, `--certificate-chain`, `--certificate-arn` (re-import) | `--tags` | **DONE** | | `describe-certificate` | `--certificate-arn` | — | **DONE** | | `list-certificates` | — | `--certificate-statuses`, `--includes`, `--max-items`, `--next-token` | **DONE** | +| `get-certificate` | `--certificate-arn` | — | **DONE** — returns the leaf certificate and its chain | | `delete-certificate` | `--certificate-arn` | — | **DONE** | | `request-certificate` | `--domain-name`, `--subject-alternative-names`, `--tags` | `--validation-method`, `--certificate-authority-arn`, `--options`, `--idempotency-token` | **PARTIAL** — `PRIVATE_CA` issues synchronously; `PROVIDER_API`/`MANUAL_TXT` are accepted and correctly shaped but stay `PENDING_VALIDATION` until a later issuance worker lands | | `add-tags-to-certificate` / `list-tags-for-certificate` / `remove-tags-from-certificate` | `--certificate-arn`, `--tags`/`--tag-keys` | — | **DONE** | @@ -968,6 +972,52 @@ IAM: `AmazonRDSFullAccess` and `AmazonRDSReadOnlyAccess` are available as manage --- +## ECR (Elastic Container Registry) + +Repository metadata is served over the AWS API on the gateway endpoint; image data moves over the OCI Distribution `/v2/` endpoint on that same host, authenticated by the bearer token `get-login-password` mints. + +| Command | Status | +|---------|--------| +| `create-repository`, `delete-repository`, `describe-repositories` | **DONE** | +| `describe-images`, `list-images`, `batch-get-image`, `batch-delete-image`, `put-image` | **DONE** | +| `put-image-tag-mutability` | **DONE** | +| `get-authorization-token` | **DONE** — mints the short-lived bearer token used by `docker login` | +| `get-repository-policy`, `set-repository-policy`, `delete-repository-policy` | **DONE** | +| `get-lifecycle-policy`, `put-lifecycle-policy`, `delete-lifecycle-policy`, `get-lifecycle-policy-preview`, `start-lifecycle-policy-preview` | **DONE** | +| `list-tags-for-resource` | **DONE** | +| `tag-resource`, `untag-resource` | **NOT STARTED** (registered stub) | +| `batch-check-layer-availability`, `initiate-layer-upload`, `upload-layer-part`, `complete-layer-upload`, `get-download-url-for-layer` | **NOT STARTED** (registered stub) — layer transfer is served by the OCI `/v2/` endpoint | +| `describe-registry`, `get-registry-policy`, `put-registry-policy`, `put-replication-configuration`, `replicate-image` | **NOT STARTED** (registered stub) — single-registry deployment, no cross-region replication | +| `list-repositories` | **NOT STARTED** (registered stub) — use `describe-repositories` | +| `start-image-scan`, `describe-image-scan-findings`, `put-image-scanning-configuration`, `get-image-scanning-configuration`, `get-registry-scanning-configuration`, `put-registry-scanning-configuration`, `batch-get-repository-scanning-configuration` | **NOT STARTED** (`OperationNotSupported`) — vulnerability scanning is not offered | +| Pull-through cache rules, repository creation templates, image replication status | **NOT STARTED** (`InvalidAction`) | + +--- + +## ECS (Elastic Container Service) + +Clusters, services and tasks run on EC2 container instances; there is no Fargate launch type. + +| Command | Status | +|---------|--------| +| `create-cluster`, `delete-cluster`, `describe-clusters`, `list-clusters` | **DONE** | +| `create-service`, `update-service`, `delete-service`, `describe-services`, `list-services` | **DONE** | +| `register-task-definition`, `deregister-task-definition`, `describe-task-definition`, `list-task-definitions` | **DONE** | +| `run-task`, `start-task`, `stop-task`, `describe-tasks`, `list-tasks` | **DONE** | +| `register-container-instance`, `deregister-container-instance`, `describe-container-instances`, `list-container-instances`, `update-container-instances-state` | **DONE** | +| `create-capacity-provider`, `delete-capacity-provider`, `describe-capacity-providers`, `put-cluster-capacity-providers` | **DONE** | +| `submit-task-state-change` | **DONE** — reported by the in-guest agent | +| `tag-resource`, `untag-resource`, `list-tags-for-resource` | **DONE** | +| `update-cluster`, `list-account-settings`, `put-account-setting`, `list-services-by-namespace`, `list-task-definition-families` | **NOT STARTED** (registered stub) | +| Task sets (blue/green): `create-task-set`, `update-task-set`, `delete-task-set`, `describe-task-sets`, `update-service-primary-task-set` | **NOT STARTED** (`InvalidAction`) | +| `execute-command`, `get-task-protection`, `update-task-protection` | **NOT STARTED** (`InvalidAction`) | +| Attributes: `put-attributes`, `delete-attributes`, `list-attributes` | **NOT STARTED** (`InvalidAction`) | +| `update-capacity-provider`, `update-cluster-settings`, `update-container-agent` | **NOT STARTED** (`InvalidAction`) | +| `delete-account-setting`, `put-account-setting-default`, `delete-task-definitions` | **NOT STARTED** (`InvalidAction`) | +| `discover-poll-endpoint`, `submit-container-state-change`, `submit-attachment-state-changes` | **NOT STARTED** (`InvalidAction`) | + +--- + ## CloudWatch (Basic Monitoring) | Command | Implemented Flags | Missing Flags | Status | diff --git a/docs/aws-model-operation-coverage.md b/docs/aws-model-operation-coverage.md index 8e1e60b16..d59381bf8 100644 --- a/docs/aws-model-operation-coverage.md +++ b/docs/aws-model-operation-coverage.md @@ -12,6 +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 | 40 | 24 | 0 | 12 | 126 | 4 | | s3 | 2006-03-01 | 99 | — | — | — | — | — | — | | sts | 2011-06-15 | 8 | 4 | 4 | 0 | 0 | 4 | 0 | @@ -457,6 +458,80 @@ None. None. + + +## rds + +Implements **24 of 162** modelled operations (14.8%). + +
Implemented (24) + +`AddTagsToResource`, `CreateDBInstance`, `CreateDBParameterGroup`, `CreateDBSnapshot`, `CreateDBSubnetGroup`, `DeleteDBInstance`, `DeleteDBParameterGroup`, `DeleteDBSnapshot` + +`DeleteDBSubnetGroup`, `DescribeDBInstanceAutomatedBackups`, `DescribeDBInstances`, `DescribeDBParameterGroups`, `DescribeDBParameters`, `DescribeDBSnapshots`, `DescribeDBSubnetGroups`, `DescribeEvents` + +`ListTagsForResource`, `ModifyDBInstance`, `ModifyDBParameterGroup`, `RebootDBInstance`, `RemoveTagsFromResource`, `RestoreDBInstanceFromDBSnapshot`, `StartDBInstance`, `StopDBInstance` + + +
+ +
Missing from dispatch (126) + +`AddRoleToDBCluster`, `AddRoleToDBInstance`, `AddSourceIdentifierToSubscription`, `ApplyPendingMaintenanceAction`, `AuthorizeDBSecurityGroupIngress`, `BacktrackDBCluster`, `CancelExportTask`, `CopyDBClusterParameterGroup` + +`CopyDBClusterSnapshot`, `CopyDBParameterGroup`, `CopyDBSnapshot`, `CopyOptionGroup`, `CreateBlueGreenDeployment`, `CreateCustomDBEngineVersion`, `CreateDBClusterEndpoint`, `CreateDBClusterParameterGroup` + +`CreateDBClusterSnapshot`, `CreateDBProxy`, `CreateDBProxyEndpoint`, `CreateDBSecurityGroup`, `CreateDBShardGroup`, `CreateEventSubscription`, `CreateGlobalCluster`, `CreateIntegration` + +`CreateTenantDatabase`, `DeleteBlueGreenDeployment`, `DeleteCustomDBEngineVersion`, `DeleteDBClusterAutomatedBackup`, `DeleteDBClusterEndpoint`, `DeleteDBClusterParameterGroup`, `DeleteDBClusterSnapshot`, `DeleteDBInstanceAutomatedBackup` + +`DeleteDBProxy`, `DeleteDBProxyEndpoint`, `DeleteDBSecurityGroup`, `DeleteDBShardGroup`, `DeleteEventSubscription`, `DeleteGlobalCluster`, `DeleteIntegration`, `DeleteTenantDatabase` + +`DeregisterDBProxyTargets`, `DescribeAccountAttributes`, `DescribeBlueGreenDeployments`, `DescribeCertificates`, `DescribeDBClusterAutomatedBackups`, `DescribeDBClusterBacktracks`, `DescribeDBClusterEndpoints`, `DescribeDBClusterParameterGroups` + +`DescribeDBClusterParameters`, `DescribeDBClusterSnapshotAttributes`, `DescribeDBClusterSnapshots`, `DescribeDBEngineVersions`, `DescribeDBLogFiles`, `DescribeDBProxies`, `DescribeDBProxyEndpoints`, `DescribeDBProxyTargetGroups` + +`DescribeDBProxyTargets`, `DescribeDBRecommendations`, `DescribeDBSecurityGroups`, `DescribeDBShardGroups`, `DescribeDBSnapshotAttributes`, `DescribeDBSnapshotTenantDatabases`, `DescribeEngineDefaultClusterParameters`, `DescribeEngineDefaultParameters` + +`DescribeEventCategories`, `DescribeEventSubscriptions`, `DescribeExportTasks`, `DescribeGlobalClusters`, `DescribeIntegrations`, `DescribeOptionGroupOptions`, `DescribeOrderableDBInstanceOptions`, `DescribePendingMaintenanceActions` + +`DescribeReservedDBInstances`, `DescribeReservedDBInstancesOfferings`, `DescribeSourceRegions`, `DescribeTenantDatabases`, `DescribeValidDBInstanceModifications`, `DisableHttpEndpoint`, `DownloadDBLogFilePortion`, `EnableHttpEndpoint` + +`FailoverGlobalCluster`, `ModifyActivityStream`, `ModifyCertificates`, `ModifyCurrentDBClusterCapacity`, `ModifyCustomDBEngineVersion`, `ModifyDBClusterEndpoint`, `ModifyDBClusterParameterGroup`, `ModifyDBClusterSnapshotAttribute` + +`ModifyDBProxy`, `ModifyDBProxyEndpoint`, `ModifyDBProxyTargetGroup`, `ModifyDBRecommendation`, `ModifyDBShardGroup`, `ModifyDBSnapshot`, `ModifyDBSnapshotAttribute`, `ModifyDBSubnetGroup` + +`ModifyEventSubscription`, `ModifyGlobalCluster`, `ModifyIntegration`, `ModifyTenantDatabase`, `PromoteReadReplicaDBCluster`, `PurchaseReservedDBInstancesOffering`, `RebootDBCluster`, `RebootDBShardGroup` + +`RegisterDBProxyTargets`, `RemoveFromGlobalCluster`, `RemoveRoleFromDBCluster`, `RemoveRoleFromDBInstance`, `RemoveSourceIdentifierFromSubscription`, `ResetDBClusterParameterGroup`, `ResetDBParameterGroup`, `RestoreDBClusterFromS3` + +`RestoreDBClusterFromSnapshot`, `RestoreDBClusterToPointInTime`, `RestoreDBInstanceFromS3`, `RevokeDBSecurityGroupIngress`, `StartActivityStream`, `StartDBCluster`, `StartDBInstanceAutomatedBackupsReplication`, `StartExportTask` + +`StopActivityStream`, `StopDBCluster`, `StopDBInstanceAutomatedBackupsReplication`, `SwitchoverBlueGreenDeployment`, `SwitchoverGlobalCluster`, `SwitchoverReadReplica` + + +
+ +
Registered stubs (0) + +None. + +
+ +
Deliberately unsupported (12) + +`CreateDBCluster`, `CreateDBInstanceReadReplica`, `CreateOptionGroup`, `DeleteDBCluster`, `DeleteOptionGroup`, `DescribeDBClusters`, `DescribeOptionGroups`, `FailoverDBCluster` + +`ModifyDBCluster`, `ModifyOptionGroup`, `PromoteReadReplica`, `RestoreDBInstanceToPointInTime` + + +
+ +
Registered outside the pinned model (4) + +`GetDBBootstrapConfig`, `PollDBCommands`, `RegisterDBInstance`, `SubmitDBStateChange` + +
## s3 diff --git a/internal/awsmodel/embed.go b/internal/awsmodel/embed.go index 0d21ef315..de8fdf341 100644 --- a/internal/awsmodel/embed.go +++ b/internal/awsmodel/embed.go @@ -16,6 +16,7 @@ var modelFiles = map[Service]string{ ECS: "models/apis/ecs/2014-11-13/api-2.json", ElasticLoadBalancingV2: "models/apis/elasticloadbalancingv2/2015-12-01/api-2.json", IAM: "models/apis/iam/2010-05-08/api-2.json", + RDS: "models/apis/rds/2014-10-31/api-2.json", S3: "models/apis/s3/2006-03-01/api-2.json", STS: "models/apis/sts/2011-06-15/api-2.json", } diff --git a/internal/awsmodel/model.go b/internal/awsmodel/model.go index 01ba0e1cf..14a6933ab 100644 --- a/internal/awsmodel/model.go +++ b/internal/awsmodel/model.go @@ -29,6 +29,7 @@ const ( ECS Service = "ecs" ElasticLoadBalancingV2 Service = "elasticloadbalancingv2" IAM Service = "iam" + RDS Service = "rds" S3 Service = "s3" STS Service = "sts" ) diff --git a/internal/awsmodel/model_test.go b/internal/awsmodel/model_test.go index f3580c2cd..7c2854794 100644 --- a/internal/awsmodel/model_test.go +++ b/internal/awsmodel/model_test.go @@ -8,7 +8,7 @@ import ( ) func TestServices(t *testing.T) { - want := []Service{ACM, EC2, ECR, ECS, ElasticLoadBalancingV2, IAM, S3, STS} + want := []Service{ACM, EC2, ECR, ECS, ElasticLoadBalancingV2, IAM, RDS, S3, STS} if got := Services(); !reflect.DeepEqual(got, want) { t.Fatalf("Services() = %v, want %v", got, want) } @@ -26,6 +26,7 @@ func TestLoadAllModels(t *testing.T) { ECS: {"2014-11-13", 56, 400}, ElasticLoadBalancingV2: {"2015-12-01", 46, 357}, IAM: {"2010-05-08", 159, 497}, + RDS: {"2014-10-31", 162, 745}, S3: {"2006-03-01", 99, 604}, STS: {"2011-06-15", 8, 76}, } diff --git a/spinifex/gateway/operations.go b/spinifex/gateway/operations.go index fa4d31b34..ccdcfbc32 100644 --- a/spinifex/gateway/operations.go +++ b/spinifex/gateway/operations.go @@ -5,6 +5,7 @@ import ( gateway_ecrapi "github.com/mulgadc/spinifex/spinifex/gateway/ecrapi" gateway_ecs "github.com/mulgadc/spinifex/spinifex/gateway/ecs" + gateway_rds "github.com/mulgadc/spinifex/spinifex/gateway/rds" ) // ServiceOperationInventory describes the authoritative dispatch state for an @@ -45,6 +46,10 @@ func AWSOperationInventory() map[string]ServiceOperationInventory { "iam": { Registered: mapKeys(iamActions), }, + "rds": { + Registered: gateway_rds.ActionNames(), + Unsupported: gateway_rds.UnsupportedActionNames(), + }, "sts": { Registered: mapKeys(stsActions), }, diff --git a/spinifex/gateway/operations_test.go b/spinifex/gateway/operations_test.go index 11621b84e..91969fc9e 100644 --- a/spinifex/gateway/operations_test.go +++ b/spinifex/gateway/operations_test.go @@ -23,6 +23,10 @@ func TestAWSOperationInventoryClassifiesDispatchers(t *testing.T) { require.NotContains(t, inventory["ecr"].Stubbed, "DescribeRepositories") require.Contains(t, inventory["ecr"].Unsupported, "StartImageScan") + require.Contains(t, inventory["rds"].Registered, "CreateDBInstance") + require.NotContains(t, inventory["rds"].Unsupported, "CreateDBInstance") + require.Contains(t, inventory["rds"].Unsupported, "CreateDBCluster") + _, hasS3 := inventory["s3"] require.False(t, hasS3, "S3 is delegated to Predastore, not dispatched here") } diff --git a/spinifex/gateway/rds/handler.go b/spinifex/gateway/rds/handler.go index d1fec09da..dbdd8a984 100644 --- a/spinifex/gateway/rds/handler.go +++ b/spinifex/gateway/rds/handler.go @@ -7,6 +7,7 @@ import ( "context" "errors" "log/slog" + "sort" "github.com/mulgadc/spinifex/spinifex/awsec2query" "github.com/mulgadc/spinifex/spinifex/awserrors" @@ -56,19 +57,6 @@ func typed[In any](handler func(context.Context, *In, *nats.Conn, Caller) (any, } } -// Recognised but deliberately outside v1, so a client sees "not offered" rather -// than "you typo'd the action name". -func unsupported() Handler { - return rejectWith(awserrors.ErrorOperationNotSupported) -} - -func rejectWith(code string) Handler { - return func(ctx context.Context, action string, _ map[string]string, _ *nats.Conn, _ Caller) ([]byte, error) { - slog.DebugContext(ctx, "RDS: action not available", "action", action, "code", code) - return nil, errors.New(code) - } -} - // 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. @@ -77,6 +65,9 @@ type actionDef struct { // Agent-only: refused to every customer principal by class, before any // policy is evaluated. internal bool + // Recognised but deliberately outside v1, so a client sees "not offered" + // rather than "you typo'd the action name". Carries no handler. + unsupported bool // nil evaluates against "*", which is right for creates and for describes // that filter rather than address one resource. scope *resourceScope @@ -138,18 +129,18 @@ var actions = map[string]actionDef{ // Recognised but out of scope. Read replicas, Aurora clusters and option // groups are not offered at all; point-in-time restore waits on WAL // archiving. - "CreateDBInstanceReadReplica": {handler: unsupported()}, - "PromoteReadReplica": {handler: unsupported()}, - "CreateDBCluster": {handler: unsupported()}, - "ModifyDBCluster": {handler: unsupported()}, - "DeleteDBCluster": {handler: unsupported()}, - "DescribeDBClusters": {handler: unsupported()}, - "FailoverDBCluster": {handler: unsupported()}, - "CreateOptionGroup": {handler: unsupported()}, - "ModifyOptionGroup": {handler: unsupported()}, - "DeleteOptionGroup": {handler: unsupported()}, - "DescribeOptionGroups": {handler: unsupported()}, - "RestoreDBInstanceToPointInTime": {handler: unsupported()}, + "CreateDBInstanceReadReplica": {unsupported: true}, + "PromoteReadReplica": {unsupported: true}, + "CreateDBCluster": {unsupported: true}, + "ModifyDBCluster": {unsupported: true}, + "DeleteDBCluster": {unsupported: true}, + "DescribeDBClusters": {unsupported: true}, + "FailoverDBCluster": {unsupported: true}, + "CreateOptionGroup": {unsupported: true}, + "ModifyOptionGroup": {unsupported: true}, + "DeleteOptionGroup": {unsupported: true}, + "DescribeOptionGroups": {unsupported: true}, + "RestoreDBInstanceToPointInTime": {unsupported: true}, } // Checked before the IAM policy check, so an unknown action is rejected as @@ -167,5 +158,31 @@ func Dispatch(ctx context.Context, action string, q map[string]string, nc *nats. if !ok { return nil, errors.New(awserrors.ErrorInvalidAction) } + if def.unsupported { + slog.DebugContext(ctx, "RDS: action not available", "action", action) + return nil, errors.New(awserrors.ErrorOperationNotSupported) + } return def.handler(ctx, action, q, nc, caller) } + +// ActionNames returns every action the gateway recognises, in stable order. +func ActionNames() []string { + return actionNames(func(actionDef) bool { return true }) +} + +// UnsupportedActionNames returns the recognised actions that deliberately +// report the feature as unavailable rather than serving it. +func UnsupportedActionNames() []string { + return actionNames(func(def actionDef) bool { return def.unsupported }) +} + +func actionNames(match func(actionDef) bool) []string { + names := make([]string, 0, len(actions)) + for name, def := range actions { + if match(def) { + names = append(names, name) + } + } + sort.Strings(names) + return names +}