diff --git a/.badges/operations.svg b/.badges/operations.svg index 03117e0f8e..1c197cc89f 100644 --- a/.badges/operations.svg +++ b/.badges/operations.svg @@ -1,4 +1,4 @@ - + @@ -12,7 +12,7 @@ PARITY entries PARITY entries - 6616 - 6616 + 6692 + 6692 diff --git a/.badges/parity.svg b/.badges/parity.svg index 494226c203..4f38e0435e 100644 --- a/.badges/parity.svg +++ b/.badges/parity.svg @@ -1,4 +1,4 @@ - + @@ -12,7 +12,7 @@ parity parity - 164 A · 5 B · 4 C - 164 A · 5 B · 4 C + 168 A · 5 B · 4 C + 168 A · 5 B · 4 C diff --git a/.badges/services.svg b/.badges/services.svg index a819cd5669..687782e190 100644 --- a/.badges/services.svg +++ b/.badges/services.svg @@ -1,4 +1,4 @@ - + @@ -12,7 +12,7 @@ AWS services AWS services - 175 - 175 + 179 + 179 diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index eb724b096c..590c980082 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -8,7 +8,7 @@ {"_type":"issue","id":"gopherstack-gb98x","title":"Flaky: iotdataplane TestGetConnection_ReportsLiveBrokerSession/clean_with_socket","description":"Failed in CI unit-tests(0) on PR #2482 (wire_sdk_roundtrip_test.go:69 Not equal); passes 20x locally under -race. Likely races broker session registration vs GetConnection under load. Make deterministic (wait on session-registered signal, no sleeps) and prove with -count=50 -race.","status":"closed","priority":1,"issue_type":"bug","owner":"blackbird7181@gmail.com","created_at":"2026-10-11T22:56:45Z","created_by":"Witness Patrol","updated_at":"2026-10-12T00:00:28Z","closed_at":"2026-10-12T00:00:28Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"gopherstack-cefu7.10","title":"Per-service feature-depth comparison for existing AWS services","description":"For every service we both cover, diff documented operations/behaviours against the reference set and file per-service gaps. Track privately; repo docs must not mention the comparison.","status":"open","priority":1,"issue_type":"task","owner":"blackbird7181@gmail.com","created_at":"2026-10-11T14:33:30Z","created_by":"Witness Patrol","updated_at":"2026-10-11T14:33:30Z","dependencies":[{"issue_id":"gopherstack-cefu7.10","depends_on_id":"gopherstack-cefu7","type":"parent-child","created_at":"2026-10-11T09:33:30Z","created_by":"Witness Patrol","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"gopherstack-cefu7","title":"Competitive feature parity programme (AWS + other clouds)","description":"Close feature gaps against the reference emulator set (mapping kept privately, see maintainer notes; never name or reference external projects in repo docs, PARITY.md, READMEs, commits or PR text). Covers missing AWS services, per-service feature depth on existing AWS services, multi-language SDK compatibility suites, test-container modules, CLI/UI tooling, and Azure/GCP/OCI emulators.","status":"open","priority":1,"issue_type":"epic","owner":"blackbird7181@gmail.com","created_at":"2026-10-11T14:32:37Z","created_by":"Witness Patrol","updated_at":"2026-10-11T14:32:37Z","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"gopherstack-7bxwn","title":"Add AWS Application Recovery Controller services","description":"User 2026-10-11: simulate ARC. New services: route53-recovery-control-config (clusters, control panels, routing controls, safety rules), route53-recovery-cluster (Get/UpdateRoutingControlState(s) with safety-rule evaluation, cluster endpoints), route53-recovery-readiness (recovery groups, cells, readiness checks, resource sets), arc-zonal-shift (zonal shifts, autoshift, practice runs). Wire routing-control state into Route53 health checks (RECOVERY_CONTROL type). SDK-driven tests; terraform fixtures where provider supports.","status":"open","priority":1,"issue_type":"feature","owner":"blackbird7181@gmail.com","created_at":"2026-10-11T14:08:51Z","created_by":"Witness Patrol","updated_at":"2026-10-11T14:08:51Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"gopherstack-7bxwn","title":"Add AWS Application Recovery Controller services","description":"User 2026-10-11: simulate ARC. New services: route53-recovery-control-config (clusters, control panels, routing controls, safety rules), route53-recovery-cluster (Get/UpdateRoutingControlState(s) with safety-rule evaluation, cluster endpoints), route53-recovery-readiness (recovery groups, cells, readiness checks, resource sets), arc-zonal-shift (zonal shifts, autoshift, practice runs). Wire routing-control state into Route53 health checks (RECOVERY_CONTROL type). SDK-driven tests; terraform fixtures where provider supports.","status":"closed","priority":1,"issue_type":"feature","owner":"blackbird7181@gmail.com","created_at":"2026-10-11T14:08:51Z","created_by":"Witness Patrol","updated_at":"2026-10-14T00:05:39Z","closed_at":"2026-10-14T00:05:39Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"gopherstack-kvze4","title":"S3/DynamoDB perf round 2 + leak hunt","description":"User 2026-10-11: S3 and DDB are primary use. Profile remaining hotspots (HeadObject ~93 allocs, GetObject 64KiB ~100 allocs, ListObjectsV2 delimiter 100k ~430us, DeleteObjects per-object allocs, DDB SDK-type conversion allocs, TransactWrite/BatchWrite deep copies, filter literal re-parse per item). Leak hunt: goroutine leaks (goleak in tests), unbounded maps/caches, janitor/ticker lifecycles, multipart upload orphans, stream/iterator state, memory growth under soak (pprof heap diff over N ops).","notes":"Approach (user 2026-10-11): build e2e benchmarks through the top-level router (full middleware stack) for common S3/DDB patterns; use their cpu/heap/goroutine profiles to pick optimisations and leaks. Leak tests assert bounded heap/goroutine growth over N iterations.","status":"closed","priority":1,"issue_type":"task","owner":"blackbird7181@gmail.com","created_at":"2026-10-11T14:08:43Z","created_by":"Witness Patrol","updated_at":"2026-10-11T18:20:18Z","closed_at":"2026-10-11T18:20:18Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"gopherstack-tumin","title":"CodeQL alerts on PR #2482: odatatable alloc (373), json_error_message reflected-xss (372), stepfunctions intrinsics alloc overflow (364)","description":"Open CodeQL alerts introduced on chore/parity-sweep-2026-10-07: pkgs/odatatable/store.go:212 go/uncontrolled-allocation-size (top used in make cap); pkgs/service/json_error_message.go:68 go/reflected-xss (mirror writer passthrough Write); services/stepfunctions/asl/intrinsics.go:386 go/allocation-size-overflow. Fix with CodeQL-recognised sanitizers (see memory codeql-and-ci-gotchas).","status":"closed","priority":1,"issue_type":"bug","owner":"blackbird7181@gmail.com","created_at":"2026-10-11T07:37:05Z","created_by":"Witness Patrol","updated_at":"2026-10-12T05:39:51Z","closed_at":"2026-10-12T05:39:51Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"gopherstack-1zitl","title":"cognitoidp exposes temporary passwords as the custom:temporaryPassword user attribute","description":"users.go:452/558/675 store the AdminCreateUser/AdminSetUserPassword temporary password in user.Attributes, so ListUsers/AdminGetUser/GetUser return it. Real Cognito never returns passwords. Store it in a non-attribute field, keep FORCE_CHANGE_PASSWORD auth working, and prove with typed SDK tests that no response carries it.","status":"closed","priority":1,"issue_type":"bug","owner":"blackbird7181@gmail.com","created_at":"2026-10-05T19:56:34Z","created_by":"Witness Patrol","updated_at":"2026-10-05T20:14:39Z","closed_at":"2026-10-05T20:14:39Z","close_reason":"fixed; temp password moved off user attributes","dependency_count":0,"dependent_count":0,"comment_count":0} @@ -1543,6 +1543,7 @@ {"_type":"issue","id":"go-hwb.106","title":"S3 Control: ~48 missing ops (access points/grants/batch jobs/MRAP)","description":"## S3 Control — Service Deep Dive\n\nAudit of [services/s3control/](services/s3control/) and UI in [ui/src/routes/s3control/](ui/src/routes/s3control/).\n\n### 1. Missing SDK Operations\n~48 missing ([sdk_completeness_test.go#L21](services/s3control/sdk_completeness_test.go#L21)): `DeleteAccessGrant`, `DeleteBucket`, `GetAccessPoint`, `ListAccessPoints`, `PutAccessPointPolicy`, Access Grants, Access Points, Batch Jobs, MRAP, Storage Lens Group. Only 13 supported (public access block + partial).\n\n### 2. Missing UI / Dashboard Features\nPublic access block display only. Missing: access points mgmt, access grants, batch job UI, MRAP, storage lens groups, Object Lambda.\n\n### 3. Goroutine / Resource / Lock Leaks\nClean. Map cloning on snapshot ([persistence.go#L44](services/s3control/persistence.go#L44)).\n\n### 4. Performance Optimizations\n1. 10+ separate maps — consolidate with typed keys to reduce Reset cost.\n2. Atomic counter for IDs ([backend.go#L178](services/s3control/backend.go#L178)) good.\n\n### Suggested Order\n1. Access Points (Create/Get/List/Put policy)\n2. Access Grants (Create/Delete/List)\n3. Batch Jobs + MRAP + Storage Lens Group\n4. Consolidate map structure\n\n\n---\n**Source:** https://github.com/BlackbirdWorks/gopherstack/issues/1223\n","status":"closed","priority":2,"issue_type":"task","owner":"andrew.bishop9625@gmail.com","created_at":"2026-05-02T18:28:35Z","created_by":"mayor","updated_at":"2026-07-30T16:59:49Z","closed_at":"2026-07-30T16:59:49Z","close_reason":"STALE (parity-5 verification sweep): verified against code, not PARITY.md prose. The authoritative reflective TestSDKCompleteness test passes for this service with an empty notImplemented list, i.e. zero unaccounted SDK operations - refuting the ticket's missing-ops claim. UI route page exists and is substantial. Ticket was auto-generated 2026-05-02, before the parity-3/4/5 campaigns did this work.","external_ref":"gh-1223","labels":["ai-queue"],"dependencies":[{"issue_id":"go-hwb.106","depends_on_id":"go-hwb","type":"parent-child","created_at":"2026-05-02T13:28:35Z","created_by":"Witness Patrol","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"go-hwb.102","title":"Timestream Write: SDK complete; per-table WriteRecords locks","description":"## Timestream Write — Service Deep Dive\n\nAudit of [services/timestreamwrite/](services/timestreamwrite/) and shared UI in [ui/src/routes/timestream/](ui/src/routes/timestream/).\n\n### 1. Missing SDK Operations\n**0 missing.** 20 ops implemented including `CreateDatabase`, `CreateTable`, `WriteRecords`, `CreateBatchLoadTask`, `ResumeBatchLoadTask`, tags.\n\n### 2. Missing UI / Dashboard Features\nShared UI covers DBs + tables + scheduled queries. Full CRUD. Batch load UI could be enhanced.\n\n### 3. Goroutine / Resource / Lock Leaks\nClean. 4 nested maps under single `lockmetrics.RWMutex` ([backend.go#L159](services/timestreamwrite/backend.go#L159)).\n\n### 4. Performance Optimizations\n1. **Single mutex serializes WriteRecords across tables** — partition by table-ARN for ~10x throughput.\n2. Dispatch pre-built ([handler.go#L62](services/timestreamwrite/handler.go#L62)).\n\n### Suggested Order\n1. Per-table-ARN partition locks for `WriteRecords`\n2. Batch load UI polish\n\n\n---\n**Source:** https://github.com/BlackbirdWorks/gopherstack/issues/1227\n","status":"closed","priority":2,"issue_type":"task","owner":"andrew.bishop9625@gmail.com","created_at":"2026-05-02T18:28:34Z","created_by":"mayor","updated_at":"2026-07-30T16:59:48Z","closed_at":"2026-07-30T16:59:48Z","close_reason":"STALE (parity-5 verification sweep): verified against code, not PARITY.md prose. The authoritative reflective TestSDKCompleteness test passes for this service with an empty notImplemented list, i.e. zero unaccounted SDK operations - refuting the ticket's missing-ops claim. UI route page exists and is substantial. Ticket was auto-generated 2026-05-02, before the parity-3/4/5 campaigns did this work.","external_ref":"gh-1227","labels":["ai-queue"],"dependencies":[{"issue_id":"go-hwb.102","depends_on_id":"go-hwb","type":"parent-child","created_at":"2026-05-02T13:28:34Z","created_by":"Witness Patrol","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"go-hwb","title":"Epic: ai-queue from BlackbirdWorks/gopherstack","description":"Autonomous grinding of GitHub issues labeled 'ai-queue' from BlackbirdWorks/gopherstack. Each child bead corresponds to one GitHub issue (external-ref gh-N). Launched via gt mountain for wave-based dispatch with Witness failure tracking and merge-on-CI-pass via Refinery.","status":"open","priority":2,"issue_type":"epic","owner":"andrew.bishop9625@gmail.com","created_at":"2026-05-02T18:27:46Z","created_by":"mayor","updated_at":"2026-05-02T18:27:46Z","labels":["ai-queue"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"gopherstack-cwyog","title":"arczonalshift: simulate AWS-initiated autoshifts","description":"ListAutoshifts is always empty because AWS starts autoshifts from internal AZ telemetry. Add an emulator-side trigger (e.g. a gopherstack admin/dashboard action to declare an AZ impaired in a region) that starts autoshifts for resources with zonal autoshift ENABLED, surfaces them in ListAutoshifts/appliedStatus, and ends them when cleared. Regression tests required.","status":"open","priority":3,"issue_type":"feature","owner":"blackbird7181@gmail.com","created_at":"2026-10-16T03:06:17Z","created_by":"Witness Patrol","updated_at":"2026-10-16T03:06:17Z","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"gopherstack-6wwqt","title":"DynamoDB GSI/LSI: stable order for items sharing an index key","description":"Index scans/queries sort by index key with an unstable sort and no table-key tiebreak, so tied items have no stable order and a deleted ExclusiveStartKey on a tie skips all ties. Add the table primary key as tiebreak (matches DynamoDB's deterministic paging); regression test.","status":"open","priority":3,"issue_type":"bug","owner":"blackbird7181@gmail.com","created_at":"2026-10-13T20:59:53Z","created_by":"Witness Patrol","updated_at":"2026-10-13T20:59:53Z","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"gopherstack-o8hov","title":"S3 UploadPart: malformed Content-MD5 returns BadDigest; real S3 returns InvalidDigest","description":"Bad base64 in Content-MD5 should be InvalidDigest (400); a valid-but-mismatched digest stays BadDigest. Regression test required.","status":"closed","priority":3,"issue_type":"bug","owner":"blackbird7181@gmail.com","created_at":"2026-10-13T04:25:01Z","created_by":"Witness Patrol","updated_at":"2026-10-13T21:16:23Z","closed_at":"2026-10-13T21:16:23Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"gopherstack-cefu7.17","title":"Oracle Cloud (OCI) local emulator","description":"New OCI emulation (Object Storage, Streaming, Vault, Functions, IAM) with SDK-verified shapes.","status":"open","priority":3,"issue_type":"feature","owner":"blackbird7181@gmail.com","created_at":"2026-10-11T14:33:59Z","created_by":"Witness Patrol","updated_at":"2026-10-11T14:33:59Z","dependencies":[{"issue_id":"gopherstack-cefu7.17","depends_on_id":"gopherstack-cefu7","type":"parent-child","created_at":"2026-10-11T09:33:59Z","created_by":"Witness Patrol","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} diff --git a/README.md b/README.md index 6eb686ca91..9cff4478ee 100644 --- a/README.md +++ b/README.md @@ -368,6 +368,8 @@ By default resources settle instantly. Set a dwell to make Creating/Processing/P | `--lifecycle-mgn` | `MGN_LIFECYCLE_DELAY` | Application Migration Service replication/job/import per-hop window (built-in `100ms`). | | `--lifecycle-forecast` | `FORECAST_LIFECYCLE_DELAY` | Forecast resource CREATE_PENDING/CREATE_IN_PROGRESS window. | | `--lifecycle-networkmonitor` | `NETWORKMONITOR_LIFECYCLE_DELAY` | Network Monitor monitor and probe PENDING window. | +| `--lifecycle-route53-recovery` | `ROUTE53_RECOVERY_LIFECYCLE_DELAY` | Route 53 ARC control plane PENDING and PENDING_DELETION window for clusters, control panels, routing controls and safety rules. | +| `--lifecycle-arczonalshift` | `ARCZONALSHIFT_PRACTICE_RUN_DURATION` | ARC zonal shift practice run ACTIVE window; 0 keeps the built-in 30m; ignores the global delay. | | `--lifecycle-managedblockchain` | `MANAGEDBLOCKCHAIN_LIFECYCLE_DELAY` | Managed Blockchain network/member/node CREATING window. | | `--lifecycle-cleanrooms` | `CLEANROOMS_LIFECYCLE_DELAY` | Clean Rooms protected query and job SUBMITTED window. | | `--lifecycle-resourcegroups` | `RESOURCEGROUPS_LIFECYCLE_DELAY` | Resource Groups transitional window. | @@ -631,6 +633,7 @@ Every service links to its own page with a coverage breakdown — audited operat | [API Gateway](services/apigateway/README.md) | A | 123 | 2 gaps; 1 structural gap; 2 deferred | | [API Gateway Management API](services/apigatewaymanagementapi/README.md) | A | 3 | 2 deferred | | [API Gateway v2](services/apigatewayv2/README.md) | A | 77 | 5 gaps; 1 structural gap | +| [ARC Zonal Shift](services/arczonalshift/README.md) | A | 15 | 1 structural gap | | [App Mesh](services/appmesh/README.md) | A | 38 | 1 structural gap | | [Cloud Map](services/servicediscovery/README.md) | A | 30 | 4 structural gaps; 1 deferred | | [CloudFront](services/cloudfront/README.md) | A | 60 | 2 gaps; 4 structural gaps; 4 deferred | @@ -638,6 +641,9 @@ Every service links to its own page with a coverage breakdown — audited operat | [ELB (Classic)](services/elb/README.md) | A | 29 | 1 gap; 1 deferred | | [ELBv2](services/elbv2/README.md) | A | 51 | 2 gaps; 2 structural gaps; 2 deferred | | [Route 53](services/route53/README.md) | A | 67 | 3 deferred | +| [Route 53 Recovery Cluster](services/route53recoverycluster/README.md) | A | 4 | clean | +| [Route 53 Recovery Control Config](services/route53recoverycontrolconfig/README.md) | A | 25 | 1 structural gap | +| [Route 53 Recovery Readiness](services/route53recoveryreadiness/README.md) | A | 32 | 1 structural gap | | [Route 53 Resolver](services/route53resolver/README.md) | A | 72 | 4 gaps; 1 structural gap; 1 deferred | | [VPC Lattice](services/vpclattice/README.md) | A | 73 | 1 gap; 3 structural gaps | diff --git a/cli.go b/cli.go index f2dcc07a4b..3610897721 100644 --- a/cli.go +++ b/cli.go @@ -83,6 +83,7 @@ import ( apprunnerbackend "github.com/blackbirdworks/gopherstack/services/apprunner" appstreambackend "github.com/blackbirdworks/gopherstack/services/appstream" appsyncbackend "github.com/blackbirdworks/gopherstack/services/appsync" + arczonalshiftbackend "github.com/blackbirdworks/gopherstack/services/arczonalshift" athenabackend "github.com/blackbirdworks/gopherstack/services/athena" autoscalingbackend "github.com/blackbirdworks/gopherstack/services/autoscaling" awsconfigbackend "github.com/blackbirdworks/gopherstack/services/awsconfig" @@ -205,6 +206,9 @@ import ( resourcegroupstaggingapibackend "github.com/blackbirdworks/gopherstack/services/resourcegroupstaggingapi" rolesanywherebackend "github.com/blackbirdworks/gopherstack/services/rolesanywhere" route53backend "github.com/blackbirdworks/gopherstack/services/route53" + route53recoveryclusterbackend "github.com/blackbirdworks/gopherstack/services/route53recoverycluster" + route53recoverycontrolconfigbackend "github.com/blackbirdworks/gopherstack/services/route53recoverycontrolconfig" + route53recoveryreadinessbackend "github.com/blackbirdworks/gopherstack/services/route53recoveryreadiness" route53resolverbackend "github.com/blackbirdworks/gopherstack/services/route53resolver" s3backend "github.com/blackbirdworks/gopherstack/services/s3" s3controlbackend "github.com/blackbirdworks/gopherstack/services/s3control" @@ -337,6 +341,10 @@ type CLI struct { swfHandler service.Registerable firehoseHandler service.Registerable networkmonitorHandler service.Registerable + arcZonalShiftHandler service.Registerable + route53RecoveryClusterHandler service.Registerable + route53RecoveryControlHandler service.Registerable + route53RecoveryReadyHandler service.Registerable schedulerHandler service.Registerable servicediscoveryHandler service.Registerable transcribeHandler service.Registerable @@ -1314,6 +1322,32 @@ func (c *CLI) GetSWFHandler() service.Registerable { return c.swfHandler } //nolint:ireturn // architecturally required to return interface func (c *CLI) GetFirehoseHandler() service.Registerable { return c.firehoseHandler } +// GetARCZonalShiftHandler returns the ARC zonal shift handler. +// +//nolint:ireturn // architecturally required to return interface +func (c *CLI) GetARCZonalShiftHandler() service.Registerable { return c.arcZonalShiftHandler } + +// GetRoute53RecoveryClusterHandler returns the Route 53 Recovery Cluster handler. +// +//nolint:ireturn // architecturally required to return interface +func (c *CLI) GetRoute53RecoveryClusterHandler() service.Registerable { + return c.route53RecoveryClusterHandler +} + +// GetRoute53RecoveryControlConfigHandler returns the Route 53 Recovery Control Config handler. +// +//nolint:ireturn // architecturally required to return interface +func (c *CLI) GetRoute53RecoveryControlConfigHandler() service.Registerable { + return c.route53RecoveryControlHandler +} + +// GetRoute53RecoveryReadinessHandler returns the Route 53 Recovery Readiness handler. +// +//nolint:ireturn // architecturally required to return interface +func (c *CLI) GetRoute53RecoveryReadinessHandler() service.Registerable { + return c.route53RecoveryReadyHandler +} + // GetNetworkMonitorHandler returns the NetworkMonitor handler. // //nolint:ireturn // architecturally required to return interface @@ -2811,6 +2845,10 @@ func storeCLIHandlers(cli *CLI, services []service.Registerable) { cli.swfHandler = byName["SWF"] cli.firehoseHandler = byName["Firehose"] cli.networkmonitorHandler = byName["NetworkMonitor"] + cli.arcZonalShiftHandler = byName["ARCZonalShift"] + cli.route53RecoveryClusterHandler = byName["Route53RecoveryCluster"] + cli.route53RecoveryControlHandler = byName["Route53RecoveryControlConfig"] + cli.route53RecoveryReadyHandler = byName["Route53RecoveryReadiness"] cli.schedulerHandler = byName["Scheduler"] cli.route53resolverHandler = byName["Route53Resolver"] cli.rdsHandler = byName["RDS"] @@ -3626,6 +3664,30 @@ func (a *elbEC2ResolverAdapter) subnet(id string) *ec2backend.Subnet { return nil } +func (a *elbEC2ResolverAdapter) subnetIn(region, id string) *ec2backend.Subnet { + if subnets := a.regions.handler.BackendFor(region).DescribeSubnets([]string{id}); len(subnets) > 0 { + return subnets[0] + } + + return nil +} + +func (a *elbEC2ResolverAdapter) SubnetVPCIn(region, id string) string { + if sn := a.subnetIn(region, id); sn != nil { + return sn.VPCID + } + + return "" +} + +func (a *elbEC2ResolverAdapter) SubnetAZIn(region, id string) string { + if sn := a.subnetIn(region, id); sn != nil { + return sn.AvailabilityZone + } + + return "" +} + func (a *elbEC2ResolverAdapter) SubnetVPC(id string) string { if sn := a.subnet(id); sn != nil { return sn.VPCID @@ -4393,6 +4455,10 @@ func getMostRecentServiceProviders() []service.Provider { &grafanabackend.Provider{}, &outpostsbackend.Provider{}, &resiliencehubbackend.Provider{}, + &route53recoverycontrolconfigbackend.Provider{}, + &route53recoveryclusterbackend.Provider{}, + &route53recoveryreadinessbackend.Provider{}, + &arczonalshiftbackend.Provider{}, &directconnectbackend.Provider{}, &mgnbackend.Provider{}, &networkmanagerbackend.Provider{}, diff --git a/cli_arc_recovery_test.go b/cli_arc_recovery_test.go new file mode 100644 index 0000000000..79416a4fee --- /dev/null +++ b/cli_arc_recovery_test.go @@ -0,0 +1,321 @@ +package main + +import ( + "log/slog" + "net" + "net/http" + "net/http/httptest" + "strconv" + "testing" + + "github.com/aws/aws-sdk-go-v2/aws" + awscfg "github.com/aws/aws-sdk-go-v2/config" + "github.com/aws/aws-sdk-go-v2/credentials" + "github.com/aws/aws-sdk-go-v2/service/route53" + r53types "github.com/aws/aws-sdk-go-v2/service/route53/types" + "github.com/aws/aws-sdk-go-v2/service/route53recoverycluster" + rctypes "github.com/aws/aws-sdk-go-v2/service/route53recoverycluster/types" + "github.com/aws/aws-sdk-go-v2/service/route53recoverycontrolconfig" + ccTypes "github.com/aws/aws-sdk-go-v2/service/route53recoverycontrolconfig/types" + "github.com/labstack/echo/v5" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/blackbirdworks/gopherstack/pkgs/chaos" + "github.com/blackbirdworks/gopherstack/pkgs/service" +) + +// arcStack serves the full production composition root on a real listener, so cluster endpoints resolve back to it. +func arcStack(t *testing.T) aws.Config { + t.Helper() + + l, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + + port := l.Addr().(*net.TCPAddr).Port + + cli := &CLI{AccountID: "000000000000", Region: regionA, Port: ":" + strconv.Itoa(port)} + cli.faultStore = chaos.NewFaultStore() + + services, err := initializeServices( + &service.AppContext{Logger: slog.Default(), Config: cli, JanitorCtx: t.Context()}, + ) + require.NoError(t, err) + + registry := service.NewRegistry() + for _, svc := range services { + require.NoError(t, registry.Register(svc)) + } + + e := echo.New() + e.Pre(awsMetaMiddleware(regionA, "000000000000")) + e.Use( + service.NewServiceRouter(registry). + WithTargetGates(routeTargetGates()). + WithPathGates(routePathGates()). + RouteHandler(), + ) + + srv := httptest.NewUnstartedServer(e) + srv.Listener = l + srv.Start() + t.Cleanup(srv.Close) + + cfg, err := awscfg.LoadDefaultConfig(t.Context(), + awscfg.WithRegion("us-west-2"), + awscfg.WithHTTPClient(http.DefaultClient), + awscfg.WithCredentialsProvider(credentials.NewStaticCredentialsProvider("test", "test", "")), + ) + require.NoError(t, err) + + cfg.BaseEndpoint = aws.String(srv.URL) + + return cfg +} + +func TestARCRecoveryControlFullStack(t *testing.T) { + t.Parallel() + + cfg := arcStack(t) + ctx := t.Context() + control := route53recoverycontrolconfig.NewFromConfig(cfg) + dns := route53.NewFromConfig(cfg) + + cluster, err := control.CreateCluster( + ctx, + &route53recoverycontrolconfig.CreateClusterInput{ClusterName: aws.String("prod")}, + ) + require.NoError(t, err) + + clusterArn := cluster.Cluster.ClusterArn + desc, err := control.DescribeCluster( + ctx, + &route53recoverycontrolconfig.DescribeClusterInput{ClusterArn: clusterArn}, + ) + require.NoError(t, err) + require.Equal(t, ccTypes.StatusDeployed, desc.Cluster.Status) + require.NotEmpty(t, desc.Cluster.ClusterEndpoints) + + panel, err := control.CreateControlPanel(ctx, &route53recoverycontrolconfig.CreateControlPanelInput{ + ClusterArn: clusterArn, ControlPanelName: aws.String("app"), + }) + require.NoError(t, err) + + newControl := func(name string) string { + out, cErr := control.CreateRoutingControl(ctx, &route53recoverycontrolconfig.CreateRoutingControlInput{ + ClusterArn: clusterArn, + ControlPanelArn: panel.ControlPanel.ControlPanelArn, + RoutingControlName: aws.String(name), + }) + require.NoError(t, cErr) + + return aws.ToString(out.RoutingControl.RoutingControlArn) + } + + east, west := newControl("east"), newControl("west") + + rule, err := control.CreateSafetyRule( + ctx, + &route53recoverycontrolconfig.CreateSafetyRuleInput{AssertionRule: &ccTypes.NewAssertionRule{ + AssertedControls: []string{ + east, + west, + }, + ControlPanelArn: panel.ControlPanel.ControlPanelArn, + Name: aws.String("keep-one-on"), + WaitPeriodMs: aws.Int32( + 0, + ), + RuleConfig: &ccTypes.RuleConfig{ + Inverted: aws.Bool(false), + Threshold: aws.Int32(1), + Type: ccTypes.RuleTypeAtleast, + }, + }}, + ) + require.NoError(t, err) + + endpoint := desc.Cluster.ClusterEndpoints[0] + data := route53recoverycluster.NewFromConfig(cfg, func(o *route53recoverycluster.Options) { + o.BaseEndpoint = endpoint.Endpoint + o.Region = aws.ToString(endpoint.Region) + }) + + set := func(arn string, s rctypes.RoutingControlState) error { + _, uErr := data.UpdateRoutingControlState(ctx, &route53recoverycluster.UpdateRoutingControlStateInput{ + RoutingControlArn: aws.String(arn), RoutingControlState: s, + }) + + return uErr + } + + zone, err := dns.CreateHostedZone( + ctx, + &route53.CreateHostedZoneInput{Name: aws.String("example.com."), CallerReference: aws.String("z")}, + ) + require.NoError(t, err) + + zoneID := aws.ToString(zone.HostedZone.Id) + + hc, err := dns.CreateHealthCheck( + ctx, + &route53.CreateHealthCheckInput{ + CallerReference: aws.String("hc"), + HealthCheckConfig: &r53types.HealthCheckConfig{ + Type: r53types.HealthCheckTypeRecoveryControl, RoutingControlArn: aws.String(east), + }, + }, + ) + require.NoError(t, err) + + hcID := aws.ToString(hc.HealthCheck.Id) + + record := func(id string, fo r53types.ResourceRecordSetFailover, ip string, check *string) r53types.Change { + return r53types.Change{Action: r53types.ChangeActionCreate, ResourceRecordSet: &r53types.ResourceRecordSet{ + Name: aws.String( + "app.example.com.", + ), + Type: r53types.RRTypeA, + TTL: aws.Int64(60), + SetIdentifier: aws.String(id), + Failover: fo, + HealthCheckId: check, + ResourceRecords: []r53types.ResourceRecord{{Value: aws.String(ip)}}, + }} + } + + _, err = dns.ChangeResourceRecordSets( + ctx, + &route53.ChangeResourceRecordSetsInput{HostedZoneId: aws.String(zoneID), ChangeBatch: &r53types.ChangeBatch{ + Changes: []r53types.Change{ + record("primary", r53types.ResourceRecordSetFailoverPrimary, "192.0.2.1", aws.String(hcID)), + record("secondary", r53types.ResourceRecordSetFailoverSecondary, "192.0.2.2", nil), + }, + }}, + ) + require.NoError(t, err) + + healthStatus := func() string { + out, sErr := dns.GetHealthCheckStatus(ctx, &route53.GetHealthCheckStatusInput{HealthCheckId: aws.String(hcID)}) + require.NoError(t, sErr) + require.NotEmpty(t, out.HealthCheckObservations) + + return aws.ToString(out.HealthCheckObservations[0].StatusReport.Status) + } + + answer := func() string { + out, aErr := dns.TestDNSAnswer(ctx, &route53.TestDNSAnswerInput{ + HostedZoneId: aws.String(zoneID), RecordName: aws.String("app.example.com."), RecordType: r53types.RRTypeA, + }) + require.NoError(t, aErr) + require.Len(t, out.RecordData, 1) + + return out.RecordData[0] + } + + // Initial state: both controls Off, the health check is Unhealthy and DNS serves the secondary. + assert.Equal(t, "Unhealthy", healthStatus()) + assert.Equal(t, "192.0.2.2", answer()) + + require.NoError(t, set(east, rctypes.RoutingControlStateOn)) + require.NoError(t, set(west, rctypes.RoutingControlStateOn)) + assert.Equal(t, "Healthy", healthStatus()) + assert.Equal(t, "192.0.2.1", answer(), "primary serves while the routing control is On") + + { // both off is refused naming the rule + _, bErr := data.UpdateRoutingControlStates(ctx, &route53recoverycluster.UpdateRoutingControlStatesInput{ + UpdateRoutingControlStateEntries: []rctypes.UpdateRoutingControlStateEntry{ + {RoutingControlArn: aws.String(east), RoutingControlState: rctypes.RoutingControlStateOff}, + {RoutingControlArn: aws.String(west), RoutingControlState: rctypes.RoutingControlStateOff}, + }, + }) + + var conflict *rctypes.ConflictException + require.ErrorAs(t, bErr, &conflict) + assert.Contains(t, aws.ToString(conflict.Message), "keep-one-on") + assert.Contains(t, aws.ToString(conflict.Message), aws.ToString(rule.AssertionRule.SafetyRuleArn)) + assert.Equal(t, "Healthy", healthStatus(), "nothing changed") + } + + { // flipping one control changes the health check and DNS + require.NoError(t, set(east, rctypes.RoutingControlStateOff)) + assert.Equal(t, "Unhealthy", healthStatus()) + assert.Equal(t, "192.0.2.2", answer(), "failover record follows the routing control") + + require.Error(t, set(west, rctypes.RoutingControlStateOff), "west is the last control left On") + + require.NoError(t, set(east, rctypes.RoutingControlStateOn)) + assert.Equal(t, "Healthy", healthStatus()) + assert.Equal(t, "192.0.2.1", answer()) + } + + { // override lets the last control go Off + require.NoError(t, set(east, rctypes.RoutingControlStateOff)) + + _, uErr := data.UpdateRoutingControlState(ctx, &route53recoverycluster.UpdateRoutingControlStateInput{ + RoutingControlArn: aws.String(west), RoutingControlState: rctypes.RoutingControlStateOff, + SafetyRulesToOverride: []string{aws.ToString(rule.AssertionRule.SafetyRuleArn)}, + }) + require.NoError(t, uErr) + } + + { // control plane lists the associated health check + out, lErr := control.ListAssociatedRoute53HealthChecks( + ctx, + &route53recoverycontrolconfig.ListAssociatedRoute53HealthChecksInput{ + RoutingControlArn: aws.String(east), + }, + ) + require.NoError(t, lErr) + assert.Equal(t, []string{hcID}, out.HealthCheckIds) + + none, lErr := control.ListAssociatedRoute53HealthChecks( + ctx, + &route53recoverycontrolconfig.ListAssociatedRoute53HealthChecksInput{ + RoutingControlArn: aws.String(west), + }, + ) + require.NoError(t, lErr) + assert.Empty(t, none.HealthCheckIds) + } + + { // the routing controls are listed through the data plane + list, lErr := data.ListRoutingControls( + ctx, + &route53recoverycluster.ListRoutingControlsInput{ControlPanelArn: panel.ControlPanel.ControlPanelArn}, + ) + require.NoError(t, lErr) + require.Len(t, list.RoutingControls, 2) + assert.Equal(t, "east", aws.ToString(list.RoutingControls[0].RoutingControlName)) + } +} + +// Every recovery service must own the requests signed with its own scope; other path matchers must not swallow them. +func TestSignedRecoveryRequestsRouteToOwner(t *testing.T) { + t.Parallel() + + owners := map[string]string{ + "route53-recovery-control-config": "Route53RecoveryControlConfig", + "route53-recovery-readiness": "Route53RecoveryReadiness", + "route53-recovery-cluster": "Route53RecoveryCluster", + "arc-zonal-shift": "ARCZonalShift", + } + + seen := map[string]int{} + + for _, rc := range loadRoutingCorpus(t) { + owner, ok := owners[rc.authSvc] + if !ok || rc.host != "localhost:4566" { + continue + } + + seen[owner]++ + + assert.Equal(t, rc.wantScan, owner, "%s %s signed as %s", rc.method, rc.uri, rc.authSvc) + assert.Equal(t, rc.wantLookup, owner, "%s %s signed as %s", rc.method, rc.uri, rc.authSvc) + } + + for _, owner := range owners { + assert.NotZero(t, seen[owner], "%s has no signed rows in the routing corpus", owner) + } +} diff --git a/cli_elbv2_region_subnet_test.go b/cli_elbv2_region_subnet_test.go new file mode 100644 index 0000000000..8b883cf1eb --- /dev/null +++ b/cli_elbv2_region_subnet_test.go @@ -0,0 +1,55 @@ +package main + +import ( + "log/slog" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/blackbirdworks/gopherstack/pkgs/chaos" + "github.com/blackbirdworks/gopherstack/pkgs/portalloc" + "github.com/blackbirdworks/gopherstack/pkgs/service" + elbv2backend "github.com/blackbirdworks/gopherstack/services/elbv2" +) + +func TestELBv2DefaultSubnetZoneFollowsLoadBalancerRegion(t *testing.T) { + t.Parallel() + + cli := &CLI{AccountID: "000000000000", Region: "us-east-1"} + portAlloc, err := portalloc.New(19700, 19800) + require.NoError(t, err) + + cli.faultStore = chaos.NewFaultStore() + + services, err := initializeServices(&service.AppContext{ + Logger: slog.Default(), Config: cli, JanitorCtx: t.Context(), PortAlloc: portAlloc, + }) + require.NoError(t, err) + + h, ok := serviceByName(services)["ELBv2"].(*elbv2backend.Handler) + require.True(t, ok) + + tests := []struct { + region string + zone string + }{ + {region: "us-east-1", zone: "us-east-1a"}, + {region: "us-west-2", zone: "us-west-2a"}, + {region: "eu-west-1", zone: "eu-west-1a"}, + {region: "ap-southeast-2", zone: "ap-southeast-2a"}, + } + + for _, tt := range tests { + t.Run(tt.region, func(t *testing.T) { + t.Parallel() + + lb, createErr := h.BackendFor(tt.region).CreateLoadBalancer(elbv2backend.CreateLoadBalancerInput{ + Name: "web", Type: "application", Subnets: []string{"subnet-default"}, + }) + require.NoError(t, createErr) + require.Len(t, lb.AvailabilityZones, 1) + assert.Equal(t, tt.zone, lb.AvailabilityZones[0].ZoneName) + }) + } +} diff --git a/cli_lifecycle.go b/cli_lifecycle.go index 3160962bc8..5c20b035d7 100644 --- a/cli_lifecycle.go +++ b/cli_lifecycle.go @@ -9,6 +9,7 @@ import ( acmpcabackend "github.com/blackbirdworks/gopherstack/services/acmpca" apprunnerbackend "github.com/blackbirdworks/gopherstack/services/apprunner" appstreambackend "github.com/blackbirdworks/gopherstack/services/appstream" + arczonalshiftbackend "github.com/blackbirdworks/gopherstack/services/arczonalshift" autoscalingbackend "github.com/blackbirdworks/gopherstack/services/autoscaling" awsconfigbackend "github.com/blackbirdworks/gopherstack/services/awsconfig" bedrockbackend "github.com/blackbirdworks/gopherstack/services/bedrock" @@ -65,6 +66,7 @@ import ( rekognitionbackend "github.com/blackbirdworks/gopherstack/services/rekognition" resiliencehubbackend "github.com/blackbirdworks/gopherstack/services/resiliencehub" resourcegroupsbackend "github.com/blackbirdworks/gopherstack/services/resourcegroups" + recoverycontrolbackend "github.com/blackbirdworks/gopherstack/services/route53recoverycontrolconfig" route53resolverbackend "github.com/blackbirdworks/gopherstack/services/route53resolver" sagemakerbackend "github.com/blackbirdworks/gopherstack/services/sagemaker" serverlessrepobackend "github.com/blackbirdworks/gopherstack/services/serverlessrepo" @@ -160,6 +162,8 @@ type LifecycleSettings struct { CodeStarConnections time.Duration `name:"codestarconnections" env:"CODESTARCONNECTIONS_LIFECYCLE_DELAY" default:"0s" help:"CodeStar Connections connection handshake and host setup PENDING window; hosts stay PENDING when 0."` //nolint:lll // config struct tags are intentionally verbose CloudControl time.Duration `name:"cloudcontrol" env:"CLOUDCONTROL_OPERATION_DELAY" default:"0s" help:"Cloud Control Create/Update/DeleteResource IN_PROGRESS window."` //nolint:lll // config struct tags are intentionally verbose MediaPackage time.Duration `name:"mediapackage" env:"MEDIAPACKAGE_HARVEST_JOB_DELAY" default:"0s" help:"MediaPackage harvest job IN_PROGRESS window; 0 keeps the built-in 1s."` //nolint:lll // config struct tags are intentionally verbose + Route53Recovery time.Duration `name:"route53-recovery" env:"ROUTE53_RECOVERY_LIFECYCLE_DELAY" default:"0s" help:"Route 53 ARC control plane PENDING/PENDING_DELETION window."` //nolint:lll // config struct tags are intentionally verbose + ARCZonalShift time.Duration `name:"arczonalshift" env:"ARCZONALSHIFT_PRACTICE_RUN_DURATION" default:"0s" help:"ARC zonal shift practice run ACTIVE window; 0 keeps the built-in 30m; ignores the global delay."` //nolint:lll // config struct tags are intentionally verbose EKS time.Duration `name:"eks" env:"EKS_LIFECYCLE_DELAY" default:"0s" help:"EKS transitional-state window (built-in 100ms)."` //nolint:lll // config struct tags are intentionally verbose } @@ -745,6 +749,18 @@ func applyCodeStarConnections(reg service.Registerable, l LifecycleSettings) { } } +func applyRoute53Recovery(reg service.Registerable, l LifecycleSettings) { + if h, ok := reg.(*recoverycontrolbackend.Handler); ok { + h.Backend.SetActivationDelay(l.effective(l.Route53Recovery)) + } +} + +func applyARCZonalShift(reg service.Registerable, l LifecycleSettings) { + if h, ok := reg.(*arczonalshiftbackend.Handler); ok { + h.Backend.SetPracticeRunDuration(l.ARCZonalShift) + } +} + // wireLifecycleDelays applies the configured dwell times to the already // initialized service backends; zero values leave services instant. func wireLifecycleDelays(byName map[string]service.Registerable, l LifecycleSettings) { @@ -824,6 +840,9 @@ func wireLifecycleDelays(byName map[string]service.Registerable, l LifecycleSett "ServerlessRepo": applyServerlessRepo, "CodeConnections": applyCodeConnections, "CodeStarConnections": applyCodeStarConnections, + + "Route53RecoveryControlConfig": applyRoute53Recovery, + "ARCZonalShift": applyARCZonalShift, } for name, apply := range appliers { diff --git a/cli_lifecycle_docs_test.go b/cli_lifecycle_docs_test.go new file mode 100644 index 0000000000..57cb96d942 --- /dev/null +++ b/cli_lifecycle_docs_test.go @@ -0,0 +1,85 @@ +package main + +import ( + "io/fs" + "os" + "path/filepath" + "reflect" + "regexp" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +var lifecycleFlagRef = regexp.MustCompile(`--lifecycle-[a-z0-9]+(?:-[a-z0-9]+)*`) + +func lifecycleFlagNames(t *testing.T) map[string]bool { + t.Helper() + + names := map[string]bool{} + typ := reflect.TypeFor[LifecycleSettings]() + + for f := range typ.Fields() { + name := f.Tag.Get("name") + require.NotEmpty(t, name, "field %s needs an explicit name tag", f.Name) + + names["--lifecycle-"+name] = true + } + + return names +} + +func TestDocumentedLifecycleFlagsExist(t *testing.T) { + t.Parallel() + + known := lifecycleFlagNames(t) + + tests := []struct { + name string + root string + }{ + {name: "root docs", root: "."}, + {name: "service docs", root: "services"}, + {name: "docs dir", root: "docs"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + var bad []string + + walk := func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + + if d.IsDir() && tt.root == "." && path != "." { + return fs.SkipDir + } + + if d.IsDir() || !strings.HasSuffix(path, ".md") { + return nil + } + + data, rErr := os.ReadFile(path) + if rErr != nil { + return rErr + } + + for _, flag := range lifecycleFlagRef.FindAllString(string(data), -1) { + if !known[flag] { + bad = append(bad, path+": "+flag) + } + } + + return nil + } + + require.NoError(t, filepath.WalkDir(tt.root, walk)) + assert.Empty(t, bad, "documented --lifecycle-* flags that do not exist") + }) + } +} diff --git a/cmd/gendocs/categories.go b/cmd/gendocs/categories.go index 175d6522e4..196dfdfa29 100644 --- a/cmd/gendocs/categories.go +++ b/cmd/gendocs/categories.go @@ -52,7 +52,8 @@ func categoryGroups() []categoryGroup { {"Networking & Content Delivery", []string{ "apigateway", "apigatewaymanagementapi", "apigatewayv2", "appmesh", "cloudfront", "elb", "elbv2", "networkmonitor", "route53", - "route53resolver", "servicediscovery", "vpclattice", + "route53recoverycluster", "route53recoverycontrolconfig", "route53recoveryreadiness", + "route53resolver", "servicediscovery", "vpclattice", "arczonalshift", }}, {"Messaging & Integration", []string{ "appsync", "eventbridge", "mq", "pinpoint", "pipes", "scheduler", "ses", @@ -243,45 +244,49 @@ func displayNamesB() map[string]string { func displayNamesC() map[string]string { return map[string]string{ - "kafka": "Managed Streaming for Kafka", - "kinesis": "Kinesis", - "kinesisanalytics": "Kinesis Analytics", - "kinesisanalyticsv2": "Kinesis Analytics v2", - "kms": "KMS", - "lakeformation": "Lake Formation", - "lambda": "Lambda", - "macie2": "Macie", - "managedblockchain": "Managed Blockchain", - "mediaconvert": "MediaConvert", - "medialive": "MediaLive", - "mediapackage": "MediaPackage", - "mediastore": "MediaStore", - "mediastoredata": "MediaStore Data", - "mediatailor": "MediaTailor", - "memorydb": "MemoryDB", - "mq": "Amazon MQ", - "mwaa": "Managed Workflows for Apache Airflow", - "neptune": "Neptune", - "networkmonitor": "CloudWatch Network Monitor", - "omics": "HealthOmics", - "opensearch": "OpenSearch", - "opsworks": "OpsWorks", - "organizations": "Organizations", - "personalize": "Personalize", - "pinpoint": "Pinpoint", - "pipes": "EventBridge Pipes", - "polly": "Polly", - "qldb": "QLDB", - "qldbsession": "QLDB Session", - "quicksight": "QuickSight", - "ram": "Resource Access Manager", - "rds": "RDS", - "rdsdata": "RDS Data", - "redshift": "Redshift", - "redshiftdata": "Redshift Data", - "rekognition": "Rekognition", - "resourcegroups": "Resource Groups", - "resourcegroupstaggingapi": "Resource Groups Tagging API", + "kafka": "Managed Streaming for Kafka", + "kinesis": "Kinesis", + "kinesisanalytics": "Kinesis Analytics", + "kinesisanalyticsv2": "Kinesis Analytics v2", + "kms": "KMS", + "lakeformation": "Lake Formation", + "lambda": "Lambda", + "macie2": "Macie", + "managedblockchain": "Managed Blockchain", + "mediaconvert": "MediaConvert", + "medialive": "MediaLive", + "mediapackage": "MediaPackage", + "mediastore": "MediaStore", + "mediastoredata": "MediaStore Data", + "mediatailor": "MediaTailor", + "memorydb": "MemoryDB", + "mq": "Amazon MQ", + "mwaa": "Managed Workflows for Apache Airflow", + "neptune": "Neptune", + "arczonalshift": "ARC Zonal Shift", + "networkmonitor": "CloudWatch Network Monitor", + "route53recoverycluster": "Route 53 Recovery Cluster", + "route53recoverycontrolconfig": "Route 53 Recovery Control Config", + "route53recoveryreadiness": "Route 53 Recovery Readiness", + "omics": "HealthOmics", + "opensearch": "OpenSearch", + "opsworks": "OpsWorks", + "organizations": "Organizations", + "personalize": "Personalize", + "pinpoint": "Pinpoint", + "pipes": "EventBridge Pipes", + "polly": "Polly", + "qldb": "QLDB", + "qldbsession": "QLDB Session", + "quicksight": "QuickSight", + "ram": "Resource Access Manager", + "rds": "RDS", + "rdsdata": "RDS Data", + "redshift": "Redshift", + "redshiftdata": "Redshift Data", + "rekognition": "Rekognition", + "resourcegroups": "Resource Groups", + "resourcegroupstaggingapi": "Resource Groups Tagging API", } } diff --git a/go.mod b/go.mod index 578d05d89b..e89e7bef97 100644 --- a/go.mod +++ b/go.mod @@ -14,7 +14,7 @@ require ( github.com/andybalholm/brotli v1.2.6 github.com/aws/aws-dax-go v1.2.15 github.com/aws/aws-sdk-go v1.55.8 - github.com/aws/aws-sdk-go-v2 v1.47.1 + github.com/aws/aws-sdk-go-v2 v1.47.3 github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.20 github.com/aws/aws-sdk-go-v2/config v1.33.3 github.com/aws/aws-sdk-go-v2/credentials v1.20.3 @@ -33,6 +33,7 @@ require ( github.com/aws/aws-sdk-go-v2/service/apprunner v1.42.4 github.com/aws/aws-sdk-go-v2/service/appstream v1.64.5 github.com/aws/aws-sdk-go-v2/service/appsync v1.60.0 + github.com/aws/aws-sdk-go-v2/service/arczonalshift v1.34.1 github.com/aws/aws-sdk-go-v2/service/athena v1.60.4 github.com/aws/aws-sdk-go-v2/service/autoscaling v1.70.4 github.com/aws/aws-sdk-go-v2/service/backup v1.64.0 @@ -150,6 +151,9 @@ require ( github.com/aws/aws-sdk-go-v2/service/resourcegroupstaggingapi v1.35.4 github.com/aws/aws-sdk-go-v2/service/rolesanywhere v1.26.3 github.com/aws/aws-sdk-go-v2/service/route53 v1.65.6 + github.com/aws/aws-sdk-go-v2/service/route53recoverycluster v1.36.4 + github.com/aws/aws-sdk-go-v2/service/route53recoverycontrolconfig v1.40.4 + github.com/aws/aws-sdk-go-v2/service/route53recoveryreadiness v1.34.4 github.com/aws/aws-sdk-go-v2/service/route53resolver v1.53.0 github.com/aws/aws-sdk-go-v2/service/s3 v1.111.0 github.com/aws/aws-sdk-go-v2/service/s3control v1.73.4 @@ -186,7 +190,7 @@ require ( github.com/aws/aws-sdk-go-v2/service/workmail v1.39.4 github.com/aws/aws-sdk-go-v2/service/workspaces v1.79.0 github.com/aws/aws-sdk-go-v2/service/xray v1.39.4 - github.com/aws/smithy-go v1.28.1 + github.com/aws/smithy-go v1.28.5 github.com/cedar-policy/cedar-go v1.8.0 github.com/distribution/distribution/v3 v3.1.1 github.com/eclipse/paho.mqtt.golang v1.5.1 @@ -231,8 +235,8 @@ require ( github.com/agnivade/levenshtein v1.2.1 // indirect github.com/antlr/antlr4 v0.0.0-20181218183524-be58ebffde8e // indirect github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.19.2 // indirect - github.com/aws/aws-sdk-go-v2/internal/configsources v1.5.4 // indirect - github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.8.4 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.5.6 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.8.6 // indirect github.com/aws/aws-sdk-go-v2/internal/v4a v1.5.2 // indirect github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.19 // indirect github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.11.2 // indirect diff --git a/go.sum b/go.sum index 75e90145db..ecdc870e78 100644 --- a/go.sum +++ b/go.sum @@ -50,6 +50,8 @@ github.com/aws/aws-sdk-go v1.55.8 h1:JRmEUbU52aJQZ2AjX4q4Wu7t4uZjOu71uyNmaWlUkJQ github.com/aws/aws-sdk-go v1.55.8/go.mod h1:ZkViS9AqA6otK+JBBNH2++sx1sgxrPKcSzPPvQkUtXk= github.com/aws/aws-sdk-go-v2 v1.47.1 h1:uOIZnp4PK3ZhKI0dNrJrhTEsLxbpXHTAJlwoS1pvAtw= github.com/aws/aws-sdk-go-v2 v1.47.1/go.mod h1:bttEH6JqnUL8LepvDVfdrds/fZ5bCIxzpe3abyUrhDU= +github.com/aws/aws-sdk-go-v2 v1.47.3 h1:HRoT/MkNi3bjO5xF4dCzLzvVmkvRGHj3/cLINDnJejk= +github.com/aws/aws-sdk-go-v2 v1.47.3/go.mod h1:xSl+OSI5ZUw6bSMupWc218zE5Pl+65MCCu3ySWFY99A= github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.20 h1:GPRlPwz40I2B2VrBEASOA3Bi77NyeqejNLkifosX0rs= github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.20/go.mod h1:g7PNzKcsOKWb4fkSRBA7BZVAS6Y8IcxzN+nRohhQ1Q8= github.com/aws/aws-sdk-go-v2/config v1.33.3 h1:h090b3O5S17bF87/0ysHZuIT/7DCb4EBRFQX2PMVPCw= @@ -60,8 +62,12 @@ github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.19.2 h1:Ldv7RPHs7qwwTscRjAl3YBu github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.19.2/go.mod h1:XyK6UV8xbo66ysVqLd2783C09pBYHOm8aKTRV5DVJ30= github.com/aws/aws-sdk-go-v2/internal/configsources v1.5.4 h1:CLq4+8UHCI+ZZYl/EuJxXovaIVN2xeeT8JV+dsApQ5E= github.com/aws/aws-sdk-go-v2/internal/configsources v1.5.4/go.mod h1:Wv4q5sAM04xAMkoOedxLx2inVf6K5FdxYp+A61L+q/0= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.5.6 h1:CqL8NN3p4I0JTwwV5MEoatIl4Y2amZ2X9dRD+0hbRL0= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.5.6/go.mod h1:exdJ32hMqXl0Wyo6NwqEC5yIhtua3+PDaKE8P29m4qE= github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.8.4 h1:dD4MR81I7YkpEBRk6UP9rocC2QnT3qVuXwzlYTtfGEs= github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.8.4/go.mod h1:EcXV1kAFd5XwSkDHlj94gnF3q5CkJyYiIJfH8N0VmrE= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.8.6 h1:FpBq24TG6WVSObTNhQOv7Q0088rounUUlwn3zlNItkM= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.8.6/go.mod h1:ONzdPVYC95oBOu0VWNEsKEntcoemP3xVKjT+qWml1s4= github.com/aws/aws-sdk-go-v2/internal/v4a v1.5.2 h1:XMgIRS+uW9F3yFKnXGRrI9pkHi99CXTmoz2kz2/TGBA= github.com/aws/aws-sdk-go-v2/internal/v4a v1.5.2/go.mod h1:vorxDzK+n3jiv9a5ST/LG0Eu9cSv1CRdKTpG6pDMs+M= github.com/aws/aws-sdk-go-v2/service/accessanalyzer v1.51.4 h1:DD5SFDxWC2jxmzOTM4b3fWeDSwlYtF52EFbCwyrxLy0= @@ -94,6 +100,8 @@ github.com/aws/aws-sdk-go-v2/service/appstream v1.64.5 h1:B81jk3swdky5nMd0Hzlo1h github.com/aws/aws-sdk-go-v2/service/appstream v1.64.5/go.mod h1:GeALZQzKDMyQafSiot37UQv8vpXXJ1EWwkWAm30TqcU= github.com/aws/aws-sdk-go-v2/service/appsync v1.60.0 h1:GHaqk4I1wXiCnRDsNr90u5XjkM2zMfgyQ6BVi6YKj0k= github.com/aws/aws-sdk-go-v2/service/appsync v1.60.0/go.mod h1:LfEjiVP+TYEsX5CFroazubKcQN8HX+2oDQfW1sUmYYE= +github.com/aws/aws-sdk-go-v2/service/arczonalshift v1.34.1 h1:nfM0dBp941RbONKXX++ibtYjsP48biow4vjnQn4DAws= +github.com/aws/aws-sdk-go-v2/service/arczonalshift v1.34.1/go.mod h1:v+QtdsMz2oLXgBDr3NEjSP62ZE6MFj797Y+kgROT9Hw= github.com/aws/aws-sdk-go-v2/service/athena v1.60.4 h1:F5vT7jKiv10kpGh3141UYjhX6gFVpEr0hnW62EGqX4A= github.com/aws/aws-sdk-go-v2/service/athena v1.60.4/go.mod h1:gq29MwvJkXpjYJiBdSrSRTEzIZGXEdavnCKb5NGS1Gc= github.com/aws/aws-sdk-go-v2/service/autoscaling v1.70.4 h1:9o2iI1wi3cc+NhFfjTuijCiVR+Jfv0H5Bx0I3KpGWAM= @@ -338,6 +346,12 @@ github.com/aws/aws-sdk-go-v2/service/rolesanywhere v1.26.3 h1:6zt3yIvqSBILx/l2p3 github.com/aws/aws-sdk-go-v2/service/rolesanywhere v1.26.3/go.mod h1:b3tepy39YmeR7Yy7IcIexc2WfG2yir8C5UU6rEAbxCY= github.com/aws/aws-sdk-go-v2/service/route53 v1.65.6 h1:MDZUFQEVG3S6W+VmhQ9qlbprAuPZDfvvWZTg+HZU0o0= github.com/aws/aws-sdk-go-v2/service/route53 v1.65.6/go.mod h1:mh85zjA/hf1PtItwFXA9Yb/2zgUCEYpkN9QmzrK4RNc= +github.com/aws/aws-sdk-go-v2/service/route53recoverycluster v1.36.4 h1:5o3UhVVZo+Hrt7z+nf0zVenC/XK3EBaaaA1FBEcw/ps= +github.com/aws/aws-sdk-go-v2/service/route53recoverycluster v1.36.4/go.mod h1:t+TZXNWR/cOHkpSi+XE5mYagUo8tVoeijs+VPH3e8jg= +github.com/aws/aws-sdk-go-v2/service/route53recoverycontrolconfig v1.40.4 h1:4Xoh29pVLsP2RFQjKr7QxSmqkKbajBx6CCxEK1Hab/o= +github.com/aws/aws-sdk-go-v2/service/route53recoverycontrolconfig v1.40.4/go.mod h1:RW5i/g+uTIQX6lFrfxHWjhQttkxgJQZvmFVfxcKApSY= +github.com/aws/aws-sdk-go-v2/service/route53recoveryreadiness v1.34.4 h1:2jAcvH3UpX9dygbRy7ynnCamFdeO5UIvrCnh2LsEo5Y= +github.com/aws/aws-sdk-go-v2/service/route53recoveryreadiness v1.34.4/go.mod h1:g5SgpVFiZ5w4luIPMffpNTqWi17lUDSWb7JYu98UrWc= github.com/aws/aws-sdk-go-v2/service/route53resolver v1.53.0 h1:mpL6r9kantOM8zWI/SR5AYfOH6KIIEfc0YhBs0K4hP8= github.com/aws/aws-sdk-go-v2/service/route53resolver v1.53.0/go.mod h1:dJrmGVzOLmT+qWT3O7PGQdnBuZincImXChQYW0EXA1Y= github.com/aws/aws-sdk-go-v2/service/s3 v1.111.0 h1:4PokON/ISKuD6Zl062n4WfQNnEFBMHvfeJy73Jp7K+g= @@ -418,6 +432,8 @@ github.com/aws/aws-sdk-go-v2/service/xray v1.39.4 h1:PATw3jzsEZtkkyzESR0DOXS4Nvh github.com/aws/aws-sdk-go-v2/service/xray v1.39.4/go.mod h1:ow/PipMD3/kO0/1NzVKYeaLhG1Rx6aYWNjsuy6uBLpc= github.com/aws/smithy-go v1.28.1 h1:R/nXH00c8qcfCzQVELtRw+eLQWtzv+VAIEFJ1/xxXlQ= github.com/aws/smithy-go v1.28.1/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= +github.com/aws/smithy-go v1.28.5 h1:OqbqKtzY9NUnr2yAe9+hiTdFb20tNaK/lI0PMOGXX9k= +github.com/aws/smithy-go v1.28.5/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/bitfield/gotestdox v0.2.2 h1:x6RcPAbBbErKLnapz1QeAlf3ospg8efBsedU93CDsnE= diff --git a/pkgs/persistence/testdata/snapshot_inventory.json b/pkgs/persistence/testdata/snapshot_inventory.json index f39e6e994c..d679d01a1f 100644 --- a/pkgs/persistence/testdata/snapshot_inventory.json +++ b/pkgs/persistence/testdata/snapshot_inventory.json @@ -2114,6 +2114,33 @@ ], "version": 2 }, + "arczonalshift": { + "fields": [ + "Alarm.AlarmIdentifier string `json:\"alarmIdentifier\"`", + "Alarm.Type string `json:\"type\"`", + "PracticeConfig.AllowedWindows []string `json:\"allowedWindows,omitempty\"`", + "PracticeConfig.BlockedDates []string `json:\"blockedDates,omitempty\"`", + "PracticeConfig.BlockedWindows []string `json:\"blockedWindows,omitempty\"`", + "PracticeConfig.BlockingAlarms []Alarm `json:\"blockingAlarms,omitempty\"`", + "PracticeConfig.OutcomeAlarms []Alarm `json:\"outcomeAlarms\"`", + "PracticeConfig.Resource string `json:\"resource\"`", + "ResourceSettings.Autoshift string `json:\"autoshift\"`", + "ResourceSettings.Resource string `json:\"resource\"`", + "Settings.ID string `json:\"id\"`", + "Settings.Observer string `json:\"observer\"`", + "Shift.AwayFrom string `json:\"awayFrom\"`", + "Shift.CanceledAt *time.Time `json:\"canceledAt,omitempty\"`", + "Shift.Comment string `json:\"comment\"`", + "Shift.ExpiryTime time.Time `json:\"expiryTime\"`", + "Shift.ID string `json:\"id\"`", + "Shift.Outcome string `json:\"outcome,omitempty\"`", + "Shift.Resource string `json:\"resource\"`", + "Shift.StartTime time.Time `json:\"startTime\"`", + "Shift.Type string `json:\"type\"`", + "backendSnapshot.Tables map[string]json.RawMessage `json:\"tables\"`" + ], + "version": 1 + }, "athena": { "fields": [ "ACLConfiguration.S3AclOption string `json:\"S3AclOption,omitempty\"`", @@ -25436,6 +25463,86 @@ ], "version": 1 }, + "route53recoverycontrolconfig": { + "fields": [ + "Cluster.ARN string `json:\"arn\"`", + "Cluster.CreatedAt time.Time `json:\"createdAt\"`", + "Cluster.DeletedAt *time.Time `json:\"deletedAt,omitempty\"`", + "Cluster.Name string `json:\"name\"`", + "Cluster.NetworkType string `json:\"networkType\"`", + "Cluster.Tags map[string]string `json:\"tags,omitempty\"`", + "ControlPanel.ARN string `json:\"arn\"`", + "ControlPanel.ClusterARN string `json:\"clusterArn\"`", + "ControlPanel.CreatedAt time.Time `json:\"createdAt\"`", + "ControlPanel.Default bool `json:\"default,omitempty\"`", + "ControlPanel.DeletedAt *time.Time `json:\"deletedAt,omitempty\"`", + "ControlPanel.Name string `json:\"name\"`", + "ControlPanel.Tags map[string]string `json:\"tags,omitempty\"`", + "RoutingControl.ARN string `json:\"arn\"`", + "RoutingControl.ClusterARN string `json:\"clusterArn\"`", + "RoutingControl.CreatedAt time.Time `json:\"createdAt\"`", + "RoutingControl.DeletedAt *time.Time `json:\"deletedAt,omitempty\"`", + "RoutingControl.Name string `json:\"name\"`", + "RoutingControl.PanelARN string `json:\"panelArn\"`", + "RoutingControl.State string `json:\"state\"`", + "SafetyRule.ARN string `json:\"arn\"`", + "SafetyRule.Controls []string `json:\"controls\"`", + "SafetyRule.CreatedAt time.Time `json:\"createdAt\"`", + "SafetyRule.DeletedAt *time.Time `json:\"deletedAt,omitempty\"`", + "SafetyRule.Inverted bool `json:\"inverted\"`", + "SafetyRule.Kind string `json:\"kind\"`", + "SafetyRule.LastChangeAt time.Time `json:\"lastChangeAt\"`", + "SafetyRule.Name string `json:\"name\"`", + "SafetyRule.PanelARN string `json:\"panelArn\"`", + "SafetyRule.RuleType string `json:\"ruleType\"`", + "SafetyRule.Tags map[string]string `json:\"tags,omitempty\"`", + "SafetyRule.Targets []string `json:\"targets,omitempty\"`", + "SafetyRule.Threshold int32 `json:\"threshold\"`", + "SafetyRule.WaitPeriodMs int32 `json:\"waitPeriodMs\"`", + "backendSnapshot.Tables map[string]json.RawMessage `json:\"tables\"`", + "backendSnapshot.Tokens map[string]string `json:\"tokens,omitempty\"`" + ], + "version": 1 + }, + "route53recoveryreadiness": { + "fields": [ + "Authorization.Value string `json:\"value\"`", + "Cell.ARN string `json:\"arn\"`", + "Cell.Cells []string `json:\"cells\"`", + "Cell.Name string `json:\"name\"`", + "Cell.Parent string `json:\"parent,omitempty\"`", + "Cell.Tags map[string]string `json:\"tags,omitempty\"`", + "DNSTarget.DomainName string `json:\"domainName,omitempty\"`", + "DNSTarget.HostedZoneArn string `json:\"hostedZoneArn,omitempty\"`", + "DNSTarget.RecordSetID string `json:\"recordSetId,omitempty\"`", + "DNSTarget.RecordType string `json:\"recordType,omitempty\"`", + "DNSTarget.Target *TargetResource `json:\"targetResource,omitempty\"`", + "NLBResource.Arn string `json:\"arn,omitempty\"`", + "R53Resource.DomainName string `json:\"domainName,omitempty\"`", + "R53Resource.RecordSetID string `json:\"recordSetId,omitempty\"`", + "ReadinessCheck.ARN string `json:\"arn\"`", + "ReadinessCheck.Name string `json:\"name\"`", + "ReadinessCheck.ResourceSet string `json:\"resourceSet\"`", + "ReadinessCheck.Tags map[string]string `json:\"tags,omitempty\"`", + "RecoveryGroup.ARN string `json:\"arn\"`", + "RecoveryGroup.Cells []string `json:\"cells\"`", + "RecoveryGroup.Name string `json:\"name\"`", + "RecoveryGroup.Tags map[string]string `json:\"tags,omitempty\"`", + "Resource.ComponentID string `json:\"componentId,omitempty\"`", + "Resource.DNS *DNSTarget `json:\"dnsTargetResource,omitempty\"`", + "Resource.ReadinessScopes []string `json:\"readinessScopes,omitempty\"`", + "Resource.ResourceArn string `json:\"resourceArn,omitempty\"`", + "ResourceSet.ARN string `json:\"arn\"`", + "ResourceSet.Name string `json:\"name\"`", + "ResourceSet.Resources []Resource `json:\"resources\"`", + "ResourceSet.Tags map[string]string `json:\"tags,omitempty\"`", + "ResourceSet.Type string `json:\"type\"`", + "TargetResource.NLB *NLBResource `json:\"nLBResource,omitempty\"`", + "TargetResource.R53 *R53Resource `json:\"r53Resource,omitempty\"`", + "backendSnapshot.Tables map[string]json.RawMessage `json:\"tables\"`" + ], + "version": 1 + }, "route53resolver": { "fields": [ "FirewallConfig.FirewallFailOpen string `json:\"firewallFailOpen\"`", diff --git a/pkgs/restroute/paginate.go b/pkgs/restroute/paginate.go new file mode 100644 index 0000000000..f5c51dddd4 --- /dev/null +++ b/pkgs/restroute/paginate.go @@ -0,0 +1,36 @@ +package restroute + +import ( + "net/url" + "strconv" + + "github.com/blackbirdworks/gopherstack/pkgs/page" +) + +// MaxResultsQuery parses an optional integer query parameter; absent yields 0. +func MaxResultsQuery(q url.Values, name string) (int, error) { + raw := q.Get(name) + if raw == "" { + return 0, nil + } + + n, err := strconv.Atoi(raw) + if err != nil { + return 0, Validation("%s must be an integer", name) + } + + return n, nil +} + +// Paginate validates maxResults (0 = default, otherwise 1..maxAllowed) and the token, then slices all. +func Paginate[T any](all []T, token string, maxResults, defaultLimit, maxAllowed int) (page.Page[T], error) { + if maxResults < 0 || maxResults > maxAllowed { + return page.Page[T]{}, Validation("maxResults must be between 1 and %d", maxAllowed) + } + + if err := page.ValidateToken(token); err != nil { + return page.Page[T]{}, Validation("nextToken is not valid") + } + + return page.New(all, token, maxResults, defaultLimit), nil +} diff --git a/pkgs/restroute/restroute.go b/pkgs/restroute/restroute.go new file mode 100644 index 0000000000..2572be2424 --- /dev/null +++ b/pkgs/restroute/restroute.go @@ -0,0 +1,136 @@ +// Package restroute matches REST-JSON requests against smithy-style URI templates and writes AWS REST-JSON errors. +package restroute + +import ( + "maps" + "net/http" + "net/url" + "strings" + + "github.com/labstack/echo/v5" + + "github.com/blackbirdworks/gopherstack/pkgs/awserr" +) + +// Route binds a method and URI template ("/cells/{CellName}") to an operation name. +type Route struct { + Method string + Path string + Op string +} + +type compiled struct { + method string + op string + segments []string +} + +// Router resolves requests to operations; the zero value matches nothing. +type Router struct { + routes []compiled +} + +// New compiles routes. Every {Label} matches exactly one (URL-decoded) path segment. +func New(routes []Route) *Router { + r := &Router{routes: make([]compiled, 0, len(routes))} + + for _, rt := range routes { + r.routes = append(r.routes, compiled{ + method: rt.Method, + op: rt.Op, + segments: splitPath(rt.Path), + }) + } + + return r +} + +func splitPath(p string) []string { + p = strings.Trim(p, "/") + if p == "" { + return nil + } + + return strings.Split(p, "/") +} + +// Match returns the operation and the captured labels for method and the request's escaped path. +func (r *Router) Match(method, escapedPath string) (string, map[string]string) { + segs := splitPath(escapedPath) + + for _, rt := range r.routes { + if rt.method != method || len(rt.segments) != len(segs) { + continue + } + + if params, ok := bind(rt.segments, segs); ok { + return rt.op, params + } + } + + return "", nil +} + +// Handles reports whether any route (of any method) has the shape of escapedPath. +func (r *Router) Handles(escapedPath string) bool { + segs := splitPath(escapedPath) + + for _, rt := range r.routes { + if len(rt.segments) != len(segs) { + continue + } + + if _, ok := bind(rt.segments, segs); ok { + return true + } + } + + return false +} + +func bind(tmpl, segs []string) (map[string]string, bool) { + var params map[string]string + + for i, t := range tmpl { + if len(t) > 2 && t[0] == '{' && t[len(t)-1] == '}' { + v, err := url.PathUnescape(segs[i]) + if err != nil { + return nil, false + } + + if params == nil { + params = map[string]string{} + } + + params[t[1:len(t)-1]] = v + + continue + } + + if t != segs[i] { + return nil, false + } + } + + return params, true +} + +// EscapedPath returns the request path with percent-encoding preserved (labels may contain "/"). +func EscapedPath(r *http.Request) string { + return r.URL.EscapedPath() +} + +// WriteError writes a REST-JSON error. The body carries both "message" and "Message" because +// legacy and schema-serde SDK clients match the modeled key case-sensitively. +func WriteError(c *echo.Context, status int, code, message string, extra map[string]any) error { + c.Response().Header().Set("X-Amzn-Errortype", code) + + body := map[string]any{ + "message": awserr.StripCode(code, message), + "Message": awserr.StripCode(code, message), + } + + maps.Copy(body, extra) + + return c.JSON(status, body) +} diff --git a/pkgs/restroute/restroute_test.go b/pkgs/restroute/restroute_test.go new file mode 100644 index 0000000000..4fa11941ff --- /dev/null +++ b/pkgs/restroute/restroute_test.go @@ -0,0 +1,248 @@ +package restroute_test + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/labstack/echo/v5" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/blackbirdworks/gopherstack/pkgs/httputils" + "github.com/blackbirdworks/gopherstack/pkgs/restroute" +) + +func newRouter() *restroute.Router { + return restroute.New([]restroute.Route{ + {Method: http.MethodGet, Path: "/cells", Op: "ListCells"}, + {Method: http.MethodPost, Path: "/cells", Op: "CreateCell"}, + {Method: http.MethodGet, Path: "/cells/{CellName}", Op: "GetCell"}, + {Method: http.MethodGet, Path: "/checks/{Name}/resource/{Id}/status", Op: "ResourceStatus"}, + }) +} + +func TestRouterMatch(t *testing.T) { + t.Parallel() + + tests := []struct { + params map[string]string + name string + method string + path string + wantOp string + }{ + {name: "static", method: http.MethodGet, path: "/cells", wantOp: "ListCells"}, + {name: "method selects the op", method: http.MethodPost, path: "/cells", wantOp: "CreateCell"}, + { + name: "label", + method: http.MethodGet, + path: "/cells/c1", + wantOp: "GetCell", + params: map[string]string{"CellName": "c1"}, + }, + { + name: "encoded slash stays one label", + method: http.MethodGet, + path: "/cells/arn%3Aaws%3Ax%2Fy", + wantOp: "GetCell", + params: map[string]string{"CellName": "arn:aws:x/y"}, + }, + { + name: "two labels", + method: http.MethodGet, + path: "/checks/rc/resource/arn%3Ax%2Fy/status", + wantOp: "ResourceStatus", + params: map[string]string{"Name": "rc", "Id": "arn:x/y"}, + }, + {name: "trailing slash", method: http.MethodGet, path: "/cells/", wantOp: "ListCells"}, + {name: "wrong method", method: http.MethodDelete, path: "/cells"}, + {name: "unknown path", method: http.MethodGet, path: "/nothing"}, + {name: "too deep", method: http.MethodGet, path: "/cells/a/b"}, + {name: "bad escape", method: http.MethodGet, path: "/cells/%zz"}, + } + + r := newRouter() + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + op, params := r.Match(tt.method, tt.path) + assert.Equal(t, tt.wantOp, op) + + if tt.params != nil { + assert.Equal(t, tt.params, params) + } + }) + } +} + +func TestRouterHandlesIgnoresMethod(t *testing.T) { + t.Parallel() + + r := newRouter() + assert.True(t, r.Handles("/cells")) + assert.True(t, r.Handles("/cells/x")) + assert.False(t, r.Handles("/cells/x/y")) + assert.False(t, r.Handles("/other")) +} + +func TestPaginate(t *testing.T) { + t.Parallel() + + all := []int{1, 2, 3, 4, 5} + + tests := []struct { + name string + token string + want []int + max int + wantErr bool + hasNext bool + }{ + {name: "default page", want: all}, + {name: "first page", max: 2, want: []int{1, 2}, hasNext: true}, + {name: "max above limit", max: 2000, wantErr: true}, + {name: "negative max", max: -1, wantErr: true}, + {name: "garbage token", token: "!!", wantErr: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + pg, err := restroute.Paginate(all, tt.token, tt.max, 100, 1000) + if tt.wantErr { + var re *restroute.Error + + require.ErrorAs(t, err, &re) + assert.Equal(t, "ValidationException", re.Code) + + return + } + + require.NoError(t, err) + assert.Equal(t, tt.want, pg.Data) + assert.Equal(t, tt.hasNext, pg.Next != "") + }) + } +} + +func TestServerDispatchAndErrors(t *testing.T) { + t.Parallel() + + s := &restroute.Server{ + Router: newRouter(), + Ops: map[string]restroute.OpFunc{ + "GetCell": func(_ context.Context, req *restroute.Request) (any, int, error) { + if req.Params["CellName"] == "missing" { + return nil, 0, restroute.NotFound("Cell %s was not found", "missing") + } + + return map[string]string{"name": req.Params["CellName"]}, 0, nil + }, + "CreateCell": func(_ context.Context, req *restroute.Request) (any, int, error) { + var in struct{ Name string } + if err := req.Decode(&in); err != nil { + return nil, 0, err + } + + return nil, http.StatusCreated, nil + }, + }, + } + + tests := []struct { + name string + method string + path string + body string + wantCode string + wantStatus int + }{ + {name: "ok", method: http.MethodGet, path: "/cells/c1", wantStatus: http.StatusOK}, + { + name: "empty result is an object", + method: http.MethodPost, + path: "/cells", + body: `{"Name":"x"}`, + wantStatus: http.StatusCreated, + }, + { + name: "not found", + method: http.MethodGet, + path: "/cells/missing", + wantStatus: http.StatusNotFound, + wantCode: "ResourceNotFoundException", + }, + { + name: "bad json", + method: http.MethodPost, + path: "/cells", + body: "{", + wantStatus: http.StatusBadRequest, + wantCode: "ValidationException", + }, + { + name: "oversized body", + method: http.MethodPost, + path: "/cells", + body: strings.Repeat("a", int(httputils.MaxRequestBodyBytes)+1), + wantStatus: http.StatusRequestEntityTooLarge, + wantCode: "RequestEntityTooLargeException", + }, + { + name: "unrouted", + method: http.MethodGet, + path: "/cells", + wantStatus: http.StatusNotFound, + wantCode: "UnknownOperationException", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + req := httptest.NewRequestWithContext(t.Context(), tt.method, tt.path, strings.NewReader(tt.body)) + rec := httptest.NewRecorder() + require.NoError(t, s.Handler()(echo.New().NewContext(req, rec))) + assert.Equal(t, tt.wantStatus, rec.Code) + assert.Equal(t, tt.wantCode, rec.Header().Get("X-Amzn-Errortype")) + + var body map[string]any + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &body)) + + if tt.wantCode != "" { + assert.NotEmpty(t, body["message"]) + assert.Equal(t, body["message"], body["Message"], "both message keys are present") + } + }) + } +} + +func TestErrorMessageDropsRepeatedCode(t *testing.T) { + t.Parallel() + + rec := httptest.NewRecorder() + c := echo.New().NewContext(httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/", nil), rec) + require.NoError( + t, + restroute.WriteError( + c, + http.StatusConflict, + "ConflictException", + "ConflictException: cell is busy", + map[string]any{"reason": "R"}, + ), + ) + + var body map[string]any + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &body)) + assert.Equal(t, "cell is busy", body["message"]) + assert.Equal(t, "R", body["reason"]) +} diff --git a/pkgs/restroute/serve.go b/pkgs/restroute/serve.go new file mode 100644 index 0000000000..90530ecdde --- /dev/null +++ b/pkgs/restroute/serve.go @@ -0,0 +1,148 @@ +package restroute + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/url" + + "github.com/labstack/echo/v5" + + "github.com/blackbirdworks/gopherstack/pkgs/httputils" +) + +// Error is an AWS REST-JSON exception: HTTP status, error code, message and optional extra body members. +type Error struct { + Extra map[string]any + Code string + Message string + Status int +} + +func (e *Error) Error() string { return e.Code + ": " + e.Message } + +// NewError builds an Error with an arbitrary status and code. +func NewError(status int, code, format string, args ...any) *Error { + return &Error{Status: status, Code: code, Message: fmt.Sprintf(format, args...)} +} + +// NotFound builds a 404 ResourceNotFoundException. +func NotFound(format string, args ...any) *Error { + return NewError(http.StatusNotFound, "ResourceNotFoundException", format, args...) +} + +// Conflict builds a 409 ConflictException. +func Conflict(format string, args ...any) *Error { + return NewError(http.StatusConflict, "ConflictException", format, args...) +} + +// Validation builds a 400 ValidationException. +func Validation(format string, args ...any) *Error { + return NewError(http.StatusBadRequest, "ValidationException", format, args...) +} + +// QuotaExceeded builds a 402 ServiceQuotaExceededException. +func QuotaExceeded(format string, args ...any) *Error { + return NewError(http.StatusPaymentRequired, "ServiceQuotaExceededException", format, args...) +} + +// Request is the decoded input of one operation. +type Request struct { + Params map[string]string + Query url.Values + Body []byte +} + +// Decode unmarshals the JSON body into v; an empty body leaves v untouched. +func (r *Request) Decode(v any) error { + if len(r.Body) == 0 { + return nil + } + + if err := json.Unmarshal(r.Body, v); err != nil { + return Validation("request body is not valid JSON for this operation") + } + + return nil +} + +// OpFunc runs one operation. A nil result is sent as an empty object; a zero status means 200. +type OpFunc func(ctx context.Context, req *Request) (result any, status int, err error) + +// Server dispatches matched requests to OpFuncs. +type Server struct { + Router *Router + Ops map[string]OpFunc + // Context derives the per-request context (region, caller identity, ...); nil uses the request context. + Context func(r *http.Request) context.Context +} + +// Operation returns the op name and path labels for r, or "" when no route matches. +func (s *Server) Operation(r *http.Request) (string, map[string]string) { + return s.Router.Match(r.Method, EscapedPath(r)) +} + +// Handler serves a request: route, read body, run the op, write JSON or the mapped error. +func (s *Server) Handler() echo.HandlerFunc { + return func(c *echo.Context) error { + r := c.Request() + op, params := s.Operation(r) + + fn, ok := s.Ops[op] + if !ok { + return WriteError(c, http.StatusNotFound, "UnknownOperationException", "Unknown operation", nil) + } + + body, err := httputils.ReadBody(r) + if err != nil { + if _, tooLarge := errors.AsType[*http.MaxBytesError](err); tooLarge { + return WriteError(c, http.StatusRequestEntityTooLarge, "RequestEntityTooLargeException", + "Request body is too large", nil) + } + + return WriteError(c, http.StatusBadRequest, "ValidationException", "Unable to read the request body", nil) + } + + ctx := r.Context() + if s.Context != nil { + ctx = s.Context(r) + } + + result, status, err := fn(ctx, &Request{Params: params, Query: r.URL.Query(), Body: body}) + if err != nil { + return s.WriteErr(c, err) + } + + if status == 0 { + status = http.StatusOK + } + + if result == nil { + result = struct{}{} + } + + return c.JSON(status, result) + } +} + +// WriteErr maps err to its REST-JSON response; unknown errors become a 500 InternalServerException. +func (s *Server) WriteErr(c *echo.Context, err error) error { + if apiErr, ok := errors.AsType[*Error](err); ok { + return WriteError(c, apiErr.Status, apiErr.Code, apiErr.Message, apiErr.Extra) + } + + return WriteError(c, http.StatusInternalServerError, "InternalServerException", "Internal server error", nil) +} + +// With attaches an extra body member (for example a modeled "reason") and returns e. +func (e *Error) With(key string, value any) *Error { + if e.Extra == nil { + e.Extra = map[string]any{} + } + + e.Extra[key] = value + + return e +} diff --git a/pkgs/sdktest/serve.go b/pkgs/sdktest/serve.go new file mode 100644 index 0000000000..cb3e2c7d56 --- /dev/null +++ b/pkgs/sdktest/serve.go @@ -0,0 +1,31 @@ +package sdktest + +import ( + "net/http/httptest" + "testing" + + "github.com/labstack/echo/v5" + + "github.com/blackbirdworks/gopherstack/pkgs/service" +) + +// Serve mounts regs behind the production service router on an httptest server and returns its URL. +func Serve(tb testing.TB, regs ...service.Registerable) string { + tb.Helper() + + e := echo.New() + registry := service.NewRegistry() + + for _, r := range regs { + if err := registry.Register(r); err != nil { + tb.Fatalf("register %s: %v", r.Name(), err) + } + } + + e.Use(service.NewServiceRouter(registry).RouteHandler()) + + srv := httptest.NewServer(e) + tb.Cleanup(srv.Close) + + return srv.URL +} diff --git a/services/_PROTOCOLS.md b/services/_PROTOCOLS.md index 1f4ee634aa..0bcf337b60 100644 --- a/services/_PROTOCOLS.md +++ b/services/_PROTOCOLS.md @@ -170,6 +170,7 @@ with evidence · unknown-key behaviour · notes. | appstream | appstream | rpc-v2-cbor / `deserializeCBOR_*` (GENERATED, but under a non-`awsXxx_` scheme) | v1.64.5 | Case-sensitive (exact `case "Field":` / map-key match) | Silently dropped | CORRECTED 2026-08-14: an earlier revision of this row called these functions hand-written. They are generated, present in the pinned SDK's own serializers.go and deserializers.go — just not under the `awsXxx_` prefix the other protocols use. What IS hand-rolled is gopherstack's own extraction off a `cbor.Map`, which is why this service is case-sensitive. | | appsync | appsync | REST-JSON 1 / `awsRestjson1_` | v1.56.4 | Case-sensitive (exact `case "Field":` / map-key match) | Silently dropped | | | athena | athena | JSON-RPC 1.1 / `awsAwsjson11_` | v1.60.4 | Case-sensitive (exact `case "Field":` / map-key match) | Silently dropped | | +| arczonalshift | arczonalshift | REST-JSON 1 / smithy schema-serde (`schemas.go`; no `awsRestjson1_`) | v1.34.1 | Case-sensitive (exact `case "Field":` / map-key match) | Silently dropped | | | autoscaling | autoscaling | AWS Query (XML) / `awsAwsquery_` | v1.70.4 | Case-insensitive (`strings.EqualFold` on element/field name) | Silently dropped | | | awsconfig | configservice | JSON-RPC 1.1 / `awsAwsjson11_` | v1.68.4 | Case-sensitive (exact `case "Field":` / map-key match) | Silently dropped | | | backup | backup | REST-JSON 1 / `awsRestjson1_` | v1.59.4 | Case-sensitive (exact `case "Field":` / map-key match) | Silently dropped | | @@ -284,6 +285,9 @@ with evidence · unknown-key behaviour · notes. | resourcegroupstaggingapi | resourcegroupstaggingapi | JSON-RPC 1.1 / `awsAwsjson11_` | v1.35.4 | Case-sensitive (exact `case "Field":` / map-key match) | Silently dropped | | | rolesanywhere | rolesanywhere | REST-JSON 1 / `awsRestjson1_` | v1.26.3 | Case-sensitive (exact `case "Field":` / map-key match) | Silently dropped | | | route53 | route53 | REST-XML / `awsRestxml_` | v1.65.6 | Case-insensitive (`strings.EqualFold` on element/field name) | Silently dropped | | +| route53recoverycluster | route53recoverycluster | JSON-RPC 1.0 / `awsAwsjson10_` | v1.36.4 | Case-sensitive (exact `case "Field":` / map-key match) | Silently dropped | | +| route53recoverycontrolconfig | route53recoverycontrolconfig | REST-JSON 1 / `awsRestjson1_` | v1.40.4 | Case-sensitive (exact `case "Field":` / map-key match) | Silently dropped | | +| route53recoveryreadiness | route53recoveryreadiness | REST-JSON 1 / `awsRestjson1_` | v1.34.4 | Case-sensitive (exact `case "Field":` / map-key match) | Silently dropped | | | route53resolver | route53resolver | JSON-RPC 1.1 / `awsAwsjson11_` | v1.48.4 | Case-sensitive (exact `case "Field":` / map-key match) | Silently dropped | | | s3 | s3 | REST-XML / `awsRestxml_` | v1.106.5 | Case-insensitive (`strings.EqualFold` on element/field name) | Silently dropped | | | s3control | s3control | REST-XML / `awsRestxml_` | v1.73.4 | Case-insensitive (`strings.EqualFold` on element/field name) | Silently dropped | | diff --git a/services/arczonalshift/PARITY.md b/services/arczonalshift/PARITY.md new file mode 100644 index 0000000000..da1b895b3d --- /dev/null +++ b/services/arczonalshift/PARITY.md @@ -0,0 +1,40 @@ +--- +service: arczonalshift +sdk_module: aws-sdk-go-v2/service/arczonalshift@v1.34.1 +last_audit_commit: 80b7bb8b3 +last_audit_date: 2026-10-14 +overall: A +ops: + StartZonalShift: {wire: ok, errors: ok, state: ok, persist: ok, note: "ExpiresIn 1m-72h; zone name or zone ID; ConflictException ZonalShiftAlreadyExists / SimultaneousZonalShiftsConflict; status ACTIVE until the expiry time passes, CANCELED on cancel, EXPIRED afterwards, all derived from the clock"} + UpdateZonalShift: {wire: ok, errors: ok, state: ok, persist: ok} + CancelZonalShift: {wire: ok, errors: ok, state: ok, persist: ok} + ListZonalShifts: {wire: ok, errors: ok, state: ok, persist: ok} + ListManagedResources: {wire: ok, errors: ok, state: ok, persist: ok, note: "ELBv2 Application and Network Load Balancers, Auto Scaling groups with AvailabilityZoneImpairmentPolicy.ZonalShiftEnabled and EKS clusters with zonalShiftConfig.enabled, in the request region"} + GetManagedResource: {wire: ok, errors: ok, state: ok, persist: ok} + UpdateZonalAutoshiftConfiguration: {wire: ok, errors: ok, state: ok, persist: ok} + CreatePracticeRunConfiguration: {wire: ok, errors: ok, state: ok, persist: ok} + UpdatePracticeRunConfiguration: {wire: ok, errors: ok, state: ok, persist: ok} + DeletePracticeRunConfiguration: {wire: ok, errors: ok, state: ok, persist: ok} + StartPracticeRun: {wire: ok, errors: ok, state: ok, persist: ok, note: "checks blocking and outcome alarms (CloudWatch), blocked dates and windows, allowed windows; runs for 30 minutes by default (--lifecycle-arczonalshift); outcome PENDING then SUCCEEDED, FAILED if an outcome alarm is in ALARM when it ends, INTERRUPTED when canceled"} + CancelPracticeRun: {wire: ok, errors: ok, state: ok, persist: ok, note: "only practice runs; CancelZonalShift only customer shifts"} + ListAutoshifts: {wire: ok, errors: ok, state: ok, persist: ok, note: "AWS initiates autoshifts from internal zone telemetry; this emulator never does, so the list is always empty after validating the status filter"} + GetAutoshiftObserverNotificationStatus: {wire: ok, errors: ok, state: ok, persist: ok} + UpdateAutoshiftObserverNotificationStatus: {wire: ok, errors: ok, state: ok, persist: ok} +families: + route_matching: {status: ok, note: "matches the SigV4 signing name or SDK user agent plus a known path shape"} + errors: {status: ok, note: "REST-JSON error bodies carry both message and Message, X-Amzn-Errortype and the modeled reason"} +gaps: [] +items_still_open: [] +structural_gaps: + - ListAutoshifts always empty: autoshifts are initiated by AWS from internal Availability Zone health telemetry, which cannot exist in an emulator +deferred: [] +leaks: {status: clean, note: "no goroutines or janitors; expiry and lifecycle are derived from the clock at read time"} +--- + +## Notes + +- Protocol: REST-JSON (schema-serde client), lowerCamel members, epoch-seconds timestamps, SigV4 signing name `arc-zonal-shift`. +- Managed resources are read live from siblings: ELBv2 Application/Network Load Balancers, Auto Scaling groups with zonal shift enabled, and EKS clusters with zonal shift enabled. Zones come from the load balancer, the group (AvailabilityZones, else its subnets) or the cluster subnets (via EC2) and are reported as zone IDs (`use1-az1`). A group or cluster without zonal shift enabled is ResourceNotFoundException, and stops being managed when it is disabled. +- appliedStatus follows the documented precedence: customer zonal shift over autoshift over practice run. appliedWeights are 0 for zones with an applied shift away from them, otherwise 1. +- Practice-run outcome is settled the first time the run is observed after it ends. + diff --git a/services/arczonalshift/README.md b/services/arczonalshift/README.md new file mode 100644 index 0000000000..bc7869db9a --- /dev/null +++ b/services/arczonalshift/README.md @@ -0,0 +1,26 @@ + +# ARC Zonal Shift + +**Parity grade: A** · SDK `aws-sdk-go-v2/service/arczonalshift@v1.34.1` · last audited 2026-10-14 (`80b7bb8b3`) + +## Coverage + +| Metric | Value | +| --- | --- | +| PARITY entries audited | 15 (15 ok) | +| Feature families | 2 (2 ok) | +| Known gaps | none | +| Structural gaps (can't be emulated) | 1 | +| Deferred items | 0 | +| Resource leaks | clean | + +### Structural gaps + +These do not block an A grade — no implementation could produce real data here because the underlying data source cannot exist in an emulator. + +- ListAutoshifts always empty: autoshifts are initiated by AWS from internal Availability Zone health telemetry, which cannot exist in an emulator + +## More + +- [Full parity audit](PARITY.md) +- [All services](../../README.md#services) diff --git a/services/arczonalshift/cross_service.go b/services/arczonalshift/cross_service.go new file mode 100644 index 0000000000..0e757bdf9c --- /dev/null +++ b/services/arczonalshift/cross_service.go @@ -0,0 +1,151 @@ +package arczonalshift + +import ( + "strings" + + awsarn "github.com/aws/aws-sdk-go-v2/aws/arn" + + "github.com/blackbirdworks/gopherstack/pkgs/restroute" + "github.com/blackbirdworks/gopherstack/pkgs/service" + cwbackend "github.com/blackbirdworks/gopherstack/services/cloudwatch" + ec2backend "github.com/blackbirdworks/gopherstack/services/ec2" + elbv2backend "github.com/blackbirdworks/gopherstack/services/elbv2" +) + +// siblingServices is matched structurally against *CLI, which cannot be imported here. +type siblingServices interface { + GetELBv2Handler() service.Registerable + GetCloudWatchHandler() service.Registerable +} + +type managedResource struct { + ARN string + Name string + ZoneNames []string + ZoneIDs []string +} + +func (b *InMemoryBackend) elbv2() (*elbv2backend.Handler, bool) { + s, ok := b.appConfig.(siblingServices) + if !ok { + return nil, false + } + + h, ok := s.GetELBv2Handler().(*elbv2backend.Handler) + + return h, ok && h != nil +} + +func toManaged(lb *elbv2backend.LoadBalancer) managedResource { + m := managedResource{ARN: lb.LoadBalancerArn, Name: lb.LoadBalancerName} + + for _, az := range lb.AvailabilityZones { + m.ZoneNames = append(m.ZoneNames, az.ZoneName) + m.ZoneIDs = append(m.ZoneIDs, zoneID(az.ZoneName)) + } + + return m +} + +// supportedLB reports whether arn names an Application or Network Load Balancer, the ELBv2 types zonal shift supports. +func supportedLB(arn string) (awsarn.ARN, bool) { + p, err := awsarn.Parse(arn) + if err != nil || p.Service != "elasticloadbalancing" { + return p, false + } + + return p, strings.HasPrefix(p.Resource, "loadbalancer/app/") || strings.HasPrefix(p.Resource, "loadbalancer/net/") +} + +// managedFor resolves a resource identifier to a live managed resource. +func (b *InMemoryBackend) managedFor(arn string) (managedResource, error) { + if m, handled, err := b.managedGroupOrCluster(arn); handled { + return m, err + } + + p, ok := supportedLB(arn) + if !ok { + return managedResource{}, invalidReason( + "InvalidResourceIdentifier", + "Resource identifier %q is not a supported resource", + arn, + ) + } + + h, ok := b.elbv2() + if !ok { + return managedResource{}, restroute.NotFound("Managed resource %s was not found", arn) + } + + lbs, err := h.BackendFor(p.Region).DescribeLoadBalancers([]string{arn}, nil) + if err != nil || len(lbs) == 0 { + return managedResource{}, restroute.NotFound("Managed resource %s was not found", arn) + } + + return toManaged(&lbs[0]), nil +} + +// listManaged returns the zonal-shift managed resources of region. +func (b *InMemoryBackend) listManaged(region string) []managedResource { + out := b.listLoadBalancers(region) + out = append(out, b.listGroupsAndClusters(region)...) + + return out +} + +func (b *InMemoryBackend) listLoadBalancers(region string) []managedResource { + h, ok := b.elbv2() + if !ok { + return nil + } + + lbs, err := h.BackendFor(region).DescribeLoadBalancers(nil, nil) + if err != nil { + return nil + } + + out := make([]managedResource, 0, len(lbs)) + + for i := range lbs { + if _, supported := supportedLB(lbs[i].LoadBalancerArn); supported { + out = append(out, toManaged(&lbs[i])) + } + } + + return out +} + +// alarmState returns the CloudWatch alarm state for an alarm ARN and whether the alarm exists. +func (b *InMemoryBackend) alarmState(alarmArn string) (string, bool) { + s, ok := b.appConfig.(siblingServices) + if !ok { + return "", false + } + + h, ok := s.GetCloudWatchHandler().(*cwbackend.Handler) + if !ok || h == nil { + return "", false + } + + p, err := awsarn.Parse(alarmArn) + if err != nil || p.Service != "cloudwatch" || !strings.HasPrefix(p.Resource, "alarm:") { + return "", false + } + + metric, _, _, err := h.BackendFor(p.Region).DescribeAlarms( + []string{strings.TrimPrefix(p.Resource, "alarm:")}, nil, "", "", "", 0, "", "", "") + if err != nil || len(metric.Data) == 0 { + return "", false + } + + return metric.Data[0].StateValue, true +} + +// zoneID maps a zone name like "us-east-1a" to its zone ID like "use1-az1"; other inputs are returned unchanged. +func zoneID(name string) string { + if id := ec2backend.AvailabilityZoneID(name); id != "" { + return id + } + + return name +} diff --git a/services/arczonalshift/errors.go b/services/arczonalshift/errors.go new file mode 100644 index 0000000000..179d03faa5 --- /dev/null +++ b/services/arczonalshift/errors.go @@ -0,0 +1,31 @@ +package arczonalshift + +import ( + "github.com/blackbirdworks/gopherstack/pkgs/page" + "github.com/blackbirdworks/gopherstack/pkgs/restroute" +) + +func invalidReason(reason, format string, args ...any) *restroute.Error { + return restroute.Validation(format, args...).With("reason", reason) +} + +func conflictReason(reason, shiftID, format string, args ...any) *restroute.Error { + e := restroute.Conflict(format, args...).With("reason", reason) + if shiftID != "" { + e = e.With("zonalShiftId", shiftID) + } + + return e +} + +func paginate[T any](all []T, token string, maxResults int) (page.Page[T], error) { + if maxResults < 0 || maxResults > listMax { + return page.Page[T]{}, invalidReason("Other", "maxResults must be between 1 and %d", listMax) + } + + if err := page.ValidateToken(token); err != nil { + return page.Page[T]{}, invalidReason("InvalidToken", "nextToken is not valid") + } + + return page.New(all, token, maxResults, listDefault), nil +} diff --git a/services/arczonalshift/extract_headers_test.go b/services/arczonalshift/extract_headers_test.go new file mode 100644 index 0000000000..c3625e9bf7 --- /dev/null +++ b/services/arczonalshift/extract_headers_test.go @@ -0,0 +1,44 @@ +package arczonalshift_test + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/labstack/echo/v5" + "github.com/stretchr/testify/assert" + + svc "github.com/blackbirdworks/gopherstack/services/arczonalshift" +) + +func TestExtractIgnoresCredentialHeaders(t *testing.T) { + t.Parallel() + + h := svc.NewHandler(svc.NewInMemoryBackend(testAccount)) + + tests := []struct { + headers map[string]string + name string + }{ + {nil, "none"}, + {map[string]string{ + "Authorization": "AWS4-HMAC-SHA256 Credential=AKIASECRET/20260101/us-east-1/s3/aws4_request", + }, "sigv4"}, + {map[string]string{"X-Amz-Security-Token": "secret-token"}, "token"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + req := httptest.NewRequestWithContext(t.Context(), http.MethodDelete, "/zonalshifts/zs-1", nil) + for k, v := range tt.headers { + req.Header.Set(k, v) + } + + c := echo.New().NewContext(req, httptest.NewRecorder()) + assert.Equal(t, "CancelZonalShift", h.ExtractOperation(c)) + assert.Equal(t, "zs-1", h.ExtractResource(c)) + }) + } +} diff --git a/services/arczonalshift/fixture_test.go b/services/arczonalshift/fixture_test.go new file mode 100644 index 0000000000..3df5db3158 --- /dev/null +++ b/services/arczonalshift/fixture_test.go @@ -0,0 +1,92 @@ +package arczonalshift_test + +import ( + "errors" + "testing" + + "github.com/aws/aws-sdk-go-v2/aws" + awscfg "github.com/aws/aws-sdk-go-v2/config" + "github.com/aws/aws-sdk-go-v2/credentials" + sdk "github.com/aws/aws-sdk-go-v2/service/arczonalshift" + "github.com/aws/smithy-go" + "github.com/stretchr/testify/require" + + "github.com/blackbirdworks/gopherstack/pkgs/sdktest" + "github.com/blackbirdworks/gopherstack/pkgs/service" + zs "github.com/blackbirdworks/gopherstack/services/arczonalshift" + cw "github.com/blackbirdworks/gopherstack/services/cloudwatch" + elbv2 "github.com/blackbirdworks/gopherstack/services/elbv2" +) + +const ( + testAccount = "123456789012" + testRegion = "us-east-1" +) + +type siblings struct { + elb service.Registerable + cw service.Registerable +} + +func (s *siblings) GetELBv2Handler() service.Registerable { return s.elb } +func (s *siblings) GetCloudWatchHandler() service.Registerable { return s.cw } + +type fixture struct { + backend *zs.InMemoryBackend + handler *zs.Handler + client *sdk.Client + elb *elbv2.InMemoryBackend + cw *cw.InMemoryBackend +} + +func newFixture(t *testing.T) *fixture { + t.Helper() + + elb := elbv2.NewInMemoryBackend(testAccount, testRegion) + t.Cleanup(elb.Close) + + alarms := cw.NewInMemoryBackendWithConfig(testAccount, testRegion) + + b := zs.NewInMemoryBackend(testAccount) + b.SetAppConfig(&siblings{elb: elbv2.NewHandler(elb), cw: cw.NewHandler(alarms)}) + + h := zs.NewHandler(b) + h.DefaultRegion = testRegion + url := sdktest.Serve(t, h) + + cfg, err := awscfg.LoadDefaultConfig(t.Context(), + awscfg.WithRegion(testRegion), + awscfg.WithCredentialsProvider(credentials.NewStaticCredentialsProvider("test", "test", "")), + ) + require.NoError(t, err) + + return &fixture{ + backend: b, handler: h, elb: elb, cw: alarms, + client: sdk.NewFromConfig(cfg, func(o *sdk.Options) { o.BaseEndpoint = aws.String(url) }), + } +} + +// loadBalancer creates an ALB over zones 1..n and returns its ARN. +func (f *fixture) loadBalancer(t *testing.T, name string, zones int) string { + t.Helper() + + subnets := make([]string, zones) + for i := range subnets { + subnets[i] = "subnet-" + string(rune('a'+i)) + } + + lb, err := f.elb.CreateLoadBalancer( + elbv2.CreateLoadBalancerInput{Name: name, Type: "application", Subnets: subnets}, + ) + require.NoError(t, err) + + return lb.LoadBalancerArn +} + +func apiCode(err error) string { + if ae, ok := errors.AsType[smithy.APIError](err); ok { + return ae.ErrorCode() + } + + return "" +} diff --git a/services/arczonalshift/handler.go b/services/arczonalshift/handler.go new file mode 100644 index 0000000000..009c63bae0 --- /dev/null +++ b/services/arczonalshift/handler.go @@ -0,0 +1,158 @@ +package arczonalshift + +import ( + "context" + "net/http" + "slices" + + "github.com/labstack/echo/v5" + + "github.com/blackbirdworks/gopherstack/pkgs/httputils" + "github.com/blackbirdworks/gopherstack/pkgs/restroute" + "github.com/blackbirdworks/gopherstack/pkgs/service" +) + +const ( + signingName = "arc-zonal-shift" + uaMarker = "api/arczonalshift" + matchPriority = 87 +) + +type regionKey struct{} + +// Handler serves the ARC zonal shift API. +type Handler struct { + Backend *InMemoryBackend + server *restroute.Server + DefaultRegion string +} + +// NewHandler creates the handler. +func NewHandler(backend *InMemoryBackend) *Handler { + h := &Handler{Backend: backend} + h.server = &restroute.Server{ + Router: restroute.New(routes()), + Ops: h.ops(), + Context: func(r *http.Request) context.Context { + return context.WithValue(r.Context(), regionKey{}, httputils.ExtractRegionFromRequest(r, h.DefaultRegion)) + }, + } + + return h +} + +// Name returns the service name. +func (h *Handler) Name() string { return "ARCZonalShift" } + +// Reset clears all state. +func (h *Handler) Reset() { h.Backend.Reset() } + +// Snapshot implements persistence.Persistable. +func (h *Handler) Snapshot(ctx context.Context) []byte { return h.Backend.Snapshot(ctx) } + +// Restore implements persistence.Persistable. +func (h *Handler) Restore(ctx context.Context, data []byte) error { + return h.Backend.Restore(ctx, data) +} + +// GetSupportedOperations lists every implemented operation. +func (h *Handler) GetSupportedOperations() []string { + ops := make([]string, 0, len(h.server.Ops)) + for op := range h.server.Ops { + ops = append(ops, op) + } + + slices.Sort(ops) + + return ops +} + +// ChaosServiceName returns the lowercase service name for fault rules. +func (h *Handler) ChaosServiceName() string { return signingName } + +// ChaosOperations returns the fault-injectable operations. +func (h *Handler) ChaosOperations() []string { return h.GetSupportedOperations() } + +// ChaosRegions returns the regions served. +func (h *Handler) ChaosRegions() []string { return []string{h.DefaultRegion} } + +// RouteMatcher matches by SigV4 signing name (or SDK user agent) plus a known path shape. +func (h *Handler) RouteMatcher() service.Matcher { + return func(c *echo.Context) bool { + r := c.Request() + if !h.server.Router.Handles(restroute.EscapedPath(r)) { + return false + } + + return httputils.ExtractServiceFromRequest(r) == signingName || + service.MatchesUserAgentMarker(r.Header, uaMarker) + } +} + +// MatchPriority returns the routing priority. +func (h *Handler) MatchPriority() int { return matchPriority } + +// ExtractOperation returns the operation name for metrics. +func (h *Handler) ExtractOperation(c *echo.Context) string { + op, _ := h.server.Operation(c.Request()) + if op == "" { + return "Unknown" + } + + return op +} + +// ExtractResource returns the first path label for metrics. +func (h *Handler) ExtractResource(c *echo.Context) string { + _, params := h.server.Operation(c.Request()) + for _, v := range params { + return v + } + + return "" +} + +// Handler returns the Echo handler. +func (h *Handler) Handler() echo.HandlerFunc { return h.server.Handler() } + +func routes() []restroute.Route { + g, p, d, u, pa := http.MethodGet, http.MethodPost, http.MethodDelete, http.MethodPut, http.MethodPatch + + return []restroute.Route{ + {Method: p, Path: "/zonalshifts", Op: "StartZonalShift"}, + {Method: g, Path: "/zonalshifts", Op: "ListZonalShifts"}, + {Method: pa, Path: "/zonalshifts/{zonalShiftId}", Op: "UpdateZonalShift"}, + {Method: d, Path: "/zonalshifts/{zonalShiftId}", Op: "CancelZonalShift"}, + {Method: p, Path: "/practiceruns", Op: "StartPracticeRun"}, + {Method: d, Path: "/practiceruns/{zonalShiftId}", Op: "CancelPracticeRun"}, + {Method: p, Path: "/configuration", Op: "CreatePracticeRunConfiguration"}, + {Method: pa, Path: "/configuration/{resourceIdentifier}", Op: "UpdatePracticeRunConfiguration"}, + {Method: d, Path: "/configuration/{resourceIdentifier}", Op: "DeletePracticeRunConfiguration"}, + {Method: g, Path: "/managedresources", Op: "ListManagedResources"}, + {Method: g, Path: "/managedresources/{resourceIdentifier}", Op: "GetManagedResource"}, + {Method: u, Path: "/managedresources/{resourceIdentifier}", Op: "UpdateZonalAutoshiftConfiguration"}, + {Method: g, Path: "/autoshifts", Op: "ListAutoshifts"}, + {Method: g, Path: "/autoshift-observer-notification", Op: "GetAutoshiftObserverNotificationStatus"}, + {Method: u, Path: "/autoshift-observer-notification", Op: "UpdateAutoshiftObserverNotificationStatus"}, + } +} + +func (h *Handler) ops() map[string]restroute.OpFunc { + return map[string]restroute.OpFunc{ + "StartZonalShift": h.startZonalShift, + "ListZonalShifts": h.listZonalShifts, + "UpdateZonalShift": h.updateZonalShift, + "CancelZonalShift": h.cancelZonalShift, + "StartPracticeRun": h.startPracticeRun, + "CancelPracticeRun": h.cancelPracticeRun, + "CreatePracticeRunConfiguration": h.createPractice, + "UpdatePracticeRunConfiguration": h.updatePractice, + "DeletePracticeRunConfiguration": h.deletePractice, + "ListManagedResources": h.listManagedResources, + "GetManagedResource": h.getManagedResource, + "UpdateZonalAutoshiftConfiguration": h.updateAutoshift, + "ListAutoshifts": h.listAutoshifts, + "GetAutoshiftObserverNotificationStatus": h.getObserver, + "UpdateAutoshiftObserverNotificationStatus": h.updateObserver, + } +} diff --git a/services/arczonalshift/handler_ops.go b/services/arczonalshift/handler_ops.go new file mode 100644 index 0000000000..4cafe0a6e1 --- /dev/null +++ b/services/arczonalshift/handler_ops.go @@ -0,0 +1,203 @@ +package arczonalshift + +import ( + "context" + "encoding/json" + "net/http" + + "github.com/blackbirdworks/gopherstack/pkgs/restroute" +) + +func pageParams(req *restroute.Request) (string, int, error) { + m, err := restroute.MaxResultsQuery(req.Query, "maxResults") + if err != nil { + return "", 0, invalidReason("Other", "maxResults must be an integer") + } + + return req.Query.Get("nextToken"), m, nil +} + +func items[T any](list []T, next string) map[string]any { + if list == nil { + list = []T{} + } + + out := map[string]any{"items": list} + if next != "" { + out["nextToken"] = next + } + + return out +} + +func (h *Handler) startZonalShift(_ context.Context, req *restroute.Request) (any, int, error) { + var in struct { + AwayFrom, Comment, ExpiresIn, ResourceIdentifier string + } + if err := req.Decode(&in); err != nil { + return nil, 0, err + } + + v, err := h.Backend.StartZonalShift(in.ResourceIdentifier, in.AwayFrom, in.Comment, in.ExpiresIn) + + return v, http.StatusCreated, err +} + +func (h *Handler) listZonalShifts(_ context.Context, req *restroute.Request) (any, int, error) { + token, m, err := pageParams(req) + if err != nil { + return nil, 0, err + } + + list, next, err := h.Backend.ListZonalShifts(req.Query.Get("status"), req.Query.Get("resourceIdentifier"), token, m) + + return items(list, next), 0, err +} + +func (h *Handler) updateZonalShift(_ context.Context, req *restroute.Request) (any, int, error) { + var in struct{ Comment, ExpiresIn string } + if err := req.Decode(&in); err != nil { + return nil, 0, err + } + + v, err := h.Backend.UpdateZonalShift(req.Params["zonalShiftId"], in.Comment, in.ExpiresIn) + + return v, 0, err +} + +func (h *Handler) cancelZonalShift(_ context.Context, req *restroute.Request) (any, int, error) { + v, err := h.Backend.CancelZonalShift(req.Params["zonalShiftId"]) + + return v, 0, err +} + +func (h *Handler) startPracticeRun(_ context.Context, req *restroute.Request) (any, int, error) { + var in struct{ AwayFrom, Comment, ResourceIdentifier string } + if err := req.Decode(&in); err != nil { + return nil, 0, err + } + + v, err := h.Backend.StartPracticeRun(in.ResourceIdentifier, in.AwayFrom, in.Comment) + + return v, 0, err +} + +func (h *Handler) cancelPracticeRun(_ context.Context, req *restroute.Request) (any, int, error) { + v, err := h.Backend.CancelPracticeRun(req.Params["zonalShiftId"]) + + return v, 0, err +} + +type practiceBody struct { + ResourceIdentifier string `json:"resourceIdentifier"` + OutcomeAlarms []Alarm `json:"outcomeAlarms"` + BlockingAlarms []Alarm `json:"blockingAlarms"` + AllowedWindows []string `json:"allowedWindows"` + BlockedWindows []string `json:"blockedWindows"` + BlockedDates []string `json:"blockedDates"` +} + +func (p practiceBody) config(resource string) PracticeConfig { + return PracticeConfig{ + Resource: resource, OutcomeAlarms: p.OutcomeAlarms, BlockingAlarms: p.BlockingAlarms, + AllowedWindows: p.AllowedWindows, BlockedWindows: p.BlockedWindows, BlockedDates: p.BlockedDates, + } +} + +func (h *Handler) createPractice(_ context.Context, req *restroute.Request) (any, int, error) { + var in practiceBody + if err := req.Decode(&in); err != nil { + return nil, 0, err + } + + v, err := h.Backend.CreatePracticeRunConfiguration(in.config(in.ResourceIdentifier)) + + return v, http.StatusCreated, err +} + +func (h *Handler) updatePractice(_ context.Context, req *restroute.Request) (any, int, error) { + var in practiceBody + if err := req.Decode(&in); err != nil { + return nil, 0, err + } + + var present map[string]json.RawMessage + if len(req.Body) > 0 { + _ = json.Unmarshal(req.Body, &present) + } + + set := map[string]bool{} + for k := range present { + set[k] = true + } + + v, err := h.Backend.UpdatePracticeRunConfiguration( + req.Params["resourceIdentifier"], + in.config(req.Params["resourceIdentifier"]), + set, + ) + + return v, 0, err +} + +func (h *Handler) deletePractice(_ context.Context, req *restroute.Request) (any, int, error) { + v, err := h.Backend.DeletePracticeRunConfiguration(req.Params["resourceIdentifier"]) + + return v, 0, err +} + +func (h *Handler) listManagedResources(ctx context.Context, req *restroute.Request) (any, int, error) { + token, m, err := pageParams(req) + if err != nil { + return nil, 0, err + } + + region, _ := ctx.Value(regionKey{}).(string) + list, next, err := h.Backend.ListManagedResources(region, token, m) + + return items(list, next), 0, err +} + +func (h *Handler) getManagedResource(_ context.Context, req *restroute.Request) (any, int, error) { + v, err := h.Backend.GetManagedResource(req.Params["resourceIdentifier"]) + + return v, 0, err +} + +func (h *Handler) updateAutoshift(_ context.Context, req *restroute.Request) (any, int, error) { + var in struct{ ZonalAutoshiftStatus string } + if err := req.Decode(&in); err != nil { + return nil, 0, err + } + + res := req.Params["resourceIdentifier"] + status, err := h.Backend.UpdateZonalAutoshiftConfiguration(res, in.ZonalAutoshiftStatus) + + return map[string]string{"resourceIdentifier": res, "zonalAutoshiftStatus": status}, 0, err +} + +func (h *Handler) listAutoshifts(_ context.Context, req *restroute.Request) (any, int, error) { + token, m, err := pageParams(req) + if err != nil { + return nil, 0, err + } + + list, next, err := h.Backend.ListAutoshifts(req.Query.Get("status"), token, m) + + return items(list, next), 0, err +} + +func (h *Handler) getObserver(_ context.Context, _ *restroute.Request) (any, int, error) { + return map[string]string{"status": h.Backend.ObserverNotificationStatus()}, 0, nil +} + +func (h *Handler) updateObserver(_ context.Context, req *restroute.Request) (any, int, error) { + var in struct{ Status string } + if err := req.Decode(&in); err != nil { + return nil, 0, err + } + + s, err := h.Backend.SetObserverNotificationStatus(in.Status) + + return map[string]string{"status": s}, 0, err +} diff --git a/services/arczonalshift/leak_main_test.go b/services/arczonalshift/leak_main_test.go new file mode 100644 index 0000000000..4e787f4d88 --- /dev/null +++ b/services/arczonalshift/leak_main_test.go @@ -0,0 +1,9 @@ +package arczonalshift_test + +import ( + "testing" + + "github.com/blackbirdworks/gopherstack/pkgs/testleak" +) + +func TestMain(m *testing.M) { testleak.VerifyTestMain(m) } diff --git a/services/arczonalshift/managed.go b/services/arczonalshift/managed.go new file mode 100644 index 0000000000..1d8751ed19 --- /dev/null +++ b/services/arczonalshift/managed.go @@ -0,0 +1,227 @@ +package arczonalshift + +import ( + "sort" + "time" + + "github.com/blackbirdworks/gopherstack/pkgs/awstime" +) + +// AutoshiftSummary is one AWS-initiated autoshift. +type AutoshiftSummary struct { + EndTime *float64 `json:"endTime,omitempty"` + AwayFrom string `json:"awayFrom"` + Status string `json:"status"` + StartTime float64 `json:"startTime"` +} + +// ShiftInResource is a shift as shown inside a managed resource. +type ShiftInResource struct { + AppliedStatus string `json:"appliedStatus"` + AwayFrom string `json:"awayFrom"` + Comment string `json:"comment"` + ResourceIdentifier string `json:"resourceIdentifier"` + ZonalShiftID string `json:"zonalShiftId"` + PracticeRunOutcome string `json:"practiceRunOutcome,omitempty"` + ShiftType string `json:"shiftType,omitempty"` + ExpiryTime float64 `json:"expiryTime"` + StartTime float64 `json:"startTime"` +} + +// AutoshiftInResource is an autoshift as shown inside a managed resource. +type AutoshiftInResource struct { + AppliedStatus string `json:"appliedStatus"` + AwayFrom string `json:"awayFrom"` + StartTime float64 `json:"startTime"` +} + +// ManagedSummary is the wire form of a managed resource in ListManagedResources. +type ManagedSummary struct { + AppliedWeights map[string]float32 `json:"appliedWeights"` + Arn string `json:"arn"` + Name string `json:"name"` + PracticeRunStatus string `json:"practiceRunStatus"` + ZonalAutoshiftStatus string `json:"zonalAutoshiftStatus"` + AvailabilityZones []string `json:"availabilityZones"` + Autoshifts []AutoshiftInResource `json:"autoshifts"` + ZonalShifts []ShiftInResource `json:"zonalShifts"` +} + +// ManagedDetail is the wire form of GetManagedResource. +type ManagedDetail struct { + AppliedWeights map[string]float32 `json:"appliedWeights"` + PracticeRunConfiguration *PracticeView `json:"practiceRunConfiguration,omitempty"` + Arn string `json:"arn"` + Name string `json:"name"` + ZonalAutoshiftStatus string `json:"zonalAutoshiftStatus"` + Autoshifts []AutoshiftInResource `json:"autoshifts"` + ZonalShifts []ShiftInResource `json:"zonalShifts"` +} + +// shiftsInResource lists the active shifts of a resource with their appliedStatus. +// Customer zonal shifts take precedence over practice runs (arczonalshift types.ZonalShiftInResource docs). +func (b *InMemoryBackend) shiftsInResource(resource string, now time.Time) []ShiftInResource { + active := b.activeShifts(resource, "", now) + customer := false + + for _, s := range active { + customer = customer || s.Type == shiftZonal + } + + out := make([]ShiftInResource, 0, len(active)) + + for _, s := range active { + applied := appliedNo + if s.Type == shiftZonal || !customer { + applied = appliedYes + } + + v := ShiftInResource{ + AppliedStatus: applied, + AwayFrom: s.AwayFrom, + Comment: s.Comment, + ResourceIdentifier: s.Resource, + ZonalShiftID: s.ID, + ShiftType: s.Type, + ExpiryTime: awstime.Epoch(s.ExpiryTime), + StartTime: awstime.Epoch(s.StartTime), + } + if s.Type == shiftPractice { + v.PracticeRunOutcome = s.Outcome + } + + out = append(out, v) + } + + return out +} + +func weights(zoneIDs []string, shifts []ShiftInResource) map[string]float32 { + w := make(map[string]float32, len(zoneIDs)) + for _, z := range zoneIDs { + w[z] = 1 + } + + for _, s := range shifts { + if s.AppliedStatus == appliedYes { + w[s.AwayFrom] = 0 + } + } + + return w +} + +func (b *InMemoryBackend) practiceStatus(resource string) string { + if b.configs.Has(resource) { + return autoshiftEnabled + } + + return autoshiftDisabled +} + +// GetManagedResource describes one managed resource. +func (b *InMemoryBackend) GetManagedResource(resource string) (ManagedDetail, error) { + m, err := b.managedFor(resource) + if err != nil { + return ManagedDetail{}, err + } + + b.mu.Lock("GetManagedResource") + defer b.mu.Unlock() + + now := time.Now().UTC() + b.settleLocked(now) + + shifts := b.shiftsInResource(resource, now) + d := ManagedDetail{ + AppliedWeights: weights( + m.ZoneIDs, + shifts, + ), + Arn: m.ARN, + Name: m.Name, + ZonalAutoshiftStatus: b.autoshiftStatus(resource), + Autoshifts: []AutoshiftInResource{}, + ZonalShifts: shifts, + } + + if cfg, ok := b.configs.Get(resource); ok { + d.PracticeRunConfiguration = b.practiceOut(m, cfg).Config + } + + return d, nil +} + +// ListManagedResources lists the load balancers of region as zonal-shift managed resources. +func (b *InMemoryBackend) ListManagedResources(region, token string, maxResults int) ([]ManagedSummary, string, error) { + all := b.listManaged(region) + sort.SliceStable(all, func(i, j int) bool { return all[i].ARN < all[j].ARN }) + + pg, err := paginate(all, token, maxResults) + if err != nil { + return nil, "", err + } + + b.mu.Lock("ListManagedResources") + defer b.mu.Unlock() + + now := time.Now().UTC() + b.settleLocked(now) + + out := make([]ManagedSummary, 0, len(pg.Data)) + + for _, m := range pg.Data { + shifts := b.shiftsInResource(m.ARN, now) + out = append(out, ManagedSummary{ + AppliedWeights: weights(m.ZoneIDs, shifts), + Arn: m.ARN, + Name: m.Name, + PracticeRunStatus: b.practiceStatus(m.ARN), + ZonalAutoshiftStatus: b.autoshiftStatus(m.ARN), + AvailabilityZones: append( + []string{}, + m.ZoneIDs...), + Autoshifts: []AutoshiftInResource{}, + ZonalShifts: shifts, + }) + } + + return out, pg.Next, nil +} + +// ListAutoshifts lists AWS-initiated autoshifts; this emulator never initiates one, so only the filter is validated. +func (b *InMemoryBackend) ListAutoshifts(status, token string, maxResults int) ([]AutoshiftSummary, string, error) { + if status != "" && status != statusActive && status != "COMPLETED" { + return nil, "", invalidReason("InvalidStatus", "status must be ACTIVE or COMPLETED") + } + + pg, err := paginate([]AutoshiftSummary{}, token, maxResults) + + return pg.Data, pg.Next, err +} + +// ObserverNotificationStatus returns the account's autoshift observer notification status. +func (b *InMemoryBackend) ObserverNotificationStatus() string { + b.mu.RLock("GetAutoshiftObserverNotificationStatus") + defer b.mu.RUnlock() + + if s, ok := b.settings.Get(settingsID); ok { + return s.Observer + } + + return autoshiftDisabled +} + +// SetObserverNotificationStatus enables or disables autoshift observer notifications. +func (b *InMemoryBackend) SetObserverNotificationStatus(status string) (string, error) { + if status != autoshiftEnabled && status != autoshiftDisabled { + return "", invalidReason("InvalidStatus", "status must be ENABLED or DISABLED") + } + + b.mu.Lock("UpdateAutoshiftObserverNotificationStatus") + defer b.mu.Unlock() + + b.settings.Put(&Settings{ID: settingsID, Observer: status}) + + return status, nil +} diff --git a/services/arczonalshift/managed_more.go b/services/arczonalshift/managed_more.go new file mode 100644 index 0000000000..189b10f9d9 --- /dev/null +++ b/services/arczonalshift/managed_more.go @@ -0,0 +1,221 @@ +package arczonalshift + +import ( + "encoding/json" + "slices" + "strings" + + awsarn "github.com/aws/aws-sdk-go-v2/aws/arn" + + "github.com/blackbirdworks/gopherstack/pkgs/restroute" + "github.com/blackbirdworks/gopherstack/pkgs/service" + asgbackend "github.com/blackbirdworks/gopherstack/services/autoscaling" + ec2backend "github.com/blackbirdworks/gopherstack/services/ec2" + eksbackend "github.com/blackbirdworks/gopherstack/services/eks" +) + +type ( + asgSiblings interface{ GetAutoscalingHandler() service.Registerable } + eksSiblings interface{ GetEKSHandler() service.Registerable } + ec2Siblings interface{ GetEC2Handler() service.Registerable } +) + +func (b *InMemoryBackend) asgHandler() (*asgbackend.Handler, bool) { + s, ok := b.appConfig.(asgSiblings) + if !ok { + return nil, false + } + + h, ok := s.GetAutoscalingHandler().(*asgbackend.Handler) + + return h, ok && h != nil +} + +func (b *InMemoryBackend) eksHandler() (*eksbackend.Handler, bool) { + s, ok := b.appConfig.(eksSiblings) + if !ok { + return nil, false + } + + h, ok := s.GetEKSHandler().(*eksbackend.Handler) + + return h, ok && h != nil +} + +func (b *InMemoryBackend) ec2Backend(region string) (ec2backend.Backend, bool) { + s, ok := b.appConfig.(ec2Siblings) + if !ok { + return nil, false + } + + h, ok := s.GetEC2Handler().(*ec2backend.Handler) + if !ok || h == nil { + return nil, false + } + + return h.BackendFor(region), true +} + +func managedFromZones(arn, name string, zoneNames []string) managedResource { + m := managedResource{ARN: arn, Name: name, ZoneNames: zoneNames} + for _, z := range zoneNames { + m.ZoneIDs = append(m.ZoneIDs, zoneID(z)) + } + + return m +} + +func (b *InMemoryBackend) managedGroupOrCluster(arn string) (managedResource, bool, error) { + p, ok := parseResourceARN(arn) + if !ok { + return managedResource{}, false, nil + } + + switch { + case p.Service == "autoscaling" && strings.Contains(p.Resource, "autoScalingGroupName/"): + m, err := b.managedGroup(arn, p) + + return m, true, err + case p.Service == "eks" && strings.HasPrefix(p.Resource, "cluster/"): + m, err := b.managedCluster(arn, p) + + return m, true, err + default: + return managedResource{}, false, nil + } +} + +func parseResourceARN(arn string) (awsarn.ARN, bool) { + p, err := awsarn.Parse(arn) + + return p, err == nil +} + +func notManaged(arn string) error { + return restroute.NotFound("Managed resource %s was not found", arn) +} + +func (b *InMemoryBackend) managedGroup(arn string, p awsarn.ARN) (managedResource, error) { + h, ok := b.asgHandler() + if !ok { + return managedResource{}, notManaged(arn) + } + + _, name, _ := strings.Cut(p.Resource, "autoScalingGroupName/") + + groups, err := h.BackendFor(p.Region).DescribeAutoScalingGroups([]string{name}, nil) + if err != nil || len(groups) == 0 || !groupZonalShiftEnabled(&groups[0]) { + return managedResource{}, notManaged(arn) + } + + return managedFromZones(groups[0].AutoScalingGroupARN, name, b.groupZones(p.Region, &groups[0])), nil +} + +func groupZonalShiftEnabled(g *asgbackend.AutoScalingGroup) bool { + return g.AvailabilityZoneImpairmentPolicy != nil && g.AvailabilityZoneImpairmentPolicy.ZonalShiftEnabled +} + +func (b *InMemoryBackend) groupZones(region string, g *asgbackend.AutoScalingGroup) []string { + if len(g.AvailabilityZones) > 0 { + return append([]string{}, g.AvailabilityZones...) + } + + return b.subnetZones(region, strings.Split(g.VPCZoneIdentifier, ",")) +} + +func (b *InMemoryBackend) subnetZones(region string, subnetIDs []string) []string { + ec2b, ok := b.ec2Backend(region) + if !ok { + return nil + } + + ids := make([]string, 0, len(subnetIDs)) + + for _, id := range subnetIDs { + if id = strings.TrimSpace(id); id != "" { + ids = append(ids, id) + } + } + + if len(ids) == 0 { + return nil + } + + var zones []string + + for _, s := range ec2b.DescribeSubnets(ids) { + if s.AvailabilityZone != "" && !slices.Contains(zones, s.AvailabilityZone) { + zones = append(zones, s.AvailabilityZone) + } + } + + return zones +} + +func clusterZonalShiftEnabled(c *eksbackend.Cluster) bool { + raw := c.ConfigBlocks["zonalShiftConfig"] + if len(raw) == 0 { + return false + } + + var cfg struct { + Enabled bool `json:"enabled"` + } + + return json.Unmarshal(raw, &cfg) == nil && cfg.Enabled +} + +func (b *InMemoryBackend) managedCluster(arn string, p awsarn.ARN) (managedResource, error) { + h, ok := b.eksHandler() + if !ok { + return managedResource{}, notManaged(arn) + } + + name := strings.TrimPrefix(p.Resource, "cluster/") + + c, err := h.BackendFor(p.Region).DescribeCluster(name) + if err != nil || c == nil || !clusterZonalShiftEnabled(c) { + return managedResource{}, notManaged(arn) + } + + return managedFromZones(c.ARN, c.Name, b.clusterZones(p.Region, c)), nil +} + +func (b *InMemoryBackend) clusterZones(region string, c *eksbackend.Cluster) []string { + if c.VpcConfig == nil { + return nil + } + + return b.subnetZones(region, c.VpcConfig.SubnetIDs) +} + +func (b *InMemoryBackend) listGroupsAndClusters(region string) []managedResource { + var out []managedResource + + if h, ok := b.asgHandler(); ok { + groups, err := h.BackendFor(region).DescribeAutoScalingGroups(nil, nil) + if err == nil { + for i := range groups { + if groupZonalShiftEnabled(&groups[i]) { + g := &groups[i] + out = append( + out, + managedFromZones(g.AutoScalingGroupARN, g.AutoScalingGroupName, b.groupZones(region, g)), + ) + } + } + } + } + + if h, ok := b.eksHandler(); ok { + eb := h.BackendFor(region) + + for _, name := range eb.ListClusters(false) { + if c, err := eb.DescribeCluster(name); err == nil && clusterZonalShiftEnabled(c) { + out = append(out, managedFromZones(c.ARN, c.Name, b.clusterZones(region, c))) + } + } + } + + return out +} diff --git a/services/arczonalshift/managed_resources_test.go b/services/arczonalshift/managed_resources_test.go new file mode 100644 index 0000000000..4c4027f9c3 --- /dev/null +++ b/services/arczonalshift/managed_resources_test.go @@ -0,0 +1,281 @@ +package arczonalshift_test + +import ( + "context" + "encoding/json" + "testing" + + "github.com/aws/aws-sdk-go-v2/aws" + sdk "github.com/aws/aws-sdk-go-v2/service/arczonalshift" + "github.com/aws/aws-sdk-go-v2/service/arczonalshift/types" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/blackbirdworks/gopherstack/pkgs/service" + asg "github.com/blackbirdworks/gopherstack/services/autoscaling" + cw "github.com/blackbirdworks/gopherstack/services/cloudwatch" + ec2bk "github.com/blackbirdworks/gopherstack/services/ec2" + eksbk "github.com/blackbirdworks/gopherstack/services/eks" + elbv2 "github.com/blackbirdworks/gopherstack/services/elbv2" +) + +type extSiblings struct { + *siblings + asg, eks, ec2 service.Registerable +} + +func (s *extSiblings) GetAutoscalingHandler() service.Registerable { return s.asg } +func (s *extSiblings) GetEKSHandler() service.Registerable { return s.eks } +func (s *extSiblings) GetEC2Handler() service.Registerable { return s.ec2 } + +type managedWorld struct { + asg *asg.InMemoryBackend + eks *eksbk.InMemoryBackend + ec2 *ec2bk.InMemoryBackend +} + +func newManagedFixture(t *testing.T) (*fixture, *managedWorld) { + t.Helper() + + f := newFixture(t) + w := &managedWorld{ + asg: asg.NewInMemoryBackendWithConfig(testAccount, testRegion), + eks: eksbk.NewInMemoryBackend(t.Context(), testAccount, testRegion), + ec2: ec2bk.NewInMemoryBackend(testAccount, testRegion), + } + + f.backend.SetAppConfig(&extSiblings{ + siblings: &siblings{elb: elbv2.NewHandler(f.elb), cw: cw.NewHandler(f.cw)}, + asg: asg.NewHandler(w.asg), + eks: eksbk.NewHandler(w.eks), + ec2: ec2bk.NewHandler(w.ec2), + }) + + return f, w +} + +func (w *managedWorld) group(t *testing.T, name string, enabled bool) string { + t.Helper() + + g, err := w.asg.CreateAutoScalingGroup(asg.CreateAutoScalingGroupInput{ + AutoScalingGroupName: name, MinSize: 0, MaxSize: 2, + AvailabilityZones: []string{"us-east-1a", "us-east-1b"}, + AvailabilityZoneImpairmentPolicy: &asg.AvailabilityZoneImpairmentPolicy{ZonalShiftEnabled: enabled}, + }) + require.NoError(t, err) + + return g.AutoScalingGroupARN +} + +func (w *managedWorld) cluster(t *testing.T, name string, zonalShift json.RawMessage) string { + t.Helper() + + vpc, err := w.ec2.CreateVpc("10.0.0.0/16", "") + require.NoError(t, err) + + subnets := make([]string, 0, 2) + + for i, az := range []string{"us-east-1a", "us-east-1c"} { + s, subErr := w.ec2.CreateSubnet(vpc.ID, "10.0."+string(rune('0'+i))+".0/24", az) + require.NoError(t, subErr) + + subnets = append(subnets, s.ID) + } + + opts := eksbk.ClusterOptionalConfig{} + if zonalShift != nil { + opts.ConfigBlocks = map[string]json.RawMessage{"zonalShiftConfig": zonalShift} + } + + c, err := w.eks.CreateCluster(name, "1.31", "arn:aws:iam::"+testAccount+":role/eks", + &eksbk.VpcConfig{SubnetIDs: subnets}, nil, nil, opts) + require.NoError(t, err) + + return c.ARN +} + +func TestManagedResourcesAutoScalingAndEKS(t *testing.T) { + t.Parallel() + + tests := []struct { + create func(t *testing.T, w *managedWorld) string + name string + wantName string + wantZones []string + managed bool + }{ + { + name: "asg with zonal shift enabled", managed: true, wantName: "web", + wantZones: []string{"use1-az1", "use1-az2"}, + create: func(t *testing.T, w *managedWorld) string { + t.Helper() + + return w.group(t, "web", true) + }, + }, + { + name: "asg without zonal shift", managed: false, + create: func(t *testing.T, w *managedWorld) string { + t.Helper() + + return w.group(t, "web", false) + }, + }, + { + name: "eks with zonal shift enabled", managed: true, wantName: "k8s", + wantZones: []string{"use1-az1", "use1-az3"}, + create: func(t *testing.T, w *managedWorld) string { + t.Helper() + + return w.cluster(t, "k8s", json.RawMessage(`{"enabled":true}`)) + }, + }, + { + name: "eks with zonal shift disabled", managed: false, + create: func(t *testing.T, w *managedWorld) string { + t.Helper() + + return w.cluster(t, "k8s", json.RawMessage(`{"enabled":false}`)) + }, + }, + { + name: "eks without zonal shift config", managed: false, + create: func(t *testing.T, w *managedWorld) string { + t.Helper() + + return w.cluster(t, "k8s", nil) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + f, w := newManagedFixture(t) + arn := tt.create(t, w) + ctx := t.Context() + + list, err := f.client.ListManagedResources(ctx, &sdk.ListManagedResourcesInput{}) + require.NoError(t, err) + + _, startErr := f.client.StartZonalShift(ctx, &sdk.StartZonalShiftInput{ + ResourceIdentifier: aws.String(arn), AwayFrom: aws.String("use1-az1"), + Comment: aws.String("drill"), ExpiresIn: aws.String("30m"), + }) + + got, getErr := f.client.GetManagedResource( + ctx, + &sdk.GetManagedResourceInput{ResourceIdentifier: aws.String(arn)}, + ) + + if !tt.managed { + assert.Empty(t, list.Items) + assert.Equal(t, "ResourceNotFoundException", apiCode(getErr)) + assert.Equal(t, "ResourceNotFoundException", apiCode(startErr)) + + return + } + + require.NoError(t, getErr) + require.NoError(t, startErr) + require.Len(t, list.Items, 1) + assert.Equal(t, arn, aws.ToString(list.Items[0].Arn)) + assert.Equal(t, tt.wantName, aws.ToString(list.Items[0].Name)) + assert.ElementsMatch(t, tt.wantZones, list.Items[0].AvailabilityZones) + assert.Equal(t, tt.wantName, aws.ToString(got.Name)) + assert.InDelta(t, float32(0), got.AppliedWeights["use1-az1"], 0.0001) + require.Len(t, got.ZonalShifts, 1) + assert.Equal(t, types.AppliedStatusApplied, got.ZonalShifts[0].AppliedStatus) + + require.NoError(t, f.cw.PutMetricAlarm(&cw.MetricAlarm{AlarmName: "outcome", StateValue: "OK"})) + + practice, err := f.client.CreatePracticeRunConfiguration(ctx, &sdk.CreatePracticeRunConfigurationInput{ + ResourceIdentifier: aws.String(arn), + OutcomeAlarms: []types.ControlCondition{{ + AlarmIdentifier: aws.String("arn:aws:cloudwatch:us-east-1:123456789012:alarm:outcome"), + Type: types.ControlConditionTypeCloudwatch, + }}, + }) + require.NoError(t, err) + assert.Equal(t, arn, aws.ToString(practice.Arn)) + + _, err = f.client.UpdateZonalAutoshiftConfiguration(ctx, &sdk.UpdateZonalAutoshiftConfigurationInput{ + ResourceIdentifier: aws.String(arn), ZonalAutoshiftStatus: types.ZonalAutoshiftStatusEnabled, + }) + require.NoError(t, err) + + again, err := f.client.GetManagedResource( + ctx, + &sdk.GetManagedResourceInput{ResourceIdentifier: aws.String(arn)}, + ) + require.NoError(t, err) + assert.Equal(t, types.ZonalAutoshiftStatusEnabled, again.ZonalAutoshiftStatus) + }) + } +} + +func TestManagedResourceStopsWhenZonalShiftDisabled(t *testing.T) { + t.Parallel() + + f, w := newManagedFixture(t) + arn := w.group(t, "web", true) + + _, err := f.client.GetManagedResource( + t.Context(), + &sdk.GetManagedResourceInput{ResourceIdentifier: aws.String(arn)}, + ) + require.NoError(t, err) + + _, err = w.asg.UpdateAutoScalingGroup(asg.UpdateAutoScalingGroupInput{ + AutoScalingGroupName: "web", + AvailabilityZoneImpairmentPolicy: &asg.AvailabilityZoneImpairmentPolicy{ZonalShiftEnabled: false}, + }) + require.NoError(t, err) + + _, err = f.client.GetManagedResource( + context.Background(), + &sdk.GetManagedResourceInput{ResourceIdentifier: aws.String(arn)}, + ) + assert.Equal(t, "ResourceNotFoundException", apiCode(err)) +} + +func TestZoneIDsFollowRegionAndZone(t *testing.T) { + t.Parallel() + + tests := []struct { + region string + zone string + want string + }{ + {region: "us-east-1", zone: "us-east-1a", want: "use1-az1"}, + {region: "us-west-2", zone: "us-west-2a", want: "usw2-az1"}, + {region: "eu-west-1", zone: "eu-west-1c", want: "euw1-az3"}, + {region: "ap-southeast-2", zone: "ap-southeast-2b", want: "apse2-az2"}, + {region: "ap-northeast-1", zone: "ap-northeast-1a", want: "apne1-az1"}, + } + + for _, tt := range tests { + t.Run(tt.region, func(t *testing.T) { + t.Parallel() + + f := newFixture(t) + w := &managedWorld{asg: asg.NewInMemoryBackendWithConfig(testAccount, tt.region)} + f.backend.SetAppConfig(&extSiblings{ + siblings: &siblings{elb: elbv2.NewHandler(f.elb), cw: cw.NewHandler(f.cw)}, + asg: asg.NewHandler(w.asg), + }) + + g, err := w.asg.CreateAutoScalingGroup(asg.CreateAutoScalingGroupInput{ + AutoScalingGroupName: "web", MaxSize: 1, AvailabilityZones: []string{tt.zone}, + AvailabilityZoneImpairmentPolicy: &asg.AvailabilityZoneImpairmentPolicy{ZonalShiftEnabled: true}, + }) + require.NoError(t, err) + + got, err := f.client.GetManagedResource(t.Context(), + &sdk.GetManagedResourceInput{ResourceIdentifier: aws.String(g.AutoScalingGroupARN)}) + require.NoError(t, err) + assert.Equal(t, map[string]float32{tt.want: 1}, got.AppliedWeights) + }) + } +} diff --git a/services/arczonalshift/misc_test.go b/services/arczonalshift/misc_test.go new file mode 100644 index 0000000000..067f3017e1 --- /dev/null +++ b/services/arczonalshift/misc_test.go @@ -0,0 +1,167 @@ +package arczonalshift_test + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + sdk "github.com/aws/aws-sdk-go-v2/service/arczonalshift" + "github.com/labstack/echo/v5" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/blackbirdworks/gopherstack/pkgs/sdkcheck" + zs "github.com/blackbirdworks/gopherstack/services/arczonalshift" +) + +func TestSDKCompleteness(t *testing.T) { + t.Parallel() + + sdkcheck.CheckCompleteness( + t, + &sdk.Client{}, + zs.NewHandler(zs.NewInMemoryBackend(testAccount)).GetSupportedOperations(), + []string{}, + ) +} + +func TestPersistenceRoundTrip(t *testing.T) { + t.Parallel() + + f := newFixture(t) + lb := f.loadBalancer(t, "web", 2) + started := f.start(t, lb, "us-east-1a", "1h") + _, err := f.backend.SetObserverNotificationStatus("ENABLED") + require.NoError(t, err) + + snap := f.handler.Snapshot(t.Context()) + require.NotEmpty(t, snap) + + restored := zs.NewHandler(zs.NewInMemoryBackend(testAccount)) + require.NoError(t, restored.Restore(t.Context(), snap)) + + shifts, _, err := restored.Backend.ListZonalShifts("", "", "", 0) + require.NoError(t, err) + require.Len(t, shifts, 1) + assert.Equal(t, *started.ZonalShiftId, shifts[0].ZonalShiftID) + assert.Equal(t, "ENABLED", restored.Backend.ObserverNotificationStatus()) + + require.NoError(t, restored.Restore(t.Context(), []byte(`{"version":999}`))) + shifts, _, err = restored.Backend.ListZonalShifts("", "", "", 0) + require.NoError(t, err) + assert.Empty(t, shifts) +} + +func TestRouting(t *testing.T) { + t.Parallel() + + h := zs.NewHandler(zs.NewInMemoryBackend(testAccount)) + + tests := []struct { + name, method, path, auth, wantOp string + wantMatch bool + }{ + {"start", http.MethodPost, "/zonalshifts", "arc-zonal-shift", "StartZonalShift", true}, + {"list", http.MethodGet, "/zonalshifts", "arc-zonal-shift", "ListZonalShifts", true}, + {"update", http.MethodPatch, "/zonalshifts/abc", "arc-zonal-shift", "UpdateZonalShift", true}, + {"cancel", http.MethodDelete, "/zonalshifts/abc", "arc-zonal-shift", "CancelZonalShift", true}, + {"start practice run", http.MethodPost, "/practiceruns", "arc-zonal-shift", "StartPracticeRun", true}, + {"cancel practice run", http.MethodDelete, "/practiceruns/abc", "arc-zonal-shift", "CancelPracticeRun", true}, + { + "create configuration", + http.MethodPost, + "/configuration", + "arc-zonal-shift", + "CreatePracticeRunConfiguration", + true, + }, + { + "update configuration", + http.MethodPatch, + "/configuration/arn%3Ax", + "arc-zonal-shift", + "UpdatePracticeRunConfiguration", + true, + }, + { + "autoshift config", + http.MethodPut, + "/managedresources/arn%3Ax", + "arc-zonal-shift", + "UpdateZonalAutoshiftConfiguration", + true, + }, + { + "observer", + http.MethodGet, + "/autoshift-observer-notification", + "arc-zonal-shift", + "GetAutoshiftObserverNotificationStatus", + true, + }, + {"autoshifts", http.MethodGet, "/autoshifts", "arc-zonal-shift", "ListAutoshifts", true}, + {"other service", http.MethodGet, "/zonalshifts", "s3", "", false}, + {"unknown path", http.MethodGet, "/nothing", "arc-zonal-shift", "", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + req := httptest.NewRequestWithContext(t.Context(), tt.method, tt.path, nil) + req.Header.Set( + "Authorization", + "AWS4-HMAC-SHA256 Credential=test/20260101/us-east-1/"+tt.auth+"/aws4_request", + ) + c := echo.New().NewContext(req, httptest.NewRecorder()) + + assert.Equal(t, tt.wantMatch, h.RouteMatcher()(c)) + + if tt.wantMatch { + assert.Equal(t, tt.wantOp, h.ExtractOperation(c)) + } + }) + } +} + +func TestErrorBodyShape(t *testing.T) { + t.Parallel() + + h := zs.NewHandler(zs.NewInMemoryBackend(testAccount)) + + tests := []struct { + name, method, path, body, wantCode string + wantStatus int + }{ + { + "not found", + http.MethodPatch, + "/zonalshifts/none", + `{"comment":"c"}`, + "ResourceNotFoundException", + http.StatusNotFound, + }, + {"validation", http.MethodPost, "/zonalshifts", `{}`, "ValidationException", http.StatusBadRequest}, + {"unknown operation", http.MethodPost, "/", "{}", "UnknownOperationException", http.StatusNotFound}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + req := httptest.NewRequestWithContext(t.Context(), tt.method, tt.path, strings.NewReader(tt.body)) + rec := httptest.NewRecorder() + require.NoError(t, h.Handler()(echo.New().NewContext(req, rec))) + assert.Equal(t, tt.wantStatus, rec.Code) + assert.Equal(t, tt.wantCode, rec.Header().Get("X-Amzn-Errortype")) + + var body map[string]any + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &body)) + assert.NotEmpty(t, body["message"]) + assert.Equal(t, body["message"], body["Message"]) + assert.False(t, strings.HasPrefix(body["message"].(string), tt.wantCode)) + }) + } +} diff --git a/services/arczonalshift/models.go b/services/arczonalshift/models.go new file mode 100644 index 0000000000..004373c337 --- /dev/null +++ b/services/arczonalshift/models.go @@ -0,0 +1,67 @@ +package arczonalshift + +import "time" + +const ( + statusActive = "ACTIVE" + statusExpired = "EXPIRED" + statusCanceled = "CANCELED" + + shiftZonal = "ZONAL_SHIFT" + shiftPractice = "PRACTICE_RUN" + shiftAutoshft = "ZONAL_AUTOSHIFT" + + outcomePending = "PENDING" + outcomeSucceeded = "SUCCEEDED" + outcomeFailed = "FAILED" + outcomeInterrupted = "INTERRUPTED" + + autoshiftEnabled = "ENABLED" + autoshiftDisabled = "DISABLED" + + alarmStateAlarm = "ALARM" + + appliedYes = "APPLIED" + appliedNo = "NOT_APPLIED" +) + +// Shift is a customer-started zonal shift or practice run. +type Shift struct { + StartTime time.Time `json:"startTime"` + ExpiryTime time.Time `json:"expiryTime"` + CanceledAt *time.Time `json:"canceledAt,omitempty"` + ID string `json:"id"` + Resource string `json:"resource"` + AwayFrom string `json:"awayFrom"` + Comment string `json:"comment"` + Type string `json:"type"` + Outcome string `json:"outcome,omitempty"` +} + +// Alarm is a practice-run alarm condition. +type Alarm struct { + AlarmIdentifier string `json:"alarmIdentifier"` + Type string `json:"type"` +} + +// PracticeConfig is a resource's practice run configuration. +type PracticeConfig struct { + Resource string `json:"resource"` + OutcomeAlarms []Alarm `json:"outcomeAlarms"` + BlockingAlarms []Alarm `json:"blockingAlarms,omitempty"` + AllowedWindows []string `json:"allowedWindows,omitempty"` + BlockedWindows []string `json:"blockedWindows,omitempty"` + BlockedDates []string `json:"blockedDates,omitempty"` +} + +// ResourceSettings is the per-resource autoshift status; present once any setting is changed. +type ResourceSettings struct { + Resource string `json:"resource"` + Autoshift string `json:"autoshift"` +} + +// Settings holds account-level options. +type Settings struct { + ID string `json:"id"` + Observer string `json:"observer"` +} diff --git a/services/arczonalshift/persistence.go b/services/arczonalshift/persistence.go new file mode 100644 index 0000000000..4e237a5ddc --- /dev/null +++ b/services/arczonalshift/persistence.go @@ -0,0 +1,61 @@ +package arczonalshift + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/blackbirdworks/gopherstack/pkgs/lockmetrics" + "github.com/blackbirdworks/gopherstack/pkgs/logger" + "github.com/blackbirdworks/gopherstack/pkgs/persistence" +) + +const snapshotVersion = 1 + +type backendSnapshot struct { + Tables map[string]json.RawMessage `json:"tables"` + Version int `json:"version"` +} + +// Snapshot serialises the backend state. It implements persistence.Persistable. +func (b *InMemoryBackend) Snapshot(ctx context.Context) []byte { + b.mu.RLock("Snapshot") + defer b.mu.RUnlock() + + tables, err := b.registry.SnapshotAll() + if err != nil { + logger.Load(ctx).WarnContext(ctx, "arczonalshift: snapshot table marshal failed", "error", err) + + return nil + } + + return persistence.MarshalSnapshot(ctx, "arczonalshift", backendSnapshot{Version: snapshotVersion, Tables: tables}) +} + +// Restore loads a snapshot; an incompatible version starts empty. It implements persistence.Persistable. +func (b *InMemoryBackend) Restore(ctx context.Context, data []byte) error { + var snap backendSnapshot + + if err := persistence.UnmarshalSnapshot(ctx, "arczonalshift", data, &snap); err != nil { + return err + } + + if b.mu == nil { + b.mu = lockmetrics.New("arczonalshift") + } + + b.mu.Lock("Restore") + defer b.mu.Unlock() + + if snap.Version != snapshotVersion { + b.registry.ResetAll() + + return nil + } + + if err := b.registry.RestoreAll(snap.Tables); err != nil { + return fmt.Errorf("arczonalshift: restore snapshot tables: %w", err) + } + + return nil +} diff --git a/services/arczonalshift/practice.go b/services/arczonalshift/practice.go new file mode 100644 index 0000000000..0b4c9124a5 --- /dev/null +++ b/services/arczonalshift/practice.go @@ -0,0 +1,383 @@ +package arczonalshift + +import ( + "regexp" + "slices" + "strconv" + "strings" + "time" + + "github.com/google/uuid" +) + +var ( + windowRE = regexp.MustCompile(`^(?i)([a-z]{3}):(\d{2}):(\d{2})-([a-z]{3}):(\d{2}):(\d{2})$`) + dateRE = regexp.MustCompile(`^\d{4}-\d{2}-\d{2}$`) +) + +func weekdays() []string { return []string{"mon", "tue", "wed", "thu", "fri", "sat", "sun"} } + +// PracticeView is the wire form of a practice run configuration. +type PracticeView struct { + OutcomeAlarms []Alarm `json:"outcomeAlarms"` + AllowedWindows []string `json:"allowedWindows,omitempty"` + BlockedDates []string `json:"blockedDates,omitempty"` + BlockedWindows []string `json:"blockedWindows,omitempty"` + BlockingAlarms []Alarm `json:"blockingAlarms,omitempty"` +} + +// PracticeOut is the output of the practice run configuration operations. +type PracticeOut struct { + Config *PracticeView `json:"practiceRunConfiguration,omitempty"` + Arn string `json:"arn"` + Name string `json:"name"` + ZonalAutoshiftStatus string `json:"zonalAutoshiftStatus"` +} + +// minuteOfWeek parses MON:18:30 style parts to minutes since Monday 00:00. +func minuteOfWeek(day, hh, mm string) (int, bool) { + d := slices.Index(weekdays(), strings.ToLower(day)) + h, m := atoi(hh), atoi(mm) + + if d < 0 || h < 0 || h > 23 || m < 0 || m > 59 { + return 0, false + } + + return d*24*60 + h*60 + m, true +} + +func atoi(s string) int { + n, err := strconv.Atoi(s) + if err != nil { + return -1 + } + + return n +} + +type window struct{ start, end int } + +func parseWindow(s string) (window, bool) { + m := windowRE.FindStringSubmatch(s) + if m == nil || !strings.EqualFold(m[1], m[4]) { + return window{}, false + } + + start, ok1 := minuteOfWeek(m[1], m[2], m[3]) + end, ok2 := minuteOfWeek(m[4], m[5], m[6]) + + return window{start, end}, ok1 && ok2 && end > start +} + +func parseWindows(list []string, reason string) ([]window, error) { + out := make([]window, 0, len(list)) + + for _, s := range list { + w, ok := parseWindow(s) + if !ok { + return nil, invalidReason(reason, "%q is not a valid same-day window (expected DAY:HH:MM-DAY:HH:MM)", s) + } + + out = append(out, w) + } + + return out, nil +} + +func (b *InMemoryBackend) validateAlarms(alarms []Alarm, required bool) error { + if required && len(alarms) == 0 { + return invalidReason("InvalidAlarmCondition", "outcomeAlarms must contain an alarm") + } + + for _, a := range alarms { + if a.Type != "CLOUDWATCH" { + return invalidReason("InvalidConditionType", "Alarm type must be CLOUDWATCH") + } + + if _, found := b.alarmState(a.AlarmIdentifier); !found { + return invalidReason("InvalidAlarmCondition", "CloudWatch alarm %q does not exist", a.AlarmIdentifier) + } + } + + return nil +} + +func validateSchedule(c *PracticeConfig) error { + allowed, err := parseWindows(c.AllowedWindows, "InvalidPracticeAllowedWindow") + if err != nil { + return err + } + + blocked, err := parseWindows(c.BlockedWindows, "InvalidPracticeBlocker") + if err != nil { + return err + } + + for _, d := range c.BlockedDates { + if _, perr := time.Parse(time.DateOnly, d); perr != nil || !dateRE.MatchString(d) { + return invalidReason("InvalidPracticeBlocker", "%q is not a valid YYYY-MM-DD date", d) + } + } + + for _, a := range allowed { + for _, bl := range blocked { + if a.start < bl.end && bl.start < a.end { + return invalidReason("InvalidPracticeWindows", "An allowed window overlaps a blocked window") + } + } + } + + return nil +} + +func (b *InMemoryBackend) practiceOut(m managedResource, cfg *PracticeConfig) PracticeOut { + out := PracticeOut{Arn: m.ARN, Name: m.Name, ZonalAutoshiftStatus: b.autoshiftStatus(m.ARN)} + if cfg != nil { + out.Config = &PracticeView{ + OutcomeAlarms: cfg.OutcomeAlarms, AllowedWindows: cfg.AllowedWindows, BlockedDates: cfg.BlockedDates, + BlockedWindows: cfg.BlockedWindows, BlockingAlarms: cfg.BlockingAlarms, + } + } + + return out +} + +func (b *InMemoryBackend) autoshiftStatus(resource string) string { + if r, ok := b.resources.Get(resource); ok { + return r.Autoshift + } + + return autoshiftDisabled +} + +// CreatePracticeRunConfiguration defines the practice run schedule and alarms for a managed resource. +func (b *InMemoryBackend) CreatePracticeRunConfiguration(cfg PracticeConfig) (PracticeOut, error) { + m, err := b.managedFor(cfg.Resource) + if err != nil { + return PracticeOut{}, err + } + + if err = b.validateAlarms(cfg.OutcomeAlarms, true); err != nil { + return PracticeOut{}, err + } + + if err = b.validateAlarms(cfg.BlockingAlarms, false); err != nil { + return PracticeOut{}, err + } + + if err = validateSchedule(&cfg); err != nil { + return PracticeOut{}, err + } + + b.mu.Lock("CreatePracticeRunConfiguration") + defer b.mu.Unlock() + + if b.configs.Has(cfg.Resource) { + return PracticeOut{}, conflictReason("PracticeConfigurationAlreadyExists", "", + "A practice run configuration already exists for %s", cfg.Resource) + } + + b.configs.Put(&cfg) + + return b.practiceOut(m, &cfg), nil +} + +// UpdatePracticeRunConfiguration replaces the supplied parts of a practice run configuration. +func (b *InMemoryBackend) UpdatePracticeRunConfiguration( + resource string, + patch PracticeConfig, + set map[string]bool, +) (PracticeOut, error) { + m, err := b.managedFor(resource) + if err != nil { + return PracticeOut{}, err + } + + b.mu.Lock("UpdatePracticeRunConfiguration") + defer b.mu.Unlock() + + cur, ok := b.configs.Get(resource) + if !ok { + return PracticeOut{}, conflictReason("PracticeConfigurationDoesNotExist", "", + "No practice run configuration exists for %s", resource) + } + + next := *cur + if set["outcomeAlarms"] { + next.OutcomeAlarms = patch.OutcomeAlarms + } + + if set["blockingAlarms"] { + next.BlockingAlarms = patch.BlockingAlarms + } + + if set["allowedWindows"] { + next.AllowedWindows = patch.AllowedWindows + } + + if set["blockedWindows"] { + next.BlockedWindows = patch.BlockedWindows + } + + if set["blockedDates"] { + next.BlockedDates = patch.BlockedDates + } + + if err = b.validateAlarms(next.OutcomeAlarms, true); err != nil { + return PracticeOut{}, err + } + + if err = b.validateAlarms(next.BlockingAlarms, false); err != nil { + return PracticeOut{}, err + } + + if err = validateSchedule(&next); err != nil { + return PracticeOut{}, err + } + + b.configs.Put(&next) + + return b.practiceOut(m, &next), nil +} + +// DeletePracticeRunConfiguration removes a configuration; zonal autoshift must be disabled first. +func (b *InMemoryBackend) DeletePracticeRunConfiguration(resource string) (PracticeOut, error) { + m, err := b.managedFor(resource) + if err != nil { + return PracticeOut{}, err + } + + b.mu.Lock("DeletePracticeRunConfiguration") + defer b.mu.Unlock() + + if !b.configs.Has(resource) { + return PracticeOut{}, conflictReason("PracticeConfigurationDoesNotExist", "", + "No practice run configuration exists for %s", resource) + } + + if b.autoshiftStatus(resource) == autoshiftEnabled { + return PracticeOut{}, conflictReason("ZonalAutoshiftActive", "", + "Zonal autoshift is enabled for %s; disable it before deleting the practice run configuration", resource) + } + + b.configs.Delete(resource) + + return b.practiceOut(m, nil), nil +} + +// UpdateZonalAutoshiftConfiguration enables or disables zonal autoshift for a managed resource. +func (b *InMemoryBackend) UpdateZonalAutoshiftConfiguration(resource, status string) (string, error) { + if status != autoshiftEnabled && status != autoshiftDisabled { + return "", invalidReason("InvalidStatus", "zonalAutoshiftStatus must be ENABLED or DISABLED") + } + + if _, err := b.managedFor(resource); err != nil { + return "", err + } + + b.mu.Lock("UpdateZonalAutoshiftConfiguration") + defer b.mu.Unlock() + + if status == autoshiftEnabled && !b.configs.Has(resource) { + return "", conflictReason("PracticeConfigurationDoesNotExist", "", + "Zonal autoshift requires a practice run configuration for %s", resource) + } + + b.resources.Put(&ResourceSettings{Resource: resource, Autoshift: status}) + + return status, nil +} + +// blocker is the conflict reason (and detail) that forbids starting a practice run now. +type blocker struct{ reason, detail string } + +// blockedReason returns the blocker for a practice run starting now; the zero value means allowed. +func (b *InMemoryBackend) blockedReason(cfg *PracticeConfig, now time.Time) blocker { + for _, a := range cfg.BlockingAlarms { + if state, found := b.alarmState(a.AlarmIdentifier); found && state == alarmStateAlarm { + return blocker{"PracticeBlockingAlarmsRed", "blocking alarm " + a.AlarmIdentifier + " is in ALARM"} + } + } + + for _, a := range cfg.OutcomeAlarms { + if state, found := b.alarmState(a.AlarmIdentifier); found && state == alarmStateAlarm { + return blocker{"PracticeOutcomeAlarmsRed", "outcome alarm " + a.AlarmIdentifier + " is in ALARM"} + } + } + + if slices.Contains(cfg.BlockedDates, now.Format(time.DateOnly)) { + return blocker{"PracticeInBlockedDates", "today is a blocked date"} + } + + at := minuteOfWeek2(now) + + blocked, _ := parseWindows(cfg.BlockedWindows, "") + if slices.ContainsFunc(blocked, func(w window) bool { return at >= w.start && at < w.end }) { + return blocker{"PracticeInBlockedWindows", "the current time is inside a blocked window"} + } + + allowed, _ := parseWindows(cfg.AllowedWindows, "") + if len(allowed) > 0 && !slices.ContainsFunc(allowed, func(w window) bool { return at >= w.start && at < w.end }) { + return blocker{"PracticeOutsideAllowedWindows", "the current time is outside every allowed window"} + } + + return blocker{} +} + +func minuteOfWeek2(t time.Time) int { + t = t.UTC() + day := (int(t.Weekday()) + len(weekdays()) - 1) % len(weekdays()) + + return day*24*60 + t.Hour()*60 + t.Minute() +} + +// StartPracticeRun starts a practice run zonal shift if the practice configuration allows it right now. +func (b *InMemoryBackend) StartPracticeRun(resource, awayFrom, comment string) (ShiftView, error) { + if resource == "" || awayFrom == "" || comment == "" { + return ShiftView{}, invalidReason("MissingValue", "awayFrom, comment and resourceIdentifier are required") + } + + m, err := b.managedFor(resource) + if err != nil { + return ShiftView{}, err + } + + az, ok := m.resolveAZ(awayFrom) + if !ok { + return ShiftView{}, invalidReason("InvalidAz", "Availability Zone %q is not used by %s", awayFrom, resource) + } + + b.mu.Lock("StartPracticeRun") + defer b.mu.Unlock() + + now := time.Now().UTC() + b.settleLocked(now) + + cfg, ok := b.configs.Get(resource) + if !ok { + return ShiftView{}, conflictReason("PracticeConfigurationDoesNotExist", "", + "No practice run configuration exists for %s", resource) + } + + if blk := b.blockedReason(cfg, now); blk.reason != "" { + return ShiftView{}, conflictReason( + blk.reason, "", "A practice run cannot start for %s: %s", resource, blk.detail) + } + + if active := b.activeShifts(m.ARN, shiftPractice, now); len(active) > 0 { + return ShiftView{}, conflictReason("ZonalShiftAlreadyExists", active[0].ID, + "A practice run is already active for %s", m.ARN) + } + + if err = b.checkNoConflict(m, az, now); err != nil { + return ShiftView{}, err + } + + s := &Shift{ + ID: uuid.NewString(), Resource: resource, AwayFrom: az, Comment: comment, Type: shiftPractice, + StartTime: now, ExpiryTime: now.Add(b.practiceDuration), Outcome: outcomePending, + } + b.shifts.Put(s) + + return viewOf(s, now), nil +} diff --git a/services/arczonalshift/practice_test.go b/services/arczonalshift/practice_test.go new file mode 100644 index 0000000000..1c95024d52 --- /dev/null +++ b/services/arczonalshift/practice_test.go @@ -0,0 +1,552 @@ +package arczonalshift_test + +import ( + "testing" + "testing/synctest" + "time" + + "github.com/aws/aws-sdk-go-v2/aws" + sdk "github.com/aws/aws-sdk-go-v2/service/arczonalshift" + "github.com/aws/aws-sdk-go-v2/service/arczonalshift/types" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/blackbirdworks/gopherstack/pkgs/restroute" + zs "github.com/blackbirdworks/gopherstack/services/arczonalshift" + cw "github.com/blackbirdworks/gopherstack/services/cloudwatch" +) + +func cond(arn string) []zs.Alarm { return []zs.Alarm{{AlarmIdentifier: arn, Type: "CLOUDWATCH"}} } + +func TestPracticeRunConfigurationCRUD(t *testing.T) { + t.Parallel() + + f := newFixture(t) + ctx := t.Context() + lb := f.loadBalancer(t, "web", 2) + require.NoError(t, f.cw.PutMetricAlarm(&cw.MetricAlarm{AlarmName: "outcome", StateValue: "OK"})) + + outcome := &types.ControlCondition{ + AlarmIdentifier: aws.String("arn:aws:cloudwatch:us-east-1:123456789012:alarm:outcome"), + Type: types.ControlConditionTypeCloudwatch, + } + + created, err := f.client.CreatePracticeRunConfiguration(ctx, &sdk.CreatePracticeRunConfigurationInput{ + ResourceIdentifier: aws.String(lb), + OutcomeAlarms: []types.ControlCondition{*outcome}, + AllowedWindows: []string{ + "MON:08:00-MON:17:00", + }, + BlockedDates: []string{"2030-12-25"}, + BlockedWindows: []string{"TUE:00:00-TUE:23:59"}, + }) + require.NoError(t, err) + assert.Equal(t, types.ZonalAutoshiftStatusDisabled, created.ZonalAutoshiftStatus) + assert.Equal(t, lb, aws.ToString(created.Arn)) + assert.Equal(t, []string{"MON:08:00-MON:17:00"}, created.PracticeRunConfiguration.AllowedWindows) + + _, err = f.client.CreatePracticeRunConfiguration(ctx, &sdk.CreatePracticeRunConfigurationInput{ + ResourceIdentifier: aws.String(lb), OutcomeAlarms: []types.ControlCondition{*outcome}, + }) + var ce *types.ConflictException + require.ErrorAs(t, err, &ce) + assert.Equal(t, types.ConflictExceptionReasonPracticeConfigurationAlreadyExists, ce.Reason) + + got, err := f.client.GetManagedResource(ctx, &sdk.GetManagedResourceInput{ResourceIdentifier: aws.String(lb)}) + require.NoError(t, err) + require.NotNil(t, got.PracticeRunConfiguration) + assert.Equal(t, []string{"2030-12-25"}, got.PracticeRunConfiguration.BlockedDates) + + list, err := f.client.ListManagedResources(ctx, &sdk.ListManagedResourcesInput{}) + require.NoError(t, err) + assert.Equal( + t, + types.ZonalAutoshiftStatusEnabled, + list.Items[0].PracticeRunStatus, + "a practice configuration exists", + ) + + upd, err := f.client.UpdatePracticeRunConfiguration(ctx, &sdk.UpdatePracticeRunConfigurationInput{ + ResourceIdentifier: aws.String(lb), BlockedDates: []string{"2031-01-01"}, + }) + require.NoError(t, err) + assert.Equal(t, []string{"2031-01-01"}, upd.PracticeRunConfiguration.BlockedDates) + assert.Equal( + t, + []string{"MON:08:00-MON:17:00"}, + upd.PracticeRunConfiguration.AllowedWindows, + "unspecified fields are kept", + ) + + en, err := f.client.UpdateZonalAutoshiftConfiguration(ctx, &sdk.UpdateZonalAutoshiftConfigurationInput{ + ResourceIdentifier: aws.String(lb), ZonalAutoshiftStatus: types.ZonalAutoshiftStatusEnabled, + }) + require.NoError(t, err) + assert.Equal(t, types.ZonalAutoshiftStatusEnabled, en.ZonalAutoshiftStatus) + + _, err = f.client.DeletePracticeRunConfiguration( + ctx, + &sdk.DeletePracticeRunConfigurationInput{ResourceIdentifier: aws.String(lb)}, + ) + require.ErrorAs(t, err, &ce) + assert.Equal(t, types.ConflictExceptionReasonZonalAutoshiftActive, ce.Reason) + + _, err = f.client.UpdateZonalAutoshiftConfiguration(ctx, &sdk.UpdateZonalAutoshiftConfigurationInput{ + ResourceIdentifier: aws.String(lb), ZonalAutoshiftStatus: types.ZonalAutoshiftStatusDisabled, + }) + require.NoError(t, err) + + del, err := f.client.DeletePracticeRunConfiguration( + ctx, + &sdk.DeletePracticeRunConfigurationInput{ResourceIdentifier: aws.String(lb)}, + ) + require.NoError(t, err) + assert.Equal(t, types.ZonalAutoshiftStatusDisabled, del.ZonalAutoshiftStatus) + + _, err = f.client.DeletePracticeRunConfiguration( + ctx, + &sdk.DeletePracticeRunConfigurationInput{ResourceIdentifier: aws.String(lb)}, + ) + require.ErrorAs(t, err, &ce) + assert.Equal(t, types.ConflictExceptionReasonPracticeConfigurationDoesNotExist, ce.Reason) +} + +func TestPracticeRunConfigurationValidation(t *testing.T) { + t.Parallel() + + f := newFixture(t) + lb := f.loadBalancer(t, "web", 2) + require.NoError(t, f.cw.PutMetricAlarm(&cw.MetricAlarm{AlarmName: "ok", StateValue: "OK"})) + + good := types.ControlCondition{ + AlarmIdentifier: aws.String("arn:aws:cloudwatch:us-east-1:123456789012:alarm:ok"), + Type: types.ControlConditionTypeCloudwatch, + } + missing := types.ControlCondition{ + AlarmIdentifier: aws.String("arn:aws:cloudwatch:us-east-1:123456789012:alarm:ghost"), + Type: types.ControlConditionTypeCloudwatch, + } + + tests := []struct { + mutate func(*sdk.CreatePracticeRunConfigurationInput) + name string + wantReason types.ValidationExceptionReason + }{ + {func(in *sdk.CreatePracticeRunConfigurationInput) { + in.OutcomeAlarms = []types.ControlCondition{missing} + }, "alarm does not exist", types.ValidationExceptionReasonInvalidAlarmCondition}, + {func(in *sdk.CreatePracticeRunConfigurationInput) { + in.BlockingAlarms = []types.ControlCondition{missing} + }, "blocking alarm does not exist", types.ValidationExceptionReasonInvalidAlarmCondition}, + { + func(in *sdk.CreatePracticeRunConfigurationInput) { in.AllowedWindows = []string{"MON-08:00"} }, + "malformed allowed window", + types.ValidationExceptionReasonInvalidPracticeAllowedWindow, + }, + { + func(in *sdk.CreatePracticeRunConfigurationInput) { in.AllowedWindows = []string{"MON:08:00-TUE:09:00"} }, + "allowed window spans days", + types.ValidationExceptionReasonInvalidPracticeAllowedWindow, + }, + { + func(in *sdk.CreatePracticeRunConfigurationInput) { in.BlockedWindows = []string{"XXX:08:00-XXX:09:00"} }, + "bad weekday", + types.ValidationExceptionReasonInvalidPracticeBlocker, + }, + { + func(in *sdk.CreatePracticeRunConfigurationInput) { in.BlockedDates = []string{"2030-13-45"} }, + "bad date", + types.ValidationExceptionReasonInvalidPracticeBlocker, + }, + {func(in *sdk.CreatePracticeRunConfigurationInput) { + in.AllowedWindows = []string{"MON:08:00-MON:12:00"} + in.BlockedWindows = []string{"MON:11:00-MON:13:00"} + }, "overlapping windows", types.ValidationExceptionReasonConflictingPracticeWindows}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + in := &sdk.CreatePracticeRunConfigurationInput{ + ResourceIdentifier: aws.String(lb), + OutcomeAlarms: []types.ControlCondition{good}, + } + tt.mutate(in) + + _, err := f.client.CreatePracticeRunConfiguration(t.Context(), in) + + var ve *types.ValidationException + require.ErrorAs(t, err, &ve) + assert.Equal(t, tt.wantReason, ve.Reason) + }) + } + + t.Run("autoshift needs a configuration", func(t *testing.T) { + t.Parallel() + + _, err := f.client.UpdateZonalAutoshiftConfiguration(t.Context(), &sdk.UpdateZonalAutoshiftConfigurationInput{ + ResourceIdentifier: aws.String(lb), ZonalAutoshiftStatus: types.ZonalAutoshiftStatusEnabled, + }) + + var ce *types.ConflictException + require.ErrorAs(t, err, &ce) + assert.Equal(t, types.ConflictExceptionReasonPracticeConfigurationDoesNotExist, ce.Reason) + }) +} + +func TestStartPracticeRunBlockers(t *testing.T) { + t.Parallel() + + // synctest starts at Saturday 2000-01-01 00:00 UTC. + tests := []struct { + cfg func(outcome, blocking string) zs.PracticeConfig + name string + alarm string + alarmState string + want string + sleep time.Duration + }{ + { + name: "allowed", + cfg: func(o, _ string) zs.PracticeConfig { return zs.PracticeConfig{OutcomeAlarms: cond(o)} }, + }, + { + name: "outcome alarm red", alarm: "outcome", alarmState: "ALARM", want: "PracticeOutcomeAlarmsRed", + cfg: func(o, _ string) zs.PracticeConfig { return zs.PracticeConfig{OutcomeAlarms: cond(o)} }, + }, + { + name: "blocking alarm red", alarm: "blocking", alarmState: "ALARM", want: "PracticeBlockingAlarmsRed", + cfg: func(o, b string) zs.PracticeConfig { + return zs.PracticeConfig{OutcomeAlarms: cond(o), BlockingAlarms: cond(b)} + }, + }, + { + name: "blocked date", want: "PracticeInBlockedDates", + cfg: func(o, _ string) zs.PracticeConfig { + return zs.PracticeConfig{OutcomeAlarms: cond(o), BlockedDates: []string{"2000-01-01"}} + }, + }, + { + name: "blocked window", sleep: 10 * time.Hour, want: "PracticeInBlockedWindows", + cfg: func(o, _ string) zs.PracticeConfig { + return zs.PracticeConfig{OutcomeAlarms: cond(o), BlockedWindows: []string{"SAT:09:00-SAT:11:00"}} + }, + }, + { + name: "outside allowed window", sleep: 10 * time.Hour, want: "PracticeOutsideAllowedWindows", + cfg: func(o, _ string) zs.PracticeConfig { + return zs.PracticeConfig{OutcomeAlarms: cond(o), AllowedWindows: []string{"MON:09:00-MON:11:00"}} + }, + }, + { + name: "inside allowed window", sleep: 10 * time.Hour, + cfg: func(o, _ string) zs.PracticeConfig { + return zs.PracticeConfig{OutcomeAlarms: cond(o), AllowedWindows: []string{"SAT:09:00-SAT:11:00"}} + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + synctest.Test(t, func(t *testing.T) { + x := newBubble(t) + outcome, blocking := x.alarm(t, "outcome"), x.alarm(t, "blocking") + + cfg := tt.cfg(outcome, blocking) + cfg.Resource = x.lb + _, err := x.b.CreatePracticeRunConfiguration(cfg) + require.NoError(t, err) + + if tt.alarm != "" { + require.NoError(t, x.alarms.SetAlarmState(t.Context(), tt.alarm, tt.alarmState, "test", "")) + } + + time.Sleep(tt.sleep) + + run, err := x.b.StartPracticeRun(x.lb, "us-east-1a", "drill") + if tt.want == "" { + require.NoError(t, err) + assert.Equal(t, "PRACTICE_RUN", run.ShiftType) + assert.Equal(t, "PENDING", run.PracticeRunOutcome) + + return + } + + requireReason(t, err, tt.want) + }) + }) + } +} + +func TestPracticeRunOutcome(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + wantStatus string + wantOutcome string + wantInFlight string + sleepAfter time.Duration + alarmDuring bool + cancel bool + }{ + { + name: "succeeds when the alarm stays green", + sleepAfter: 31 * time.Minute, + wantStatus: "EXPIRED", + wantOutcome: "SUCCEEDED", + wantInFlight: "PENDING", + }, + { + name: "fails when the outcome alarm goes red", + alarmDuring: true, + sleepAfter: 31 * time.Minute, + wantStatus: "EXPIRED", + wantOutcome: "FAILED", + wantInFlight: "PENDING", + }, + { + name: "interrupted when canceled", + cancel: true, + sleepAfter: time.Minute, + wantStatus: "CANCELED", + wantOutcome: "INTERRUPTED", + wantInFlight: "PENDING", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + synctest.Test(t, func(t *testing.T) { + x := newBubble(t) + outcome := x.alarm(t, "outcome") + _, err := x.b.CreatePracticeRunConfiguration( + zs.PracticeConfig{Resource: x.lb, OutcomeAlarms: cond(outcome)}, + ) + require.NoError(t, err) + + run, err := x.b.StartPracticeRun(x.lb, "us-east-1a", "drill") + require.NoError(t, err) + assert.Equal(t, tt.wantInFlight, run.PracticeRunOutcome) + + time.Sleep(time.Minute) + + if tt.alarmDuring { + require.NoError(t, x.alarms.SetAlarmState(t.Context(), "outcome", "ALARM", "test", "")) + } + + if tt.cancel { + _, err = x.b.CancelZonalShift(run.ZonalShiftID) + require.Error(t, err, "a practice run is canceled with CancelPracticeRun") + + canceled, cErr := x.b.CancelPracticeRun(run.ZonalShiftID) + require.NoError(t, cErr) + assert.Equal(t, "CANCELED", canceled.Status) + } + + time.Sleep(tt.sleepAfter) + + shifts, _, err := x.b.ListZonalShifts("", "", "", 0) + require.NoError(t, err) + require.Len(t, shifts, 1) + assert.Equal(t, tt.wantStatus, shifts[0].Status) + assert.Equal(t, tt.wantOutcome, shifts[0].PracticeRunOutcome) + }) + }) + } +} + +func TestPracticeRunDurationKnob(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + want string + duration time.Duration + sleep time.Duration + }{ + {name: "default is 30 minutes", sleep: 29 * time.Minute, want: "ACTIVE"}, + {name: "default expires after 30 minutes", sleep: 31 * time.Minute, want: "EXPIRED"}, + {name: "shortened", duration: 2 * time.Minute, sleep: 3 * time.Minute, want: "EXPIRED"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + synctest.Test(t, func(t *testing.T) { + x := newBubble(t) + x.b.SetPracticeRunDuration(tt.duration) + + outcome := x.alarm(t, "outcome") + _, err := x.b.CreatePracticeRunConfiguration( + zs.PracticeConfig{Resource: x.lb, OutcomeAlarms: cond(outcome)}, + ) + require.NoError(t, err) + + run, err := x.b.StartPracticeRun(x.lb, "us-east-1a", "drill") + require.NoError(t, err) + + time.Sleep(tt.sleep) + assert.Equal(t, tt.want, statusOf(t, x.b, run.ZonalShiftID)) + }) + }) + } +} + +func TestPracticeRunViaSDK(t *testing.T) { + t.Parallel() + + f := newFixture(t) + ctx := t.Context() + lb := f.loadBalancer(t, "web", 2) + require.NoError(t, f.cw.PutMetricAlarm(&cw.MetricAlarm{AlarmName: "outcome", StateValue: "OK"})) + + _, err := f.client.StartPracticeRun(ctx, &sdk.StartPracticeRunInput{ + ResourceIdentifier: aws.String(lb), AwayFrom: aws.String("us-east-1a"), Comment: aws.String("c"), + }) + var ce *types.ConflictException + require.ErrorAs(t, err, &ce) + assert.Equal(t, types.ConflictExceptionReasonPracticeConfigurationDoesNotExist, ce.Reason) + + _, err = f.client.CreatePracticeRunConfiguration(ctx, &sdk.CreatePracticeRunConfigurationInput{ + ResourceIdentifier: aws.String(lb), + OutcomeAlarms: []types.ControlCondition{ + { + AlarmIdentifier: aws.String( + "arn:aws:cloudwatch:us-east-1:123456789012:alarm:outcome", + ), + Type: types.ControlConditionTypeCloudwatch, + }, + }, + }) + require.NoError(t, err) + + run, err := f.client.StartPracticeRun(ctx, &sdk.StartPracticeRunInput{ + ResourceIdentifier: aws.String(lb), AwayFrom: aws.String("us-east-1a"), Comment: aws.String("c"), + }) + require.NoError(t, err) + assert.Equal(t, types.ZonalShiftStatusActive, run.Status) + + managed, err := f.client.GetManagedResource(ctx, &sdk.GetManagedResourceInput{ResourceIdentifier: aws.String(lb)}) + require.NoError(t, err) + require.Len(t, managed.ZonalShifts, 1) + assert.Equal(t, types.ShiftTypePracticeRun, managed.ZonalShifts[0].ShiftType) + assert.Equal(t, types.PracticeRunOutcomePending, managed.ZonalShifts[0].PracticeRunOutcome) + + manual := f.start(t, lb, "us-east-1b", "10m") + + managed, err = f.client.GetManagedResource(ctx, &sdk.GetManagedResourceInput{ResourceIdentifier: aws.String(lb)}) + require.NoError(t, err) + + applied := map[string]types.AppliedStatus{} + for _, s := range managed.ZonalShifts { + applied[aws.ToString(s.ZonalShiftId)] = s.AppliedStatus + } + + assert.Equal( + t, + types.AppliedStatusApplied, + applied[aws.ToString(manual.ZonalShiftId)], + "customer shifts take precedence", + ) + assert.Equal(t, types.AppliedStatusNotApplied, applied[aws.ToString(run.ZonalShiftId)]) + + _, err = f.client.CancelPracticeRun(ctx, &sdk.CancelPracticeRunInput{ZonalShiftId: manual.ZonalShiftId}) + var ve *types.ValidationException + require.ErrorAs(t, err, &ve) + assert.Equal(t, types.ValidationExceptionReasonUnsupportedPracticeCancelShiftType, ve.Reason) + + canceled, err := f.client.CancelPracticeRun(ctx, &sdk.CancelPracticeRunInput{ZonalShiftId: run.ZonalShiftId}) + require.NoError(t, err) + assert.Equal(t, types.ZonalShiftStatusCanceled, canceled.Status) +} + +func TestAutoshiftObserverNotification(t *testing.T) { + t.Parallel() + + f := newFixture(t) + ctx := t.Context() + + got, err := f.client.GetAutoshiftObserverNotificationStatus(ctx, &sdk.GetAutoshiftObserverNotificationStatusInput{}) + require.NoError(t, err) + assert.Equal(t, types.AutoshiftObserverNotificationStatusDisabled, got.Status) + + upd, err := f.client.UpdateAutoshiftObserverNotificationStatus( + ctx, + &sdk.UpdateAutoshiftObserverNotificationStatusInput{Status: types.AutoshiftObserverNotificationStatusEnabled}, + ) + require.NoError(t, err) + assert.Equal(t, types.AutoshiftObserverNotificationStatusEnabled, upd.Status) + + got, err = f.client.GetAutoshiftObserverNotificationStatus(ctx, &sdk.GetAutoshiftObserverNotificationStatusInput{}) + require.NoError(t, err) + assert.Equal(t, types.AutoshiftObserverNotificationStatusEnabled, got.Status) + + _, err = f.client.UpdateAutoshiftObserverNotificationStatus( + ctx, + &sdk.UpdateAutoshiftObserverNotificationStatusInput{Status: "MAYBE"}, + ) + assert.Equal(t, "ValidationException", apiCode(err)) + + list, err := f.client.ListAutoshifts(ctx, &sdk.ListAutoshiftsInput{Status: types.AutoshiftExecutionStatusActive}) + require.NoError(t, err) + assert.Empty(t, list.Items, "this emulator never initiates an autoshift") + + _, err = f.client.ListAutoshifts(ctx, &sdk.ListAutoshiftsInput{Status: "BOGUS"}) + assert.Equal(t, "ValidationException", apiCode(err)) +} + +func TestStartPracticeRunRejectsSecondActiveRun(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + second string + sleep time.Duration + wantErr bool + }{ + {name: "same zone while active", second: "us-east-1a", wantErr: true}, + {name: "other zone while active", second: "us-east-1b", wantErr: true}, + {name: "after the first expires", second: "us-east-1a", sleep: 31 * time.Minute}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + synctest.Test(t, func(t *testing.T) { + x := newBubble(t) + outcome := x.alarm(t, "outcome") + _, err := x.b.CreatePracticeRunConfiguration( + zs.PracticeConfig{Resource: x.lb, OutcomeAlarms: cond(outcome)}, + ) + require.NoError(t, err) + + first, err := x.b.StartPracticeRun(x.lb, "us-east-1a", "drill") + require.NoError(t, err) + + time.Sleep(tt.sleep) + + _, err = x.b.StartPracticeRun(x.lb, tt.second, "again") + if tt.wantErr { + requireReason(t, err, "ZonalShiftAlreadyExists") + + var re *restroute.Error + + require.ErrorAs(t, err, &re) + assert.Equal(t, first.ZonalShiftID, re.Extra["zonalShiftId"]) + + return + } + + require.NoError(t, err) + }) + }) + } +} diff --git a/services/arczonalshift/provider.go b/services/arczonalshift/provider.go new file mode 100644 index 0000000000..438ea2ab04 --- /dev/null +++ b/services/arczonalshift/provider.go @@ -0,0 +1,36 @@ +// Package arczonalshift emulates Application Recovery Controller zonal shift and zonal autoshift. +package arczonalshift + +import ( + "errors" + + "github.com/blackbirdworks/gopherstack/pkgs/service" +) + +// ErrNilAppContext is returned by Init when no AppContext is supplied. +var ErrNilAppContext = errors.New("arczonalshift: AppContext must not be nil") + +// Provider implements service.Provider. +type Provider struct{} + +// Name returns the provider name. +func (p *Provider) Name() string { return "ARCZonalShift" } + +// Init builds the backend and handler. +// +//nolint:ireturn,nolintlint // architecturally required to return interface +func (p *Provider) Init(ctx *service.AppContext) (service.Registerable, error) { + if ctx == nil { + return nil, ErrNilAppContext + } + + accountID, region := service.AccountRegionOrDefault(ctx) + + backend := NewInMemoryBackend(accountID) + backend.SetAppConfig(ctx.Config) + + handler := NewHandler(backend) + handler.DefaultRegion = region + + return handler, nil +} diff --git a/services/arczonalshift/shifts.go b/services/arczonalshift/shifts.go new file mode 100644 index 0000000000..afa05ef2f6 --- /dev/null +++ b/services/arczonalshift/shifts.go @@ -0,0 +1,331 @@ +package arczonalshift + +import ( + "regexp" + "strconv" + "time" + + "github.com/google/uuid" + + "github.com/blackbirdworks/gopherstack/pkgs/awstime" + "github.com/blackbirdworks/gopherstack/pkgs/restroute" +) + +var expiresInRE = regexp.MustCompile(`^(\d+)([mh])$`) + +// ShiftView is the wire form of a zonal shift (summary and Start/Update/Cancel output). +type ShiftView struct { + AwayFrom string `json:"awayFrom"` + Comment string `json:"comment"` + ResourceIdentifier string `json:"resourceIdentifier"` + Status string `json:"status"` + ZonalShiftID string `json:"zonalShiftId"` + PracticeRunOutcome string `json:"practiceRunOutcome,omitempty"` + ShiftType string `json:"shiftType,omitempty"` + ExpiryTime float64 `json:"expiryTime"` + StartTime float64 `json:"startTime"` +} + +func viewOf(s *Shift, now time.Time) ShiftView { + v := ShiftView{ + AwayFrom: s.AwayFrom, + Comment: s.Comment, + ResourceIdentifier: s.Resource, + Status: s.status(now), + ZonalShiftID: s.ID, + ShiftType: s.Type, + ExpiryTime: awstime.Epoch(s.ExpiryTime), + StartTime: awstime.Epoch(s.StartTime), + } + if s.Type == shiftPractice { + v.PracticeRunOutcome = s.Outcome + } + + return v +} + +// parseExpiresIn converts "20m"/"3h" to a duration within 1 minute .. 72 hours. +func parseExpiresIn(s string) (time.Duration, error) { + m := expiresInRE.FindStringSubmatch(s) + if m == nil { + return 0, invalidReason( + "InvalidExpiresIn", + "ExpiresIn must be a whole number followed by m (minutes) or h (hours)", + ) + } + + n, err := strconv.Atoi(m[1]) + if err != nil || n < 1 { + return 0, invalidReason("InvalidExpiresIn", "ExpiresIn must be at least one minute") + } + + unit := time.Minute + if m[2] == "h" { + unit = time.Hour + } + + if n > int(maxShiftDuration/unit) { + return 0, invalidReason("InvalidExpiresIn", "ExpiresIn must not exceed 72 hours") + } + + return time.Duration(n) * unit, nil +} + +func (b *InMemoryBackend) activeShifts(resource, shiftType string, now time.Time) []*Shift { + var out []*Shift + + for _, s := range b.shifts.All() { + if s.Resource == resource && s.status(now) == statusActive && (shiftType == "" || s.Type == shiftType) { + out = append(out, s) + } + } + + return sortedShifts(out) +} + +// resolveAZ maps a zone name or ID to the resource's zone ID. +func (m managedResource) resolveAZ(away string) (string, bool) { + for i, id := range m.ZoneIDs { + if away == id || away == m.ZoneNames[i] { + return id, true + } + } + + return "", false +} + +// settleLocked finalises the outcome of practice runs that have ended; callers hold the write lock. +func (b *InMemoryBackend) settleLocked(now time.Time) { + for _, s := range b.shifts.All() { + if s.Type != shiftPractice || s.Outcome != outcomePending || s.status(now) == statusActive { + continue + } + + s.Outcome = b.practiceOutcome(s, now) + } +} + +func (b *InMemoryBackend) practiceOutcome(s *Shift, now time.Time) string { + if s.status(now) == statusCanceled { + return outcomeInterrupted + } + + if cfg, ok := b.configs.Get(s.Resource); ok { + for _, a := range cfg.OutcomeAlarms { + if state, found := b.alarmState(a.AlarmIdentifier); found && state == alarmStateAlarm { + return outcomeFailed + } + } + } + + return outcomeSucceeded +} + +// StartZonalShift moves traffic for a managed resource away from one Availability Zone until it expires. +func (b *InMemoryBackend) StartZonalShift(resource, awayFrom, comment, expiresIn string) (ShiftView, error) { + if comment == "" || awayFrom == "" || resource == "" { + return ShiftView{}, invalidReason( + "MissingValue", + "awayFrom, comment, expiresIn and resourceIdentifier are required", + ) + } + + d, err := parseExpiresIn(expiresIn) + if err != nil { + return ShiftView{}, err + } + + m, err := b.managedFor(resource) + if err != nil { + return ShiftView{}, err + } + + az, ok := m.resolveAZ(awayFrom) + if !ok { + return ShiftView{}, invalidReason("InvalidAz", "Availability Zone %q is not used by %s", awayFrom, resource) + } + + b.mu.Lock("StartZonalShift") + defer b.mu.Unlock() + + now := time.Now().UTC() + b.settleLocked(now) + + if err = b.checkNoConflict(m, az, now); err != nil { + return ShiftView{}, err + } + + s := &Shift{ + ID: uuid.NewString(), Resource: resource, AwayFrom: az, Comment: comment, Type: shiftZonal, + StartTime: now, ExpiryTime: now.Add(d), + } + b.shifts.Put(s) + + return viewOf(s, now), nil +} + +// checkNoConflict rejects a duplicate shift for the same zone, or one that would leave the resource with no zone. +func (b *InMemoryBackend) checkNoConflict(m managedResource, az string, now time.Time) error { + away := map[string]bool{az: true} + + for _, s := range b.activeShifts(m.ARN, shiftZonal, now) { + if s.AwayFrom == az { + return conflictReason("ZonalShiftAlreadyExists", s.ID, + "A zonal shift away from %s already exists for %s", az, m.ARN) + } + + away[s.AwayFrom] = true + } + + if len(m.ZoneIDs) > 0 && len(away) >= len(m.ZoneIDs) { + return conflictReason("SimultaneousZonalShiftsConflict", "", + "Shifting away from %s would leave %s with no Availability Zone serving traffic", az, m.ARN) + } + + return nil +} + +func (b *InMemoryBackend) shiftByID(id string) (*Shift, error) { + s, ok := b.shifts.Get(id) + if !ok { + return nil, restroute.NotFound("Zonal shift %s was not found", id) + } + + return s, nil +} + +// UpdateZonalShift changes the comment and/or resets the expiry of an active zonal shift. +func (b *InMemoryBackend) UpdateZonalShift(id, comment, expiresIn string) (ShiftView, error) { + if comment == "" && expiresIn == "" { + return ShiftView{}, invalidReason("MissingValue", "Specify comment, expiresIn or both") + } + + var d time.Duration + + if expiresIn != "" { + var err error + if d, err = parseExpiresIn(expiresIn); err != nil { + return ShiftView{}, err + } + } + + b.mu.Lock("UpdateZonalShift") + defer b.mu.Unlock() + + now := time.Now().UTC() + b.settleLocked(now) + + s, err := b.shiftByID(id) + if err != nil { + return ShiftView{}, err + } + + if s.Type != shiftZonal { + return ShiftView{}, invalidReason( + "AutoshiftUpdateNotAllowed", + "Only customer zonal shifts can be updated; %s is a %s", + id, + s.Type, + ) + } + + if s.status(now) != statusActive { + return ShiftView{}, conflictReason( + "ZonalShiftStatusNotActive", + id, + "Zonal shift %s is %s and cannot be updated", + id, + s.status(now), + ) + } + + if comment != "" { + s.Comment = comment + } + + if expiresIn != "" { + s.ExpiryTime = now.Add(d) + } + + return viewOf(s, now), nil +} + +// CancelZonalShift ends an active customer zonal shift immediately. +func (b *InMemoryBackend) CancelZonalShift(id string) (ShiftView, error) { + return b.cancel(id, shiftZonal, "CancelZonalShift") +} + +// CancelPracticeRun ends an active practice run immediately. +func (b *InMemoryBackend) CancelPracticeRun(id string) (ShiftView, error) { + return b.cancel(id, shiftPractice, "CancelPracticeRun") +} + +func (b *InMemoryBackend) cancel(id, wantType, op string) (ShiftView, error) { + b.mu.Lock(op) + defer b.mu.Unlock() + + now := time.Now().UTC() + b.settleLocked(now) + + s, err := b.shiftByID(id) + if err != nil { + return ShiftView{}, err + } + + if s.Type != wantType { + return ShiftView{}, invalidReason("UnsupportedPracticeCancelShiftType", + "Shift %s is a %s and cannot be canceled with %s", id, s.Type, op) + } + + if s.status(now) != statusActive { + return ShiftView{}, conflictReason( + "ZonalShiftStatusNotActive", + id, + "Zonal shift %s is %s and cannot be canceled", + id, + s.status(now), + ) + } + + s.CanceledAt = &now + if s.Type == shiftPractice { + s.Outcome = outcomeInterrupted + } + + return viewOf(s, now), nil +} + +// ListZonalShifts lists shifts, optionally filtered by status and resource. +func (b *InMemoryBackend) ListZonalShifts(status, resource, token string, maxResults int) ([]ShiftView, string, error) { + switch status { + case "", statusActive, statusExpired, statusCanceled: + default: + return nil, "", invalidReason("InvalidStatus", "status must be ACTIVE, EXPIRED or CANCELED") + } + + b.mu.Lock("ListZonalShifts") + defer b.mu.Unlock() + + now := time.Now().UTC() + b.settleLocked(now) + + var all []*Shift + + for _, s := range b.shifts.All() { + if (status == "" || s.status(now) == status) && (resource == "" || s.Resource == resource) { + all = append(all, s) + } + } + + pg, err := paginate(sortedShifts(all), token, maxResults) + if err != nil { + return nil, "", err + } + + out := make([]ShiftView, 0, len(pg.Data)) + for _, s := range pg.Data { + out = append(out, viewOf(s, now)) + } + + return out, pg.Next, nil +} diff --git a/services/arczonalshift/shifts_test.go b/services/arczonalshift/shifts_test.go new file mode 100644 index 0000000000..a4168cb85c --- /dev/null +++ b/services/arczonalshift/shifts_test.go @@ -0,0 +1,296 @@ +package arczonalshift_test + +import ( + "testing" + "time" + + "github.com/aws/aws-sdk-go-v2/aws" + sdk "github.com/aws/aws-sdk-go-v2/service/arczonalshift" + "github.com/aws/aws-sdk-go-v2/service/arczonalshift/types" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func (f *fixture) start(t *testing.T, lb, az, expires string) *sdk.StartZonalShiftOutput { + t.Helper() + + out, err := f.client.StartZonalShift(t.Context(), &sdk.StartZonalShiftInput{ + ResourceIdentifier: aws.String( + lb, + ), + AwayFrom: aws.String(az), + Comment: aws.String("drill"), + ExpiresIn: aws.String(expires), + }) + require.NoError(t, err) + + return out +} + +func TestZonalShiftLifecycle(t *testing.T) { + t.Parallel() + + f := newFixture(t) + ctx := t.Context() + lb := f.loadBalancer(t, "web", 2) + before := time.Now() + + started := f.start(t, lb, "us-east-1a", "20m") + assert.Equal(t, types.ZonalShiftStatusActive, started.Status) + assert.Equal(t, "use1-az1", aws.ToString(started.AwayFrom), "zone names resolve to zone IDs") + assert.Equal(t, "drill", aws.ToString(started.Comment)) + assert.Equal(t, lb, aws.ToString(started.ResourceIdentifier)) + assert.NotEmpty(t, aws.ToString(started.ZonalShiftId)) + assert.WithinDuration(t, before.Add(20*time.Minute), aws.ToTime(started.ExpiryTime), time.Minute) + + managed, err := f.client.ListManagedResources(ctx, &sdk.ListManagedResourcesInput{}) + require.NoError(t, err) + require.Len(t, managed.Items, 1) + + item := managed.Items[0] + assert.Equal(t, lb, aws.ToString(item.Arn)) + assert.Equal(t, "web", aws.ToString(item.Name)) + assert.Equal(t, []string{"use1-az1", "use1-az2"}, item.AvailabilityZones) + assert.Equal(t, map[string]float32{"use1-az1": 0, "use1-az2": 1}, item.AppliedWeights) + assert.Equal(t, types.ZonalAutoshiftStatusDisabled, item.ZonalAutoshiftStatus) + assert.Equal(t, types.ZonalAutoshiftStatusDisabled, item.PracticeRunStatus) + require.Len(t, item.ZonalShifts, 1) + assert.Equal(t, types.AppliedStatusApplied, item.ZonalShifts[0].AppliedStatus) + assert.Equal(t, aws.ToString(started.ZonalShiftId), aws.ToString(item.ZonalShifts[0].ZonalShiftId)) + + got, err := f.client.GetManagedResource(ctx, &sdk.GetManagedResourceInput{ResourceIdentifier: aws.String(lb)}) + require.NoError(t, err) + assert.Equal(t, "web", aws.ToString(got.Name)) + assert.InDelta(t, float32(0), got.AppliedWeights["use1-az1"], 0.0001) + assert.Len(t, got.ZonalShifts, 1) + + upd, err := f.client.UpdateZonalShift(ctx, &sdk.UpdateZonalShiftInput{ + ZonalShiftId: started.ZonalShiftId, Comment: aws.String("longer"), ExpiresIn: aws.String("3h"), + }) + require.NoError(t, err) + assert.Equal(t, "longer", aws.ToString(upd.Comment)) + assert.WithinDuration(t, before.Add(3*time.Hour), aws.ToTime(upd.ExpiryTime), time.Minute) + + canceled, err := f.client.CancelZonalShift(ctx, &sdk.CancelZonalShiftInput{ZonalShiftId: started.ZonalShiftId}) + require.NoError(t, err) + assert.Equal(t, types.ZonalShiftStatusCanceled, canceled.Status) + + _, err = f.client.CancelZonalShift(ctx, &sdk.CancelZonalShiftInput{ZonalShiftId: started.ZonalShiftId}) + var conflict *types.ConflictException + require.ErrorAs(t, err, &conflict) + assert.Equal(t, types.ConflictExceptionReasonZonalShiftStatusNotActive, conflict.Reason) + assert.Equal(t, aws.ToString(started.ZonalShiftId), aws.ToString(conflict.ZonalShiftId)) + + _, err = f.client.UpdateZonalShift( + ctx, + &sdk.UpdateZonalShiftInput{ZonalShiftId: started.ZonalShiftId, Comment: aws.String("x")}, + ) + require.ErrorAs(t, err, &conflict) + + managed, err = f.client.ListManagedResources(ctx, &sdk.ListManagedResourcesInput{}) + require.NoError(t, err) + assert.Empty(t, managed.Items[0].ZonalShifts, "a canceled shift no longer applies") + assert.InDelta(t, float32(1), managed.Items[0].AppliedWeights["use1-az1"], 0.0001) +} + +func TestZonalShiftListFilters(t *testing.T) { + t.Parallel() + + f := newFixture(t) + ctx := t.Context() + lb1, lb2 := f.loadBalancer(t, "one", 3), f.loadBalancer(t, "two", 3) + + active := f.start(t, lb1, "us-east-1a", "1h") + canceled := f.start(t, lb1, "us-east-1b", "1h") + _, err := f.client.CancelZonalShift(ctx, &sdk.CancelZonalShiftInput{ZonalShiftId: canceled.ZonalShiftId}) + require.NoError(t, err) + other := f.start(t, lb2, "us-east-1a", "1h") + + ids := func(in *sdk.ListZonalShiftsInput) []string { + out, lErr := f.client.ListZonalShifts(ctx, in) + require.NoError(t, lErr) + + s := make([]string, 0, len(out.Items)) + for _, i := range out.Items { + s = append(s, aws.ToString(i.ZonalShiftId)) + } + + return s + } + + assert.ElementsMatch( + t, + []string{ + aws.ToString(active.ZonalShiftId), + aws.ToString(canceled.ZonalShiftId), + aws.ToString(other.ZonalShiftId), + }, + ids(&sdk.ListZonalShiftsInput{}), + ) + assert.ElementsMatch( + t, + []string{aws.ToString(active.ZonalShiftId), aws.ToString(other.ZonalShiftId)}, + ids(&sdk.ListZonalShiftsInput{Status: types.ZonalShiftStatusActive}), + ) + assert.Equal( + t, + []string{aws.ToString(canceled.ZonalShiftId)}, + ids(&sdk.ListZonalShiftsInput{Status: types.ZonalShiftStatusCanceled}), + ) + assert.Empty(t, ids(&sdk.ListZonalShiftsInput{Status: types.ZonalShiftStatusExpired})) + assert.Equal( + t, + []string{aws.ToString(other.ZonalShiftId)}, + ids(&sdk.ListZonalShiftsInput{ResourceIdentifier: aws.String(lb2)}), + ) + + p := sdk.NewListZonalShiftsPaginator(f.client, &sdk.ListZonalShiftsInput{MaxResults: aws.Int32(2)}) + pages := 0 + + for p.HasMorePages() { + _, pErr := p.NextPage(ctx) + require.NoError(t, pErr) + + pages++ + } + + assert.Equal(t, 2, pages) + + _, err = f.client.ListZonalShifts(ctx, &sdk.ListZonalShiftsInput{Status: "BOGUS"}) + var ve *types.ValidationException + require.ErrorAs(t, err, &ve) + assert.Equal(t, types.ValidationExceptionReasonInvalidStatus, ve.Reason) +} + +func TestStartZonalShiftErrors(t *testing.T) { + t.Parallel() + + f := newFixture(t) + lb := f.loadBalancer(t, "web", 2) + missing := "arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/ghost/1234567890abcdef" + nlb := "arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/gwy/g/1234567890abcdef" + + tests := []struct { + name string + resource string + az string + expires string + wantCode string + wantReason string + }{ + {"bad expiry unit", lb, "us-east-1a", "90d", "ValidationException", "InvalidExpiresIn"}, + {"expiry above 72h", lb, "us-east-1a", "73h", "ValidationException", "InvalidExpiresIn"}, + {"expiry zero", lb, "us-east-1a", "0m", "ValidationException", "InvalidExpiresIn"}, + {"zone not on the resource", lb, "us-east-1c", "10m", "ValidationException", "InvalidAz"}, + {"resource type unsupported", nlb, "us-east-1a", "10m", "ValidationException", "InvalidResourceIdentifier"}, + {"resource not found", missing, "us-east-1a", "10m", "ResourceNotFoundException", ""}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + _, err := f.client.StartZonalShift(t.Context(), &sdk.StartZonalShiftInput{ + ResourceIdentifier: aws.String( + tt.resource, + ), + AwayFrom: aws.String(tt.az), + Comment: aws.String("c"), + ExpiresIn: aws.String(tt.expires), + }) + assert.Equal(t, tt.wantCode, apiCode(err)) + + var ve *types.ValidationException + if tt.wantReason != "" { + require.ErrorAs(t, err, &ve) + assert.Equal(t, types.ValidationExceptionReason(tt.wantReason), ve.Reason) + } + }) + } +} + +func TestStartZonalShiftConflicts(t *testing.T) { + t.Parallel() + + f := newFixture(t) + lb := f.loadBalancer(t, "web", 2) + f.start(t, lb, "us-east-1a", "10m") + + reason := func(az string) types.ConflictExceptionReason { + _, err := f.client.StartZonalShift(t.Context(), &sdk.StartZonalShiftInput{ + ResourceIdentifier: aws.String( + lb, + ), + AwayFrom: aws.String(az), + Comment: aws.String("c"), + ExpiresIn: aws.String("10m"), + }) + + var ce *types.ConflictException + require.ErrorAs(t, err, &ce) + + return ce.Reason + } + + assert.Equal(t, types.ConflictExceptionReasonZonalShiftAlreadyExists, reason("us-east-1a")) + assert.Equal( + t, + types.ConflictExceptionReasonSimultaneousZonalShiftsConflict, + reason("use1-az2"), + "shifting away from every zone is refused", + ) +} + +func TestUpdateZonalShiftErrors(t *testing.T) { + t.Parallel() + + f := newFixture(t) + started := f.start(t, f.loadBalancer(t, "web", 2), "us-east-1a", "10m") + + _, err := f.client.UpdateZonalShift(t.Context(), &sdk.UpdateZonalShiftInput{ZonalShiftId: started.ZonalShiftId}) + var ve *types.ValidationException + require.ErrorAs(t, err, &ve) + assert.Equal(t, types.ValidationExceptionReasonMissingValue, ve.Reason) + + _, err = f.client.UpdateZonalShift( + t.Context(), + &sdk.UpdateZonalShiftInput{ZonalShiftId: started.ZonalShiftId, ExpiresIn: aws.String("99d")}, + ) + require.ErrorAs(t, err, &ve) + assert.Equal(t, types.ValidationExceptionReasonInvalidExpiresIn, ve.Reason) + + _, err = f.client.UpdateZonalShift( + t.Context(), + &sdk.UpdateZonalShiftInput{ZonalShiftId: aws.String("nope"), Comment: aws.String("c")}, + ) + assert.Equal(t, "ResourceNotFoundException", apiCode(err)) + + _, err = f.client.CancelZonalShift(t.Context(), &sdk.CancelZonalShiftInput{ZonalShiftId: aws.String("nope")}) + assert.Equal(t, "ResourceNotFoundException", apiCode(err)) +} + +func TestManagedResourceErrors(t *testing.T) { + t.Parallel() + + f := newFixture(t) + + _, err := f.client.GetManagedResource( + t.Context(), + &sdk.GetManagedResourceInput{ + ResourceIdentifier: aws.String( + "arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/ghost/1", + ), + }, + ) + assert.Equal(t, "ResourceNotFoundException", apiCode(err)) + + _, err = f.client.GetManagedResource( + t.Context(), + &sdk.GetManagedResourceInput{ResourceIdentifier: aws.String("arn:aws:s3:::bucket")}, + ) + assert.Equal(t, "ValidationException", apiCode(err)) + + out, err := f.client.ListManagedResources(t.Context(), &sdk.ListManagedResourcesInput{}) + require.NoError(t, err) + assert.Empty(t, out.Items) +} diff --git a/services/arczonalshift/store.go b/services/arczonalshift/store.go new file mode 100644 index 0000000000..9472592315 --- /dev/null +++ b/services/arczonalshift/store.go @@ -0,0 +1,100 @@ +package arczonalshift + +import ( + "slices" + "sort" + "time" + + "github.com/blackbirdworks/gopherstack/pkgs/lockmetrics" + "github.com/blackbirdworks/gopherstack/pkgs/store" +) + +const ( + listDefault = 100 + listMax = 100 + + defaultPracticeRun = 30 * time.Minute + maxShiftDuration = 72 * time.Hour + settingsID = "account" +) + +// InMemoryBackend holds zonal shift state; managed resources are read live from ELBv2. +type InMemoryBackend struct { + appConfig any + registry *store.Registry + shifts *store.Table[Shift] + configs *store.Table[PracticeConfig] + resources *store.Table[ResourceSettings] + settings *store.Table[Settings] + mu *lockmetrics.RWMutex + accountID string + practiceDuration time.Duration +} + +// NewInMemoryBackend creates an empty backend for accountID. +func NewInMemoryBackend(accountID string) *InMemoryBackend { + b := &InMemoryBackend{ + registry: store.NewRegistry(), + mu: lockmetrics.New("arczonalshift"), + accountID: accountID, + practiceDuration: defaultPracticeRun, + } + + b.shifts = store.Register(b.registry, "shifts", store.New(func(v *Shift) string { return v.ID })) + b.configs = store.Register(b.registry, "configs", store.New(func(v *PracticeConfig) string { return v.Resource })) + b.resources = store.Register( + b.registry, + "resources", + store.New(func(v *ResourceSettings) string { return v.Resource }), + ) + b.settings = store.Register(b.registry, "settings", store.New(func(v *Settings) string { return v.ID })) + + return b +} + +// Reset clears all state. +func (b *InMemoryBackend) Reset() { + b.mu.Lock("Reset") + defer b.mu.Unlock() + + b.registry.ResetAll() +} + +// SetPracticeRunDuration sets how long a practice run stays ACTIVE (default 30 minutes); d <= 0 restores the default. +func (b *InMemoryBackend) SetPracticeRunDuration(d time.Duration) { + b.mu.Lock("SetPracticeRunDuration") + defer b.mu.Unlock() + + if d <= 0 { + d = defaultPracticeRun + } + + b.practiceDuration = d +} + +// SetAppConfig records service.AppContext.Config so siblings are resolved at call time. +func (b *InMemoryBackend) SetAppConfig(cfg any) { b.appConfig = cfg } + +func (s *Shift) status(now time.Time) string { + switch { + case s.CanceledAt != nil: + return statusCanceled + case !now.Before(s.ExpiryTime): + return statusExpired + default: + return statusActive + } +} + +func sortedShifts(items []*Shift) []*Shift { + out := slices.Clone(items) + sort.SliceStable(out, func(i, j int) bool { + if !out[i].StartTime.Equal(out[j].StartTime) { + return out[i].StartTime.Before(out[j].StartTime) + } + + return out[i].ID < out[j].ID + }) + + return out +} diff --git a/services/arczonalshift/time_test.go b/services/arczonalshift/time_test.go new file mode 100644 index 0000000000..f10869cb3a --- /dev/null +++ b/services/arczonalshift/time_test.go @@ -0,0 +1,161 @@ +package arczonalshift_test + +import ( + "testing" + "testing/synctest" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/blackbirdworks/gopherstack/pkgs/restroute" + + zs "github.com/blackbirdworks/gopherstack/services/arczonalshift" + cw "github.com/blackbirdworks/gopherstack/services/cloudwatch" + elbv2 "github.com/blackbirdworks/gopherstack/services/elbv2" +) + +// bubble is a backend wired to real ELBv2/CloudWatch siblings, for tests that move the fake clock. +type bubble struct { + b *zs.InMemoryBackend + alarms *cw.InMemoryBackend + lb string +} + +func newBubble(t *testing.T) *bubble { + t.Helper() + + elb := elbv2.NewInMemoryBackend(testAccount, testRegion) + t.Cleanup(elb.Close) + + alarms := cw.NewInMemoryBackendWithConfig(testAccount, testRegion) + b := zs.NewInMemoryBackend(testAccount) + b.SetAppConfig(&siblings{elb: elbv2.NewHandler(elb), cw: cw.NewHandler(alarms)}) + + lb, err := elb.CreateLoadBalancer( + elbv2.CreateLoadBalancerInput{Name: "web", Type: "application", Subnets: []string{"s1", "s2", "s3"}}, + ) + require.NoError(t, err) + + return &bubble{b: b, alarms: alarms, lb: lb.LoadBalancerArn} +} + +func (x *bubble) alarm(t *testing.T, name string) string { + t.Helper() + + require.NoError(t, x.alarms.PutMetricAlarm(&cw.MetricAlarm{AlarmName: name, StateValue: "OK"})) + + return "arn:aws:cloudwatch:" + testRegion + ":" + testAccount + ":alarm:" + name +} + +func statusOf(t *testing.T, b *zs.InMemoryBackend, id string) string { + t.Helper() + + all, _, err := b.ListZonalShifts("", "", "", 0) + require.NoError(t, err) + + for _, s := range all { + if s.ZonalShiftID == id { + return s.Status + } + } + + t.Fatalf("shift %s not listed", id) + + return "" +} + +func TestZonalShiftExpiresOverTime(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + want string + extend string + sleep time.Duration + afterUp time.Duration + cancel bool + }{ + {name: "active before expiry", sleep: 9 * time.Minute, want: "ACTIVE"}, + {name: "expired at expiry", sleep: 10 * time.Minute, want: "EXPIRED"}, + {name: "extended by update", sleep: 9 * time.Minute, extend: "30m", afterUp: 20 * time.Minute, want: "ACTIVE"}, + { + name: "extension also expires", + sleep: 9 * time.Minute, + extend: "30m", + afterUp: 31 * time.Minute, + want: "EXPIRED", + }, + {name: "cancel wins over expiry", sleep: time.Minute, cancel: true, afterUp: time.Hour, want: "CANCELED"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + synctest.Test(t, func(t *testing.T) { + x := newBubble(t) + + s, err := x.b.StartZonalShift(x.lb, "us-east-1a", "c", "10m") + require.NoError(t, err) + + time.Sleep(tt.sleep) + + switch { + case tt.extend != "": + _, err = x.b.UpdateZonalShift(s.ZonalShiftID, "", tt.extend) + require.NoError(t, err) + case tt.cancel: + _, err = x.b.CancelZonalShift(s.ZonalShiftID) + require.NoError(t, err) + } + + time.Sleep(tt.afterUp) + + assert.Equal(t, tt.want, statusOf(t, x.b, s.ZonalShiftID)) + + if tt.want == "EXPIRED" { + _, err = x.b.UpdateZonalShift(s.ZonalShiftID, "late", "") + require.Error(t, err, "an expired shift cannot be updated") + + _, err = x.b.CancelZonalShift(s.ZonalShiftID) + assert.Error(t, err, "an expired shift cannot be canceled") + } + }) + }) + } +} + +func TestExpiredShiftNoLongerAppliesWeights(t *testing.T) { + t.Parallel() + + synctest.Test(t, func(t *testing.T) { + x := newBubble(t) + + _, err := x.b.StartZonalShift(x.lb, "us-east-1a", "c", "10m") + require.NoError(t, err) + + res, err := x.b.GetManagedResource(x.lb) + require.NoError(t, err) + assert.InDelta(t, float32(0), res.AppliedWeights["use1-az1"], 0.0001) + + time.Sleep(11 * time.Minute) + + res, err = x.b.GetManagedResource(x.lb) + require.NoError(t, err) + assert.InDelta(t, float32(1), res.AppliedWeights["use1-az1"], 0.0001) + assert.Empty(t, res.ZonalShifts) + + _, err = x.b.StartZonalShift(x.lb, "us-east-1a", "again", "10m") + require.NoError(t, err, "an expired shift does not block a new one for the same zone") + }) +} + +func requireReason(t *testing.T, err error, reason string) { + t.Helper() + + var re *restroute.Error + + require.ErrorAs(t, err, &re) + assert.Equal(t, reason, re.Extra["reason"]) +} diff --git a/services/ec2/az_ids.go b/services/ec2/az_ids.go index d9772e86e4..600df0d116 100644 --- a/services/ec2/az_ids.go +++ b/services/ec2/az_ids.go @@ -22,6 +22,9 @@ func azDirectionAbbrev(p string) string { return p[:1] } +// AvailabilityZoneID is the exported form of the AZ name to zone ID mapping. +func AvailabilityZoneID(az string) string { return availabilityZoneID(az) } + // availabilityZoneID maps a zone name like "us-east-1a" to a stable zone ID // like "use1-az1"; names outside the region-plus-letter form yield "". func availabilityZoneID(az string) string { diff --git a/services/elbv2/crossservice.go b/services/elbv2/crossservice.go index 6a5cb4eea5..c5917b32d7 100644 --- a/services/elbv2/crossservice.go +++ b/services/elbv2/crossservice.go @@ -28,6 +28,13 @@ type InstanceStateResolver interface { InstanceStateName(id string) (string, bool) } +// RegionalSubnetPlacementResolver resolves a subnet within the load balancer's own region; subnet IDs such as +// subnet-default exist in every region, so a region-blind lookup can answer for the wrong one. +type RegionalSubnetPlacementResolver interface { + SubnetVPCIn(region, id string) string + SubnetAZIn(region, id string) string +} + // SubnetPlacementResolver is the optional EC2Resolver extension CreateLoadBalancer uses to // report a subnet's real VPC and Availability Zone instead of placeholders. type SubnetPlacementResolver interface { diff --git a/services/elbv2/load_balancers.go b/services/elbv2/load_balancers.go index f082d26f4d..b7b08f2b22 100644 --- a/services/elbv2/load_balancers.go +++ b/services/elbv2/load_balancers.go @@ -180,6 +180,10 @@ func subnetMappingsToAZs(region string, mappings []SubnetMapping) []Availability func (b *InMemoryBackend) placeSubnets(mappings []SubnetMapping) ([]AvailabilityZone, string) { azs := subnetMappingsToAZs(b.region, mappings) + if regional, ok := b.ec2Resolver.(RegionalSubnetPlacementResolver); ok { + return b.placeSubnetsIn(regional, azs, mappings) + } + placer, ok := b.ec2Resolver.(SubnetPlacementResolver) if !ok { return azs, "" @@ -200,6 +204,24 @@ func (b *InMemoryBackend) placeSubnets(mappings []SubnetMapping) ([]Availability return azs, vpcID } +func (b *InMemoryBackend) placeSubnetsIn( + r RegionalSubnetPlacementResolver, azs []AvailabilityZone, mappings []SubnetMapping, +) ([]AvailabilityZone, string) { + vpcID := "" + + for i, m := range mappings { + if az := r.SubnetAZIn(b.region, m.SubnetID); az != "" { + azs[i].ZoneName = az + } + + if vpcID == "" { + vpcID = r.SubnetVPCIn(b.region, m.SubnetID) + } + } + + return azs, vpcID +} + // subnetsToMappings converts plain subnet ID strings into SubnetMapping values. func subnetsToMappings(subnets []string) []SubnetMapping { out := make([]SubnetMapping, len(subnets)) diff --git a/services/iot/handler_routing.go b/services/iot/handler_routing.go index a8f0d58230..1e072ff68b 100644 --- a/services/iot/handler_routing.go +++ b/services/iot/handler_routing.go @@ -26,7 +26,7 @@ func matchIoTPath(method, path, svc string) bool { return matchIoTJobsShape(method, path) && (svc == "" || svc == iotServiceName) } - if isIoTWirelessSharedPath(path) && svc != "" && svc != iotServiceName { + if claimedByOtherScope(path, svc) { return false } @@ -35,6 +35,14 @@ func matchIoTPath(method, path, svc string) bool { matchPolicyPrincipalPath(path) || matchCertificateTransferPath(path) || matchMiscUnroutedPath(path) } +// pathListRules is IoT's ListTopicRules path; Route 53 Recovery Readiness ListRules uses the same path. +const pathListRules = "/rules" + +// claimedByOtherScope reports shared paths whose request is signed for a different service. +func claimedByOtherScope(path, svc string) bool { + return (isIoTWirelessSharedPath(path) || path == pathListRules) && svc != "" && svc != iotServiceName +} + // isIoTWirelessSharedPath reports paths IoT Wireless also serves (destinations, event-configurations, tags). func isIoTWirelessSharedPath(path string) bool { return path == pathDestinations || strings.HasPrefix(path, pathDestinations+"/") || diff --git a/services/iot/route_scope_rules_test.go b/services/iot/route_scope_rules_test.go new file mode 100644 index 0000000000..4ac86014ff --- /dev/null +++ b/services/iot/route_scope_rules_test.go @@ -0,0 +1,45 @@ +package iot_test + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/labstack/echo/v5" + "github.com/stretchr/testify/assert" + + "github.com/blackbirdworks/gopherstack/services/iot" +) + +// GET /rules is IoT ListTopicRules and also Route 53 Recovery Readiness ListRules; the SigV4 scope decides. +func TestRouteMatcher_RulesPathHonoursSigningScope(t *testing.T) { + t.Parallel() + + matcher := iot.NewHandler(iot.NewInMemoryBackend(), nil).RouteMatcher() + + tests := []struct { + name string + scope string + want bool + }{ + {"iot scope", "iot", true}, + {"unsigned", "", true}, + {"recovery readiness scope", "route53-recovery-readiness", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/rules", nil) + if tt.scope != "" { + req.Header.Set( + "Authorization", + "AWS4-HMAC-SHA256 Credential=test/20260101/us-east-1/"+tt.scope+"/aws4_request", + ) + } + + assert.Equal(t, tt.want, matcher(echo.New().NewContext(req, httptest.NewRecorder()))) + }) + } +} diff --git a/services/route53/cross_service.go b/services/route53/cross_service.go new file mode 100644 index 0000000000..bee05d2736 --- /dev/null +++ b/services/route53/cross_service.go @@ -0,0 +1,78 @@ +package route53 + +import ( + "strings" + + "github.com/blackbirdworks/gopherstack/pkgs/service" +) + +// recoveryControlSiblings is matched structurally against *CLI, which cannot be imported here. +type recoveryControlSiblings interface { + GetRoute53RecoveryControlConfigHandler() service.Registerable +} + +type routingControlStates interface { + RoutingControlIsOn(arn string) (bool, bool) +} + +// SetAppConfig records service.AppContext.Config so the recovery-control sibling is resolved at call time. +func (b *InMemoryBackend) SetAppConfig(cfg any) { b.appConfig = cfg } + +// routingControlState returns (on, found) for the routing control, resolving the sibling at call time. +func (b *InMemoryBackend) routingControlState(arn string) (bool, bool) { + s, ok := b.appConfig.(recoveryControlSiblings) + if !ok { + return false, false + } + + st, ok := s.GetRoute53RecoveryControlConfigHandler().(routingControlStates) + if !ok { + return false, false + } + + return st.RoutingControlIsOn(arn) +} + +// effectiveStatus is the stored status, except for RECOVERY_CONTROL checks whose status follows the routing control. +func (b *InMemoryBackend) effectiveStatus(hc *HealthCheck) string { + if hc.Config.Type != HealthCheckTypeRecoveryControl || hc.Config.RoutingControlArn == "" { + return hc.Status + } + + on, found := b.routingControlState(hc.Config.RoutingControlArn) + if !found { + return hc.Status + } + + if on { + return defaultHealthStatus + } + + return healthUnhealthy +} + +// RecoveryControlHealthCheckIDs returns the IDs of RECOVERY_CONTROL health checks bound to the routing control. +func (b *InMemoryBackend) RecoveryControlHealthCheckIDs(routingControlArn string) []string { + b.mu.RLock("RecoveryControlHealthCheckIDs") + defer b.mu.RUnlock() + + var ids []string + + for _, hc := range b.healthChecks.All() { + if hc.Config.Type == HealthCheckTypeRecoveryControl && + strings.EqualFold(hc.Config.RoutingControlArn, routingControlArn) { + ids = append(ids, hc.ID) + } + } + + return ids +} + +// RecoveryControlHealthCheckIDs returns the IDs of RECOVERY_CONTROL health checks bound to the routing control. +func (h *Handler) RecoveryControlHealthCheckIDs(routingControlArn string) []string { + if bk, ok := h.Backend.(interface{ RecoveryControlHealthCheckIDs(string) []string }); ok { + return bk.RecoveryControlHealthCheckIDs(routingControlArn) + } + + return nil +} diff --git a/services/route53/health_checks.go b/services/route53/health_checks.go index c8c5f0e0d3..03e0763217 100644 --- a/services/route53/health_checks.go +++ b/services/route53/health_checks.go @@ -232,7 +232,7 @@ func (b *InMemoryBackend) GetHealthCheckStatus(id string) (string, error) { return "", fmt.Errorf("%w: health check %s not found", ErrHealthCheckNotFound, id) } - return hc.Status, nil + return b.effectiveStatus(hc), nil } // SetHealthCheckStatus overrides the mocked health status for a health check. diff --git a/services/route53/provider.go b/services/route53/provider.go index faf2ba33fe..93f53d565f 100644 --- a/services/route53/provider.go +++ b/services/route53/provider.go @@ -24,6 +24,7 @@ func (p *Provider) Init(ctx *service.AppContext) (service.Registerable, error) { } backend := NewInMemoryBackend() + backend.SetAppConfig(ctx.Config) handler := NewHandler(backend) return handler, nil diff --git a/services/route53/recovery_control_test.go b/services/route53/recovery_control_test.go new file mode 100644 index 0000000000..295e304318 --- /dev/null +++ b/services/route53/recovery_control_test.go @@ -0,0 +1,194 @@ +package route53_test + +import ( + "fmt" + "sync/atomic" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/blackbirdworks/gopherstack/pkgs/service" + "github.com/blackbirdworks/gopherstack/services/route53" + cc "github.com/blackbirdworks/gopherstack/services/route53recoverycontrolconfig" +) + +// recoveryStack exposes the control-plane handler the way *CLI does. +type recoveryStack struct { + cc atomic.Pointer[cc.Handler] +} + +func (s *recoveryStack) GetRoute53RecoveryControlConfigHandler() service.Registerable { + if h := s.cc.Load(); h != nil { + return h + } + + return nil +} + +type recoveryWorld struct { + backend *route53.InMemoryBackend + control *cc.InMemoryBackend + arn string + zone string +} + +func newRecoveryWorld(t *testing.T, wire bool) *recoveryWorld { + t.Helper() + + control := cc.NewInMemoryBackend("123456789012") + cl, err := control.CreateCluster(t.Context(), "c", "", "", nil) + require.NoError(t, err) + rcOut, err := control.CreateRoutingControl(cl.ClusterArn, "", "primary-up", "") + require.NoError(t, err) + + b := route53.NewInMemoryBackend() + + if wire { + st := &recoveryStack{} + st.cc.Store(cc.NewHandler(control)) + b.SetAppConfig(st) + } + + zone, err := b.CreateHostedZone("example.com.", "ref", "", false, "", "", "") + require.NoError(t, err) + + return &recoveryWorld{backend: b, control: control, arn: rcOut.RoutingControlArn, zone: zone.ID} +} + +func (w *recoveryWorld) flip(t *testing.T, state string) { + t.Helper() + require.NoError(t, w.control.UpdateRoutingControlStates([]cc.StateUpdate{{ARN: w.arn, State: state}}, nil, "")) +} + +func (w *recoveryWorld) healthCheck(t *testing.T, inverted bool) string { + t.Helper() + + hc, err := w.backend.CreateHealthCheck(fmt.Sprintf("hc-%s-%t", t.Name(), inverted), route53.HealthCheckConfig{ + Type: route53.HealthCheckTypeRecoveryControl, RoutingControlArn: w.arn, Inverted: inverted, + }) + require.NoError(t, err) + + return hc.ID +} + +func (w *recoveryWorld) failoverRecords(t *testing.T, hcID string) { + t.Helper() + + rec := func(id string, fo route53.FailoverPolicy, ip, hc string) route53.Change { + return route53.Change{Action: route53.ChangeActionCreate, ResourceRecordSet: route53.ResourceRecordSet{ + Name: "app.example.com.", Type: "A", TTL: 60, SetIdentifier: id, Failover: fo, HealthCheckID: hc, + Records: []route53.ResourceRecord{{Value: ip}}, + }} + } + + _, err := w.backend.ChangeResourceRecordSets(w.zone, []route53.Change{ + rec("primary", route53.FailoverPrimary, "192.0.2.1", hcID), + rec("secondary", route53.FailoverSecondary, "192.0.2.2", ""), + }) + require.NoError(t, err) +} + +func (w *recoveryWorld) answer(t *testing.T) string { + t.Helper() + + got, err := w.backend.TestDNSAnswer(w.zone, "app.example.com.", "A", route53.DNSQueryContext{}) + require.NoError(t, err) + require.Len(t, got, 1) + + return got[0] +} + +func TestRecoveryControlHealthCheckFollowsRoutingControl(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + state string + wantStatus string + wantResolves string + inverted bool + }{ + {name: "on is healthy", state: "On", wantStatus: "Healthy", wantResolves: "192.0.2.1"}, + {name: "off is unhealthy", state: "Off", wantStatus: "Unhealthy", wantResolves: "192.0.2.2"}, + {name: "inverted on fails over", inverted: true, state: "On", wantStatus: "Healthy", wantResolves: "192.0.2.2"}, + { + name: "inverted off serves primary", + inverted: true, + state: "Off", + wantStatus: "Unhealthy", + wantResolves: "192.0.2.1", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + w := newRecoveryWorld(t, true) + hc := w.healthCheck(t, tt.inverted) + w.failoverRecords(t, hc) + w.flip(t, tt.state) + + status, err := w.backend.GetHealthCheckStatus(hc) + require.NoError(t, err) + assert.Equal(t, tt.wantStatus, status) + assert.Equal(t, tt.wantResolves, w.answer(t)) + }) + } +} + +func TestRecoveryControlFailoverFollowsFlips(t *testing.T) { + t.Parallel() + + w := newRecoveryWorld(t, true) + w.failoverRecords(t, w.healthCheck(t, false)) + + w.flip(t, "On") + assert.Equal(t, "192.0.2.1", w.answer(t)) + w.flip(t, "Off") + assert.Equal(t, "192.0.2.2", w.answer(t), "turning the routing control Off fails over to the secondary") + w.flip(t, "On") + assert.Equal(t, "192.0.2.1", w.answer(t), "turning it back On restores the primary") +} + +func TestRecoveryControlWithoutSiblingKeepsStoredStatus(t *testing.T) { + t.Parallel() + + w := newRecoveryWorld(t, false) + hc := w.healthCheck(t, false) + w.flip(t, "Off") + + status, err := w.backend.GetHealthCheckStatus(hc) + require.NoError(t, err) + assert.Equal(t, "Healthy", status, "no routing control store is reachable, so the stored status is kept") +} + +func TestRecoveryControlDeletedControlKeepsStoredStatus(t *testing.T) { + t.Parallel() + + w := newRecoveryWorld(t, true) + hc := w.healthCheck(t, false) + w.flip(t, "Off") + require.NoError(t, w.control.DeleteRoutingControl(w.arn)) + + status, err := w.backend.GetHealthCheckStatus(hc) + require.NoError(t, err) + assert.Equal(t, "Healthy", status) +} + +func TestRecoveryControlHealthCheckIDs(t *testing.T) { + t.Parallel() + + w := newRecoveryWorld(t, true) + a, b := w.healthCheck(t, false), w.healthCheck(t, true) + + _, err := w.backend.CreateHealthCheck( + "tcp", + route53.HealthCheckConfig{Type: route53.HealthCheckTypeTCP, IPAddress: "192.0.2.9", Port: 80}, + ) + require.NoError(t, err) + + assert.ElementsMatch(t, []string{a, b}, w.backend.RecoveryControlHealthCheckIDs(w.arn)) + assert.Empty(t, w.backend.RecoveryControlHealthCheckIDs(w.arn+"x")) +} diff --git a/services/route53/routing.go b/services/route53/routing.go index 89dec5148e..71d180c4eb 100644 --- a/services/route53/routing.go +++ b/services/route53/routing.go @@ -261,7 +261,12 @@ func (b *InMemoryBackend) recordHealthy(rrs *ResourceRecordSet) bool { return true } - return !strings.EqualFold(hc.Status, healthUnhealthy) + status := b.effectiveStatus(hc) + if hc.Config.Type == HealthCheckTypeRecoveryControl && hc.Config.Inverted { + return strings.EqualFold(status, healthUnhealthy) + } + + return !strings.EqualFold(status, healthUnhealthy) } // routingKind classifies a set of candidate record sets into a single routing diff --git a/services/route53/store.go b/services/route53/store.go index 62a313afd8..b44d07ace6 100644 --- a/services/route53/store.go +++ b/services/route53/store.go @@ -28,6 +28,7 @@ const ( // fields are deliberately left as plain maps -- see the doc comment atop // store_setup.go for the full list and why. type InMemoryBackend struct { + appConfig any dns DNSRegistrar registry *store.Registry zones *store.Table[zoneData] diff --git a/services/route53recoverycluster/PARITY.md b/services/route53recoverycluster/PARITY.md new file mode 100644 index 0000000000..173146b718 --- /dev/null +++ b/services/route53recoverycluster/PARITY.md @@ -0,0 +1,28 @@ +--- +service: route53recoverycluster +sdk_module: aws-sdk-go-v2/service/route53recoverycluster@v1.36.4 +last_audit_commit: 80b7bb8b3 +last_audit_date: 2026-10-14 +overall: A +ops: + GetRoutingControlState: {wire: ok, errors: ok, state: ok, persist: ok, note: "On or Off; ResourceNotFound for controls outside the endpoint's cluster"} + ListRoutingControls: {wire: ok, errors: ok, state: ok, persist: ok} + UpdateRoutingControlState: {wire: ok, errors: ok, state: ok, persist: ok, note: "evaluates assertion and gating rules; honours SafetyRulesToOverride"} + UpdateRoutingControlStates: {wire: ok, errors: ok, state: ok, persist: ok, note: "atomic batch; a violated rule rejects every entry"} +families: + route_matching: {status: ok, note: "matches the ToggleCustomerAPI target prefix, or the signing name on POST"} + errors: {status: ok, note: "JSON error bodies carry __type, message and Message; ConflictException adds resourceId and resourceType"} +gaps: [] +items_still_open: [] +structural_gaps: [] +deferred: [] +leaks: {status: clean, note: "no goroutines or janitors; expiry and lifecycle are derived from the clock at read time"} +--- + +## Notes + +- Protocol: JSON 1.0 with `X-Amz-Target: ToggleCustomerAPI.`; SigV4 signing name `route53-recovery-cluster`. +- No state of its own: every call resolves `route53recoverycontrolconfig` at call time and reads or updates routing control state there. +- Clients are pointed at a cluster endpoint from `DescribeCluster`. Endpoints are `:///route53-recovery-cluster//` on this server (the host the control-plane caller reached, or `ROUTE53_RECOVERY_CONTROL_ENDPOINT`), so the data plane is reachable through the normal router. A control that belongs to another cluster is ResourceNotFound on that endpoint. +- ConflictException names the blocking safety rule (message, `resourceId` is the rule ARN) and covers wait periods. + diff --git a/services/route53recoverycluster/README.md b/services/route53recoverycluster/README.md new file mode 100644 index 0000000000..04394e1dc4 --- /dev/null +++ b/services/route53recoverycluster/README.md @@ -0,0 +1,19 @@ + +# Route 53 Recovery Cluster + +**Parity grade: A** · SDK `aws-sdk-go-v2/service/route53recoverycluster@v1.36.4` · last audited 2026-10-14 (`80b7bb8b3`) + +## Coverage + +| Metric | Value | +| --- | --- | +| PARITY entries audited | 4 (4 ok) | +| Feature families | 2 (2 ok) | +| Known gaps | none | +| Deferred items | 0 | +| Resource leaks | clean | + +## More + +- [Full parity audit](PARITY.md) +- [All services](../../README.md#services) diff --git a/services/route53recoverycluster/dataplane_test.go b/services/route53recoverycluster/dataplane_test.go new file mode 100644 index 0000000000..3f1f30732f --- /dev/null +++ b/services/route53recoverycluster/dataplane_test.go @@ -0,0 +1,109 @@ +package route53recoverycluster_test + +import ( + "errors" + "testing" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/route53recoverycluster" + "github.com/aws/aws-sdk-go-v2/service/route53recoverycluster/types" + rccfg "github.com/aws/aws-sdk-go-v2/service/route53recoverycontrolconfig" + "github.com/aws/smithy-go" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// topology is a deployed cluster with two routing controls in its default panel. +type topology struct { + client *route53recoverycluster.Client + clusterArn string + panelArn string + controlA string + controlB string + endpoint string +} + +func (f *fixture) build(t *testing.T) topology { + t.Helper() + + ctx := t.Context() + cl, err := f.ccClient.CreateCluster(ctx, &rccfg.CreateClusterInput{ClusterName: aws.String("c1")}) + require.NoError(t, err) + + desc, err := f.ccClient.DescribeCluster(ctx, &rccfg.DescribeClusterInput{ClusterArn: cl.Cluster.ClusterArn}) + require.NoError(t, err) + require.NotEmpty(t, desc.Cluster.ClusterEndpoints) + + panels, err := f.ccClient.ListControlPanels(ctx, &rccfg.ListControlPanelsInput{ClusterArn: cl.Cluster.ClusterArn}) + require.NoError(t, err) + require.Len(t, panels.ControlPanels, 1) + + mk := func(name string) string { + out, cErr := f.ccClient.CreateRoutingControl(ctx, &rccfg.CreateRoutingControlInput{ + ClusterArn: cl.Cluster.ClusterArn, RoutingControlName: aws.String(name), + }) + require.NoError(t, cErr) + + return aws.ToString(out.RoutingControl.RoutingControlArn) + } + + ep := desc.Cluster.ClusterEndpoints[0] + + return topology{ + clusterArn: aws.ToString( + cl.Cluster.ClusterArn, + ), + panelArn: aws.ToString(panels.ControlPanels[0].ControlPanelArn), + controlA: mk( + "a", + ), + controlB: mk("b"), + endpoint: aws.ToString(ep.Endpoint), + client: f.dataPlane(aws.ToString(ep.Endpoint)), + } +} + +func apiCode(err error) string { + if ae, ok := errors.AsType[smithy.APIError](err); ok { + return ae.ErrorCode() + } + + return "" +} + +func TestClusterEndpointsServeDataPlane(t *testing.T) { + t.Parallel() + + f := newFixture(t) + topo := f.build(t) + ctx := t.Context() + + assert.Contains(t, topo.endpoint, f.url) + + got, err := topo.client.GetRoutingControlState(ctx, &route53recoverycluster.GetRoutingControlStateInput{ + RoutingControlArn: aws.String(topo.controlA), + }) + require.NoError(t, err) + assert.Equal(t, types.RoutingControlStateOff, got.RoutingControlState) + assert.Equal(t, "a", aws.ToString(got.RoutingControlName)) + + _, err = topo.client.UpdateRoutingControlState(ctx, &route53recoverycluster.UpdateRoutingControlStateInput{ + RoutingControlArn: aws.String(topo.controlA), RoutingControlState: types.RoutingControlStateOn, + }) + require.NoError(t, err) + + got, err = topo.client.GetRoutingControlState(ctx, &route53recoverycluster.GetRoutingControlStateInput{ + RoutingControlArn: aws.String(topo.controlA), + }) + require.NoError(t, err) + assert.Equal(t, types.RoutingControlStateOn, got.RoutingControlState) + + list, err := topo.client.ListRoutingControls(ctx, &route53recoverycluster.ListRoutingControlsInput{ + ControlPanelArn: aws.String(topo.panelArn), + }) + require.NoError(t, err) + require.Len(t, list.RoutingControls, 2) + assert.Equal(t, "a", aws.ToString(list.RoutingControls[0].RoutingControlName)) + assert.Equal(t, types.RoutingControlStateOn, list.RoutingControls[0].RoutingControlState) + assert.Equal(t, "DefaultControlPanel", aws.ToString(list.RoutingControls[0].ControlPanelName)) +} diff --git a/services/route53recoverycluster/fixture_test.go b/services/route53recoverycluster/fixture_test.go new file mode 100644 index 0000000000..e55dd74ef0 --- /dev/null +++ b/services/route53recoverycluster/fixture_test.go @@ -0,0 +1,67 @@ +package route53recoverycluster_test + +import ( + "sync/atomic" + "testing" + + "github.com/aws/aws-sdk-go-v2/aws" + awscfg "github.com/aws/aws-sdk-go-v2/config" + "github.com/aws/aws-sdk-go-v2/credentials" + "github.com/aws/aws-sdk-go-v2/service/route53recoverycluster" + rccfg "github.com/aws/aws-sdk-go-v2/service/route53recoverycontrolconfig" + "github.com/stretchr/testify/require" + + "github.com/blackbirdworks/gopherstack/pkgs/sdktest" + "github.com/blackbirdworks/gopherstack/pkgs/service" + cluster "github.com/blackbirdworks/gopherstack/services/route53recoverycluster" + controlconfig "github.com/blackbirdworks/gopherstack/services/route53recoverycontrolconfig" +) + +const testAccount = "123456789012" + +// stack is a minimal composition root: the control plane, the data plane and a config that exposes the sibling. +type stack struct { + controlPlane *controlconfig.Handler + endpoint atomic.Value +} + +func (s *stack) GetRoute53RecoveryControlConfigHandler() service.Registerable { return s.controlPlane } + +func (s *stack) GetEndpoint() string { return s.endpoint.Load().(string) } + +type fixture struct { + stack *stack + ccClient *rccfg.Client + cfg aws.Config + url string +} + +func newFixture(t *testing.T) *fixture { + t.Helper() + + st := &stack{} + st.endpoint.Store("") + + st.controlPlane = controlconfig.NewHandler(controlconfig.NewInMemoryBackend(testAccount)) + st.controlPlane.Backend.SetAppConfig(st) + + url := sdktest.Serve(t, st.controlPlane, cluster.NewHandler(st)) + st.endpoint.Store(url) + + cfg, err := awscfg.LoadDefaultConfig(t.Context(), + awscfg.WithRegion("us-west-2"), + awscfg.WithCredentialsProvider(credentials.NewStaticCredentialsProvider("test", "test", "")), + ) + require.NoError(t, err) + + return &fixture{ + stack: st, cfg: cfg, url: url, + ccClient: rccfg.NewFromConfig(cfg, func(o *rccfg.Options) { o.BaseEndpoint = aws.String(url) }), + } +} + +func (f *fixture) dataPlane(endpoint string) *route53recoverycluster.Client { + return route53recoverycluster.NewFromConfig(f.cfg, func(o *route53recoverycluster.Options) { + o.BaseEndpoint = aws.String(endpoint) + }) +} diff --git a/services/route53recoverycluster/handler.go b/services/route53recoverycluster/handler.go new file mode 100644 index 0000000000..ff75d707b4 --- /dev/null +++ b/services/route53recoverycluster/handler.go @@ -0,0 +1,176 @@ +// Package route53recoverycluster emulates the Route 53 ARC cluster data plane: routing control state reads and updates. +package route53recoverycluster + +import ( + "context" + "errors" + "net/http" + "strings" + + "github.com/labstack/echo/v5" + + "github.com/blackbirdworks/gopherstack/pkgs/httputils" + "github.com/blackbirdworks/gopherstack/pkgs/logger" + "github.com/blackbirdworks/gopherstack/pkgs/restroute" + "github.com/blackbirdworks/gopherstack/pkgs/service" + cc "github.com/blackbirdworks/gopherstack/services/route53recoverycontrolconfig" +) + +const ( + signingName = "route53-recovery-cluster" + targetPrefix = "ToggleCustomerAPI." + contentType = "application/x-amz-json-1.0" + matchPriority = service.PriorityHeaderExact + listDefault = 500 + listMax = 1000 +) + +// Handler serves the cluster data plane; state lives in the control-plane backend, resolved at call time. +type Handler struct { + appConfig any + DefaultRegion string +} + +// siblingServices is matched structurally against *CLI, which cannot be imported here. +type siblingServices interface { + GetRoute53RecoveryControlConfigHandler() service.Registerable +} + +// NewHandler creates the handler; cfg is service.AppContext.Config. +func NewHandler(cfg any) *Handler { return &Handler{appConfig: cfg} } + +func (h *Handler) controlConfig() (*cc.InMemoryBackend, error) { + if s, ok := h.appConfig.(siblingServices); ok { + if ch, isCC := s.GetRoute53RecoveryControlConfigHandler().(*cc.Handler); isCC && ch != nil { + return ch.Backend, nil + } + } + + return nil, &apiError{status: http.StatusInternalServerError, code: "InternalServerException", + message: "The routing control store is unavailable"} +} + +// Name returns the service name. +func (h *Handler) Name() string { return "Route53RecoveryCluster" } + +// GetSupportedOperations lists every implemented operation. +func (h *Handler) GetSupportedOperations() []string { + return []string{ + "GetRoutingControlState", + "ListRoutingControls", + "UpdateRoutingControlState", + "UpdateRoutingControlStates", + } +} + +// ChaosServiceName returns the lowercase service name for fault rules. +func (h *Handler) ChaosServiceName() string { return signingName } + +// ChaosOperations returns the fault-injectable operations. +func (h *Handler) ChaosOperations() []string { return h.GetSupportedOperations() } + +// ChaosRegions returns the regions served. +func (h *Handler) ChaosRegions() []string { return []string{h.DefaultRegion} } + +// RouteMatcher matches the ToggleCustomerAPI target or the data-plane signing name. +func (h *Handler) RouteMatcher() service.Matcher { + return func(c *echo.Context) bool { + r := c.Request() + + return strings.HasPrefix(httputils.HeaderValue(r.Header, "X-Amz-Target"), targetPrefix) || + (httputils.ExtractServiceFromRequest(r) == signingName && r.Method == http.MethodPost) + } +} + +// MatchPriority returns the routing priority. +func (h *Handler) MatchPriority() int { return matchPriority } + +// ExtractOperation returns the operation name for metrics. +func (h *Handler) ExtractOperation(c *echo.Context) string { + target := c.Request().Header.Get("X-Amz-Target") + for _, op := range h.GetSupportedOperations() { + if target == targetPrefix+op { + return op + } + } + + return "Unknown" +} + +// ExtractResource returns no resource label; operations name routing controls in the body. +func (h *Handler) ExtractResource(_ *echo.Context) string { return "" } + +// Handler returns the Echo handler. +func (h *Handler) Handler() echo.HandlerFunc { + return func(c *echo.Context) error { + clusterID := clusterFromPath(c.Request().URL.Path) + dispatch := func(ctx context.Context, action string, body []byte) ([]byte, error) { + return h.dispatch(ctx, action, clusterID, body) + } + + return service.HandleTarget(c, logger.Load(c.Request().Context()), "", contentType, + h.GetSupportedOperations(), dispatch, h.handleError) + } +} + +// clusterFromPath extracts from /route53-recovery-cluster///. +func clusterFromPath(path string) string { + parts := strings.Split(strings.Trim(path, "/"), "/") + if len(parts) >= 2 && parts[0] == signingName { + return parts[1] + } + + return "" +} + +type apiError struct { + code string + message string + resourceID string + status int +} + +func (e *apiError) Error() string { return e.code + ": " + e.message } + +func (h *Handler) handleError(_ context.Context, c *echo.Context, _ string, err error) error { + var ae *apiError + if !errors.As(err, &ae) { + ae = mapError(err) + } + + c.Response().Header().Set("X-Amzn-Errortype", ae.code) + c.Response().Header().Set("Content-Type", contentType) + + body := map[string]any{ + "__type": ae.code, + "message": ae.message, + "Message": ae.message, + } + if ae.resourceID != "" { + body["resourceId"] = ae.resourceID + body["resourceType"] = "SafetyRule" + } + + return c.JSON(ae.status, body) +} + +func mapError(err error) *apiError { + if conflict, ok := errors.AsType[*cc.ConflictError](err); ok { + return &apiError{ + status: http.StatusConflict, + code: "ConflictException", + message: conflict.Message, + resourceID: conflict.RuleARN, + } + } + + if rest, ok := errors.AsType[*restroute.Error](err); ok { + return &apiError{status: rest.Status, code: rest.Code, message: rest.Message} + } + + return &apiError{ + status: http.StatusInternalServerError, + code: "InternalServerException", + message: "Internal server error", + } +} diff --git a/services/route53recoverycluster/leak_main_test.go b/services/route53recoverycluster/leak_main_test.go new file mode 100644 index 0000000000..13a347338f --- /dev/null +++ b/services/route53recoverycluster/leak_main_test.go @@ -0,0 +1,9 @@ +package route53recoverycluster_test + +import ( + "testing" + + "github.com/blackbirdworks/gopherstack/pkgs/testleak" +) + +func TestMain(m *testing.M) { testleak.VerifyTestMain(m) } diff --git a/services/route53recoverycluster/misc_test.go b/services/route53recoverycluster/misc_test.go new file mode 100644 index 0000000000..127e6aee29 --- /dev/null +++ b/services/route53recoverycluster/misc_test.go @@ -0,0 +1,145 @@ +package route53recoverycluster_test + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/aws/aws-sdk-go-v2/service/route53recoverycluster" + "github.com/labstack/echo/v5" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/blackbirdworks/gopherstack/pkgs/sdkcheck" + cluster "github.com/blackbirdworks/gopherstack/services/route53recoverycluster" +) + +func TestSDKCompleteness(t *testing.T) { + t.Parallel() + + sdkcheck.CheckCompleteness( + t, + &route53recoverycluster.Client{}, + cluster.NewHandler(nil).GetSupportedOperations(), + []string{}, + ) +} + +func TestRouting(t *testing.T) { + t.Parallel() + + h := cluster.NewHandler(nil) + + tests := []struct { + name, target, auth, method string + want bool + }{ + {"toggle target", "ToggleCustomerAPI.GetRoutingControlState", "", http.MethodPost, true}, + {"signing name", "", "route53-recovery-cluster", http.MethodPost, true}, + {"other target", "DynamoDB_20120810.GetItem", "dynamodb", http.MethodPost, false}, + {"signing name wrong method", "", "route53-recovery-cluster", http.MethodGet, false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + req := httptest.NewRequestWithContext( + t.Context(), + tt.method, + "/route53-recovery-cluster/abc/us-west-2/", + nil, + ) + if tt.target != "" { + req.Header.Set("X-Amz-Target", tt.target) + } + + if tt.auth != "" { + req.Header.Set( + "Authorization", + "AWS4-HMAC-SHA256 Credential=test/20260101/us-west-2/"+tt.auth+"/aws4_request", + ) + } + + c := echo.New().NewContext(req, httptest.NewRecorder()) + assert.Equal(t, tt.want, h.RouteMatcher()(c)) + + if tt.want && tt.target != "" { + assert.Equal(t, "GetRoutingControlState", h.ExtractOperation(c)) + } + }) + } +} + +func TestErrorBodyShape(t *testing.T) { + t.Parallel() + + h := cluster.NewHandler(nil) + + tests := []struct { + name, target, body string + }{ + {"store unavailable", "ToggleCustomerAPI.GetRoutingControlState", `{"RoutingControlArn":"x"}`}, + {"unknown operation", "ToggleCustomerAPI.Nope", `{}`}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + req := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/", strings.NewReader(tt.body)) + req.Header.Set("X-Amz-Target", tt.target) + rec := httptest.NewRecorder() + require.NoError(t, h.Handler()(echo.New().NewContext(req, rec))) + assert.GreaterOrEqual(t, rec.Code, http.StatusBadRequest) + + var body map[string]string + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &body)) + assert.NotEmpty(t, body["message"]) + assert.Equal(t, body["message"], body["Message"]) + assert.False( + t, + strings.HasPrefix(body["message"], body["__type"]), + "message %q repeats code", + body["message"], + ) + }) + } +} + +func TestExtractOperationIgnoresCredentialHeaders(t *testing.T) { + t.Parallel() + + h := cluster.NewHandler(nil) + + tests := []struct { + name, target, want string + }{ + {"known op", "ToggleCustomerAPI.GetRoutingControlState", "GetRoutingControlState"}, + {"unknown op", "ToggleCustomerAPI.Bogus", "Unknown"}, + {"no target", "", "Unknown"}, + {"foreign prefix", "Other.GetRoutingControlState", "Unknown"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + req := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/", nil) + req.Header.Set("Authorization", "AWS4-HMAC-SHA256 Credential=AKIASECRET/20260101/us-east-1/s3/aws4_request") + req.Header.Set("X-Amz-Security-Token", "secret-token") + + if tt.target != "" { + req.Header.Set("X-Amz-Target", tt.target) + } + + c := echo.New().NewContext(req, httptest.NewRecorder()) + got := h.ExtractOperation(c) + assert.Equal(t, tt.want, got) + assert.Empty(t, h.ExtractResource(c)) + assert.NotContains(t, got, "AKIASECRET") + }) + } +} diff --git a/services/route53recoverycluster/ops.go b/services/route53recoverycluster/ops.go new file mode 100644 index 0000000000..e9b0857b66 --- /dev/null +++ b/services/route53recoverycluster/ops.go @@ -0,0 +1,161 @@ +package route53recoverycluster + +import ( + "context" + "encoding/json" + "net/http" + + "github.com/blackbirdworks/gopherstack/pkgs/restroute" + cc "github.com/blackbirdworks/gopherstack/services/route53recoverycontrolconfig" +) + +type wireRoutingControl struct { + ControlPanelArn string `json:"ControlPanelArn"` + ControlPanelName string `json:"ControlPanelName"` + Owner string `json:"Owner"` + RoutingControlArn string `json:"RoutingControlArn"` + RoutingControlName string `json:"RoutingControlName"` + RoutingControlState string `json:"RoutingControlState"` +} + +type stateEntry struct { + RoutingControlArn string `json:"RoutingControlArn"` + RoutingControlState string `json:"RoutingControlState"` +} + +func (h *Handler) dispatch(_ context.Context, action, clusterID string, body []byte) ([]byte, error) { + bk, err := h.controlConfig() + if err != nil { + return nil, err + } + + var out any + + switch action { + case "GetRoutingControlState": + out, err = getState(bk, clusterID, body) + case "ListRoutingControls": + out, err = listControls(bk, clusterID, body) + case "UpdateRoutingControlState": + out, err = updateOne(bk, clusterID, body) + case "UpdateRoutingControlStates": + out, err = updateMany(bk, clusterID, body) + default: + return nil, restroute.NewError(http.StatusBadRequest, "ValidationException", "Unknown operation %s", action) + } + + if err != nil { + return nil, err + } + + return json.Marshal(out) +} + +func decode(body []byte, v any) error { + if len(body) == 0 { + return nil + } + + if err := json.Unmarshal(body, v); err != nil { + return restroute.Validation("request body is not valid JSON for this operation") + } + + return nil +} + +func getState(bk *cc.InMemoryBackend, clusterID string, body []byte) (any, error) { + var in struct{ RoutingControlArn string } + if err := decode(body, &in); err != nil { + return nil, err + } + + if in.RoutingControlArn == "" { + return nil, restroute.Validation("RoutingControlArn is required") + } + + v, err := bk.RoutingControlState(in.RoutingControlArn, clusterID) + if err != nil { + return nil, err + } + + return map[string]string{ + "RoutingControlArn": v.ARN, "RoutingControlName": v.Name, "RoutingControlState": v.State, + }, nil +} + +func listControls(bk *cc.InMemoryBackend, clusterID string, body []byte) (any, error) { + var in struct { + ControlPanelArn string + NextToken string + MaxResults int + } + if err := decode(body, &in); err != nil { + return nil, err + } + + if in.MaxResults < 0 || in.MaxResults > listMax { + return nil, restroute.Validation("MaxResults must be between 1 and %d", listMax) + } + + views, next, err := bk.ListRoutingControlStates(in.ControlPanelArn, clusterID, in.NextToken, in.MaxResults) + if err != nil { + return nil, err + } + + items := make([]wireRoutingControl, 0, len(views)) + for _, v := range views { + items = append(items, wireRoutingControl{ + ControlPanelArn: v.PanelARN, ControlPanelName: v.PanelName, Owner: v.Owner, + RoutingControlArn: v.ARN, RoutingControlName: v.Name, RoutingControlState: v.State, + }) + } + + out := map[string]any{"RoutingControls": items} + if next != "" { + out["NextToken"] = next + } + + return out, nil +} + +func updateOne(bk *cc.InMemoryBackend, clusterID string, body []byte) (any, error) { + var in struct { + RoutingControlArn string + RoutingControlState string + SafetyRulesToOverride []string + } + if err := decode(body, &in); err != nil { + return nil, err + } + + if in.RoutingControlArn == "" || in.RoutingControlState == "" { + return nil, restroute.Validation("RoutingControlArn and RoutingControlState are required") + } + + err := bk.UpdateRoutingControlStates( + []cc.StateUpdate{ + {ARN: in.RoutingControlArn, State: in.RoutingControlState}, + }, + in.SafetyRulesToOverride, + clusterID, + ) + + return struct{}{}, err +} + +func updateMany(bk *cc.InMemoryBackend, clusterID string, body []byte) (any, error) { + var in struct { + UpdateRoutingControlStateEntries []stateEntry + SafetyRulesToOverride []string + } + if err := decode(body, &in); err != nil { + return nil, err + } + + updates := make([]cc.StateUpdate, 0, len(in.UpdateRoutingControlStateEntries)) + for _, e := range in.UpdateRoutingControlStateEntries { + updates = append(updates, cc.StateUpdate{ARN: e.RoutingControlArn, State: e.RoutingControlState}) + } + + return struct{}{}, bk.UpdateRoutingControlStates(updates, in.SafetyRulesToOverride, clusterID) +} diff --git a/services/route53recoverycluster/provider.go b/services/route53recoverycluster/provider.go new file mode 100644 index 0000000000..1165f22e50 --- /dev/null +++ b/services/route53recoverycluster/provider.go @@ -0,0 +1,32 @@ +package route53recoverycluster + +import ( + "errors" + + "github.com/blackbirdworks/gopherstack/pkgs/service" +) + +// ErrNilAppContext is returned by Init when no AppContext is supplied. +var ErrNilAppContext = errors.New("route53recoverycluster: AppContext must not be nil") + +// Provider implements service.Provider. +type Provider struct{} + +// Name returns the provider name. +func (p *Provider) Name() string { return "Route53RecoveryCluster" } + +// Init builds the handler; the control-plane sibling is resolved at call time. +// +//nolint:ireturn,nolintlint // architecturally required to return interface +func (p *Provider) Init(ctx *service.AppContext) (service.Registerable, error) { + if ctx == nil { + return nil, ErrNilAppContext + } + + _, region := service.AccountRegionOrDefault(ctx) + + h := NewHandler(ctx.Config) + h.DefaultRegion = region + + return h, nil +} diff --git a/services/route53recoverycluster/safety_test.go b/services/route53recoverycluster/safety_test.go new file mode 100644 index 0000000000..d946ba3c89 --- /dev/null +++ b/services/route53recoverycluster/safety_test.go @@ -0,0 +1,241 @@ +package route53recoverycluster_test + +import ( + "testing" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/route53recoverycluster" + "github.com/aws/aws-sdk-go-v2/service/route53recoverycluster/types" + rccfg "github.com/aws/aws-sdk-go-v2/service/route53recoverycontrolconfig" + rctypes "github.com/aws/aws-sdk-go-v2/service/route53recoverycontrolconfig/types" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func (f *fixture) atleastOne(t *testing.T, topo topology, inverted bool) string { + t.Helper() + + out, err := f.ccClient.CreateSafetyRule( + t.Context(), + &rccfg.CreateSafetyRuleInput{AssertionRule: &rctypes.NewAssertionRule{ + AssertedControls: []string{topo.controlA, topo.controlB}, + ControlPanelArn: aws.String(topo.panelArn), + Name: aws.String("keep-one"), + WaitPeriodMs: aws.Int32(0), + RuleConfig: &rctypes.RuleConfig{ + Inverted: aws.Bool(inverted), + Threshold: aws.Int32(1), + Type: rctypes.RuleTypeAtleast, + }, + }}, + ) + require.NoError(t, err) + + return aws.ToString(out.AssertionRule.SafetyRuleArn) +} + +func set(arn string, s types.RoutingControlState) types.UpdateRoutingControlStateEntry { + return types.UpdateRoutingControlStateEntry{RoutingControlArn: aws.String(arn), RoutingControlState: s} +} + +func TestAssertionRuleBlocksBothOff(t *testing.T) { + t.Parallel() + + f := newFixture(t) + topo := f.build(t) + ctx := t.Context() + rule := f.atleastOne(t, topo, false) + + _, err := topo.client.UpdateRoutingControlStates(ctx, &route53recoverycluster.UpdateRoutingControlStatesInput{ + UpdateRoutingControlStateEntries: []types.UpdateRoutingControlStateEntry{ + set(topo.controlA, types.RoutingControlStateOn), set(topo.controlB, types.RoutingControlStateOn), + }, + }) + require.NoError(t, err) + + _, err = topo.client.UpdateRoutingControlStates(ctx, &route53recoverycluster.UpdateRoutingControlStatesInput{ + UpdateRoutingControlStateEntries: []types.UpdateRoutingControlStateEntry{ + set(topo.controlA, types.RoutingControlStateOff), set(topo.controlB, types.RoutingControlStateOff), + }, + }) + require.Error(t, err) + assert.Equal(t, "ConflictException", apiCode(err)) + + var conflict *types.ConflictException + require.ErrorAs(t, err, &conflict) + assert.Contains(t, aws.ToString(conflict.Message), "keep-one") + assert.Contains(t, aws.ToString(conflict.Message), rule) + assert.Equal(t, rule, aws.ToString(conflict.ResourceId)) + + for _, arn := range []string{topo.controlA, topo.controlB} { + got, gErr := topo.client.GetRoutingControlState( + ctx, + &route53recoverycluster.GetRoutingControlStateInput{RoutingControlArn: aws.String(arn)}, + ) + require.NoError(t, gErr) + assert.Equal(t, types.RoutingControlStateOn, got.RoutingControlState, "a rejected batch changes nothing") + } + + _, err = topo.client.UpdateRoutingControlStates(ctx, &route53recoverycluster.UpdateRoutingControlStatesInput{ + UpdateRoutingControlStateEntries: []types.UpdateRoutingControlStateEntry{ + set(topo.controlA, types.RoutingControlStateOff), set(topo.controlB, types.RoutingControlStateOff), + }, + SafetyRulesToOverride: []string{rule}, + }) + require.NoError(t, err, "SafetyRulesToOverride lets the update through") + + got, err := topo.client.GetRoutingControlState( + ctx, + &route53recoverycluster.GetRoutingControlStateInput{RoutingControlArn: aws.String(topo.controlA)}, + ) + require.NoError(t, err) + assert.Equal(t, types.RoutingControlStateOff, got.RoutingControlState) +} + +func TestSingleUpdateEvaluatesRules(t *testing.T) { + t.Parallel() + + f := newFixture(t) + topo := f.build(t) + ctx := t.Context() + rule := f.atleastOne(t, topo, true) + + _, err := topo.client.UpdateRoutingControlState(ctx, &route53recoverycluster.UpdateRoutingControlStateInput{ + RoutingControlArn: aws.String(topo.controlA), RoutingControlState: types.RoutingControlStateOn, + }) + assert.Equal(t, "ConflictException", apiCode(err), "an inverted ATLEAST-1 forbids any control being On") + + _, err = topo.client.UpdateRoutingControlState(ctx, &route53recoverycluster.UpdateRoutingControlStateInput{ + RoutingControlArn: aws.String( + topo.controlA, + ), + RoutingControlState: types.RoutingControlStateOn, + SafetyRulesToOverride: []string{rule}, + }) + require.NoError(t, err) +} + +func TestEndpointIsScopedToItsCluster(t *testing.T) { + t.Parallel() + + f := newFixture(t) + topo := f.build(t) + ctx := t.Context() + + other, err := f.ccClient.CreateCluster(ctx, &rccfg.CreateClusterInput{ClusterName: aws.String("other")}) + require.NoError(t, err) + + otherClient := f.dataPlane(aws.ToString(other.Cluster.ClusterEndpoints[0].Endpoint)) + + _, err = otherClient.GetRoutingControlState( + ctx, + &route53recoverycluster.GetRoutingControlStateInput{RoutingControlArn: aws.String(topo.controlA)}, + ) + assert.Equal(t, "ResourceNotFoundException", apiCode(err)) + + list, err := otherClient.ListRoutingControls(ctx, &route53recoverycluster.ListRoutingControlsInput{}) + require.NoError(t, err) + assert.Empty(t, list.RoutingControls) + + all, err := topo.client.ListRoutingControls(ctx, &route53recoverycluster.ListRoutingControlsInput{}) + require.NoError(t, err) + assert.Len(t, all.RoutingControls, 2) +} + +func TestDataPlaneErrors(t *testing.T) { + t.Parallel() + + f := newFixture(t) + topo := f.build(t) + ctx := t.Context() + + tests := []struct { + call func() error + name string + want string + }{ + {name: "unknown control", call: func() error { + _, err := topo.client.GetRoutingControlState( + ctx, + &route53recoverycluster.GetRoutingControlStateInput{RoutingControlArn: aws.String(topo.controlA + "z")}, + ) + + return err + }, want: "ResourceNotFoundException"}, + {name: "invalid state", call: func() error { + _, err := topo.client.UpdateRoutingControlState(ctx, &route53recoverycluster.UpdateRoutingControlStateInput{ + RoutingControlArn: aws.String(topo.controlA), RoutingControlState: "Maybe", + }) + + return err + }, want: "ValidationException"}, + {name: "duplicate entries", call: func() error { + _, err := topo.client.UpdateRoutingControlStates( + ctx, + &route53recoverycluster.UpdateRoutingControlStatesInput{ + UpdateRoutingControlStateEntries: []types.UpdateRoutingControlStateEntry{ + set( + topo.controlA, + types.RoutingControlStateOn, + ), + set(topo.controlA, types.RoutingControlStateOff), + }, + }, + ) + + return err + }, want: "ValidationException"}, + {name: "unknown override", call: func() error { + _, err := topo.client.UpdateRoutingControlState(ctx, &route53recoverycluster.UpdateRoutingControlStateInput{ + RoutingControlArn: aws.String( + topo.controlA, + ), + RoutingControlState: types.RoutingControlStateOn, + SafetyRulesToOverride: []string{"arn:nope"}, + }) + + return err + }, want: "ValidationException"}, + {name: "unknown panel", call: func() error { + _, err := topo.client.ListRoutingControls( + ctx, + &route53recoverycluster.ListRoutingControlsInput{ControlPanelArn: aws.String("arn:nope")}, + ) + + return err + }, want: "ResourceNotFoundException"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + assert.Equal(t, tt.want, apiCode(tt.call())) + }) + } +} + +func TestListRoutingControlsPaginates(t *testing.T) { + t.Parallel() + + f := newFixture(t) + topo := f.build(t) + + p := route53recoverycluster.NewListRoutingControlsPaginator( + topo.client, + &route53recoverycluster.ListRoutingControlsInput{ + ControlPanelArn: aws.String(topo.panelArn), MaxResults: aws.Int32(1), + }, + ) + + pages := 0 + + for p.HasMorePages() { + pg, err := p.NextPage(t.Context()) + require.NoError(t, err) + assert.Len(t, pg.RoutingControls, 1) + + pages++ + } + + assert.Equal(t, 2, pages) +} diff --git a/services/route53recoverycontrolconfig/PARITY.md b/services/route53recoverycontrolconfig/PARITY.md new file mode 100644 index 0000000000..785defb3e9 --- /dev/null +++ b/services/route53recoverycontrolconfig/PARITY.md @@ -0,0 +1,51 @@ +--- +service: route53recoverycontrolconfig +sdk_module: aws-sdk-go-v2/service/route53recoverycontrolconfig@v1.40.4 +last_audit_commit: 80b7bb8b3 +last_audit_date: 2026-10-14 +overall: A +ops: + CreateCluster: {wire: ok, errors: ok, state: ok, persist: ok, note: "creates the default control panel with the cluster; ClientToken idempotent; PENDING->DEPLOYED via opt-in activation delay; DEPLOYED clusters expose five regional endpoints that route back to this server on the scheme and host the caller used, or on ROUTE53_RECOVERY_CONTROL_ENDPOINT when set"} + DescribeCluster: {wire: ok, errors: ok, state: ok, persist: ok} + ListClusters: {wire: ok, errors: ok, state: ok, persist: ok} + UpdateCluster: {wire: ok, errors: ok, state: ok, persist: ok} + DeleteCluster: {wire: ok, errors: ok, state: ok, persist: ok, note: "ConflictException while custom panels, routing controls or rules remain; PENDING_DELETION while the delay window runs"} + CreateControlPanel: {wire: ok, errors: ok, state: ok, persist: ok} + DescribeControlPanel: {wire: ok, errors: ok, state: ok, persist: ok} + ListControlPanels: {wire: ok, errors: ok, state: ok, persist: ok} + UpdateControlPanel: {wire: ok, errors: ok, state: ok, persist: ok} + DeleteControlPanel: {wire: ok, errors: ok, state: ok, persist: ok, note: "ConflictException for the default panel and for panels that still hold routing controls or safety rules"} + CreateRoutingControl: {wire: ok, errors: ok, state: ok, persist: ok} + DescribeRoutingControl: {wire: ok, errors: ok, state: ok, persist: ok} + ListRoutingControls: {wire: ok, errors: ok, state: ok, persist: ok} + UpdateRoutingControl: {wire: ok, errors: ok, state: ok, persist: ok} + DeleteRoutingControl: {wire: ok, errors: ok, state: ok, persist: ok, note: "ConflictException while a safety rule references the control"} + ListAssociatedRoute53HealthChecks: {wire: ok, errors: ok, state: ok, persist: ok, note: "reads RECOVERY_CONTROL health checks from Route 53 at call time"} + CreateSafetyRule: {wire: ok, errors: ok, state: ok, persist: ok, note: "ATLEAST threshold bounded by control count; controls must live in the rule's panel; gating and target sets are disjoint"} + DescribeSafetyRule: {wire: ok, errors: ok, state: ok, persist: ok} + ListSafetyRules: {wire: ok, errors: ok, state: ok, persist: ok} + UpdateSafetyRule: {wire: ok, errors: ok, state: ok, persist: ok} + DeleteSafetyRule: {wire: ok, errors: ok, state: ok, persist: ok} + GetResourcePolicy: {wire: ok, errors: ok, state: ok, persist: ok, note: "existence-checked; an existing resource returns no Policy (see structural_gaps)"} + ListTagsForResource: {wire: ok, errors: ok, state: ok, persist: ok} + TagResource: {wire: ok, errors: ok, state: ok, persist: ok} + UntagResource: {wire: ok, errors: ok, state: ok, persist: ok} +families: + route_matching: {status: ok, note: "matches the SigV4 signing name or SDK user agent plus a known path shape; ARN labels arrive percent-encoded and are decoded per segment"} + errors: {status: ok, note: "REST-JSON error bodies carry both message and Message plus X-Amzn-Errortype; messages never repeat the code"} +gaps: [] +items_still_open: [] +structural_gaps: + - GetResourcePolicy cannot return a policy: ARC policies come from RAM sharing, but services/ram has no ARC shareable resource types or managed permissions, and the SDK does not publish the policy AWS attaches +deferred: [] +leaks: {status: clean, note: "no goroutines or janitors; expiry and lifecycle are derived from the clock at read time"} +--- + +## Notes + +- Protocol: REST-JSON, PascalCase members, SigV4 signing name `route53-recovery-control-config` (`services/_PROTOCOLS.md`). ARNs have no region segment (`arn:aws:route53-recovery-control:::cluster/`). +- Routing control state (On/Off) lives here and is the single source of truth for the data plane (`route53recoverycluster`) and for Route 53 `RECOVERY_CONTROL` health checks. +- Safety rules are evaluated on every data-plane update against the resulting state of the controls the update touches. ASSERTION: the rule's controls must satisfy `RuleConfig` after the update. GATING: updates to target controls require the gating controls to satisfy `RuleConfig`. ATLEAST needs `Threshold` controls On, AND needs all, OR needs one; `Inverted` negates. `WaitPeriodMs` blocks further changes to governed controls after one lands. `SafetyRulesToOverride` skips both checks for the named rules. A rejected batch changes nothing. +- Lifecycle: `--lifecycle-route53-recovery` (or the global delay) keeps new resources PENDING and deleted ones PENDING_DELETION for the window; default 0 is instant. +- Quotas are not enforced; the SDK documents none per operation. + diff --git a/services/route53recoverycontrolconfig/README.md b/services/route53recoverycontrolconfig/README.md new file mode 100644 index 0000000000..2a5f739dc4 --- /dev/null +++ b/services/route53recoverycontrolconfig/README.md @@ -0,0 +1,26 @@ + +# Route 53 Recovery Control Config + +**Parity grade: A** · SDK `aws-sdk-go-v2/service/route53recoverycontrolconfig@v1.40.4` · last audited 2026-10-14 (`80b7bb8b3`) + +## Coverage + +| Metric | Value | +| --- | --- | +| PARITY entries audited | 25 (25 ok) | +| Feature families | 2 (2 ok) | +| Known gaps | none | +| Structural gaps (can't be emulated) | 1 | +| Deferred items | 0 | +| Resource leaks | clean | + +### Structural gaps + +These do not block an A grade — no implementation could produce real data here because the underlying data source cannot exist in an emulator. + +- GetResourcePolicy cannot return a policy: ARC policies come from RAM sharing, but services/ram has no ARC shareable resource types or managed permissions, and the SDK does not publish the policy AWS attaches + +## More + +- [Full parity audit](PARITY.md) +- [All services](../../README.md#services) diff --git a/services/route53recoverycontrolconfig/clusters.go b/services/route53recoverycontrolconfig/clusters.go new file mode 100644 index 0000000000..860ce16e57 --- /dev/null +++ b/services/route53recoverycontrolconfig/clusters.go @@ -0,0 +1,250 @@ +package route53recoverycontrolconfig + +import ( + "context" + "net/url" + "strings" + "time" + + "github.com/blackbirdworks/gopherstack/pkgs/restroute" +) + +const ( + maxNameLen = 64 + defaultPanel = "DefaultControlPanel" + listDefault = 100 + listMax = 1000 + endpointPrefix = "/route53-recovery-cluster/" +) + +// clusterRegions are the five regions every deployed cluster exposes a data-plane endpoint in. +func clusterRegions() []string { + return []string{"us-east-1", "us-west-2", "eu-west-1", "ap-southeast-2", "ap-northeast-1"} +} + +func validName(kind, name string) error { + if name == "" || len(name) > maxNameLen { + return restroute.Validation("%s name must be between 1 and %d characters", kind, maxNameLen) + } + + return nil +} + +func validNetworkType(t string) bool { return t == networkIPv4 || t == networkDualstack } + +func idOf(arn string) string { + if _, after, ok := strings.CutLast(arn, "/"); ok { + return after + } + + return arn +} + +type requestBaseCtxKey struct{} + +// requestBase returns scheme://host for the address the caller reached the server on, or false when it is not a +// plain host[:port]. +func requestBase(scheme, host string) (string, bool) { + if scheme != "http" && scheme != "https" { + return "", false + } + + u, err := url.Parse(scheme + "://" + host) + if err != nil || host == "" || u.Host != host || u.User != nil || u.Path != "" || u.RawQuery != "" || + u.Fragment != "" { + return "", false + } + + return scheme + "://" + host, true +} + +// endpointBase is the URL cluster endpoints hang from: the configured external endpoint, else the scheme and +// host the caller reached this server on, else the server's own listen address. +func (b *InMemoryBackend) endpointBase(ctx context.Context) string { + if ext := b.externalEndpoint.Load(); ext != nil && *ext != "" { + return *ext + } + + if base, ok := ctx.Value(requestBaseCtxKey{}).(string); ok { + return base + } + + if ep, ok := b.appConfig.(interface{ GetEndpoint() string }); ok { + return strings.TrimRight(ep.GetEndpoint(), "/") + } + + return "http://localhost:4566" +} + +// SetExternalEndpoint pins the base URL of cluster endpoints, overriding the caller's host. +func (b *InMemoryBackend) SetExternalEndpoint(base string) { + base = strings.TrimRight(base, "/") + b.externalEndpoint.Store(&base) +} + +func (b *InMemoryBackend) clusterView(ctx context.Context, c *Cluster) wireCluster { + v := wireCluster{ + ClusterArn: c.ARN, + Name: c.Name, + NetworkType: c.NetworkType, + Owner: b.accountID, + Status: b.statusOf(c.CreatedAt, c.DeletedAt), + } + + if v.Status == statusDeployed { + for _, region := range clusterRegions() { + v.ClusterEndpoints = append(v.ClusterEndpoints, wireEndpoint{ + Endpoint: b.endpointBase(ctx) + endpointPrefix + idOf(c.ARN) + "/" + region, + Region: region, + }) + } + } + + return v +} + +// CreateCluster creates a cluster together with its default control panel. +func (b *InMemoryBackend) CreateCluster( + ctx context.Context, + name, networkType, token string, + tags map[string]string, +) (wireCluster, error) { + if err := validName("cluster", name); err != nil { + return wireCluster{}, err + } + + if networkType == "" { + networkType = networkIPv4 + } + + if !validNetworkType(networkType) { + return wireCluster{}, restroute.Validation("NetworkType must be IPV4 or DUALSTACK") + } + + b.mu.Lock("CreateCluster") + defer b.mu.Unlock() + + if existing, ok := b.idempotent("cluster", token); ok { + if c, err := b.getCluster(existing); err == nil { + return b.clusterView(ctx, c), nil + } + } + + now := time.Now().UTC() + id := newID() + cluster := &Cluster{ + ARN: b.clusterARN(id), Name: name, NetworkType: networkType, CreatedAt: now, Tags: cloneTags(tags), + } + b.clusters.Put(cluster) + b.panels.Put(&ControlPanel{ + ARN: b.panelARN(newID()), ClusterARN: cluster.ARN, Name: defaultPanel, Default: true, CreatedAt: now, + }) + b.rememberToken("cluster", token, cluster.ARN) + + return b.clusterView(ctx, cluster), nil +} + +// DescribeCluster returns one cluster. +func (b *InMemoryBackend) DescribeCluster(ctx context.Context, arn string) (wireCluster, error) { + b.mu.RLock("DescribeCluster") + defer b.mu.RUnlock() + + c, err := b.getCluster(arn) + if err != nil { + return wireCluster{}, err + } + + return b.clusterView(ctx, c), nil +} + +// ListClusters lists clusters. +func (b *InMemoryBackend) ListClusters( + ctx context.Context, + token string, + maxResults int, +) ([]wireCluster, string, error) { + b.mu.RLock("ListClusters") + defer b.mu.RUnlock() + + var all []*Cluster + + for _, c := range b.clusters.All() { + if b.alive(c.DeletedAt) { + all = append(all, c) + } + } + + pg, err := restroute.Paginate(sortedBy(all, func(c *Cluster) string { return c.Name + c.ARN }), + token, maxResults, listDefault, listMax) + if err != nil { + return nil, "", err + } + + out := make([]wireCluster, 0, len(pg.Data)) + for _, c := range pg.Data { + out = append(out, b.clusterView(ctx, c)) + } + + return out, pg.Next, nil +} + +// UpdateCluster changes the network type. +func (b *InMemoryBackend) UpdateCluster(ctx context.Context, arn, networkType string) (wireCluster, error) { + if !validNetworkType(networkType) { + return wireCluster{}, restroute.Validation("NetworkType must be IPV4 or DUALSTACK") + } + + b.mu.Lock("UpdateCluster") + defer b.mu.Unlock() + + c, err := b.getCluster(arn) + if err != nil { + return wireCluster{}, err + } + + c.NetworkType = networkType + + return b.clusterView(ctx, c), nil +} + +// DeleteCluster removes a cluster; it must not hold custom control panels, routing controls or safety rules. +func (b *InMemoryBackend) DeleteCluster(arn string) error { + b.mu.Lock("DeleteCluster") + defer b.mu.Unlock() + + c, err := b.getCluster(arn) + if err != nil { + return err + } + + var defaultArn string + + for _, p := range b.panels.All() { + if p.ClusterARN != arn || !b.alive(p.DeletedAt) { + continue + } + + if !p.Default { + return restroute.Conflict("Cluster %s still has control panel %q; delete it first", arn, p.Name) + } + + defaultArn = p.ARN + } + + if b.panelHasChildren(defaultArn) { + return restroute.Conflict( + "Cluster %s still has routing controls or safety rules in its default control panel", + arn, + ) + } + + b.markDeleted(&c.DeletedAt, func() { b.clusters.Delete(arn) }) + + if defaultArn != "" { + if p, ok := b.panels.Get(defaultArn); ok { + b.markDeleted(&p.DeletedAt, func() { b.panels.Delete(defaultArn) }) + } + } + + return nil +} diff --git a/services/route53recoverycontrolconfig/controlpanels.go b/services/route53recoverycontrolconfig/controlpanels.go new file mode 100644 index 0000000000..d5149d5407 --- /dev/null +++ b/services/route53recoverycontrolconfig/controlpanels.go @@ -0,0 +1,180 @@ +package route53recoverycontrolconfig + +import ( + "time" + + "github.com/blackbirdworks/gopherstack/pkgs/restroute" +) + +func (b *InMemoryBackend) controlsIn(panelArn string) []*RoutingControl { + var out []*RoutingControl + + for _, c := range b.controls.All() { + if c.PanelARN == panelArn && b.alive(c.DeletedAt) { + out = append(out, c) + } + } + + return out +} + +func (b *InMemoryBackend) rulesIn(panelArn string) []*SafetyRule { + var out []*SafetyRule + + for _, r := range b.rules.All() { + if r.PanelARN == panelArn && b.alive(r.DeletedAt) { + out = append(out, r) + } + } + + return out +} + +func (b *InMemoryBackend) panelHasChildren(panelArn string) bool { + return panelArn != "" && (len(b.controlsIn(panelArn)) > 0 || len(b.rulesIn(panelArn)) > 0) +} + +func (b *InMemoryBackend) panelView(p *ControlPanel) wireControlPanel { + return wireControlPanel{ + ClusterArn: p.ClusterARN, + ControlPanelArn: p.ARN, + Name: p.Name, + Owner: b.accountID, + Status: b.statusOf(p.CreatedAt, p.DeletedAt), + DefaultControlPanel: p.Default, + RoutingControlCount: int32(len(b.controlsIn(p.ARN))), //nolint:gosec // bounded by listMax-scale quotas + } +} + +// CreateControlPanel creates a custom control panel in a cluster. +func (b *InMemoryBackend) CreateControlPanel( + clusterArn, name, token string, + tags map[string]string, +) (wireControlPanel, error) { + if err := validName("control panel", name); err != nil { + return wireControlPanel{}, err + } + + b.mu.Lock("CreateControlPanel") + defer b.mu.Unlock() + + if existing, ok := b.idempotent("panel", token); ok { + if p, err := b.getPanel(existing); err == nil { + return b.panelView(p), nil + } + } + + if err := b.activeCluster(clusterArn); err != nil { + return wireControlPanel{}, err + } + + p := &ControlPanel{ + ARN: b.panelARN( + newID(), + ), + ClusterARN: clusterArn, + Name: name, + CreatedAt: time.Now().UTC(), + Tags: cloneTags(tags), + } + b.panels.Put(p) + b.rememberToken("panel", token, p.ARN) + + return b.panelView(p), nil +} + +// DescribeControlPanel returns one control panel. +func (b *InMemoryBackend) DescribeControlPanel(arn string) (wireControlPanel, error) { + b.mu.RLock("DescribeControlPanel") + defer b.mu.RUnlock() + + p, err := b.getPanel(arn) + if err != nil { + return wireControlPanel{}, err + } + + return b.panelView(p), nil +} + +// ListControlPanels lists panels, optionally restricted to one cluster. +func (b *InMemoryBackend) ListControlPanels( + clusterArn, token string, + maxResults int, +) ([]wireControlPanel, string, error) { + b.mu.RLock("ListControlPanels") + defer b.mu.RUnlock() + + var all []*ControlPanel + + for _, p := range b.panels.All() { + if b.alive(p.DeletedAt) && (clusterArn == "" || p.ClusterARN == clusterArn) { + all = append(all, p) + } + } + + pg, err := restroute.Paginate(sortedBy(all, func(p *ControlPanel) string { return p.Name + p.ARN }), + token, maxResults, listDefault, listMax) + if err != nil { + return nil, "", err + } + + out := make([]wireControlPanel, 0, len(pg.Data)) + for _, p := range pg.Data { + out = append(out, b.panelView(p)) + } + + return out, pg.Next, nil +} + +// UpdateControlPanel renames a control panel. +func (b *InMemoryBackend) UpdateControlPanel(arn, name string) (wireControlPanel, error) { + if err := validName("control panel", name); err != nil { + return wireControlPanel{}, err + } + + b.mu.Lock("UpdateControlPanel") + defer b.mu.Unlock() + + p, err := b.getPanel(arn) + if err != nil { + return wireControlPanel{}, err + } + + p.Name = name + + return b.panelView(p), nil +} + +// DeleteControlPanel deletes an empty, non-default control panel. +func (b *InMemoryBackend) DeleteControlPanel(arn string) error { + b.mu.Lock("DeleteControlPanel") + defer b.mu.Unlock() + + p, err := b.getPanel(arn) + if err != nil { + return err + } + + if p.Default { + return restroute.Conflict("The default control panel %s cannot be deleted", arn) + } + + if b.panelHasChildren(arn) { + return restroute.Conflict("Control panel %s still has routing controls or safety rules", arn) + } + + b.markDeleted(&p.DeletedAt, func() { b.panels.Delete(arn) }) + + return nil +} + +// defaultPanelOf returns the default control panel ARN of a cluster. +func (b *InMemoryBackend) defaultPanelOf(clusterArn string) (string, error) { + for _, p := range b.panels.All() { + if p.ClusterARN == clusterArn && p.Default && b.alive(p.DeletedAt) { + return p.ARN, nil + } + } + + return "", restroute.NotFound("Cluster %s has no default control panel", clusterArn) +} diff --git a/services/route53recoverycontrolconfig/cross_service.go b/services/route53recoverycontrolconfig/cross_service.go new file mode 100644 index 0000000000..2854dcf7b5 --- /dev/null +++ b/services/route53recoverycontrolconfig/cross_service.go @@ -0,0 +1,38 @@ +package route53recoverycontrolconfig + +import "github.com/blackbirdworks/gopherstack/pkgs/service" + +// siblingServices is matched structurally against *CLI, which cannot be imported here. +type siblingServices interface { + GetRoute53Handler() service.Registerable +} + +type healthCheckLister interface { + RecoveryControlHealthCheckIDs(routingControlArn string) []string +} + +// SetAppConfig records service.AppContext.Config so siblings are resolved at call time, after every provider exists. +func (b *InMemoryBackend) SetAppConfig(cfg any) { b.appConfig = cfg } + +// AssociatedHealthCheckIDs lists the Route 53 RECOVERY_CONTROL health checks bound to a routing control. +func (b *InMemoryBackend) AssociatedHealthCheckIDs(routingControlArn string) ([]string, error) { + b.mu.RLock("AssociatedHealthCheckIDs") + _, err := b.getControl(routingControlArn) + b.mu.RUnlock() + + if err != nil { + return nil, err + } + + s, ok := b.appConfig.(siblingServices) + if !ok { + return nil, nil + } + + l, ok := s.GetRoute53Handler().(healthCheckLister) + if !ok { + return nil, nil + } + + return l.RecoveryControlHealthCheckIDs(routingControlArn), nil +} diff --git a/services/route53recoverycontrolconfig/endpoints_test.go b/services/route53recoverycontrolconfig/endpoints_test.go new file mode 100644 index 0000000000..5e7d130190 --- /dev/null +++ b/services/route53recoverycontrolconfig/endpoints_test.go @@ -0,0 +1,106 @@ +package route53recoverycontrolconfig_test + +import ( + "net/http" + "strings" + "testing" + + "github.com/aws/aws-sdk-go-v2/aws" + awscfg "github.com/aws/aws-sdk-go-v2/config" + "github.com/aws/aws-sdk-go-v2/credentials" + sdk "github.com/aws/aws-sdk-go-v2/service/route53recoverycontrolconfig" + "github.com/aws/aws-sdk-go-v2/service/route53recoverycontrolconfig/types" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type hostRoundTripper struct{ host string } + +func (h hostRoundTripper) RoundTrip(r *http.Request) (*http.Response, error) { + r = r.Clone(r.Context()) + r.Host = h.host + + return http.DefaultTransport.RoundTrip(r) +} + +func (f *fixture) clientVia(t *testing.T, host string) *sdk.Client { + t.Helper() + + cfg, err := awscfg.LoadDefaultConfig(t.Context(), + awscfg.WithRegion("us-west-2"), + awscfg.WithCredentialsProvider(credentials.NewStaticCredentialsProvider("test", "test", "")), + ) + require.NoError(t, err) + + return sdk.NewFromConfig(cfg, func(o *sdk.Options) { + o.BaseEndpoint = aws.String(f.url) + o.HTTPClient = &http.Client{Transport: hostRoundTripper{host: host}} + }) +} + +func TestClusterEndpointsFollowCallerHost(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + host string + external string + want string + }{ + {name: "docker host", host: "host.docker.internal:4566", want: "http://host.docker.internal:4566"}, + {name: "ip host", host: "10.1.2.3:8123", want: "http://10.1.2.3:8123"}, + {name: "explicit endpoint wins", host: "host.docker.internal:4566", external: "https://arc.example.com:9443/", + want: "https://arc.example.com:9443"}, + {name: "host with path falls back", host: "evil.example/x", want: "SERVER"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + f := newFixture(t) + f.backend.SetExternalEndpoint(tt.external) + + want := tt.want + if want == "SERVER" { + want = f.url + } + + client := f.clientVia(t, tt.host) + ctx := t.Context() + + created, err := client.CreateCluster(ctx, &sdk.CreateClusterInput{ClusterName: aws.String("c")}) + require.NoError(t, err) + + described, err := client.DescribeCluster( + ctx, + &sdk.DescribeClusterInput{ClusterArn: created.Cluster.ClusterArn}, + ) + require.NoError(t, err) + require.Len(t, described.Cluster.ClusterEndpoints, 5) + + list, err := client.ListClusters(ctx, &sdk.ListClustersInput{}) + require.NoError(t, err) + require.Len(t, list.Clusters, 1) + + for _, eps := range [][]string{ + endpointsOf(created.Cluster), endpointsOf(described.Cluster), endpointsOf(&list.Clusters[0]), + } { + require.Len(t, eps, 5) + + for _, ep := range eps { + assert.True(t, strings.HasPrefix(ep, want+"/route53-recovery-cluster/"), "%s vs %s", ep, want) + } + } + }) + } +} + +func endpointsOf(c *types.Cluster) []string { + out := make([]string, 0, len(c.ClusterEndpoints)) + for _, e := range c.ClusterEndpoints { + out = append(out, aws.ToString(e.Endpoint)) + } + + return out +} diff --git a/services/route53recoverycontrolconfig/extract_headers_test.go b/services/route53recoverycontrolconfig/extract_headers_test.go new file mode 100644 index 0000000000..c9958b4a29 --- /dev/null +++ b/services/route53recoverycontrolconfig/extract_headers_test.go @@ -0,0 +1,44 @@ +package route53recoverycontrolconfig_test + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/labstack/echo/v5" + "github.com/stretchr/testify/assert" + + svc "github.com/blackbirdworks/gopherstack/services/route53recoverycontrolconfig" +) + +func TestExtractIgnoresCredentialHeaders(t *testing.T) { + t.Parallel() + + h := svc.NewHandler(svc.NewInMemoryBackend(testAccount)) + + tests := []struct { + headers map[string]string + name string + }{ + {nil, "none"}, + {map[string]string{ + "Authorization": "AWS4-HMAC-SHA256 Credential=AKIASECRET/20260101/us-east-1/s3/aws4_request", + }, "sigv4"}, + {map[string]string{"X-Amz-Security-Token": "secret-token"}, "token"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/cluster/arn-1", nil) + for k, v := range tt.headers { + req.Header.Set(k, v) + } + + c := echo.New().NewContext(req, httptest.NewRecorder()) + assert.Equal(t, "DescribeCluster", h.ExtractOperation(c)) + assert.Equal(t, "arn-1", h.ExtractResource(c)) + }) + } +} diff --git a/services/route53recoverycontrolconfig/fixture_test.go b/services/route53recoverycontrolconfig/fixture_test.go new file mode 100644 index 0000000000..16d26eb60f --- /dev/null +++ b/services/route53recoverycontrolconfig/fixture_test.go @@ -0,0 +1,72 @@ +package route53recoverycontrolconfig_test + +import ( + "errors" + "sync/atomic" + "testing" + + "github.com/aws/aws-sdk-go-v2/aws" + awscfg "github.com/aws/aws-sdk-go-v2/config" + "github.com/aws/aws-sdk-go-v2/credentials" + sdk "github.com/aws/aws-sdk-go-v2/service/route53recoverycontrolconfig" + "github.com/aws/smithy-go" + "github.com/stretchr/testify/require" + + "github.com/blackbirdworks/gopherstack/pkgs/sdktest" + cc "github.com/blackbirdworks/gopherstack/services/route53recoverycontrolconfig" +) + +const testAccount = "123456789012" + +// endpointConfig is the AppContext.Config stand-in; the URL is only known once the server is up. +type endpointConfig struct{ url atomic.Value } + +func (c *endpointConfig) GetEndpoint() string { return c.url.Load().(string) } + +type fixture struct { + backend *cc.InMemoryBackend + handler *cc.Handler + client *sdk.Client + url string +} + +func newFixture(t *testing.T) *fixture { + t.Helper() + + ec := &endpointConfig{} + ec.url.Store("") + + b := cc.NewInMemoryBackend(testAccount) + b.SetAppConfig(ec) + + h := cc.NewHandler(b) + url := sdktest.Serve(t, h) + ec.url.Store(url) + + cfg, err := awscfg.LoadDefaultConfig(t.Context(), + awscfg.WithRegion("us-west-2"), + awscfg.WithCredentialsProvider(credentials.NewStaticCredentialsProvider("test", "test", "")), + ) + require.NoError(t, err) + + return &fixture{ + backend: b, handler: h, url: url, + client: sdk.NewFromConfig(cfg, func(o *sdk.Options) { o.BaseEndpoint = aws.String(url) }), + } +} + +func apiCode(err error) string { + if ae, ok := errors.AsType[smithy.APIError](err); ok { + return ae.ErrorCode() + } + + return "" +} + +func apiMessage(err error) string { + if ae, ok := errors.AsType[smithy.APIError](err); ok { + return ae.ErrorMessage() + } + + return "" +} diff --git a/services/route53recoverycontrolconfig/handler.go b/services/route53recoverycontrolconfig/handler.go new file mode 100644 index 0000000000..153e383f5b --- /dev/null +++ b/services/route53recoverycontrolconfig/handler.go @@ -0,0 +1,188 @@ +package route53recoverycontrolconfig + +import ( + "context" + "net/http" + "slices" + + "github.com/labstack/echo/v5" + + "github.com/blackbirdworks/gopherstack/pkgs/httputils" + "github.com/blackbirdworks/gopherstack/pkgs/restroute" + "github.com/blackbirdworks/gopherstack/pkgs/service" +) + +const ( + signingName = "route53-recovery-control-config" + pathCluster = "/cluster" + pathTags = "/tags/{ResourceArn}" + uaMarker = "api/route53recoverycontrolconfig" + matchPriority = 87 +) + +// Handler serves the Route 53 Recovery Control Config control plane. +type Handler struct { + Backend *InMemoryBackend + server *restroute.Server + DefaultRegion string +} + +// NewHandler creates the handler. +func NewHandler(backend *InMemoryBackend) *Handler { + h := &Handler{Backend: backend} + h.server = &restroute.Server{Router: restroute.New(routes()), Ops: h.ops()} + + return h +} + +// Name returns the service name. +func (h *Handler) Name() string { return "Route53RecoveryControlConfig" } + +// Reset clears all state. +func (h *Handler) Reset() { h.Backend.Reset() } + +// Snapshot implements persistence.Persistable. +func (h *Handler) Snapshot(ctx context.Context) []byte { return h.Backend.Snapshot(ctx) } + +// Restore implements persistence.Persistable. +func (h *Handler) Restore(ctx context.Context, data []byte) error { + return h.Backend.Restore(ctx, data) +} + +// RoutingControlIsOn reports (on, found) for a routing control, for sibling services. +func (h *Handler) RoutingControlIsOn(arn string) (bool, bool) { + return h.Backend.RoutingControlIsOn(arn) +} + +// GetSupportedOperations lists every implemented operation. +func (h *Handler) GetSupportedOperations() []string { + ops := make([]string, 0, len(h.server.Ops)) + for op := range h.server.Ops { + ops = append(ops, op) + } + + slices.Sort(ops) + + return ops +} + +// ChaosServiceName returns the lowercase service name for fault rules. +func (h *Handler) ChaosServiceName() string { return signingName } + +// ChaosOperations returns the fault-injectable operations. +func (h *Handler) ChaosOperations() []string { return h.GetSupportedOperations() } + +// ChaosRegions returns the regions served. +func (h *Handler) ChaosRegions() []string { return []string{h.DefaultRegion} } + +// RouteMatcher matches by SigV4 signing name (or SDK user agent) plus a known path shape. +func (h *Handler) RouteMatcher() service.Matcher { + return func(c *echo.Context) bool { + r := c.Request() + if !h.server.Router.Handles(restroute.EscapedPath(r)) { + return false + } + + return httputils.ExtractServiceFromRequest(r) == signingName || + service.MatchesUserAgentMarker(r.Header, uaMarker) + } +} + +// MatchPriority returns the routing priority. +func (h *Handler) MatchPriority() int { return matchPriority } + +// ExtractOperation returns the operation name for metrics. +func (h *Handler) ExtractOperation(c *echo.Context) string { + op, _ := h.server.Operation(c.Request()) + if op == "" { + return "Unknown" + } + + return op +} + +// ExtractResource returns the primary ARN label for metrics. +func (h *Handler) ExtractResource(c *echo.Context) string { + _, params := h.server.Operation(c.Request()) + for _, v := range params { + return v + } + + return "" +} + +// Handler returns the Echo handler. +func (h *Handler) Handler() echo.HandlerFunc { + serve := h.server.Handler() + + return func(c *echo.Context) error { + if base, ok := requestBase(c.Scheme(), c.Request().Host); ok { + c.SetRequest(c.Request().WithContext(context.WithValue(c.Request().Context(), requestBaseCtxKey{}, base))) + } + + return serve(c) + } +} + +func routes() []restroute.Route { + return []restroute.Route{ + {Method: http.MethodPost, Path: pathCluster, Op: "CreateCluster"}, + {Method: http.MethodGet, Path: pathCluster, Op: "ListClusters"}, + {Method: http.MethodPut, Path: pathCluster, Op: "UpdateCluster"}, + {Method: http.MethodGet, Path: "/cluster/{ClusterArn}", Op: "DescribeCluster"}, + {Method: http.MethodDelete, Path: "/cluster/{ClusterArn}", Op: "DeleteCluster"}, + {Method: http.MethodPost, Path: "/controlpanel", Op: "CreateControlPanel"}, + {Method: http.MethodPut, Path: "/controlpanel", Op: "UpdateControlPanel"}, + {Method: http.MethodGet, Path: "/controlpanels", Op: "ListControlPanels"}, + {Method: http.MethodGet, Path: "/controlpanel/{ControlPanelArn}", Op: "DescribeControlPanel"}, + {Method: http.MethodDelete, Path: "/controlpanel/{ControlPanelArn}", Op: "DeleteControlPanel"}, + {Method: http.MethodGet, Path: "/controlpanel/{ControlPanelArn}/routingcontrols", Op: "ListRoutingControls"}, + {Method: http.MethodGet, Path: "/controlpanel/{ControlPanelArn}/safetyrules", Op: "ListSafetyRules"}, + {Method: http.MethodPost, Path: "/routingcontrol", Op: "CreateRoutingControl"}, + {Method: http.MethodPut, Path: "/routingcontrol", Op: "UpdateRoutingControl"}, + {Method: http.MethodGet, Path: "/routingcontrol/{RoutingControlArn}", Op: "DescribeRoutingControl"}, + {Method: http.MethodDelete, Path: "/routingcontrol/{RoutingControlArn}", Op: "DeleteRoutingControl"}, + { + Method: http.MethodGet, Path: "/routingcontrol/{RoutingControlArn}/associatedRoute53HealthChecks", + Op: "ListAssociatedRoute53HealthChecks", + }, + {Method: http.MethodPost, Path: "/safetyrule", Op: "CreateSafetyRule"}, + {Method: http.MethodPut, Path: "/safetyrule", Op: "UpdateSafetyRule"}, + {Method: http.MethodGet, Path: "/safetyrule/{SafetyRuleArn}", Op: "DescribeSafetyRule"}, + {Method: http.MethodDelete, Path: "/safetyrule/{SafetyRuleArn}", Op: "DeleteSafetyRule"}, + {Method: http.MethodGet, Path: "/resourcePolicy/{ResourceArn}", Op: "GetResourcePolicy"}, + {Method: http.MethodGet, Path: pathTags, Op: "ListTagsForResource"}, + {Method: http.MethodPost, Path: pathTags, Op: "TagResource"}, + {Method: http.MethodDelete, Path: pathTags, Op: "UntagResource"}, + } +} + +func (h *Handler) ops() map[string]restroute.OpFunc { + return map[string]restroute.OpFunc{ + "CreateCluster": h.createCluster, + "DescribeCluster": h.describeCluster, + "ListClusters": h.listClusters, + "UpdateCluster": h.updateCluster, + "DeleteCluster": h.deleteCluster, + "CreateControlPanel": h.createControlPanel, + "DescribeControlPanel": h.describeControlPanel, + "ListControlPanels": h.listControlPanels, + "UpdateControlPanel": h.updateControlPanel, + "DeleteControlPanel": h.deleteControlPanel, + "CreateRoutingControl": h.createRoutingControl, + "DescribeRoutingControl": h.describeRoutingControl, + "ListRoutingControls": h.listRoutingControls, + "UpdateRoutingControl": h.updateRoutingControl, + "DeleteRoutingControl": h.deleteRoutingControl, + "ListAssociatedRoute53HealthChecks": h.listAssociatedHealthChecks, + "CreateSafetyRule": h.createSafetyRule, + "DescribeSafetyRule": h.describeSafetyRule, + "ListSafetyRules": h.listSafetyRules, + "UpdateSafetyRule": h.updateSafetyRule, + "DeleteSafetyRule": h.deleteSafetyRule, + "GetResourcePolicy": h.getResourcePolicy, + "ListTagsForResource": h.listTags, + "TagResource": h.tagResource, + "UntagResource": h.untagResource, + } +} diff --git a/services/route53recoverycontrolconfig/handler_ops.go b/services/route53recoverycontrolconfig/handler_ops.go new file mode 100644 index 0000000000..59feea2198 --- /dev/null +++ b/services/route53recoverycontrolconfig/handler_ops.go @@ -0,0 +1,353 @@ +package route53recoverycontrolconfig + +import ( + "context" + + "github.com/blackbirdworks/gopherstack/pkgs/restroute" +) + +const ( + keyCluster = "Cluster" + keyControlPanel = "ControlPanel" + keyRoutingControl = "RoutingControl" +) + +type listQuery struct { + token string + max int +} + +func listParams(req *restroute.Request) (listQuery, error) { + m, err := restroute.MaxResultsQuery(req.Query, "MaxResults") + if err != nil { + return listQuery{}, err + } + + return listQuery{token: req.Query.Get("NextToken"), max: m}, nil +} + +func (h *Handler) createCluster(ctx context.Context, req *restroute.Request) (any, int, error) { + var in struct { + Tags map[string]string + ClusterName string + ClientToken string + NetworkType string + } + if err := req.Decode(&in); err != nil { + return nil, 0, err + } + + c, err := h.Backend.CreateCluster(ctx, in.ClusterName, in.NetworkType, in.ClientToken, in.Tags) + + return map[string]any{keyCluster: c}, 0, err +} + +func (h *Handler) describeCluster(ctx context.Context, req *restroute.Request) (any, int, error) { + c, err := h.Backend.DescribeCluster(ctx, req.Params["ClusterArn"]) + + return map[string]any{keyCluster: c}, 0, err +} + +func (h *Handler) listClusters(ctx context.Context, req *restroute.Request) (any, int, error) { + q, err := listParams(req) + if err != nil { + return nil, 0, err + } + + items, next, err := h.Backend.ListClusters(ctx, q.token, q.max) + + return pageOut("Clusters", items, next), 0, err +} + +func (h *Handler) updateCluster(ctx context.Context, req *restroute.Request) (any, int, error) { + var in struct{ ClusterArn, NetworkType string } + if err := req.Decode(&in); err != nil { + return nil, 0, err + } + + c, err := h.Backend.UpdateCluster(ctx, in.ClusterArn, in.NetworkType) + + return map[string]any{keyCluster: c}, 0, err +} + +func (h *Handler) deleteCluster(_ context.Context, req *restroute.Request) (any, int, error) { + return nil, 0, h.Backend.DeleteCluster(req.Params["ClusterArn"]) +} + +func (h *Handler) createControlPanel(_ context.Context, req *restroute.Request) (any, int, error) { + var in struct { + Tags map[string]string + ClusterArn string + ControlPanelName string + ClientToken string + } + if err := req.Decode(&in); err != nil { + return nil, 0, err + } + + p, err := h.Backend.CreateControlPanel(in.ClusterArn, in.ControlPanelName, in.ClientToken, in.Tags) + + return map[string]any{keyControlPanel: p}, 0, err +} + +func (h *Handler) describeControlPanel(_ context.Context, req *restroute.Request) (any, int, error) { + p, err := h.Backend.DescribeControlPanel(req.Params["ControlPanelArn"]) + + return map[string]any{keyControlPanel: p}, 0, err +} + +func (h *Handler) listControlPanels(_ context.Context, req *restroute.Request) (any, int, error) { + q, err := listParams(req) + if err != nil { + return nil, 0, err + } + + items, next, err := h.Backend.ListControlPanels(req.Query.Get("ClusterArn"), q.token, q.max) + + return pageOut("ControlPanels", items, next), 0, err +} + +func (h *Handler) updateControlPanel(_ context.Context, req *restroute.Request) (any, int, error) { + var in struct{ ControlPanelArn, ControlPanelName string } + if err := req.Decode(&in); err != nil { + return nil, 0, err + } + + p, err := h.Backend.UpdateControlPanel(in.ControlPanelArn, in.ControlPanelName) + + return map[string]any{keyControlPanel: p}, 0, err +} + +func (h *Handler) deleteControlPanel(_ context.Context, req *restroute.Request) (any, int, error) { + return nil, 0, h.Backend.DeleteControlPanel(req.Params["ControlPanelArn"]) +} + +func (h *Handler) createRoutingControl(_ context.Context, req *restroute.Request) (any, int, error) { + var in struct{ ClusterArn, RoutingControlName, ClientToken, ControlPanelArn string } + if err := req.Decode(&in); err != nil { + return nil, 0, err + } + + c, err := h.Backend.CreateRoutingControl(in.ClusterArn, in.ControlPanelArn, in.RoutingControlName, in.ClientToken) + + return map[string]any{keyRoutingControl: c}, 0, err +} + +func (h *Handler) describeRoutingControl(_ context.Context, req *restroute.Request) (any, int, error) { + c, err := h.Backend.DescribeRoutingControl(req.Params["RoutingControlArn"]) + + return map[string]any{keyRoutingControl: c}, 0, err +} + +func (h *Handler) listRoutingControls(_ context.Context, req *restroute.Request) (any, int, error) { + q, err := listParams(req) + if err != nil { + return nil, 0, err + } + + items, next, err := h.Backend.ListRoutingControls(req.Params["ControlPanelArn"], q.token, q.max) + + return pageOut("RoutingControls", items, next), 0, err +} + +func (h *Handler) updateRoutingControl(_ context.Context, req *restroute.Request) (any, int, error) { + var in struct{ RoutingControlArn, RoutingControlName string } + if err := req.Decode(&in); err != nil { + return nil, 0, err + } + + c, err := h.Backend.UpdateRoutingControl(in.RoutingControlArn, in.RoutingControlName) + + return map[string]any{keyRoutingControl: c}, 0, err +} + +func (h *Handler) deleteRoutingControl(_ context.Context, req *restroute.Request) (any, int, error) { + return nil, 0, h.Backend.DeleteRoutingControl(req.Params["RoutingControlArn"]) +} + +func (h *Handler) listAssociatedHealthChecks(_ context.Context, req *restroute.Request) (any, int, error) { + q, err := listParams(req) + if err != nil { + return nil, 0, err + } + + ids, err := h.Backend.AssociatedHealthCheckIDs(req.Params["RoutingControlArn"]) + if err != nil { + return nil, 0, err + } + + pg, err := restroute.Paginate(ids, q.token, q.max, listDefault, listMax) + if err != nil { + return nil, 0, err + } + + return pageOut("HealthCheckIds", append([]string{}, pg.Data...), pg.Next), 0, nil +} + +func (h *Handler) createSafetyRule(_ context.Context, req *restroute.Request) (any, int, error) { + var in struct { + AssertionRule *wireNewAssertionRule + GatingRule *wireNewGatingRule + Tags map[string]string + ClientToken string + } + if err := req.Decode(&in); err != nil { + return nil, 0, err + } + + spec, err := newRuleSpec(in.AssertionRule, in.GatingRule) + if err != nil { + return nil, 0, err + } + + r, err := h.Backend.CreateSafetyRule(spec, in.ClientToken, in.Tags) + if err != nil { + return nil, 0, err + } + + return ruleBody(r), 0, nil +} + +func newRuleSpec(a *wireNewAssertionRule, g *wireNewGatingRule) (*NewRuleSpec, error) { + if (a == nil) == (g == nil) { + return nil, restroute.Validation("Specify exactly one of AssertionRule or GatingRule") + } + + var ( + cfg *wireNewRuleConfig + spec NewRuleSpec + wait *int32 + ) + + if a != nil { + cfg, wait = a.RuleConfig, a.WaitPeriodMs + spec = NewRuleSpec{Name: a.Name, PanelARN: a.ControlPanelArn, Kind: ruleAssertion, Controls: a.AssertedControls} + } else { + cfg, wait = g.RuleConfig, g.WaitPeriodMs + spec = NewRuleSpec{ + Name: g.Name, + PanelARN: g.ControlPanelArn, + Kind: ruleGating, + Controls: g.GatingControls, + Targets: g.TargetControls, + } + } + + if cfg == nil || cfg.Inverted == nil || cfg.Threshold == nil || wait == nil { + return nil, restroute.Validation("RuleConfig (Inverted, Threshold, Type) and WaitPeriodMs are required") + } + + spec.RuleType, spec.Inverted, spec.Threshold, spec.WaitPeriodMs = cfg.Type, *cfg.Inverted, *cfg.Threshold, *wait + + return &spec, nil +} + +func ruleBody(r wireRule) map[string]any { + if r.Gating != nil { + return map[string]any{"GatingRule": r.Gating} + } + + return map[string]any{"AssertionRule": r.Assertion} +} + +func (h *Handler) describeSafetyRule(_ context.Context, req *restroute.Request) (any, int, error) { + r, err := h.Backend.DescribeSafetyRule(req.Params["SafetyRuleArn"]) + if err != nil { + return nil, 0, err + } + + return ruleBody(r), 0, nil +} + +func (h *Handler) listSafetyRules(_ context.Context, req *restroute.Request) (any, int, error) { + q, err := listParams(req) + if err != nil { + return nil, 0, err + } + + items, next, err := h.Backend.ListSafetyRules(req.Params["ControlPanelArn"], q.token, q.max) + + return pageOut("SafetyRules", items, next), 0, err +} + +func (h *Handler) updateSafetyRule(_ context.Context, req *restroute.Request) (any, int, error) { + var in struct { + AssertionRuleUpdate *wireRuleUpdate + GatingRuleUpdate *wireRuleUpdate + } + if err := req.Decode(&in); err != nil { + return nil, 0, err + } + + if (in.AssertionRuleUpdate == nil) == (in.GatingRuleUpdate == nil) { + return nil, 0, restroute.Validation("Specify exactly one of AssertionRuleUpdate or GatingRuleUpdate") + } + + kind, u := ruleAssertion, in.AssertionRuleUpdate + if u == nil { + kind, u = ruleGating, in.GatingRuleUpdate + } + + if u.WaitPeriodMs == nil { + return nil, 0, restroute.Validation("WaitPeriodMs is required") + } + + r, err := h.Backend.UpdateSafetyRule(kind, u.SafetyRuleArn, u.Name, *u.WaitPeriodMs) + if err != nil { + return nil, 0, err + } + + return ruleBody(r), 0, nil +} + +func (h *Handler) deleteSafetyRule(_ context.Context, req *restroute.Request) (any, int, error) { + return nil, 0, h.Backend.DeleteSafetyRule(req.Params["SafetyRuleArn"]) +} + +func (h *Handler) getResourcePolicy(_ context.Context, req *restroute.Request) (any, int, error) { + return nil, 0, h.Backend.requireResource(req.Params["ResourceArn"]) +} + +func (h *Handler) listTags(_ context.Context, req *restroute.Request) (any, int, error) { + t, err := h.Backend.ListTagsForResource(req.Params["ResourceArn"]) + if t == nil { + t = map[string]string{} + } + + return map[string]any{"Tags": t}, 0, err +} + +func (h *Handler) tagResource(_ context.Context, req *restroute.Request) (any, int, error) { + var in struct{ Tags map[string]string } + if err := req.Decode(&in); err != nil { + return nil, 0, err + } + + if len(in.Tags) == 0 { + return nil, 0, restroute.Validation("Tags is required") + } + + return nil, 0, h.Backend.TagResource(req.Params["ResourceArn"], in.Tags) +} + +func (h *Handler) untagResource(_ context.Context, req *restroute.Request) (any, int, error) { + keys := req.Query["TagKeys"] + if len(keys) == 0 { + return nil, 0, restroute.Validation("TagKeys is required") + } + + return nil, 0, h.Backend.UntagResource(req.Params["ResourceArn"], keys) +} + +func pageOut[T any](key string, items []T, next string) map[string]any { + if items == nil { + items = []T{} + } + + out := map[string]any{key: items} + if next != "" { + out["NextToken"] = next + } + + return out +} diff --git a/services/route53recoverycontrolconfig/leak_main_test.go b/services/route53recoverycontrolconfig/leak_main_test.go new file mode 100644 index 0000000000..6afd072faf --- /dev/null +++ b/services/route53recoverycontrolconfig/leak_main_test.go @@ -0,0 +1,9 @@ +package route53recoverycontrolconfig_test + +import ( + "testing" + + "github.com/blackbirdworks/gopherstack/pkgs/testleak" +) + +func TestMain(m *testing.M) { testleak.VerifyTestMain(m) } diff --git a/services/route53recoverycontrolconfig/lifecycle_test.go b/services/route53recoverycontrolconfig/lifecycle_test.go new file mode 100644 index 0000000000..f899a49725 --- /dev/null +++ b/services/route53recoverycontrolconfig/lifecycle_test.go @@ -0,0 +1,207 @@ +package route53recoverycontrolconfig_test + +import ( + "testing" + "testing/synctest" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/blackbirdworks/gopherstack/pkgs/restroute" + cc "github.com/blackbirdworks/gopherstack/services/route53recoverycontrolconfig" +) + +func TestActivationDelay(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + wantStatus string + delay time.Duration + elapsed time.Duration + wantEndpoints bool + }{ + {name: "instant by default", wantStatus: "DEPLOYED", wantEndpoints: true}, + {name: "pending inside the window", delay: time.Minute, elapsed: 10 * time.Second, wantStatus: "PENDING"}, + { + name: "deployed after the window", + delay: time.Minute, + elapsed: 2 * time.Minute, + wantStatus: "DEPLOYED", + wantEndpoints: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + synctest.Test(t, func(t *testing.T) { + b := cc.NewInMemoryBackend(testAccount) + b.SetActivationDelay(tt.delay) + + cl, err := b.CreateCluster(t.Context(), "c", "", "", nil) + require.NoError(t, err) + + rc, err := b.CreateRoutingControl(cl.ClusterArn, "", "r", "") + require.NoError(t, err) + + time.Sleep(tt.elapsed) + + got, err := b.DescribeCluster(t.Context(), cl.ClusterArn) + require.NoError(t, err) + assert.Equal(t, tt.wantStatus, got.Status) + assert.Equal(t, tt.wantEndpoints, len(got.ClusterEndpoints) > 0) + + ctl, err := b.DescribeRoutingControl(rc.RoutingControlArn) + require.NoError(t, err) + assert.Equal(t, tt.wantStatus, ctl.Status) + }) + }) + } +} + +func TestPendingDeletion(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + wantStatus string + elapsed time.Duration + wantGone bool + }{ + {name: "visible while deleting", elapsed: 10 * time.Second, wantStatus: "PENDING_DELETION"}, + {name: "gone after the window", elapsed: 2 * time.Minute, wantGone: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + synctest.Test(t, func(t *testing.T) { + b := cc.NewInMemoryBackend(testAccount) + b.SetActivationDelay(time.Minute) + + cl, err := b.CreateCluster(t.Context(), "c", "", "", nil) + require.NoError(t, err) + + time.Sleep(2 * time.Minute) + require.NoError(t, b.DeleteCluster(cl.ClusterArn)) + time.Sleep(tt.elapsed) + + got, err := b.DescribeCluster(t.Context(), cl.ClusterArn) + if tt.wantGone { + require.Error(t, err) + + list, _, lErr := b.ListClusters(t.Context(), "", 0) + require.NoError(t, lErr) + assert.Empty(t, list) + + return + } + + require.NoError(t, err) + assert.Equal(t, tt.wantStatus, got.Status) + + list, _, err := b.ListClusters(t.Context(), "", 0) + require.NoError(t, err) + assert.Len(t, list, 1) + }) + }) + } +} + +func TestNoChildrenUnderPendingDeletion(t *testing.T) { + t.Parallel() + + tests := []struct { + create func(b *cc.InMemoryBackend, cluster, panel string) error + name string + deleteCluster bool + }{ + { + name: "panel under deleting cluster", deleteCluster: true, + create: func(b *cc.InMemoryBackend, cluster, _ string) error { + _, err := b.CreateControlPanel(cluster, "p2", "", nil) + + return err + }, + }, + { + name: "control under deleting cluster", deleteCluster: true, + create: func(b *cc.InMemoryBackend, cluster, _ string) error { + _, err := b.CreateRoutingControl(cluster, "", "r2", "") + + return err + }, + }, + { + name: "control under deleting panel", + create: func(b *cc.InMemoryBackend, cluster, panel string) error { + _, err := b.CreateRoutingControl(cluster, panel, "r2", "") + + return err + }, + }, + { + name: "rule under deleting panel", + create: func(b *cc.InMemoryBackend, _, panel string) error { + _, err := b.CreateSafetyRule(&cc.NewRuleSpec{ + Name: "a", + PanelARN: panel, + Kind: "ASSERTION", + RuleType: "ATLEAST", + Controls: []string{ + "arn:aws:route53-recovery-control::123456789012:controlpanel/x/routingcontrol/y", + }, + Threshold: 1, + }, "", nil) + + return err + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + synctest.Test(t, func(t *testing.T) { + b := cc.NewInMemoryBackend(testAccount) + b.SetActivationDelay(time.Minute) + + cl, err := b.CreateCluster(t.Context(), "c", "", "", nil) + require.NoError(t, err) + + time.Sleep(2 * time.Minute) + + panel, err := b.CreateControlPanel(cl.ClusterArn, "p", "", nil) + require.NoError(t, err) + + if tt.deleteCluster { + panels, _, lErr := b.ListControlPanels(cl.ClusterArn, "", 0) + require.NoError(t, lErr) + + for _, p := range panels { + if !p.DefaultControlPanel { + require.NoError(t, b.DeleteControlPanel(p.ControlPanelArn)) + } + } + + time.Sleep(2 * time.Minute) + require.NoError(t, b.DeleteCluster(cl.ClusterArn)) + } else { + require.NoError(t, b.DeleteControlPanel(panel.ControlPanelArn)) + } + + err = tt.create(b, cl.ClusterArn, panel.ControlPanelArn) + + var re *restroute.Error + + require.ErrorAs(t, err, &re) + assert.Equal(t, "ConflictException", re.Code) + }) + }) + } +} diff --git a/services/route53recoverycontrolconfig/misc_test.go b/services/route53recoverycontrolconfig/misc_test.go new file mode 100644 index 0000000000..4be704046e --- /dev/null +++ b/services/route53recoverycontrolconfig/misc_test.go @@ -0,0 +1,170 @@ +package route53recoverycontrolconfig_test + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/aws/aws-sdk-go-v2/aws" + sdk "github.com/aws/aws-sdk-go-v2/service/route53recoverycontrolconfig" + "github.com/labstack/echo/v5" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/blackbirdworks/gopherstack/pkgs/sdkcheck" + cc "github.com/blackbirdworks/gopherstack/services/route53recoverycontrolconfig" +) + +func TestSDKCompleteness(t *testing.T) { + t.Parallel() + + sdkcheck.CheckCompleteness( + t, + &sdk.Client{}, + cc.NewHandler(cc.NewInMemoryBackend(testAccount)).GetSupportedOperations(), + []string{}, + ) +} + +func TestPersistenceRoundTrip(t *testing.T) { + t.Parallel() + + f := newFixture(t) + topo := f.topology(t) + ctx := t.Context() + + _, err := f.client.CreateSafetyRule(ctx, newAssertion(topo, nil)) + require.NoError(t, err) + require.NoError(t, f.backend.UpdateRoutingControlStates([]cc.StateUpdate{{ARN: topo.ctlA, State: "On"}}, nil, "")) + _, err = f.client.TagResource( + ctx, + &sdk.TagResourceInput{ResourceArn: aws.String(topo.cluster), Tags: map[string]string{"k": "v"}}, + ) + require.NoError(t, err) + + snap := f.handler.Snapshot(ctx) + require.NotEmpty(t, snap) + + restored := cc.NewHandler(cc.NewInMemoryBackend(testAccount)) + require.NoError(t, restored.Restore(ctx, snap)) + + v, err := restored.Backend.RoutingControlState(topo.ctlA, "") + require.NoError(t, err) + assert.Equal(t, "On", v.State) + + rules, _, err := restored.Backend.ListSafetyRules(topo.defaultPanel, "", 0) + require.NoError(t, err) + assert.Len(t, rules, 1) + + tags, err := restored.Backend.ListTagsForResource(topo.cluster) + require.NoError(t, err) + assert.Equal(t, map[string]string{"k": "v"}, tags) + + require.NoError(t, restored.Restore(ctx, []byte(`{"version":999}`))) + _, err = restored.Backend.DescribeCluster(t.Context(), topo.cluster) + assert.Error(t, err, "an incompatible snapshot starts empty") +} + +func serve(h *cc.Handler, method, target string, auth string, body string) *httptest.ResponseRecorder { + req := httptest.NewRequestWithContext(context.Background(), method, target, strings.NewReader(body)) + if auth != "" { + req.Header.Set("Authorization", "AWS4-HMAC-SHA256 Credential=test/20260101/us-east-1/"+auth+"/aws4_request") + } + + rec := httptest.NewRecorder() + c := echo.New().NewContext(req, rec) + _ = h.Handler()(c) + + return rec +} + +func TestRouting(t *testing.T) { + t.Parallel() + + h := cc.NewHandler(cc.NewInMemoryBackend(testAccount)) + + tests := []struct { + name, method, path, auth, wantOp string + wantMatch bool + }{ + {"list clusters", http.MethodGet, "/cluster", "route53-recovery-control-config", "ListClusters", true}, + {"update cluster", http.MethodPut, "/cluster", "route53-recovery-control-config", "UpdateCluster", true}, + { + "describe encoded arn", + http.MethodGet, + "/cluster/arn%3Aaws%3Aroute53-recovery-control%3A%3A1%3Acluster%2Fabc", + "route53-recovery-control-config", + "DescribeCluster", + true, + }, + {"tags", http.MethodPost, "/tags/arn%3Aaws%3Ax", "route53-recovery-control-config", "TagResource", true}, + { + "health checks", + http.MethodGet, + "/routingcontrol/x/associatedRoute53HealthChecks", + "route53-recovery-control-config", + "ListAssociatedRoute53HealthChecks", + true, + }, + {"other service same path", http.MethodGet, "/cluster", "route53-recovery-readiness", "ListClusters", false}, + {"unknown path", http.MethodGet, "/nothing", "route53-recovery-control-config", "Unknown", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + req := httptest.NewRequestWithContext(t.Context(), tt.method, tt.path, nil) + req.Header.Set( + "Authorization", + "AWS4-HMAC-SHA256 Credential=test/20260101/us-east-1/"+tt.auth+"/aws4_request", + ) + c := echo.New().NewContext(req, httptest.NewRecorder()) + + assert.Equal(t, tt.wantMatch, h.RouteMatcher()(c)) + + if tt.wantMatch { + assert.Equal(t, tt.wantOp, h.ExtractOperation(c)) + } + }) + } +} + +func TestErrorBodyShape(t *testing.T) { + t.Parallel() + + h := cc.NewHandler(cc.NewInMemoryBackend(testAccount)) + + tests := []struct { + name, method, path, body, wantCode string + wantStatus int + }{ + {"not found", http.MethodGet, "/cluster/arn%3Anone", "", "ResourceNotFoundException", http.StatusNotFound}, + {"bad json", http.MethodPost, "/cluster", "{", "ValidationException", http.StatusBadRequest}, + {"unknown operation", http.MethodPost, "/", "{}", "UnknownOperationException", http.StatusNotFound}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + rec := serve(h, tt.method, tt.path, "route53-recovery-control-config", tt.body) + assert.Equal(t, tt.wantStatus, rec.Code) + assert.Equal(t, tt.wantCode, rec.Header().Get("X-Amzn-Errortype")) + + var body map[string]string + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &body)) + assert.NotEmpty(t, body["message"]) + assert.Equal(t, body["message"], body["Message"]) + assert.False( + t, + strings.HasPrefix(body["message"], tt.wantCode), + "message must not repeat the code: %q", + body["message"], + ) + }) + } +} diff --git a/services/route53recoverycontrolconfig/models.go b/services/route53recoverycontrolconfig/models.go new file mode 100644 index 0000000000..14014101cc --- /dev/null +++ b/services/route53recoverycontrolconfig/models.go @@ -0,0 +1,74 @@ +package route53recoverycontrolconfig + +import "time" + +const ( + statusPending = "PENDING" + statusDeployed = "DEPLOYED" + statusPendingDeletion = "PENDING_DELETION" + + ruleAssertion = "ASSERTION" + ruleGating = "GATING" + + ruleTypeAtLeast = "ATLEAST" + ruleTypeAnd = "AND" + ruleTypeOr = "OR" + + stateOn = "On" + stateOff = "Off" + + networkIPv4 = "IPV4" + networkDualstack = "DUALSTACK" + + arnService = "route53-recovery-control" +) + +// Cluster is a Route 53 ARC cluster. +type Cluster struct { + CreatedAt time.Time `json:"createdAt"` + DeletedAt *time.Time `json:"deletedAt,omitempty"` + Tags map[string]string `json:"tags,omitempty"` + ARN string `json:"arn"` + Name string `json:"name"` + NetworkType string `json:"networkType"` +} + +// ControlPanel groups routing controls and safety rules inside a cluster. +type ControlPanel struct { + CreatedAt time.Time `json:"createdAt"` + DeletedAt *time.Time `json:"deletedAt,omitempty"` + Tags map[string]string `json:"tags,omitempty"` + ARN string `json:"arn"` + ClusterARN string `json:"clusterArn"` + Name string `json:"name"` + Default bool `json:"default,omitempty"` +} + +// RoutingControl is an On/Off switch inside a control panel. +type RoutingControl struct { + CreatedAt time.Time `json:"createdAt"` + DeletedAt *time.Time `json:"deletedAt,omitempty"` + ARN string `json:"arn"` + PanelARN string `json:"panelArn"` + ClusterARN string `json:"clusterArn"` + Name string `json:"name"` + State string `json:"state"` +} + +// SafetyRule is an ASSERTION or GATING rule guarding routing control updates. +type SafetyRule struct { + CreatedAt time.Time `json:"createdAt"` + LastChangeAt time.Time `json:"lastChangeAt"` + DeletedAt *time.Time `json:"deletedAt,omitempty"` + Tags map[string]string `json:"tags,omitempty"` + ARN string `json:"arn"` + PanelARN string `json:"panelArn"` + Name string `json:"name"` + Kind string `json:"kind"` + RuleType string `json:"ruleType"` + Controls []string `json:"controls"` + Targets []string `json:"targets,omitempty"` + Threshold int32 `json:"threshold"` + WaitPeriodMs int32 `json:"waitPeriodMs"` + Inverted bool `json:"inverted"` +} diff --git a/services/route53recoverycontrolconfig/ops_test.go b/services/route53recoverycontrolconfig/ops_test.go new file mode 100644 index 0000000000..cfc2cf4e9c --- /dev/null +++ b/services/route53recoverycontrolconfig/ops_test.go @@ -0,0 +1,479 @@ +package route53recoverycontrolconfig_test + +import ( + "testing" + + "github.com/aws/aws-sdk-go-v2/aws" + sdk "github.com/aws/aws-sdk-go-v2/service/route53recoverycontrolconfig" + "github.com/aws/aws-sdk-go-v2/service/route53recoverycontrolconfig/types" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type topology struct { + cluster, defaultPanel, customPanel, ctlA, ctlB, ctlC string +} + +func (f *fixture) topology(t *testing.T) topology { + t.Helper() + + ctx := t.Context() + cl, err := f.client.CreateCluster(ctx, &sdk.CreateClusterInput{ClusterName: aws.String("prod")}) + require.NoError(t, err) + + arn := aws.ToString(cl.Cluster.ClusterArn) + panels, err := f.client.ListControlPanels(ctx, &sdk.ListControlPanelsInput{ClusterArn: aws.String(arn)}) + require.NoError(t, err) + require.Len(t, panels.ControlPanels, 1) + + custom, err := f.client.CreateControlPanel(ctx, &sdk.CreateControlPanelInput{ + ClusterArn: aws.String(arn), ControlPanelName: aws.String("custom"), + }) + require.NoError(t, err) + + ctl := func(name string, panel *string) string { + out, cErr := f.client.CreateRoutingControl(ctx, &sdk.CreateRoutingControlInput{ + ClusterArn: aws.String(arn), RoutingControlName: aws.String(name), ControlPanelArn: panel, + }) + require.NoError(t, cErr) + + return aws.ToString(out.RoutingControl.RoutingControlArn) + } + + customArn := aws.ToString(custom.ControlPanel.ControlPanelArn) + + return topology{ + cluster: arn, defaultPanel: aws.ToString(panels.ControlPanels[0].ControlPanelArn), customPanel: customArn, + ctlA: ctl("a", nil), ctlB: ctl("b", nil), ctlC: ctl("c", aws.String(customArn)), + } +} + +func TestClusterLifecycle(t *testing.T) { + t.Parallel() + + tests := []struct { + run func(t *testing.T, f *fixture) + name string + }{ + {name: "create and describe", run: func(t *testing.T, f *fixture) { + t.Helper() + + out, err := f.client.CreateCluster(t.Context(), &sdk.CreateClusterInput{ + ClusterName: aws.String("c"), NetworkType: types.NetworkTypeDualstack, + Tags: map[string]string{"env": "dev"}, + }) + require.NoError(t, err) + assert.Equal(t, types.StatusDeployed, out.Cluster.Status) + assert.Equal(t, types.NetworkTypeDualstack, out.Cluster.NetworkType) + assert.Equal(t, testAccount, aws.ToString(out.Cluster.Owner)) + assert.Regexp( + t, + `^arn:aws:route53-recovery-control::123456789012:cluster/[0-9a-f]{32}$`, + aws.ToString(out.Cluster.ClusterArn), + ) + require.Len(t, out.Cluster.ClusterEndpoints, 5) + + for _, ep := range out.Cluster.ClusterEndpoints { + assert.Contains(t, aws.ToString(ep.Endpoint), f.url) + assert.NotEmpty(t, aws.ToString(ep.Region)) + } + + tags, err := f.client.ListTagsForResource( + t.Context(), + &sdk.ListTagsForResourceInput{ResourceArn: out.Cluster.ClusterArn}, + ) + require.NoError(t, err) + assert.Equal(t, map[string]string{"env": "dev"}, tags.Tags) + }}, + {name: "default control panel is created", run: func(t *testing.T, f *fixture) { + t.Helper() + + topo := f.topology(t) + out, err := f.client.DescribeControlPanel( + t.Context(), + &sdk.DescribeControlPanelInput{ControlPanelArn: aws.String(topo.defaultPanel)}, + ) + require.NoError(t, err) + assert.True(t, aws.ToBool(out.ControlPanel.DefaultControlPanel)) + assert.Equal(t, "DefaultControlPanel", aws.ToString(out.ControlPanel.Name)) + assert.EqualValues(t, 2, aws.ToInt32(out.ControlPanel.RoutingControlCount)) + }}, + {name: "client token is idempotent", run: func(t *testing.T, f *fixture) { + t.Helper() + + in := &sdk.CreateClusterInput{ClusterName: aws.String("c"), ClientToken: aws.String("tok")} + a, err := f.client.CreateCluster(t.Context(), in) + require.NoError(t, err) + b, err := f.client.CreateCluster(t.Context(), in) + require.NoError(t, err) + assert.Equal(t, aws.ToString(a.Cluster.ClusterArn), aws.ToString(b.Cluster.ClusterArn)) + + list, err := f.client.ListClusters(t.Context(), &sdk.ListClustersInput{}) + require.NoError(t, err) + assert.Len(t, list.Clusters, 1) + }}, + {name: "update network type", run: func(t *testing.T, f *fixture) { + t.Helper() + + topo := f.topology(t) + out, err := f.client.UpdateCluster(t.Context(), &sdk.UpdateClusterInput{ + ClusterArn: aws.String(topo.cluster), NetworkType: types.NetworkTypeDualstack, + }) + require.NoError(t, err) + assert.Equal(t, types.NetworkTypeDualstack, out.Cluster.NetworkType) + }}, + {name: "list paginates", run: func(t *testing.T, f *fixture) { + t.Helper() + + for _, n := range []string{"c1", "c2", "c3"} { + _, err := f.client.CreateCluster(t.Context(), &sdk.CreateClusterInput{ClusterName: aws.String(n)}) + require.NoError(t, err) + } + + p := sdk.NewListClustersPaginator(f.client, &sdk.ListClustersInput{MaxResults: aws.Int32(2)}) + var names []string + pages := 0 + + for p.HasMorePages() { + pg, err := p.NextPage(t.Context()) + require.NoError(t, err) + pages++ + + for _, c := range pg.Clusters { + names = append(names, aws.ToString(c.Name)) + } + } + + assert.Equal(t, []string{"c1", "c2", "c3"}, names) + assert.Equal(t, 2, pages) + }}, + {name: "delete empty cluster", run: func(t *testing.T, f *fixture) { + t.Helper() + + out, err := f.client.CreateCluster(t.Context(), &sdk.CreateClusterInput{ClusterName: aws.String("c")}) + require.NoError(t, err) + _, err = f.client.DeleteCluster(t.Context(), &sdk.DeleteClusterInput{ClusterArn: out.Cluster.ClusterArn}) + require.NoError(t, err) + _, err = f.client.DescribeCluster( + t.Context(), + &sdk.DescribeClusterInput{ClusterArn: out.Cluster.ClusterArn}, + ) + assert.Equal(t, "ResourceNotFoundException", apiCode(err)) + + panels, err := f.client.ListControlPanels(t.Context(), &sdk.ListControlPanelsInput{}) + require.NoError(t, err) + assert.Empty(t, panels.ControlPanels, "the default panel goes with the cluster") + }}, + {name: "delete cluster with custom panel conflicts", run: func(t *testing.T, f *fixture) { + t.Helper() + + topo := f.topology(t) + _, err := f.client.DeleteCluster(t.Context(), &sdk.DeleteClusterInput{ClusterArn: aws.String(topo.cluster)}) + assert.Equal(t, "ConflictException", apiCode(err)) + assert.Contains(t, apiMessage(err), "custom") + }}, + {name: "describe unknown", run: func(t *testing.T, f *fixture) { + t.Helper() + + _, err := f.client.DescribeCluster( + t.Context(), + &sdk.DescribeClusterInput{ + ClusterArn: aws.String("arn:aws:route53-recovery-control::123456789012:cluster/nope"), + }, + ) + assert.Equal(t, "ResourceNotFoundException", apiCode(err)) + }}, + {name: "invalid name and network type", run: func(t *testing.T, f *fixture) { + t.Helper() + + _, err := f.client.CreateCluster(t.Context(), &sdk.CreateClusterInput{ClusterName: aws.String("")}) + assert.Equal(t, "ValidationException", apiCode(err)) + _, err = f.client.CreateCluster( + t.Context(), + &sdk.CreateClusterInput{ClusterName: aws.String("x"), NetworkType: "IPV9"}, + ) + assert.Equal(t, "ValidationException", apiCode(err)) + }}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + tt.run(t, newFixture(t)) + }) + } +} + +func TestControlPanelLifecycle(t *testing.T) { + t.Parallel() + + tests := []struct { + run func(t *testing.T, f *fixture, topo topology) + name string + }{ + {name: "rename", run: func(t *testing.T, f *fixture, topo topology) { + t.Helper() + + out, err := f.client.UpdateControlPanel(t.Context(), &sdk.UpdateControlPanelInput{ + ControlPanelArn: aws.String(topo.customPanel), ControlPanelName: aws.String("renamed"), + }) + require.NoError(t, err) + assert.Equal(t, "renamed", aws.ToString(out.ControlPanel.Name)) + assert.EqualValues(t, 1, aws.ToInt32(out.ControlPanel.RoutingControlCount)) + }}, + {name: "delete default conflicts", run: func(t *testing.T, f *fixture, topo topology) { + t.Helper() + + _, err := f.client.DeleteControlPanel( + t.Context(), + &sdk.DeleteControlPanelInput{ControlPanelArn: aws.String(topo.defaultPanel)}, + ) + assert.Equal(t, "ConflictException", apiCode(err)) + assert.Contains(t, apiMessage(err), "default") + }}, + {name: "delete non-empty conflicts", run: func(t *testing.T, f *fixture, topo topology) { + t.Helper() + + _, err := f.client.DeleteControlPanel( + t.Context(), + &sdk.DeleteControlPanelInput{ControlPanelArn: aws.String(topo.customPanel)}, + ) + assert.Equal(t, "ConflictException", apiCode(err)) + }}, + {name: "delete empty custom panel", run: func(t *testing.T, f *fixture, topo topology) { + t.Helper() + + _, err := f.client.DeleteRoutingControl( + t.Context(), + &sdk.DeleteRoutingControlInput{RoutingControlArn: aws.String(topo.ctlC)}, + ) + require.NoError(t, err) + _, err = f.client.DeleteControlPanel( + t.Context(), + &sdk.DeleteControlPanelInput{ControlPanelArn: aws.String(topo.customPanel)}, + ) + require.NoError(t, err) + _, err = f.client.DescribeControlPanel( + t.Context(), + &sdk.DescribeControlPanelInput{ControlPanelArn: aws.String(topo.customPanel)}, + ) + assert.Equal(t, "ResourceNotFoundException", apiCode(err)) + }}, + {name: "list filters by cluster", run: func(t *testing.T, f *fixture, topo topology) { + t.Helper() + + other, err := f.client.CreateCluster(t.Context(), &sdk.CreateClusterInput{ClusterName: aws.String("other")}) + require.NoError(t, err) + + mine, err := f.client.ListControlPanels( + t.Context(), + &sdk.ListControlPanelsInput{ClusterArn: aws.String(topo.cluster)}, + ) + require.NoError(t, err) + assert.Len(t, mine.ControlPanels, 2) + + theirs, err := f.client.ListControlPanels( + t.Context(), + &sdk.ListControlPanelsInput{ClusterArn: other.Cluster.ClusterArn}, + ) + require.NoError(t, err) + assert.Len(t, theirs.ControlPanels, 1) + + all, err := f.client.ListControlPanels(t.Context(), &sdk.ListControlPanelsInput{}) + require.NoError(t, err) + assert.Len(t, all.ControlPanels, 3) + }}, + {name: "create in unknown cluster", run: func(t *testing.T, f *fixture, _ topology) { + t.Helper() + + _, err := f.client.CreateControlPanel(t.Context(), &sdk.CreateControlPanelInput{ + ClusterArn: aws.String( + "arn:aws:route53-recovery-control::123456789012:cluster/nope", + ), + ControlPanelName: aws.String("x"), + }) + assert.Equal(t, "ResourceNotFoundException", apiCode(err)) + }}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + f := newFixture(t) + tt.run(t, f, f.topology(t)) + }) + } +} + +func TestRoutingControlLifecycle(t *testing.T) { + t.Parallel() + + tests := []struct { + run func(t *testing.T, f *fixture, topo topology) + name string + }{ + { + name: "defaults to the cluster default panel and starts Off", + run: func(t *testing.T, f *fixture, topo topology) { + t.Helper() + + out, err := f.client.DescribeRoutingControl( + t.Context(), + &sdk.DescribeRoutingControlInput{RoutingControlArn: aws.String(topo.ctlA)}, + ) + require.NoError(t, err) + assert.Equal(t, topo.defaultPanel, aws.ToString(out.RoutingControl.ControlPanelArn)) + assert.Equal(t, types.StatusDeployed, out.RoutingControl.Status) + assert.Regexp( + t, + `:controlpanel/[0-9a-f]{32}/routingcontrol/[0-9a-f]{12}$`, + aws.ToString(out.RoutingControl.RoutingControlArn), + ) + }, + }, + {name: "list by panel", run: func(t *testing.T, f *fixture, topo topology) { + t.Helper() + + out, err := f.client.ListRoutingControls( + t.Context(), + &sdk.ListRoutingControlsInput{ControlPanelArn: aws.String(topo.defaultPanel)}, + ) + require.NoError(t, err) + require.Len(t, out.RoutingControls, 2) + assert.Equal(t, "a", aws.ToString(out.RoutingControls[0].Name)) + }}, + {name: "rename", run: func(t *testing.T, f *fixture, topo topology) { + t.Helper() + + out, err := f.client.UpdateRoutingControl(t.Context(), &sdk.UpdateRoutingControlInput{ + RoutingControlArn: aws.String(topo.ctlA), RoutingControlName: aws.String("alpha"), + }) + require.NoError(t, err) + assert.Equal(t, "alpha", aws.ToString(out.RoutingControl.Name)) + }}, + {name: "delete", run: func(t *testing.T, f *fixture, topo topology) { + t.Helper() + + _, err := f.client.DeleteRoutingControl( + t.Context(), + &sdk.DeleteRoutingControlInput{RoutingControlArn: aws.String(topo.ctlA)}, + ) + require.NoError(t, err) + _, err = f.client.DescribeRoutingControl( + t.Context(), + &sdk.DescribeRoutingControlInput{RoutingControlArn: aws.String(topo.ctlA)}, + ) + assert.Equal(t, "ResourceNotFoundException", apiCode(err)) + }}, + {name: "panel from another cluster is rejected", run: func(t *testing.T, f *fixture, topo topology) { + t.Helper() + + other, err := f.client.CreateCluster(t.Context(), &sdk.CreateClusterInput{ClusterName: aws.String("other")}) + require.NoError(t, err) + _, err = f.client.CreateRoutingControl(t.Context(), &sdk.CreateRoutingControlInput{ + ClusterArn: other.Cluster.ClusterArn, + RoutingControlName: aws.String("x"), + ControlPanelArn: aws.String(topo.customPanel), + }) + assert.Equal(t, "ValidationException", apiCode(err)) + }}, + {name: "associated health checks without route53", run: func(t *testing.T, f *fixture, topo topology) { + t.Helper() + + out, err := f.client.ListAssociatedRoute53HealthChecks( + t.Context(), + &sdk.ListAssociatedRoute53HealthChecksInput{RoutingControlArn: aws.String(topo.ctlA)}, + ) + require.NoError(t, err) + assert.Empty(t, out.HealthCheckIds) + + _, err = f.client.ListAssociatedRoute53HealthChecks( + t.Context(), + &sdk.ListAssociatedRoute53HealthChecksInput{ + RoutingControlArn: aws.String(topo.ctlA + "x"), + }, + ) + assert.Equal(t, "ResourceNotFoundException", apiCode(err)) + }}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + f := newFixture(t) + tt.run(t, f, f.topology(t)) + }) + } +} + +func TestTags(t *testing.T) { + t.Parallel() + + tests := []struct { + arn func(topology) string + name string + wantCode string + }{ + {func(o topology) string { return o.cluster }, "cluster", ""}, + {func(o topology) string { return o.customPanel }, "control panel", ""}, + {func(o topology) string { return o.ctlA }, "routing control", "ValidationException"}, + { + func(topology) string { return "arn:aws:route53-recovery-control::123456789012:cluster/none" }, + "unknown", + "ResourceNotFoundException", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + f := newFixture(t) + arn := aws.String(tt.arn(f.topology(t))) + + _, err := f.client.TagResource( + t.Context(), + &sdk.TagResourceInput{ResourceArn: arn, Tags: map[string]string{"a": "1", "b": "2"}}, + ) + if tt.wantCode != "" { + assert.Equal(t, tt.wantCode, apiCode(err)) + + return + } + + require.NoError(t, err) + + _, err = f.client.UntagResource( + t.Context(), + &sdk.UntagResourceInput{ResourceArn: arn, TagKeys: []string{"a"}}, + ) + require.NoError(t, err) + + out, err := f.client.ListTagsForResource(t.Context(), &sdk.ListTagsForResourceInput{ResourceArn: arn}) + require.NoError(t, err) + assert.Equal(t, map[string]string{"b": "2"}, out.Tags) + }) + } +} + +func TestResourcePolicy(t *testing.T) { + t.Parallel() + + f := newFixture(t) + topo := f.topology(t) + + out, err := f.client.GetResourcePolicy( + t.Context(), + &sdk.GetResourcePolicyInput{ResourceArn: aws.String(topo.cluster)}, + ) + require.NoError(t, err) + assert.Nil(t, out.Policy) + + _, err = f.client.GetResourcePolicy( + t.Context(), + &sdk.GetResourcePolicyInput{ResourceArn: aws.String(topo.cluster + "x")}, + ) + assert.Equal(t, "ResourceNotFoundException", apiCode(err)) +} diff --git a/services/route53recoverycontrolconfig/persistence.go b/services/route53recoverycontrolconfig/persistence.go new file mode 100644 index 0000000000..865a6b511d --- /dev/null +++ b/services/route53recoverycontrolconfig/persistence.go @@ -0,0 +1,69 @@ +package route53recoverycontrolconfig + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/blackbirdworks/gopherstack/pkgs/lockmetrics" + "github.com/blackbirdworks/gopherstack/pkgs/logger" + "github.com/blackbirdworks/gopherstack/pkgs/persistence" +) + +const snapshotVersion = 1 + +type backendSnapshot struct { + Tables map[string]json.RawMessage `json:"tables"` + Tokens map[string]string `json:"tokens,omitempty"` + Version int `json:"version"` +} + +// Snapshot serialises the backend state. It implements persistence.Persistable. +func (b *InMemoryBackend) Snapshot(ctx context.Context) []byte { + b.mu.RLock("Snapshot") + defer b.mu.RUnlock() + + tables, err := b.registry.SnapshotAll() + if err != nil { + logger.Load(ctx).WarnContext(ctx, "route53recoverycontrolconfig: snapshot table marshal failed", "error", err) + + return nil + } + + return persistence.MarshalSnapshot(ctx, "route53recoverycontrolconfig", + backendSnapshot{Version: snapshotVersion, Tables: tables, Tokens: b.tokens}) +} + +// Restore loads a snapshot; an incompatible version starts empty. It implements persistence.Persistable. +func (b *InMemoryBackend) Restore(ctx context.Context, data []byte) error { + var snap backendSnapshot + + if err := persistence.UnmarshalSnapshot(ctx, "route53recoverycontrolconfig", data, &snap); err != nil { + return err + } + + if b.mu == nil { + b.mu = lockmetrics.New("route53recoverycontrolconfig") + } + + b.mu.Lock("Restore") + defer b.mu.Unlock() + + if snap.Version != snapshotVersion { + b.registry.ResetAll() + b.tokens = map[string]string{} + + return nil + } + + if err := b.registry.RestoreAll(snap.Tables); err != nil { + return fmt.Errorf("route53recoverycontrolconfig: restore snapshot tables: %w", err) + } + + b.tokens = snap.Tokens + if b.tokens == nil { + b.tokens = map[string]string{} + } + + return nil +} diff --git a/services/route53recoverycontrolconfig/provider.go b/services/route53recoverycontrolconfig/provider.go new file mode 100644 index 0000000000..d7e2d11a93 --- /dev/null +++ b/services/route53recoverycontrolconfig/provider.go @@ -0,0 +1,41 @@ +// Package route53recoverycontrolconfig emulates the Route 53 Application Recovery Controller control plane. +package route53recoverycontrolconfig + +import ( + "errors" + "os" + + "github.com/blackbirdworks/gopherstack/pkgs/service" +) + +// ErrNilAppContext is returned by Init when no AppContext is supplied. +var ErrNilAppContext = errors.New("route53recoverycontrolconfig: AppContext must not be nil") + +// externalEndpointEnv pins the base URL that cluster endpoints advertise, overriding the caller's host. +const externalEndpointEnv = "ROUTE53_RECOVERY_CONTROL_ENDPOINT" + +// Provider implements service.Provider. +type Provider struct{} + +// Name returns the provider name. +func (p *Provider) Name() string { return "Route53RecoveryControlConfig" } + +// Init builds the backend and handler. +// +//nolint:ireturn,nolintlint // architecturally required to return interface +func (p *Provider) Init(ctx *service.AppContext) (service.Registerable, error) { + if ctx == nil { + return nil, ErrNilAppContext + } + + accountID, region := service.AccountRegionOrDefault(ctx) + + backend := NewInMemoryBackend(accountID) + backend.SetAppConfig(ctx.Config) + backend.SetExternalEndpoint(os.Getenv(externalEndpointEnv)) + + handler := NewHandler(backend) + handler.DefaultRegion = region + + return handler, nil +} diff --git a/services/route53recoverycontrolconfig/routingcontrols.go b/services/route53recoverycontrolconfig/routingcontrols.go new file mode 100644 index 0000000000..8d435dd123 --- /dev/null +++ b/services/route53recoverycontrolconfig/routingcontrols.go @@ -0,0 +1,164 @@ +package route53recoverycontrolconfig + +import ( + "time" + + "github.com/blackbirdworks/gopherstack/pkgs/arn" + "github.com/blackbirdworks/gopherstack/pkgs/restroute" +) + +func (b *InMemoryBackend) controlView(c *RoutingControl) wireRoutingControl { + return wireRoutingControl{ + ControlPanelArn: c.PanelARN, + Name: c.Name, + Owner: b.accountID, + RoutingControlArn: c.ARN, + Status: b.statusOf(c.CreatedAt, c.DeletedAt), + } +} + +// CreateRoutingControl creates a routing control (state Off) in the given or the cluster's default control panel. +func (b *InMemoryBackend) CreateRoutingControl(clusterArn, panelArn, name, token string) (wireRoutingControl, error) { + if err := validName("routing control", name); err != nil { + return wireRoutingControl{}, err + } + + b.mu.Lock("CreateRoutingControl") + defer b.mu.Unlock() + + if existing, ok := b.idempotent("control", token); ok { + if c, err := b.getControl(existing); err == nil { + return b.controlView(c), nil + } + } + + if err := b.activeCluster(clusterArn); err != nil { + return wireRoutingControl{}, err + } + + if panelArn == "" { + def, err := b.defaultPanelOf(clusterArn) + if err != nil { + return wireRoutingControl{}, err + } + + panelArn = def + } + + panel, err := b.activePanel(panelArn) + if err != nil { + return wireRoutingControl{}, err + } + + if panel.ClusterARN != clusterArn { + return wireRoutingControl{}, restroute.Validation( + "Control panel %s does not belong to cluster %s", + panelArn, + clusterArn, + ) + } + + c := &RoutingControl{ + ARN: arn.BuildGlobal( + arnService, + "", + b.accountID, + "controlpanel/"+idOf(panelArn)+"/routingcontrol/"+newID()[:12], + ), + PanelARN: panelArn, + ClusterARN: clusterArn, + Name: name, + State: stateOff, + CreatedAt: time.Now().UTC(), + } + b.controls.Put(c) + b.rememberToken("control", token, c.ARN) + + return b.controlView(c), nil +} + +// DescribeRoutingControl returns one routing control. +func (b *InMemoryBackend) DescribeRoutingControl(arn string) (wireRoutingControl, error) { + b.mu.RLock("DescribeRoutingControl") + defer b.mu.RUnlock() + + c, err := b.getControl(arn) + if err != nil { + return wireRoutingControl{}, err + } + + return b.controlView(c), nil +} + +// ListRoutingControls lists the routing controls of a control panel. +func (b *InMemoryBackend) ListRoutingControls( + panelArn, token string, + maxResults int, +) ([]wireRoutingControl, string, error) { + b.mu.RLock("ListRoutingControls") + defer b.mu.RUnlock() + + if _, err := b.getPanel(panelArn); err != nil { + return nil, "", err + } + + pg, err := restroute.Paginate( + sortedBy(b.controlsIn(panelArn), func(c *RoutingControl) string { return c.Name + c.ARN }), + token, + maxResults, + listDefault, + listMax, + ) + if err != nil { + return nil, "", err + } + + out := make([]wireRoutingControl, 0, len(pg.Data)) + for _, c := range pg.Data { + out = append(out, b.controlView(c)) + } + + return out, pg.Next, nil +} + +// UpdateRoutingControl renames a routing control. +func (b *InMemoryBackend) UpdateRoutingControl(arn, name string) (wireRoutingControl, error) { + if err := validName("routing control", name); err != nil { + return wireRoutingControl{}, err + } + + b.mu.Lock("UpdateRoutingControl") + defer b.mu.Unlock() + + c, err := b.getControl(arn) + if err != nil { + return wireRoutingControl{}, err + } + + c.Name = name + + return b.controlView(c), nil +} + +// DeleteRoutingControl deletes a routing control that no safety rule references. +func (b *InMemoryBackend) DeleteRoutingControl(arn string) error { + b.mu.Lock("DeleteRoutingControl") + defer b.mu.Unlock() + + c, err := b.getControl(arn) + if err != nil { + return err + } + + rules := sortedBy(b.rulesIn(c.PanelARN), func(r *SafetyRule) string { return r.Name + r.ARN }) + + for _, r := range rules { + if r.governs(arn) { + return restroute.Conflict("Routing control %s is used by safety rule %q (%s)", arn, r.Name, r.ARN) + } + } + + b.markDeleted(&c.DeletedAt, func() { b.controls.Delete(arn) }) + + return nil +} diff --git a/services/route53recoverycontrolconfig/rules.go b/services/route53recoverycontrolconfig/rules.go new file mode 100644 index 0000000000..43df14725e --- /dev/null +++ b/services/route53recoverycontrolconfig/rules.go @@ -0,0 +1,238 @@ +package route53recoverycontrolconfig + +import ( + "slices" + "time" + + "github.com/blackbirdworks/gopherstack/pkgs/arn" + "github.com/blackbirdworks/gopherstack/pkgs/restroute" +) + +// governs reports whether the rule references the routing control. +func (r *SafetyRule) governs(controlArn string) bool { + return slices.Contains(r.Controls, controlArn) || slices.Contains(r.Targets, controlArn) +} + +// ruleView is the wire form of a rule: exactly one of the two members is set. +func (b *InMemoryBackend) ruleView(r *SafetyRule) wireRule { + status := b.statusOf(r.CreatedAt, r.DeletedAt) + cfg := &wireRuleConfig{Inverted: r.Inverted, Threshold: r.Threshold, Type: r.RuleType} + + if r.Kind == ruleGating { + return wireRule{Gating: &wireGatingRule{ + RuleConfig: cfg, ControlPanelArn: r.PanelARN, Name: r.Name, SafetyRuleArn: r.ARN, Status: status, + Owner: b.accountID, GatingControls: slices.Clone(r.Controls), TargetControls: slices.Clone(r.Targets), + WaitPeriodMs: r.WaitPeriodMs, + }} + } + + return wireRule{Assertion: &wireAssertionRule{ + RuleConfig: cfg, ControlPanelArn: r.PanelARN, Name: r.Name, SafetyRuleArn: r.ARN, Status: status, + Owner: b.accountID, AssertedControls: slices.Clone(r.Controls), WaitPeriodMs: r.WaitPeriodMs, + }} +} + +// NewRuleSpec is the validated input of CreateSafetyRule. +type NewRuleSpec struct { + Name string + PanelARN string + Kind string + RuleType string + Controls []string + Targets []string + Threshold int32 + WaitPeriodMs int32 + Inverted bool +} + +func validateRuleConfig(spec *NewRuleSpec) error { + switch spec.RuleType { + case ruleTypeAtLeast: + if spec.Threshold < 1 || int(spec.Threshold) > len(spec.Controls) { + return restroute.Validation( + "Threshold must be between 1 and the number of controls (%d) for an ATLEAST rule", + len(spec.Controls), + ) + } + case ruleTypeAnd, ruleTypeOr: + default: + return restroute.Validation("RuleConfig.Type must be ATLEAST, AND or OR") + } + + if spec.WaitPeriodMs < 0 { + return restroute.Validation("WaitPeriodMs must not be negative") + } + + return validName("safety rule", spec.Name) +} + +func (b *InMemoryBackend) requireControlsIn(panelArn string, controls []string) error { + seen := map[string]bool{} + + for _, c := range controls { + if seen[c] { + return restroute.Validation("Routing control %s is listed more than once", c) + } + + seen[c] = true + + rc, err := b.getControl(c) + if err != nil { + return restroute.Validation("Routing control %s does not exist", c) + } + + if rc.PanelARN != panelArn { + return restroute.Validation("Routing control %s is not in control panel %s", c, panelArn) + } + } + + return nil +} + +// CreateSafetyRule creates an assertion or gating rule. +func (b *InMemoryBackend) CreateSafetyRule(spec *NewRuleSpec, token string, tags map[string]string) (wireRule, error) { + if len(spec.Controls) == 0 { + return wireRule{}, restroute.Validation("A safety rule needs at least one routing control") + } + + if spec.Kind == ruleGating && len(spec.Targets) == 0 { + return wireRule{}, restroute.Validation("A gating rule needs at least one target control") + } + + if err := validateRuleConfig(spec); err != nil { + return wireRule{}, err + } + + b.mu.Lock("CreateSafetyRule") + defer b.mu.Unlock() + + if existing, ok := b.idempotent("rule", token); ok { + if r, err := b.getRule(existing); err == nil { + return b.ruleView(r), nil + } + } + + if _, err := b.activePanel(spec.PanelARN); err != nil { + return wireRule{}, err + } + + if err := b.requireControlsIn(spec.PanelARN, spec.Controls); err != nil { + return wireRule{}, err + } + + if err := b.requireControlsIn(spec.PanelARN, spec.Targets); err != nil { + return wireRule{}, err + } + + for _, t := range spec.Targets { + if slices.Contains(spec.Controls, t) { + return wireRule{}, restroute.Validation( + "Routing control %s cannot be both a gating and a target control", + t, + ) + } + } + + r := &SafetyRule{ + ARN: arn.BuildGlobal( + arnService, + "", + b.accountID, + "controlpanel/"+idOf(spec.PanelARN)+"/safetyrule/"+newID()[:12], + ), + PanelARN: spec.PanelARN, + Name: spec.Name, + Kind: spec.Kind, + RuleType: spec.RuleType, + Controls: slices.Clone(spec.Controls), + Targets: slices.Clone(spec.Targets), + Threshold: spec.Threshold, + WaitPeriodMs: spec.WaitPeriodMs, + Inverted: spec.Inverted, + CreatedAt: time.Now().UTC(), + Tags: cloneTags(tags), + } + b.rules.Put(r) + b.rememberToken("rule", token, r.ARN) + + return b.ruleView(r), nil +} + +// DescribeSafetyRule returns one rule. +func (b *InMemoryBackend) DescribeSafetyRule(arn string) (wireRule, error) { + b.mu.RLock("DescribeSafetyRule") + defer b.mu.RUnlock() + + r, err := b.getRule(arn) + if err != nil { + return wireRule{}, err + } + + return b.ruleView(r), nil +} + +// ListSafetyRules lists the rules of a control panel. +func (b *InMemoryBackend) ListSafetyRules(panelArn, token string, maxResults int) ([]wireRule, string, error) { + b.mu.RLock("ListSafetyRules") + defer b.mu.RUnlock() + + if _, err := b.getPanel(panelArn); err != nil { + return nil, "", err + } + + pg, err := restroute.Paginate(sortedBy(b.rulesIn(panelArn), func(r *SafetyRule) string { return r.Name + r.ARN }), + token, maxResults, listDefault, listMax) + if err != nil { + return nil, "", err + } + + out := make([]wireRule, 0, len(pg.Data)) + for _, r := range pg.Data { + out = append(out, b.ruleView(r)) + } + + return out, pg.Next, nil +} + +// UpdateSafetyRule changes a rule's name and wait period; kind must match the stored rule. +func (b *InMemoryBackend) UpdateSafetyRule(kind, arn, name string, waitPeriodMs int32) (wireRule, error) { + if waitPeriodMs < 0 { + return wireRule{}, restroute.Validation("WaitPeriodMs must not be negative") + } + + if err := validName("safety rule", name); err != nil { + return wireRule{}, err + } + + b.mu.Lock("UpdateSafetyRule") + defer b.mu.Unlock() + + r, err := b.getRule(arn) + if err != nil { + return wireRule{}, err + } + + if r.Kind != kind { + return wireRule{}, restroute.Validation("Safety rule %s is a %s rule", arn, r.Kind) + } + + r.Name = name + r.WaitPeriodMs = waitPeriodMs + + return b.ruleView(r), nil +} + +// DeleteSafetyRule deletes a rule. +func (b *InMemoryBackend) DeleteSafetyRule(arn string) error { + b.mu.Lock("DeleteSafetyRule") + defer b.mu.Unlock() + + r, err := b.getRule(arn) + if err != nil { + return err + } + + b.markDeleted(&r.DeletedAt, func() { b.rules.Delete(arn) }) + + return nil +} diff --git a/services/route53recoverycontrolconfig/rules_test.go b/services/route53recoverycontrolconfig/rules_test.go new file mode 100644 index 0000000000..75226edb1b --- /dev/null +++ b/services/route53recoverycontrolconfig/rules_test.go @@ -0,0 +1,216 @@ +package route53recoverycontrolconfig_test + +import ( + "testing" + + "github.com/aws/aws-sdk-go-v2/aws" + sdk "github.com/aws/aws-sdk-go-v2/service/route53recoverycontrolconfig" + "github.com/aws/aws-sdk-go-v2/service/route53recoverycontrolconfig/types" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func newAssertion(topo topology, mutate func(*types.NewAssertionRule)) *sdk.CreateSafetyRuleInput { + r := &types.NewAssertionRule{ + AssertedControls: []string{topo.ctlA, topo.ctlB}, ControlPanelArn: aws.String(topo.defaultPanel), + Name: aws.String("keep-one"), WaitPeriodMs: aws.Int32(5000), + RuleConfig: &types.RuleConfig{Inverted: aws.Bool(false), Threshold: aws.Int32(1), Type: types.RuleTypeAtleast}, + } + if mutate != nil { + mutate(r) + } + + return &sdk.CreateSafetyRuleInput{AssertionRule: r} +} + +func TestSafetyRuleCRUD(t *testing.T) { + t.Parallel() + + f := newFixture(t) + topo := f.topology(t) + ctx := t.Context() + + created, err := f.client.CreateSafetyRule(ctx, newAssertion(topo, nil)) + require.NoError(t, err) + require.NotNil(t, created.AssertionRule) + assert.Nil(t, created.GatingRule) + + ar := created.AssertionRule + assert.Equal(t, "keep-one", aws.ToString(ar.Name)) + assert.Equal(t, types.StatusDeployed, ar.Status) + assert.Equal(t, []string{topo.ctlA, topo.ctlB}, ar.AssertedControls) + assert.Equal(t, types.RuleTypeAtleast, ar.RuleConfig.Type) + assert.EqualValues(t, 1, aws.ToInt32(ar.RuleConfig.Threshold)) + assert.False(t, aws.ToBool(ar.RuleConfig.Inverted)) + assert.EqualValues(t, 5000, aws.ToInt32(ar.WaitPeriodMs)) + assert.Equal(t, testAccount, aws.ToString(ar.Owner)) + assert.Regexp(t, `/safetyrule/[0-9a-f]{12}$`, aws.ToString(ar.SafetyRuleArn)) + + gating, err := f.client.CreateSafetyRule(ctx, &sdk.CreateSafetyRuleInput{GatingRule: &types.NewGatingRule{ + ControlPanelArn: aws.String( + topo.defaultPanel, + ), + GatingControls: []string{topo.ctlA}, + TargetControls: []string{topo.ctlB}, + Name: aws.String("gate"), + WaitPeriodMs: aws.Int32(0), + RuleConfig: &types.RuleConfig{Inverted: aws.Bool(true), Threshold: aws.Int32(1), Type: types.RuleTypeOr}, + }, Tags: map[string]string{"k": "v"}}) + require.NoError(t, err) + require.NotNil(t, gating.GatingRule) + assert.Equal(t, []string{topo.ctlA}, gating.GatingRule.GatingControls) + assert.Equal(t, []string{topo.ctlB}, gating.GatingRule.TargetControls) + assert.True(t, aws.ToBool(gating.GatingRule.RuleConfig.Inverted)) + + list, err := f.client.ListSafetyRules( + ctx, + &sdk.ListSafetyRulesInput{ControlPanelArn: aws.String(topo.defaultPanel)}, + ) + require.NoError(t, err) + require.Len(t, list.SafetyRules, 2) + assert.Equal(t, "gate", aws.ToString(list.SafetyRules[0].GATING.Name)) + assert.Equal(t, "keep-one", aws.ToString(list.SafetyRules[1].ASSERTION.Name)) + + desc, err := f.client.DescribeSafetyRule(ctx, &sdk.DescribeSafetyRuleInput{SafetyRuleArn: ar.SafetyRuleArn}) + require.NoError(t, err) + assert.Equal(t, "keep-one", aws.ToString(desc.AssertionRule.Name)) + + upd, err := f.client.UpdateSafetyRule( + ctx, + &sdk.UpdateSafetyRuleInput{AssertionRuleUpdate: &types.AssertionRuleUpdate{ + SafetyRuleArn: ar.SafetyRuleArn, Name: aws.String("renamed"), WaitPeriodMs: aws.Int32(100), + }}, + ) + require.NoError(t, err) + assert.Equal(t, "renamed", aws.ToString(upd.AssertionRule.Name)) + assert.EqualValues(t, 100, aws.ToInt32(upd.AssertionRule.WaitPeriodMs)) + + _, err = f.client.UpdateSafetyRule(ctx, &sdk.UpdateSafetyRuleInput{GatingRuleUpdate: &types.GatingRuleUpdate{ + SafetyRuleArn: ar.SafetyRuleArn, Name: aws.String("x"), WaitPeriodMs: aws.Int32(1), + }}) + assert.Equal(t, "ValidationException", apiCode(err), "an assertion rule cannot be updated as a gating rule") + + tags, err := f.client.ListTagsForResource( + ctx, + &sdk.ListTagsForResourceInput{ResourceArn: gating.GatingRule.SafetyRuleArn}, + ) + require.NoError(t, err) + assert.Equal(t, map[string]string{"k": "v"}, tags.Tags) + + _, err = f.client.DeleteRoutingControl( + ctx, + &sdk.DeleteRoutingControlInput{RoutingControlArn: aws.String(topo.ctlA)}, + ) + assert.Equal(t, "ConflictException", apiCode(err), "a routing control used by a rule cannot be deleted") + assert.Contains(t, apiMessage(err), `"gate"`, "the first rule by name is reported") + + _, err = f.client.DeleteSafetyRule(ctx, &sdk.DeleteSafetyRuleInput{SafetyRuleArn: ar.SafetyRuleArn}) + require.NoError(t, err) + _, err = f.client.DescribeSafetyRule(ctx, &sdk.DescribeSafetyRuleInput{SafetyRuleArn: ar.SafetyRuleArn}) + assert.Equal(t, "ResourceNotFoundException", apiCode(err)) + + _, err = f.client.DeleteControlPanel( + ctx, + &sdk.DeleteControlPanelInput{ControlPanelArn: aws.String(topo.customPanel)}, + ) + assert.Equal(t, "ConflictException", apiCode(err)) +} + +func TestSafetyRuleValidation(t *testing.T) { + t.Parallel() + + tests := []struct { + mutate func(topology, *types.NewAssertionRule) + name string + }{ + { + name: "threshold zero", + mutate: func(_ topology, r *types.NewAssertionRule) { r.RuleConfig.Threshold = aws.Int32(0) }, + }, + { + name: "threshold above control count", + mutate: func(_ topology, r *types.NewAssertionRule) { r.RuleConfig.Threshold = aws.Int32(3) }, + }, + {name: "unknown rule type", mutate: func(_ topology, r *types.NewAssertionRule) { r.RuleConfig.Type = "XOR" }}, + { + name: "negative wait period", + mutate: func(_ topology, r *types.NewAssertionRule) { r.WaitPeriodMs = aws.Int32(-1) }, + }, + { + name: "empty controls", + mutate: func(_ topology, r *types.NewAssertionRule) { r.AssertedControls = []string{} }, + }, + {name: "unknown control", mutate: func(_ topology, r *types.NewAssertionRule) { + r.AssertedControls = []string{ + "arn:aws:route53-recovery-control::123456789012:controlpanel/x/routingcontrol/y", + } + }}, + { + name: "control from another panel", + mutate: func(topo topology, r *types.NewAssertionRule) { r.AssertedControls = []string{topo.ctlC} }, + }, + { + name: "duplicate control", + mutate: func(topo topology, r *types.NewAssertionRule) { r.AssertedControls = []string{topo.ctlA, topo.ctlA} }, + }, + {name: "empty name", mutate: func(_ topology, r *types.NewAssertionRule) { r.Name = aws.String("") }}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + f := newFixture(t) + topo := f.topology(t) + + _, err := f.client.CreateSafetyRule( + t.Context(), + newAssertion(topo, func(r *types.NewAssertionRule) { tt.mutate(topo, r) }), + ) + assert.Equal(t, "ValidationException", apiCode(err)) + }) + } + + t.Run("both rule kinds", func(t *testing.T) { + t.Parallel() + + f := newFixture(t) + in := newAssertion(f.topology(t), nil) + in.GatingRule = &types.NewGatingRule{ + ControlPanelArn: aws.String( + "p", + ), + GatingControls: []string{"a"}, + TargetControls: []string{"b"}, + Name: aws.String("g"), + WaitPeriodMs: aws.Int32( + 0, + ), + RuleConfig: &types.RuleConfig{Inverted: aws.Bool(false), Threshold: aws.Int32(1), Type: types.RuleTypeOr}, + } + _, err := f.client.CreateSafetyRule(t.Context(), in) + assert.Equal(t, "ValidationException", apiCode(err)) + }) + + t.Run("gating control also a target", func(t *testing.T) { + t.Parallel() + + f := newFixture(t) + topo := f.topology(t) + _, err := f.client.CreateSafetyRule(t.Context(), &sdk.CreateSafetyRuleInput{GatingRule: &types.NewGatingRule{ + ControlPanelArn: aws.String( + topo.defaultPanel, + ), + GatingControls: []string{topo.ctlA}, + TargetControls: []string{topo.ctlA}, + Name: aws.String("g"), + WaitPeriodMs: aws.Int32(0), + RuleConfig: &types.RuleConfig{ + Inverted: aws.Bool(false), + Threshold: aws.Int32(1), + Type: types.RuleTypeOr, + }, + }}) + assert.Equal(t, "ValidationException", apiCode(err)) + }) +} diff --git a/services/route53recoverycontrolconfig/state.go b/services/route53recoverycontrolconfig/state.go new file mode 100644 index 0000000000..624bf74814 --- /dev/null +++ b/services/route53recoverycontrolconfig/state.go @@ -0,0 +1,321 @@ +package route53recoverycontrolconfig + +import ( + "fmt" + "maps" + "slices" + "time" + + "github.com/blackbirdworks/gopherstack/pkgs/restroute" +) + +// ConflictError is a data-plane rejection naming the safety rule that blocked an update. +type ConflictError struct { + RuleARN string + Message string +} + +func (e *ConflictError) Error() string { return e.Message } + +// StateView is the data-plane view of a routing control. +type StateView struct { + ARN string + Name string + PanelARN string + PanelName string + Owner string + State string +} + +// StateUpdate is one requested routing control state change. +type StateUpdate struct { + ARN string + State string +} + +func (b *InMemoryBackend) stateView(c *RoutingControl) StateView { + v := StateView{ARN: c.ARN, Name: c.Name, PanelARN: c.PanelARN, Owner: b.accountID, State: c.State} + if p, ok := b.panels.Get(c.PanelARN); ok { + v.PanelName = p.Name + } + + return v +} + +func (b *InMemoryBackend) controlInCluster(arn, clusterID string) (*RoutingControl, error) { + c, err := b.getControl(arn) + if err != nil { + return nil, err + } + + if clusterID != "" && idOf(c.ClusterARN) != clusterID { + return nil, restroute.NotFound("Routing control %s is not served by this cluster endpoint", arn) + } + + return c, nil +} + +// RoutingControlState returns one routing control's state; clusterID, when set, scopes it to that cluster. +func (b *InMemoryBackend) RoutingControlState(arn, clusterID string) (StateView, error) { + b.mu.RLock("RoutingControlState") + defer b.mu.RUnlock() + + c, err := b.controlInCluster(arn, clusterID) + if err != nil { + return StateView{}, err + } + + return b.stateView(c), nil +} + +// RoutingControlIsOn reports (on, found) for a routing control, for sibling services (Route 53 health checks). +func (b *InMemoryBackend) RoutingControlIsOn(arn string) (bool, bool) { + b.mu.RLock("RoutingControlIsOn") + defer b.mu.RUnlock() + + c, err := b.getControl(arn) + if err != nil { + return false, false + } + + return c.State == stateOn, true +} + +// ListRoutingControlStates lists routing control states, optionally by control panel and/or cluster. +func (b *InMemoryBackend) ListRoutingControlStates( + panelArn, clusterID, token string, + maxResults int, +) ([]StateView, string, error) { + b.mu.RLock("ListRoutingControlStates") + defer b.mu.RUnlock() + + if panelArn != "" { + if _, err := b.getPanel(panelArn); err != nil { + return nil, "", err + } + } + + var all []*RoutingControl + + for _, c := range b.controls.All() { + if !b.alive(c.DeletedAt) || (panelArn != "" && c.PanelARN != panelArn) || + (clusterID != "" && idOf(c.ClusterARN) != clusterID) { + continue + } + + all = append(all, c) + } + + pg, err := restroute.Paginate(sortedBy(all, func(c *RoutingControl) string { return c.Name + c.ARN }), + token, maxResults, listDefault, listMax) + if err != nil { + return nil, "", err + } + + out := make([]StateView, 0, len(pg.Data)) + for _, c := range pg.Data { + out = append(out, b.stateView(c)) + } + + return out, pg.Next, nil +} + +// onCount counts routing controls that are On in the proposed state. +func (b *InMemoryBackend) onCount(controls []string, proposed map[string]string) int { + n := 0 + + for _, arn := range controls { + state, ok := proposed[arn] + if !ok { + if c, found := b.controls.Get(arn); found { + state = c.State + } + } + + if state == stateOn { + n++ + } + } + + return n +} + +func (r *SafetyRule) satisfied(on int) bool { + var ok bool + + switch r.RuleType { + case ruleTypeAtLeast: + ok = on >= int(r.Threshold) + case ruleTypeAnd: + ok = on == len(r.Controls) + default: + ok = on >= 1 + } + + return ok != r.Inverted +} + +func (r *SafetyRule) describeConfig() string { + inv := "" + if r.Inverted { + inv = ", inverted" + } + + if r.RuleType == ruleTypeAtLeast { + return fmt.Sprintf("ATLEAST %d of %d%s", r.Threshold, len(r.Controls), inv) + } + + return fmt.Sprintf("%s of %d%s", r.RuleType, len(r.Controls), inv) +} + +func (b *InMemoryBackend) checkRule(r *SafetyRule, changed map[string]string, now time.Time) error { + governed := r.Controls + if r.Kind == ruleGating { + governed = r.Targets + } + + if !slices.ContainsFunc(governed, func(a string) bool { + _, ok := changed[a] + + return ok + }) { + return nil + } + + if wait := time.Duration(r.WaitPeriodMs) * time.Millisecond; wait > 0 && now.Sub(r.LastChangeAt) < wait { + return &ConflictError{RuleARN: r.ARN, Message: fmt.Sprintf( + "Safety rule %q (%s) is in its %d ms wait period; retry after it elapses", r.Name, r.ARN, r.WaitPeriodMs)} + } + + if r.satisfied(b.onCount(r.Controls, changed)) { + return nil + } + + return &ConflictError{RuleARN: r.ARN, Message: fmt.Sprintf( + "Safety rule %q (%s) would be violated: the %s requirement (%s) is not met by the resulting routing control states", + r.Name, + r.ARN, + r.Kind, + r.describeConfig(), + )} +} + +// UpdateRoutingControlStates applies all updates atomically after evaluating every affected safety rule. +func (b *InMemoryBackend) UpdateRoutingControlStates( + updates []StateUpdate, + overrides []string, + clusterID string, +) error { + if len(updates) == 0 { + return restroute.Validation("At least one routing control state entry is required") + } + + b.mu.Lock("UpdateRoutingControlStates") + defer b.mu.Unlock() + + override := map[string]bool{} + + for _, o := range overrides { + if _, err := b.getRule(o); err != nil { + return restroute.Validation("SafetyRulesToOverride contains %s, which is not a safety rule", o) + } + + override[o] = true + } + + controls := make([]*RoutingControl, 0, len(updates)) + changed := map[string]string{} + panels := map[string]bool{} + + for _, u := range updates { + if u.State != stateOn && u.State != stateOff { + return restroute.Validation("RoutingControlState must be On or Off") + } + + c, err := b.controlInCluster(u.ARN, clusterID) + if err != nil { + return err + } + + if _, dup := changed[u.ARN]; dup { + return restroute.Validation("Routing control %s appears more than once", u.ARN) + } + + changed[u.ARN] = u.State + controls = append(controls, c) + panels[c.PanelARN] = true + } + + effective := map[string]string{} + + for _, c := range controls { + if c.State != changed[c.ARN] { + effective[c.ARN] = changed[c.ARN] + } + } + + now := time.Now() + touched := b.evaluateRules(panels, effective, override, now) + + if touched.err != nil { + return touched.err + } + + for _, c := range controls { + c.State = changed[c.ARN] + } + + for _, r := range touched.rules { + r.LastChangeAt = now + } + + return nil +} + +type ruleEvaluation struct { + err error + rules []*SafetyRule +} + +func (b *InMemoryBackend) evaluateRules( + panels map[string]bool, + effective map[string]string, + override map[string]bool, + now time.Time, +) ruleEvaluation { + var ev ruleEvaluation + + if len(effective) == 0 { + return ev + } + + for _, panel := range slices.Sorted(maps.Keys(panels)) { + for _, r := range sortedBy(b.rulesIn(panel), func(r *SafetyRule) string { return r.ARN }) { + if !r.touchedBy(effective) { + continue + } + + ev.rules = append(ev.rules, r) + + if override[r.ARN] { + continue + } + + if err := b.checkRule(r, effective, now); err != nil { + return ruleEvaluation{err: err} + } + } + } + + return ev +} + +func (r *SafetyRule) touchedBy(changed map[string]string) bool { + for a := range changed { + if r.governs(a) { + return true + } + } + + return false +} diff --git a/services/route53recoverycontrolconfig/state_test.go b/services/route53recoverycontrolconfig/state_test.go new file mode 100644 index 0000000000..b35719b67a --- /dev/null +++ b/services/route53recoverycontrolconfig/state_test.go @@ -0,0 +1,311 @@ +package route53recoverycontrolconfig_test + +import ( + "errors" + "testing" + "testing/synctest" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/blackbirdworks/gopherstack/pkgs/restroute" + cc "github.com/blackbirdworks/gopherstack/services/route53recoverycontrolconfig" +) + +type world struct { + b *cc.InMemoryBackend + cluster string + panel string + a, bb, c string +} + +func newWorld(t *testing.T) *world { + t.Helper() + + b := cc.NewInMemoryBackend(testAccount) + cl, err := b.CreateCluster(t.Context(), "w", "", "", nil) + require.NoError(t, err) + + w := &world{b: b, cluster: cl.ClusterArn} + mk := func(name string) string { + rc, cErr := b.CreateRoutingControl(w.cluster, "", name, "") + require.NoError(t, cErr) + w.panel = rc.ControlPanelArn + + return rc.RoutingControlArn + } + + w.a, w.bb, w.c = mk("a"), mk("b"), mk("c") + + return w +} + +func (w *world) rule(t *testing.T, spec *cc.NewRuleSpec) string { + t.Helper() + + spec.PanelARN = w.panel + r, err := w.b.CreateSafetyRule(spec, "", nil) + require.NoError(t, err) + + if r.Gating != nil { + return r.Gating.SafetyRuleArn + } + + return r.Assertion.SafetyRuleArn +} + +func (w *world) state(t *testing.T, arn string) string { + t.Helper() + + v, err := w.b.RoutingControlState(arn, "") + require.NoError(t, err) + + return v.State +} + +func (w *world) set(arns []string, state string, overrides ...string) error { + ups := make([]cc.StateUpdate, 0, len(arns)) + for _, a := range arns { + ups = append(ups, cc.StateUpdate{ARN: a, State: state}) + } + + return w.b.UpdateRoutingControlStates(ups, overrides, "") +} + +func conflictRule(err error) (string, bool) { + if ce, ok := errors.AsType[*cc.ConflictError](err); ok { + return ce.RuleARN, true + } + + return "", false +} + +func TestSafetyRuleEvaluation(t *testing.T) { + t.Parallel() + + tests := []struct { + run func(t *testing.T, w *world) + name string + }{ + {name: "atleast one blocks turning everything off", run: func(t *testing.T, w *world) { + t.Helper() + + rule := w.rule(t, &cc.NewRuleSpec{ + Name: "keep-one", Kind: "ASSERTION", RuleType: "ATLEAST", Threshold: 1, Controls: []string{w.a, w.bb}, + }) + require.NoError(t, w.set([]string{w.a, w.bb}, "On")) + + err := w.set([]string{w.a, w.bb}, "Off") + got, isConflict := conflictRule(err) + require.True(t, isConflict, "got %v", err) + assert.Equal(t, rule, got) + assert.Contains(t, err.Error(), "keep-one") + assert.Equal(t, "On", w.state(t, w.a), "a rejected batch changes nothing") + assert.Equal(t, "On", w.state(t, w.bb)) + + require.NoError(t, w.set([]string{w.a}, "Off"), "one control stays On") + require.Error(t, w.set([]string{w.bb}, "Off")) + }}, + {name: "override lets the violating update through", run: func(t *testing.T, w *world) { + t.Helper() + + rule := w.rule(t, &cc.NewRuleSpec{ + Name: "keep-one", Kind: "ASSERTION", RuleType: "ATLEAST", Threshold: 1, Controls: []string{w.a, w.bb}, + }) + require.NoError(t, w.set([]string{w.a}, "On")) + require.Error(t, w.set([]string{w.a}, "Off")) + require.NoError(t, w.set([]string{w.a}, "Off", rule)) + assert.Equal(t, "Off", w.state(t, w.a)) + }}, + {name: "and rule needs every control on", run: func(t *testing.T, w *world) { + t.Helper() + + w.rule(t, &cc.NewRuleSpec{Name: "all", Kind: "ASSERTION", RuleType: "AND", Controls: []string{w.a, w.bb}}) + require.NoError(t, w.set([]string{w.a, w.bb}, "On")) + require.Error(t, w.set([]string{w.a}, "Off")) + require.Error(t, w.set([]string{w.a, w.bb}, "Off")) + assert.Equal(t, "On", w.state(t, w.bb)) + }}, + {name: "or rule needs one control on", run: func(t *testing.T, w *world) { + t.Helper() + + w.rule(t, &cc.NewRuleSpec{Name: "any", Kind: "ASSERTION", RuleType: "OR", Controls: []string{w.a, w.bb}}) + require.NoError(t, w.set([]string{w.a}, "On")) + require.Error(t, w.set([]string{w.a}, "Off")) + }}, + {name: "inverted assertion forbids controls being on", run: func(t *testing.T, w *world) { + t.Helper() + + w.rule(t, &cc.NewRuleSpec{ + Name: "none-on", + Kind: "ASSERTION", + RuleType: "ATLEAST", + Threshold: 1, + Inverted: true, + Controls: []string{w.a, w.bb}, + }) + require.Error(t, w.set([]string{w.a}, "On")) + assert.Equal(t, "Off", w.state(t, w.a)) + }}, + {name: "gating rule guards its targets", run: func(t *testing.T, w *world) { + t.Helper() + + rule := w.rule(t, &cc.NewRuleSpec{ + Name: "gate", Kind: "GATING", RuleType: "OR", Controls: []string{w.a}, Targets: []string{w.bb}, + }) + err := w.set([]string{w.bb}, "On") + got, isConflict := conflictRule(err) + require.True(t, isConflict, "got %v", err) + assert.Equal(t, rule, got) + assert.Contains(t, err.Error(), "gate") + + require.NoError(t, w.set([]string{w.a}, "On"), "the gating control itself is not guarded") + require.NoError(t, w.set([]string{w.bb}, "On")) + }}, + {name: "gating and target in one batch evaluate the resulting state", run: func(t *testing.T, w *world) { + t.Helper() + + w.rule( + t, + &cc.NewRuleSpec{ + Name: "gate", + Kind: "GATING", + RuleType: "OR", + Controls: []string{w.a}, + Targets: []string{w.bb}, + }, + ) + require.NoError(t, w.set([]string{w.a, w.bb}, "On")) + }}, + {name: "gating override", run: func(t *testing.T, w *world) { + t.Helper() + + rule := w.rule( + t, + &cc.NewRuleSpec{ + Name: "gate", + Kind: "GATING", + RuleType: "OR", + Controls: []string{w.a}, + Targets: []string{w.bb}, + }, + ) + require.NoError(t, w.set([]string{w.bb}, "On", rule)) + }}, + {name: "unrelated control is never blocked", run: func(t *testing.T, w *world) { + t.Helper() + + w.rule(t, &cc.NewRuleSpec{Name: "all", Kind: "ASSERTION", RuleType: "AND", Controls: []string{w.a, w.bb}}) + require.NoError(t, w.set([]string{w.c}, "On")) + }}, + {name: "no-op update on a violated rule passes", run: func(t *testing.T, w *world) { + t.Helper() + + w.rule(t, &cc.NewRuleSpec{Name: "all", Kind: "ASSERTION", RuleType: "AND", Controls: []string{w.a, w.bb}}) + require.NoError(t, w.set([]string{w.a}, "Off")) + }}, + {name: "batch is atomic", run: func(t *testing.T, w *world) { + t.Helper() + + w.rule(t, &cc.NewRuleSpec{Name: "all", Kind: "ASSERTION", RuleType: "AND", Controls: []string{w.a, w.bb}}) + require.Error(t, w.set([]string{w.c, w.a}, "On")) + assert.Equal(t, "Off", w.state(t, w.c), "c must not change when a's update is rejected") + }}, + {name: "input validation", run: func(t *testing.T, w *world) { + t.Helper() + + ups := func(u ...cc.StateUpdate) error { return w.b.UpdateRoutingControlStates(u, nil, "") } + + var re *restroute.Error + + require.ErrorAs(t, ups(), &re) + require.ErrorAs(t, ups(cc.StateUpdate{ARN: w.a, State: "Maybe"}), &re) + assert.Equal(t, "ValidationException", re.Code) + require.ErrorAs(t, ups(cc.StateUpdate{ARN: w.a, State: "On"}, cc.StateUpdate{ARN: w.a, State: "Off"}), &re) + assert.Equal(t, "ValidationException", re.Code) + require.ErrorAs(t, ups(cc.StateUpdate{ARN: w.a + "x", State: "On"}), &re) + assert.Equal(t, "ResourceNotFoundException", re.Code) + require.ErrorAs( + t, + w.b.UpdateRoutingControlStates([]cc.StateUpdate{{ARN: w.a, State: "On"}}, []string{"arn:nope"}, ""), + &re, + ) + assert.Equal(t, "ValidationException", re.Code) + }}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + tt.run(t, newWorld(t)) + }) + } +} + +func TestSafetyRuleWaitPeriod(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + wait time.Duration + override bool + wantErr bool + }{ + {name: "blocked inside the wait period", wait: 500 * time.Millisecond, wantErr: true}, + {name: "allowed after the wait period", wait: 2 * time.Second}, + {name: "override skips the wait period", wait: 500 * time.Millisecond, override: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + synctest.Test(t, func(t *testing.T) { + w := newWorld(t) + rule := w.rule(t, &cc.NewRuleSpec{ + Name: "slow", Kind: "ASSERTION", RuleType: "OR", Controls: []string{w.a, w.bb}, WaitPeriodMs: 1000, + }) + require.NoError(t, w.set([]string{w.a}, "On")) + + time.Sleep(tt.wait) + + var overrides []string + if tt.override { + overrides = append(overrides, rule) + } + + err := w.set([]string{w.bb}, "On", overrides...) + if tt.wantErr { + got, isConflict := conflictRule(err) + require.True(t, isConflict, "got %v", err) + assert.Equal(t, rule, got) + assert.Contains(t, err.Error(), "wait period") + + return + } + + require.NoError(t, err) + }) + }) + } +} + +func TestClusterScopedState(t *testing.T) { + t.Parallel() + + w := newWorld(t) + other, err := w.b.CreateCluster(t.Context(), "other", "", "", nil) + require.NoError(t, err) + + otherID := other.ClusterArn[len(other.ClusterArn)-32:] + + _, err = w.b.RoutingControlState(w.a, otherID) + var re *restroute.Error + require.ErrorAs(t, err, &re) + assert.Equal(t, "ResourceNotFoundException", re.Code) + + views, _, err := w.b.ListRoutingControlStates("", otherID, "", 0) + require.NoError(t, err) + assert.Empty(t, views) +} diff --git a/services/route53recoverycontrolconfig/store.go b/services/route53recoverycontrolconfig/store.go new file mode 100644 index 0000000000..a6871fb23f --- /dev/null +++ b/services/route53recoverycontrolconfig/store.go @@ -0,0 +1,201 @@ +package route53recoverycontrolconfig + +import ( + "maps" + "slices" + "sort" + "strings" + "sync/atomic" + "time" + + "github.com/google/uuid" + + "github.com/blackbirdworks/gopherstack/pkgs/arn" + "github.com/blackbirdworks/gopherstack/pkgs/lockmetrics" + "github.com/blackbirdworks/gopherstack/pkgs/restroute" + "github.com/blackbirdworks/gopherstack/pkgs/store" +) + +// InMemoryBackend holds the Route 53 ARC control plane: clusters, control panels, routing controls, safety rules. +type InMemoryBackend struct { + appConfig any + externalEndpoint atomic.Pointer[string] + registry *store.Registry + clusters *store.Table[Cluster] + panels *store.Table[ControlPanel] + controls *store.Table[RoutingControl] + rules *store.Table[SafetyRule] + tokens map[string]string + mu *lockmetrics.RWMutex + accountID string + activationDelay time.Duration +} + +// NewInMemoryBackend creates an empty backend for accountID. +func NewInMemoryBackend(accountID string) *InMemoryBackend { + b := &InMemoryBackend{ + registry: store.NewRegistry(), + tokens: map[string]string{}, + mu: lockmetrics.New("route53recoverycontrolconfig"), + accountID: accountID, + } + + b.clusters = store.Register(b.registry, "clusters", store.New(func(v *Cluster) string { return v.ARN })) + b.panels = store.Register(b.registry, "panels", store.New(func(v *ControlPanel) string { return v.ARN })) + b.controls = store.Register(b.registry, "controls", store.New(func(v *RoutingControl) string { return v.ARN })) + b.rules = store.Register(b.registry, "rules", store.New(func(v *SafetyRule) string { return v.ARN })) + + return b +} + +// Reset clears all state. +func (b *InMemoryBackend) Reset() { + b.mu.Lock("Reset") + defer b.mu.Unlock() + + b.registry.ResetAll() + b.tokens = map[string]string{} +} + +// SetActivationDelay keeps new resources PENDING (and deleted ones PENDING_DELETION) for d; 0 makes both instant. +func (b *InMemoryBackend) SetActivationDelay(d time.Duration) { + b.mu.Lock("SetActivationDelay") + defer b.mu.Unlock() + + b.activationDelay = d +} + +func newID() string { return strings.ReplaceAll(uuid.NewString(), "-", "") } + +func (b *InMemoryBackend) clusterARN(id string) string { + return arn.BuildGlobal(arnService, "", b.accountID, "cluster/"+id) +} + +func (b *InMemoryBackend) panelARN(id string) string { + return arn.BuildGlobal(arnService, "", b.accountID, "controlpanel/"+id) +} + +func (b *InMemoryBackend) statusOf(created time.Time, deleted *time.Time) string { + if deleted != nil { + return statusPendingDeletion + } + + if b.activationDelay > 0 && time.Since(created) < b.activationDelay { + return statusPending + } + + return statusDeployed +} + +// alive reports whether a possibly-deleted resource is still visible. +func (b *InMemoryBackend) alive(deleted *time.Time) bool { + return deleted == nil || time.Since(*deleted) < b.activationDelay +} + +// markDeleted removes immediately when no delay is configured, otherwise stamps PENDING_DELETION. +func (b *InMemoryBackend) markDeleted(deleted **time.Time, remove func()) { + if b.activationDelay <= 0 { + remove() + + return + } + + now := time.Now().UTC() + *deleted = &now +} + +func (b *InMemoryBackend) getCluster(arn string) (*Cluster, error) { + c, ok := b.clusters.Get(arn) + if !ok || !b.alive(c.DeletedAt) { + return nil, restroute.NotFound("Cluster %s was not found", arn) + } + + return c, nil +} + +func (b *InMemoryBackend) getPanel(arn string) (*ControlPanel, error) { + p, ok := b.panels.Get(arn) + if !ok || !b.alive(p.DeletedAt) { + return nil, restroute.NotFound("Control panel %s was not found", arn) + } + + return p, nil +} + +// activeCluster is getCluster for new children: a cluster being deleted rejects them. +func (b *InMemoryBackend) activeCluster(arn string) error { + c, err := b.getCluster(arn) + if err != nil { + return err + } + + if c.DeletedAt != nil { + return restroute.Conflict("Cluster %s is being deleted", arn) + } + + return nil +} + +// activePanel is getPanel for new children: a control panel being deleted rejects them. +func (b *InMemoryBackend) activePanel(arn string) (*ControlPanel, error) { + p, err := b.getPanel(arn) + if err != nil { + return nil, err + } + + if p.DeletedAt != nil { + return nil, restroute.Conflict("Control panel %s is being deleted", arn) + } + + return p, nil +} + +func (b *InMemoryBackend) getControl(arn string) (*RoutingControl, error) { + c, ok := b.controls.Get(arn) + if !ok || !b.alive(c.DeletedAt) { + return nil, restroute.NotFound("Routing control %s was not found", arn) + } + + return c, nil +} + +func (b *InMemoryBackend) getRule(arn string) (*SafetyRule, error) { + r, ok := b.rules.Get(arn) + if !ok || !b.alive(r.DeletedAt) { + return nil, restroute.NotFound("Safety rule %s was not found", arn) + } + + return r, nil +} + +func sortedBy[T any](items []*T, key func(*T) string) []*T { + out := slices.Clone(items) + sort.SliceStable(out, func(i, j int) bool { return key(out[i]) < key(out[j]) }) + + return out +} + +func cloneTags(t map[string]string) map[string]string { + if len(t) == 0 { + return nil + } + + return maps.Clone(t) +} + +// idempotent returns the ARN previously created with the same kind and client token. +func (b *InMemoryBackend) idempotent(kind, token string) (string, bool) { + if token == "" { + return "", false + } + + a, ok := b.tokens[kind+"|"+token] + + return a, ok +} + +func (b *InMemoryBackend) rememberToken(kind, token, arn string) { + if token != "" { + b.tokens[kind+"|"+token] = arn + } +} diff --git a/services/route53recoverycontrolconfig/tags.go b/services/route53recoverycontrolconfig/tags.go new file mode 100644 index 0000000000..d90bfcf106 --- /dev/null +++ b/services/route53recoverycontrolconfig/tags.go @@ -0,0 +1,103 @@ +package route53recoverycontrolconfig + +import ( + "maps" + + "github.com/blackbirdworks/gopherstack/pkgs/restroute" +) + +const maxTags = 50 + +// tagsFor returns the live tag map of a taggable resource (cluster, control panel or safety rule). +func (b *InMemoryBackend) tagsFor(arn string) (*map[string]string, error) { + if c, err := b.getCluster(arn); err == nil { + return &c.Tags, nil + } + + if p, err := b.getPanel(arn); err == nil { + return &p.Tags, nil + } + + if r, err := b.getRule(arn); err == nil { + return &r.Tags, nil + } + + if _, err := b.getControl(arn); err == nil { + return nil, restroute.Validation("Routing controls do not support tags") + } + + return nil, restroute.NotFound("Resource %s was not found", arn) +} + +// ListTagsForResource returns a resource's tags. +func (b *InMemoryBackend) ListTagsForResource(arn string) (map[string]string, error) { + b.mu.RLock("ListTagsForResource") + defer b.mu.RUnlock() + + t, err := b.tagsFor(arn) + if err != nil { + return nil, err + } + + return maps.Clone(*t), nil +} + +// TagResource adds or overwrites tags. +func (b *InMemoryBackend) TagResource(arn string, tags map[string]string) error { + b.mu.Lock("TagResource") + defer b.mu.Unlock() + + t, err := b.tagsFor(arn) + if err != nil { + return err + } + + merged := maps.Clone(*t) + if merged == nil { + merged = map[string]string{} + } + + maps.Copy(merged, tags) + + if len(merged) > maxTags { + return restroute.Validation("A resource can have at most %d tags", maxTags) + } + + *t = merged + + return nil +} + +// UntagResource removes tags by key. +func (b *InMemoryBackend) UntagResource(arn string, keys []string) error { + b.mu.Lock("UntagResource") + defer b.mu.Unlock() + + t, err := b.tagsFor(arn) + if err != nil { + return err + } + + for _, k := range keys { + delete(*t, k) + } + + return nil +} + +// requireResource backs GetResourcePolicy: policies only arrive through RAM sharing, so an existing resource has none. +func (b *InMemoryBackend) requireResource(arn string) error { + b.mu.RLock("GetResourcePolicy") + defer b.mu.RUnlock() + + _, e1 := b.getCluster(arn) + _, e2 := b.getPanel(arn) + _, e3 := b.getControl(arn) + _, e4 := b.getRule(arn) + + if e1 == nil || e2 == nil || e3 == nil || e4 == nil { + return nil + } + + return restroute.NotFound("Resource %s was not found", arn) +} diff --git a/services/route53recoverycontrolconfig/wire.go b/services/route53recoverycontrolconfig/wire.go new file mode 100644 index 0000000000..1a4747044c --- /dev/null +++ b/services/route53recoverycontrolconfig/wire.go @@ -0,0 +1,98 @@ +package route53recoverycontrolconfig + +// Wire shapes follow route53recoverycontrolconfig@v1.40.4 serializers.go/deserializers.go (PascalCase members). + +type wireEndpoint struct { + Endpoint string `json:"Endpoint"` + Region string `json:"Region"` +} + +type wireCluster struct { + ClusterArn string `json:"ClusterArn"` + Name string `json:"Name"` + NetworkType string `json:"NetworkType"` + Owner string `json:"Owner"` + Status string `json:"Status"` + ClusterEndpoints []wireEndpoint `json:"ClusterEndpoints,omitempty"` +} + +type wireControlPanel struct { + ClusterArn string `json:"ClusterArn"` + ControlPanelArn string `json:"ControlPanelArn"` + Name string `json:"Name"` + Owner string `json:"Owner"` + Status string `json:"Status"` + DefaultControlPanel bool `json:"DefaultControlPanel"` + RoutingControlCount int32 `json:"RoutingControlCount"` +} + +type wireRoutingControl struct { + ControlPanelArn string `json:"ControlPanelArn"` + Name string `json:"Name"` + Owner string `json:"Owner"` + RoutingControlArn string `json:"RoutingControlArn"` + Status string `json:"Status"` +} + +type wireRuleConfig struct { + Type string `json:"Type"` + Threshold int32 `json:"Threshold"` + Inverted bool `json:"Inverted"` +} + +type wireAssertionRule struct { + RuleConfig *wireRuleConfig `json:"RuleConfig"` + ControlPanelArn string `json:"ControlPanelArn"` + Name string `json:"Name"` + SafetyRuleArn string `json:"SafetyRuleArn"` + Status string `json:"Status"` + Owner string `json:"Owner"` + AssertedControls []string `json:"AssertedControls"` + WaitPeriodMs int32 `json:"WaitPeriodMs"` +} + +type wireGatingRule struct { + RuleConfig *wireRuleConfig `json:"RuleConfig"` + ControlPanelArn string `json:"ControlPanelArn"` + Name string `json:"Name"` + SafetyRuleArn string `json:"SafetyRuleArn"` + Status string `json:"Status"` + Owner string `json:"Owner"` + GatingControls []string `json:"GatingControls"` + TargetControls []string `json:"TargetControls"` + WaitPeriodMs int32 `json:"WaitPeriodMs"` +} + +type wireRule struct { + Assertion *wireAssertionRule `json:"ASSERTION,omitempty"` + Gating *wireGatingRule `json:"GATING,omitempty"` +} + +type wireNewRuleConfig struct { + Inverted *bool `json:"Inverted"` + Threshold *int32 `json:"Threshold"` + Type string `json:"Type"` +} + +type wireNewAssertionRule struct { + RuleConfig *wireNewRuleConfig `json:"RuleConfig"` + WaitPeriodMs *int32 `json:"WaitPeriodMs"` + ControlPanelArn string `json:"ControlPanelArn"` + Name string `json:"Name"` + AssertedControls []string `json:"AssertedControls"` +} + +type wireNewGatingRule struct { + RuleConfig *wireNewRuleConfig `json:"RuleConfig"` + WaitPeriodMs *int32 `json:"WaitPeriodMs"` + ControlPanelArn string `json:"ControlPanelArn"` + Name string `json:"Name"` + GatingControls []string `json:"GatingControls"` + TargetControls []string `json:"TargetControls"` +} + +type wireRuleUpdate struct { + WaitPeriodMs *int32 `json:"WaitPeriodMs"` + Name string `json:"Name"` + SafetyRuleArn string `json:"SafetyRuleArn"` +} diff --git a/services/route53recoveryreadiness/PARITY.md b/services/route53recoveryreadiness/PARITY.md new file mode 100644 index 0000000000..47aa9d53f2 --- /dev/null +++ b/services/route53recoveryreadiness/PARITY.md @@ -0,0 +1,57 @@ +--- +service: route53recoveryreadiness +sdk_module: aws-sdk-go-v2/service/route53recoveryreadiness@v1.34.4 +last_audit_commit: 80b7bb8b3 +last_audit_date: 2026-10-14 +overall: A +ops: + CreateCell: {wire: ok, errors: ok, state: ok, persist: ok} + GetCell: {wire: ok, errors: ok, state: ok, persist: ok} + ListCells: {wire: ok, errors: ok, state: ok, persist: ok} + UpdateCell: {wire: ok, errors: ok, state: ok, persist: ok} + DeleteCell: {wire: ok, errors: ok, state: ok, persist: ok, note: "ConflictException while the cell belongs to a recovery group or another cell"} + GetCellReadinessSummary: {wire: ok, errors: ok, state: ok, persist: ok, note: "worst readiness of the checks whose resources are scoped to the cell or the cells below it; UNKNOWN when no check covers it"} + CreateRecoveryGroup: {wire: ok, errors: ok, state: ok, persist: ok} + GetRecoveryGroup: {wire: ok, errors: ok, state: ok, persist: ok} + ListRecoveryGroups: {wire: ok, errors: ok, state: ok, persist: ok} + UpdateRecoveryGroup: {wire: ok, errors: ok, state: ok, persist: ok} + DeleteRecoveryGroup: {wire: ok, errors: ok, state: ok, persist: ok} + GetArchitectureRecommendations: {wire: ok, errors: ok, state: ok, persist: ok, note: "derived from the group's real cell, resource set and readiness check layout"} + GetRecoveryGroupReadinessSummary: {wire: ok, errors: ok, state: ok, persist: ok} + CreateResourceSet: {wire: ok, errors: ok, state: ok, persist: ok} + GetResourceSet: {wire: ok, errors: ok, state: ok, persist: ok} + ListResourceSets: {wire: ok, errors: ok, state: ok, persist: ok} + UpdateResourceSet: {wire: ok, errors: ok, state: ok, persist: ok} + DeleteResourceSet: {wire: ok, errors: ok, state: ok, persist: ok, note: "ConflictException while a readiness check uses the set"} + CreateReadinessCheck: {wire: ok, errors: ok, state: ok, persist: ok} + GetReadinessCheck: {wire: ok, errors: ok, state: ok, persist: ok} + ListReadinessChecks: {wire: ok, errors: ok, state: ok, persist: ok} + UpdateReadinessCheck: {wire: ok, errors: ok, state: ok, persist: ok} + DeleteReadinessCheck: {wire: ok, errors: ok, state: ok, persist: ok} + GetReadinessCheckStatus: {wire: ok, errors: ok, state: ok, persist: ok, note: "readiness is derived from the live state of the owning sibling service for every documented resource set type; UNKNOWN only when the sibling is unreachable"} + GetReadinessCheckResourceStatus: {wire: ok, errors: ok, state: ok, persist: ok, note: "per-rule results with timestamps; the resource is addressed by ARN or componentId"} + CreateCrossAccountAuthorization: {wire: ok, errors: ok, state: ok, persist: ok} + ListCrossAccountAuthorizations: {wire: ok, errors: ok, state: ok, persist: ok} + DeleteCrossAccountAuthorization: {wire: ok, errors: ok, state: ok, persist: ok} + ListRules: {wire: ok, errors: ok, state: ok, persist: ok, note: "lists the rules this emulator evaluates (see Notes)"} + ListTagsForResources: {wire: ok, errors: ok, state: ok, persist: ok} + TagResource: {wire: ok, errors: ok, state: ok, persist: ok} + UntagResource: {wire: ok, errors: ok, state: ok, persist: ok} +families: + route_matching: {status: ok, note: "matches the SigV4 signing name or SDK user agent plus a known path shape; /rules is shared with IoT and resolved by scope"} + errors: {status: ok, note: "REST-JSON error bodies carry both message and Message plus X-Amzn-Errortype"} +gaps: [] +items_still_open: [] +structural_gaps: + - ListRules IDs and descriptions are gopherstack-defined: the real ARC rule catalog is not published in the SDK, so ID parity is unverifiable +deferred: [] +leaks: {status: clean, note: "no goroutines or janitors; expiry and lifecycle are derived from the clock at read time"} +--- + +## Notes + +- Protocol: REST-JSON, camelCase members, ISO-8601 timestamps, SigV4 signing name `route53-recovery-readiness`. +- Readiness is never assumed. Each resource is evaluated by looking the resource up in the sibling that owns its type (resolved at call time): ELBv2 load balancer (exists, active), Route 53 health check (exists, Healthy), SQS queue, SNS topic, Lambda function, EC2 VPC, CloudWatch alarm (exists, not in ALARM), DynamoDB table (exists, ACTIVE), Route 53 DNS target record (exists in the hosted zone). API Gateway REST and HTTP stages, Auto Scaling group (exists, not deleting, in-service count at least MinSize), EC2 customer gateway, VPN gateway, VPN connection and volume (exists, available or in-use), classic ELB, MSK cluster (ACTIVE), RDS DB cluster (available) and SNS subscription (confirmed). Every resource set type the SDK lists has an evaluator; a sibling that is not reachable is UNKNOWN with a message. Rule sets are existence plus state, not the full AWS capacity and limit checks. A resource in another account is NOT_AUTHORIZED until a cross-account authorization naming that account exists. +- Check readiness: any NOT_READY gives NOT_READY, then NOT_AUTHORIZED, then UNKNOWN; READY only when every rule of every resource is READY. Cell and recovery group summaries use the same reduction over the checks scoped to them. +- Architecture recommendations are generated from configuration facts (single or missing cells, resource sets without checks, resource types present in some cells only, groups with no scoped resource sets). + diff --git a/services/route53recoveryreadiness/README.md b/services/route53recoveryreadiness/README.md new file mode 100644 index 0000000000..57b9e07c25 --- /dev/null +++ b/services/route53recoveryreadiness/README.md @@ -0,0 +1,26 @@ + +# Route 53 Recovery Readiness + +**Parity grade: A** · SDK `aws-sdk-go-v2/service/route53recoveryreadiness@v1.34.4` · last audited 2026-10-14 (`80b7bb8b3`) + +## Coverage + +| Metric | Value | +| --- | --- | +| PARITY entries audited | 32 (32 ok) | +| Feature families | 2 (2 ok) | +| Known gaps | none | +| Structural gaps (can't be emulated) | 1 | +| Deferred items | 0 | +| Resource leaks | clean | + +### Structural gaps + +These do not block an A grade — no implementation could produce real data here because the underlying data source cannot exist in an emulator. + +- ListRules IDs and descriptions are gopherstack-defined: the real ARC rule catalog is not published in the SDK, so ID parity is unverifiable + +## More + +- [Full parity audit](PARITY.md) +- [All services](../../README.md#services) diff --git a/services/route53recoveryreadiness/cells.go b/services/route53recoveryreadiness/cells.go new file mode 100644 index 0000000000..6bd94cccf5 --- /dev/null +++ b/services/route53recoveryreadiness/cells.go @@ -0,0 +1,204 @@ +package route53recoveryreadiness + +import ( + "slices" + + "github.com/blackbirdworks/gopherstack/pkgs/restroute" +) + +// CellView is the wire form of a cell. +type CellView struct { + Tags map[string]string `json:"tags,omitempty"` + CellArn string `json:"cellArn"` + CellName string `json:"cellName"` + Cells []string `json:"cells"` + ParentReadinessScopes []string `json:"parentReadinessScopes"` +} + +func cellView(c *Cell) CellView { + parents := []string{} + if c.Parent != "" { + parents = append(parents, c.Parent) + } + + return CellView{ + CellArn: c.ARN, + CellName: c.Name, + Cells: slices.Clone(c.Cells), + ParentReadinessScopes: parents, + Tags: cloneTags(c.Tags), + } +} + +// attachChildren validates that children can sit under parentArn; children already owned by it are allowed. +func (b *InMemoryBackend) attachChildren(parentArn, parentCell string, children []string) error { + seen := map[string]bool{} + + for _, ca := range children { + child, ok := b.cellByARN(ca) + if !ok { + return restroute.NotFound("Cell %s was not found", ca) + } + + if seen[ca] { + return restroute.Validation("Cell %s is listed more than once", ca) + } + + seen[ca] = true + + if parentCell != "" && b.isAncestorOrSelf(child, parentCell) { + return restroute.Validation("Cell %s cannot contain %s: that would create a cycle", parentCell, ca) + } + + if child.Parent != "" && child.Parent != parentArn { + return restroute.Conflict("Cell %s already belongs to %s", ca, child.Parent) + } + } + + return nil +} + +// isAncestorOrSelf reports whether candidate is cellName itself or one of its ancestors. +func (b *InMemoryBackend) isAncestorOrSelf(candidate *Cell, cellName string) bool { + if candidate.Name == cellName { + return true + } + + cur, err := b.getCell(cellName) + for err == nil && cur.Parent != "" { + if cur.Parent == candidate.ARN { + return true + } + + next, ok := b.cellByARN(cur.Parent) + if !ok { + return false + } + + cur = next + } + + return false +} + +func (b *InMemoryBackend) setParent(children []string, parentArn string) { + for _, ca := range children { + if c, ok := b.cellByARN(ca); ok { + c.Parent = parentArn + } + } +} + +// adopt links children under arn and returns the stored child list (never nil). +func (b *InMemoryBackend) adopt(arn string, children []string) ([]string, error) { + if err := b.attachChildren(arn, "", children); err != nil { + return nil, err + } + + b.setParent(children, arn) + + return append([]string{}, children...), nil +} + +// CreateCell creates a cell. +func (b *InMemoryBackend) CreateCell(name string, children []string, tags map[string]string) (CellView, error) { + if err := validName("cell", name); err != nil { + return CellView{}, err + } + + b.mu.Lock("CreateCell") + defer b.mu.Unlock() + + if b.cells.Has(name) { + return CellView{}, restroute.Conflict("Cell %s already exists", name) + } + + arn := b.arnFor("cell", name) + + kids, err := b.adopt(arn, children) + if err != nil { + return CellView{}, err + } + + cell := &Cell{ARN: arn, Name: name, Cells: kids, Tags: cloneTags(tags)} + b.cells.Put(cell) + + return cellView(cell), nil +} + +// GetCell returns a cell. +func (b *InMemoryBackend) GetCell(name string) (CellView, error) { + b.mu.RLock("GetCell") + defer b.mu.RUnlock() + + c, err := b.getCell(name) + if err != nil { + return CellView{}, err + } + + return cellView(c), nil +} + +// ListCells lists cells. +func (b *InMemoryBackend) ListCells(token string, maxResults int) ([]CellView, string, error) { + b.mu.RLock("ListCells") + defer b.mu.RUnlock() + + pg, err := restroute.Paginate(sorted(b.cells.All(), func(c *Cell) string { return c.Name }), + token, maxResults, listDefault, listMax) + if err != nil { + return nil, "", err + } + + out := make([]CellView, 0, len(pg.Data)) + for _, c := range pg.Data { + out = append(out, cellView(c)) + } + + return out, pg.Next, nil +} + +// UpdateCell replaces a cell's child list. +func (b *InMemoryBackend) UpdateCell(name string, children []string) (CellView, error) { + b.mu.Lock("UpdateCell") + defer b.mu.Unlock() + + c, err := b.getCell(name) + if err != nil { + return CellView{}, err + } + + if err = b.attachChildren(c.ARN, c.Name, children); err != nil { + return CellView{}, err + } + + b.setParent(c.Cells, "") + b.setParent(children, c.ARN) + c.Cells = slices.Clone(children) + + if c.Cells == nil { + c.Cells = []string{} + } + + return cellView(c), nil +} + +// DeleteCell deletes a cell that is not part of a recovery group or another cell. +func (b *InMemoryBackend) DeleteCell(name string) error { + b.mu.Lock("DeleteCell") + defer b.mu.Unlock() + + c, err := b.getCell(name) + if err != nil { + return err + } + + if c.Parent != "" { + return restroute.Conflict("Cell %s still belongs to %s; remove it from there first", name, c.Parent) + } + + b.setParent(c.Cells, "") + b.cells.Delete(name) + + return nil +} diff --git a/services/route53recoveryreadiness/checks.go b/services/route53recoveryreadiness/checks.go new file mode 100644 index 0000000000..3b05df00f3 --- /dev/null +++ b/services/route53recoveryreadiness/checks.go @@ -0,0 +1,110 @@ +package route53recoveryreadiness + +import "github.com/blackbirdworks/gopherstack/pkgs/restroute" + +// CheckView is the wire form of a readiness check. +type CheckView struct { + Tags map[string]string `json:"tags,omitempty"` + ReadinessCheckArn string `json:"readinessCheckArn"` + ReadinessCheckName string `json:"readinessCheckName"` + ResourceSet string `json:"resourceSet"` +} + +func checkView(c *ReadinessCheck) CheckView { + return CheckView{ + ReadinessCheckArn: c.ARN, ReadinessCheckName: c.Name, ResourceSet: c.ResourceSet, Tags: cloneTags(c.Tags), + } +} + +// CreateReadinessCheck binds a resource set to a new check. +func (b *InMemoryBackend) CreateReadinessCheck(name, resourceSet string, tags map[string]string) (CheckView, error) { + if err := validName("readiness check", name); err != nil { + return CheckView{}, err + } + + b.mu.Lock("CreateReadinessCheck") + defer b.mu.Unlock() + + if b.checks.Has(name) { + return CheckView{}, restroute.Conflict("Readiness check %s already exists", name) + } + + if _, err := b.getSet(resourceSet); err != nil { + return CheckView{}, err + } + + c := &ReadinessCheck{ + ARN: b.arnFor("readiness-check", name), + Name: name, + ResourceSet: resourceSet, + Tags: cloneTags(tags), + } + b.checks.Put(c) + + return checkView(c), nil +} + +// GetReadinessCheck returns a check. +func (b *InMemoryBackend) GetReadinessCheck(name string) (CheckView, error) { + b.mu.RLock("GetReadinessCheck") + defer b.mu.RUnlock() + + c, err := b.getCheck(name) + if err != nil { + return CheckView{}, err + } + + return checkView(c), nil +} + +// ListReadinessChecks lists checks. +func (b *InMemoryBackend) ListReadinessChecks(token string, maxResults int) ([]CheckView, string, error) { + b.mu.RLock("ListReadinessChecks") + defer b.mu.RUnlock() + + pg, err := restroute.Paginate(sorted(b.checks.All(), func(c *ReadinessCheck) string { return c.Name }), + token, maxResults, listDefault, listMax) + if err != nil { + return nil, "", err + } + + out := make([]CheckView, 0, len(pg.Data)) + for _, c := range pg.Data { + out = append(out, checkView(c)) + } + + return out, pg.Next, nil +} + +// UpdateReadinessCheck points a check at another resource set. +func (b *InMemoryBackend) UpdateReadinessCheck(name, resourceSet string) (CheckView, error) { + b.mu.Lock("UpdateReadinessCheck") + defer b.mu.Unlock() + + c, err := b.getCheck(name) + if err != nil { + return CheckView{}, err + } + + if _, err = b.getSet(resourceSet); err != nil { + return CheckView{}, err + } + + c.ResourceSet = resourceSet + + return checkView(c), nil +} + +// DeleteReadinessCheck deletes a check. +func (b *InMemoryBackend) DeleteReadinessCheck(name string) error { + b.mu.Lock("DeleteReadinessCheck") + defer b.mu.Unlock() + + if _, err := b.getCheck(name); err != nil { + return err + } + + b.checks.Delete(name) + + return nil +} diff --git a/services/route53recoveryreadiness/evaluate.go b/services/route53recoveryreadiness/evaluate.go new file mode 100644 index 0000000000..2eec07e4ae --- /dev/null +++ b/services/route53recoveryreadiness/evaluate.go @@ -0,0 +1,276 @@ +package route53recoveryreadiness + +import ( + "context" + "slices" + "strings" + "time" +) + +type ruleKind int + +const ( + kindExists ruleKind = iota + kindHealthy +) + +type ruleDef struct { + Type string + ID string + Description string + kind ruleKind +} + +// ruleCatalog lists the rules this emulator evaluates against sibling services; all are defined by gopherstack. +func ruleCatalog() []ruleDef { + return []ruleDef{ + {typeELBv2, "ElbV2LoadBalancerExists", "The load balancer exists", kindExists}, + { + typeELBv2, + "ElbV2LoadBalancerActive", + "The load balancer is in the active state", + kindHealthy, + }, + {typeHealthCheck, "Route53HealthCheckExists", "The health check exists", kindExists}, + {typeHealthCheck, "Route53HealthCheckHealthy", "The health check reports Healthy", kindHealthy}, + {typeSQS, "SqsQueueExists", "The queue exists", kindExists}, + {typeSNS, "SnsTopicExists", "The topic exists", kindExists}, + {typeLambda, "LambdaFunctionExists", "The function exists", kindExists}, + {typeVPC, "Ec2VpcExists", "The VPC exists", kindExists}, + {typeAlarm, "CloudWatchAlarmExists", "The alarm exists", kindExists}, + {typeAlarm, "CloudWatchAlarmNotInAlarm", "The alarm is not in the ALARM state", kindHealthy}, + {typeTable, "DynamoDbTableExists", "The table exists", kindExists}, + {typeTable, "DynamoDbTableActive", "The table status is ACTIVE", kindHealthy}, + {typeDNSTarget, "DnsTargetRecordExists", "The DNS record exists in the hosted zone", kindExists}, + {typeAPIGWStage, "ApiGatewayStageExists", "The stage exists", kindExists}, + {typeAPIGWV2Stage, "ApiGatewayV2StageExists", "The stage exists", kindExists}, + {typeASG, "AutoScalingGroupExists", "The Auto Scaling group exists", kindExists}, + { + typeASG, "AutoScalingGroupCapacityMet", + "The group is not deleting and has at least its minimum size in service", kindHealthy, + }, + {typeCGW, "Ec2CustomerGatewayExists", "The customer gateway exists", kindExists}, + {typeCGW, "Ec2CustomerGatewayAvailable", "The customer gateway is available", kindHealthy}, + {typeVolume, "Ec2VolumeExists", "The volume exists", kindExists}, + {typeVolume, "Ec2VolumeAvailable", "The volume is available or in use", kindHealthy}, + {typeELBClassic, "ElbLoadBalancerExists", "The classic load balancer exists", kindExists}, + {typeMSK, "MskClusterExists", "The cluster exists", kindExists}, + {typeMSK, "MskClusterActive", "The cluster is in the ACTIVE state", kindHealthy}, + {typeRDSCluster, "RdsDbClusterExists", "The DB cluster exists", kindExists}, + {typeRDSCluster, "RdsDbClusterAvailable", "The DB cluster is available", kindHealthy}, + {typeSNSSub, "SnsSubscriptionExists", "The subscription exists", kindExists}, + {typeSNSSub, "SnsSubscriptionConfirmed", "The subscription is confirmed", kindHealthy}, + {typeVPNConn, "Ec2VpnConnectionExists", "The VPN connection exists", kindExists}, + {typeVPNConn, "Ec2VpnConnectionAvailable", "The VPN connection is available", kindHealthy}, + {typeVPNGateway, "Ec2VpnGatewayExists", "The VPN gateway exists", kindExists}, + {typeVPNGateway, "Ec2VpnGatewayAvailable", "The VPN gateway is available", kindHealthy}, + } +} + +type ruleOutcome struct { + At time.Time + ID string + Readiness string + Messages []string +} + +type resourceOutcome struct { + At time.Time + Readiness string + Note string + Resource Resource + Rules []ruleOutcome +} + +func (r Resource) identifier() string { + if r.ResourceArn != "" { + return r.ResourceArn + } + + return r.ComponentID +} + +// combine reduces rule or resource readiness values to one: NOT_READY > NOT_AUTHORIZED > UNKNOWN > READY. +func combine(values []string) string { + if len(values) == 0 { + return readinessUnknown + } + + for _, want := range []string{readinessNotReady, readinessNotAuthorized, readinessUnknown} { + if slices.Contains(values, want) { + return want + } + } + + return readinessReady +} + +func rulesFor(typ string) []ruleDef { + var out []ruleDef + + for _, d := range ruleCatalog() { + if d.Type == typ { + out = append(out, d) + } + } + + return out +} + +// authorized reports whether the resource may be read, plus the foreign account ID when it may not. +func (b *InMemoryBackend) authorized(resourceArn string, auths []string) (bool, string) { + p, ok := parseARN(resourceArn) + if !ok || p.AccountID == "" || p.AccountID == b.accountID { + return true, "" + } + + return slices.ContainsFunc(auths, func(a string) bool { return authAccount(a) == p.AccountID }), p.AccountID +} + +// authAccount is the account an authorization names: the ARN's account, else the whole string. +func authAccount(auth string) string { + if p, ok := parseARN(auth); ok { + return p.AccountID + } + + return auth +} + +func outcomeFor(def ruleDef, p probe, now time.Time) ruleOutcome { + ro := ruleOutcome{At: now, ID: def.ID, Readiness: readinessReady} + + ok := p.exists + if def.kind == kindHealthy { + ok = p.exists && p.healthy + } + + if !ok { + ro.Readiness = readinessNotReady + ro.Messages = []string{def.Description + ": not satisfied, " + p.detail} + } + + return ro +} + +// evaluateResource derives readiness from the live state of the dependent sibling service. +func (b *InMemoryBackend) evaluateResource( + ctx context.Context, + typ string, + res Resource, + auths []string, + now time.Time, +) resourceOutcome { + out := resourceOutcome{At: now, Resource: res} + defs := rulesFor(typ) + + if len(defs) == 0 { + out.Readiness = readinessUnknown + out.Note = "no readiness rules are evaluated for resource type " + typ + + return out + } + + var p probe + + switch { + case res.DNS != nil: + p = b.lookupDNS(res.DNS) + default: + if ok, account := b.authorized(res.ResourceArn, auths); !ok { + for _, d := range defs { + out.Rules = append(out.Rules, ruleOutcome{ + At: now, ID: d.ID, Readiness: readinessNotAuthorized, + Messages: []string{"no cross-account authorization exists for account " + account}, + }) + } + + out.Readiness = readinessNotAuthorized + + return out + } + + p = resolver{cfg: b.appConfig}.lookup(ctx, typ, res.ResourceArn) + } + + if !p.answered { + for _, d := range defs { + out.Rules = append(out.Rules, ruleOutcome{ + At: now, + ID: d.ID, + Readiness: readinessUnknown, + Messages: []string{ + "the service that owns this resource type is not reachable from the readiness evaluator", + }, + }) + } + + out.Readiness = readinessUnknown + + return out + } + + values := make([]string, 0, len(defs)) + + for _, d := range defs { + ro := outcomeFor(d, p, now) + out.Rules = append(out.Rules, ro) + values = append(values, ro.Readiness) + } + + out.Readiness = combine(values) + + return out +} + +// checkInputs copies what a readiness check needs so sibling lookups run without the backend lock. +type checkInputs struct { + typ string + resources []Resource + auths []string +} + +func (b *InMemoryBackend) inputsFor(checkName string) (checkInputs, error) { + b.mu.RLock("readinessInputs") + defer b.mu.RUnlock() + + c, err := b.getCheck(checkName) + if err != nil { + return checkInputs{}, err + } + + s, err := b.getSet(c.ResourceSet) + if err != nil { + return checkInputs{}, err + } + + in := checkInputs{typ: s.Type, resources: slices.Clone(s.Resources)} + for _, a := range b.auths.All() { + in.auths = append(in.auths, a.Value) + } + + return in, nil +} + +func (b *InMemoryBackend) evaluateAll(ctx context.Context, in checkInputs) []resourceOutcome { + now := time.Now().UTC() + out := make([]resourceOutcome, 0, len(in.resources)) + + for _, r := range in.resources { + out = append(out, b.evaluateResource(ctx, in.typ, r, in.auths, now)) + } + + slices.SortStableFunc(out, func(a, c resourceOutcome) int { + return strings.Compare(a.Resource.identifier(), c.Resource.identifier()) + }) + + return out +} + +func readinessOf(outs []resourceOutcome) string { + values := make([]string, 0, len(outs)) + for _, o := range outs { + values = append(values, o.Readiness) + } + + return combine(values) +} diff --git a/services/route53recoveryreadiness/evaluators_test.go b/services/route53recoveryreadiness/evaluators_test.go new file mode 100644 index 0000000000..a1d006f264 --- /dev/null +++ b/services/route53recoveryreadiness/evaluators_test.go @@ -0,0 +1,427 @@ +package route53recoveryreadiness_test + +import ( + "context" + "strings" + "testing" + "time" + + "github.com/aws/aws-sdk-go-v2/aws" + sdk "github.com/aws/aws-sdk-go-v2/service/route53recoveryreadiness" + "github.com/aws/aws-sdk-go-v2/service/route53recoveryreadiness/types" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/blackbirdworks/gopherstack/pkgs/service" + apigw "github.com/blackbirdworks/gopherstack/services/apigateway" + apigwv2 "github.com/blackbirdworks/gopherstack/services/apigatewayv2" + asg "github.com/blackbirdworks/gopherstack/services/autoscaling" + ec2bk "github.com/blackbirdworks/gopherstack/services/ec2" + elbbk "github.com/blackbirdworks/gopherstack/services/elb" + kafkabk "github.com/blackbirdworks/gopherstack/services/kafka" + rdsbk "github.com/blackbirdworks/gopherstack/services/rds" + snsbk "github.com/blackbirdworks/gopherstack/services/sns" +) + +// evalSiblings adds the siblings needed by the per-type evaluators. +type evalSiblings struct { + *siblings + apigw, apigwv2, asg, ec2, elb, kafka, rds, sns service.Registerable +} + +func (s *evalSiblings) GetAPIGatewayHandler() service.Registerable { return s.apigw } +func (s *evalSiblings) GetAPIGatewayV2Handler() service.Registerable { return s.apigwv2 } +func (s *evalSiblings) GetAutoscalingHandler() service.Registerable { return s.asg } +func (s *evalSiblings) GetEC2Handler() service.Registerable { return s.ec2 } +func (s *evalSiblings) GetELBHandler() service.Registerable { return s.elb } +func (s *evalSiblings) GetKafkaHandler() service.Registerable { return s.kafka } +func (s *evalSiblings) GetRDSHandler() service.Registerable { return s.rds } +func (s *evalSiblings) GetSNSHandler() service.Registerable { return s.sns } + +type evalWorld struct { + apigw *apigw.InMemoryBackend + apigwv2 *apigwv2.InMemoryBackend + asg *asg.InMemoryBackend + ec2 *ec2bk.InMemoryBackend + elb *elbbk.InMemoryBackend + kafka *kafkabk.InMemoryBackend + rds *rdsbk.InMemoryBackend + sns *snsbk.InMemoryBackend +} + +func newEvalFixture(t *testing.T) (*fixture, *evalWorld) { + t.Helper() + + f := newFixture(t) + w := &evalWorld{ + apigw: apigw.NewInMemoryBackend(), + apigwv2: apigwv2.NewInMemoryBackend(), + asg: asg.NewInMemoryBackendWithConfig(testAccount, testRegion), + ec2: ec2bk.NewInMemoryBackend(testAccount, testRegion), + elb: elbbk.NewInMemoryBackend(testAccount, testRegion), + kafka: kafkabk.NewInMemoryBackend(testAccount, testRegion), + rds: rdsbk.NewInMemoryBackend(testAccount, testRegion), + sns: snsbk.NewInMemoryBackendWithConfig(testAccount, testRegion), + } + t.Cleanup(w.kafka.Close) + + f.backend.SetAppConfig(&evalSiblings{ + siblings: &siblings{}, + apigw: apigw.NewHandler(w.apigw), + apigwv2: apigwv2.NewHandler(w.apigwv2), + asg: asg.NewHandler(w.asg), + ec2: ec2bk.NewHandler(w.ec2), + elb: elbbk.NewHandler(w.elb), + kafka: kafkabk.NewHandler(w.kafka), + rds: rdsbk.NewHandler(w.rds), + sns: snsbk.NewHandler(w.sns), + }) + + return f, w +} + +const evalARNPrefix = "arn:aws:" + +func evalARN(svc, resource string) string { + return evalARN3(svc, testRegion, resource) +} + +func evalARN3(svc, region, resource string) string { + return evalARNPrefix + svc + ":" + region + ":" + testAccount + ":" + resource +} + +func TestReadinessEvaluatesEveryResourceSetType(t *testing.T) { + t.Parallel() + + ready := func(ids ...string) map[string]types.Readiness { + m := map[string]types.Readiness{} + for _, id := range ids { + m[id] = types.ReadinessReady + } + + return m + } + + tests := []struct { + create func(t *testing.T, w *evalWorld) string + want map[string]types.Readiness + name string + typ string + bogus string + }{ + { + name: "api gateway stage", typ: "AWS::ApiGateway::Stage", + bogus: "arn:aws:apigateway:us-east-1::/restapis/nope/stages/prod", + want: ready("ApiGatewayStageExists"), + create: func(t *testing.T, w *evalWorld) string { + t.Helper() + + api, err := w.apigw.CreateRestAPI(apigw.CreateRestAPIInput{Name: "a"}) + require.NoError(t, err) + d, err := w.apigw.CreateDeployment(api.ID, "", "v1") + require.NoError(t, err) + _, err = w.apigw.CreateStage( + apigw.CreateStageInput{RestAPIID: api.ID, StageName: "prod", DeploymentID: d.ID}, + ) + require.NoError(t, err) + + return "arn:aws:apigateway:us-east-1::/restapis/" + api.ID + "/stages/prod" + }, + }, + { + name: "api gateway v2 stage", typ: "AWS::ApiGatewayV2::Stage", + bogus: "arn:aws:apigateway:us-east-1::/apis/nope/stages/prod", + want: ready("ApiGatewayV2StageExists"), + create: func(t *testing.T, w *evalWorld) string { + t.Helper() + + api, err := w.apigwv2.CreateAPI( + context.Background(), + apigwv2.CreateAPIInput{Name: "a", ProtocolType: "HTTP"}, + ) + require.NoError(t, err) + _, err = w.apigwv2.CreateStage(api.APIID, apigwv2.CreateStageInput{StageName: "prod"}) + require.NoError(t, err) + + return "arn:aws:apigateway:us-east-1::/apis/" + api.APIID + "/stages/prod" + }, + }, + { + name: "auto scaling group", typ: "AWS::AutoScaling::AutoScalingGroup", + bogus: evalARN("autoscaling", "autoScalingGroup:x:autoScalingGroupName/nope"), + want: ready("AutoScalingGroupExists", "AutoScalingGroupCapacityMet"), + create: func(t *testing.T, w *evalWorld) string { + t.Helper() + + g, err := w.asg.CreateAutoScalingGroup(asg.CreateAutoScalingGroupInput{ + AutoScalingGroupName: "web", MinSize: 0, MaxSize: 2, AvailabilityZones: []string{"us-east-1a"}, + }) + require.NoError(t, err) + + return g.AutoScalingGroupARN + }, + }, + { + name: "ec2 customer gateway", typ: "AWS::EC2::CustomerGateway", + bogus: evalARN("ec2", "customer-gateway/cgw-nope"), + want: ready("Ec2CustomerGatewayExists", "Ec2CustomerGatewayAvailable"), + create: func(t *testing.T, w *evalWorld) string { + t.Helper() + + g, err := w.ec2.CreateCustomerGateway("ipsec.1", "203.0.113.5", "65000") + require.NoError(t, err) + + return evalARN("ec2", "customer-gateway/"+g.CustomerGatewayID) + }, + }, + { + name: "ec2 volume", typ: "AWS::EC2::Volume", + bogus: evalARN("ec2", "volume/vol-nope"), + want: ready("Ec2VolumeExists", "Ec2VolumeAvailable"), + create: func(t *testing.T, w *evalWorld) string { + t.Helper() + + v, err := w.ec2.CreateVolume("us-east-1a", "gp3", 8, "") + require.NoError(t, err) + + return evalARN("ec2", "volume/"+v.ID) + }, + }, + { + name: "ec2 vpn gateway", typ: "AWS::EC2::VPNGateway", + bogus: evalARN("ec2", "vpn-gateway/vgw-nope"), + want: ready("Ec2VpnGatewayExists", "Ec2VpnGatewayAvailable"), + create: func(t *testing.T, w *evalWorld) string { + t.Helper() + + g, err := w.ec2.CreateVpnGateway("ipsec.1", 0) + require.NoError(t, err) + + return evalARN("ec2", "vpn-gateway/"+g.VpnGatewayID) + }, + }, + { + name: "ec2 vpn connection", typ: "AWS::EC2::VPNConnection", + bogus: evalARN("ec2", "vpn-connection/vpn-nope"), + want: ready("Ec2VpnConnectionExists", "Ec2VpnConnectionAvailable"), + create: func(t *testing.T, w *evalWorld) string { + t.Helper() + + cgw, err := w.ec2.CreateCustomerGateway("ipsec.1", "203.0.113.6", "65000") + require.NoError(t, err) + vgw, err := w.ec2.CreateVpnGateway("ipsec.1", 0) + require.NoError(t, err) + c, err := w.ec2.CreateVpnConnection("ipsec.1", cgw.CustomerGatewayID, vgw.VpnGatewayID) + require.NoError(t, err) + + return evalARN("ec2", "vpn-connection/"+c.VpnConnectionID) + }, + }, + { + name: "classic load balancer", typ: "AWS::ElasticLoadBalancing::LoadBalancer", + bogus: evalARN("elasticloadbalancing", "loadbalancer/nope"), + want: ready("ElbLoadBalancerExists"), + create: func(t *testing.T, w *evalWorld) string { + t.Helper() + + _, err := w.elb.CreateLoadBalancer(context.Background(), elbbk.CreateLoadBalancerInput{ + LoadBalancerName: "classic", AvailabilityZones: []string{"us-east-1a"}, + Listeners: []elbbk.Listener{ + {Protocol: "HTTP", InstanceProtocol: "HTTP", LoadBalancerPort: 80, InstancePort: 8080}, + }, + }) + require.NoError(t, err) + + return evalARN("elasticloadbalancing", "loadbalancer/classic") + }, + }, + { + name: "msk cluster", typ: "AWS::MSK::Cluster", + bogus: evalARN("kafka", "cluster/nope/1"), + want: ready("MskClusterExists", "MskClusterActive"), + create: func(t *testing.T, w *evalWorld) string { + t.Helper() + + c, err := w.kafka.CreateCluster( + context.Background(), "msk", "3.9.1", 1, kafkabk.BrokerNodeGroupInfo{}, nil, nil) + require.NoError(t, err) + + return c.ClusterArn + }, + }, + { + name: "rds db cluster", typ: "AWS::RDS::DBCluster", + bogus: evalARN("rds", "cluster:nope"), + want: ready("RdsDbClusterExists", "RdsDbClusterAvailable"), + create: func(t *testing.T, w *evalWorld) string { + t.Helper() + + _, err := w.rds.CreateDBCluster( + "aurora1", "aurora-postgresql", "admin", "", "", 0, nil, rdsbk.DBClusterOptions{}) + require.NoError(t, err) + require.Eventually(t, func() bool { + cs, descErr := w.rds.DescribeDBClusters("aurora1") + + return descErr == nil && len(cs) == 1 && cs[0].Status == "available" + }, 10*time.Second, 20*time.Millisecond) + + return evalARN("rds", "cluster:aurora1") + }, + }, + { + name: "sns subscription confirmed", typ: "AWS::SNS::Subscription", + bogus: evalARN("sns", "t:00000000-0000-0000-0000-000000000000"), + want: ready("SnsSubscriptionExists", "SnsSubscriptionConfirmed"), + create: func(t *testing.T, w *evalWorld) string { + t.Helper() + + topic, err := w.sns.CreateTopic("t", nil) + require.NoError(t, err) + sub, err := w.sns.Subscribe(topic.TopicArn, "sqs", evalARN("sqs", "q"), "") + require.NoError(t, err) + + return sub.SubscriptionArn + }, + }, + { + name: "sns subscription pending", typ: "AWS::SNS::Subscription", + bogus: evalARN("sns", "t:00000000-0000-0000-0000-000000000000"), + want: map[string]types.Readiness{ + "SnsSubscriptionExists": types.ReadinessReady, "SnsSubscriptionConfirmed": types.ReadinessNotReady, + }, + create: func(t *testing.T, w *evalWorld) string { + t.Helper() + + topic, err := w.sns.CreateTopic("t", nil) + require.NoError(t, err) + sub, err := w.sns.Subscribe(topic.TopicArn, "https", "https://example.com/hook", "") + require.NoError(t, err) + + return sub.SubscriptionArn + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + f, w := newEvalFixture(t) + existing := tt.create(t, w) + rc := f.checkOf(t, tt.typ, + types.Resource{ResourceArn: aws.String(existing)}, types.Resource{ResourceArn: aws.String(tt.bogus)}) + + assert.Equal(t, tt.want, f.rules(t, rc, existing)) + + missing := f.rules(t, rc, tt.bogus) + require.NotEmpty(t, missing) + assert.Equal(t, types.ReadinessNotReady, missing[firstRule(tt.want)]) + + status, err := f.client.GetReadinessCheckStatus( + t.Context(), &sdk.GetReadinessCheckStatusInput{ReadinessCheckName: aws.String(rc)}) + require.NoError(t, err) + assert.Equal(t, types.ReadinessNotReady, status.Readiness) + }) + } +} + +func firstRule(m map[string]types.Readiness) string { + for id := range m { + if strings.HasSuffix(id, "Exists") { + return id + } + } + + return "" +} + +func TestEveryResourceSetTypeHasRules(t *testing.T) { + t.Parallel() + + typesInSDK := []string{ + "AWS::ApiGateway::Stage", "AWS::ApiGatewayV2::Stage", "AWS::AutoScaling::AutoScalingGroup", + "AWS::CloudWatch::Alarm", "AWS::EC2::CustomerGateway", "AWS::DynamoDB::Table", "AWS::EC2::Volume", + "AWS::ElasticLoadBalancing::LoadBalancer", "AWS::ElasticLoadBalancingV2::LoadBalancer", + "AWS::Lambda::Function", "AWS::MSK::Cluster", "AWS::RDS::DBCluster", "AWS::Route53::HealthCheck", + "AWS::SQS::Queue", "AWS::SNS::Topic", "AWS::SNS::Subscription", "AWS::EC2::VPC", + "AWS::EC2::VPNConnection", "AWS::EC2::VPNGateway", "AWS::Route53RecoveryReadiness::DNSTargetResource", + } + + f := newFixture(t) + + for _, typ := range typesInSDK { + t.Run(typ, func(t *testing.T) { + t.Parallel() + + out, err := f.client.ListRules(t.Context(), &sdk.ListRulesInput{ResourceType: aws.String(typ)}) + require.NoError(t, err) + assert.NotEmpty(t, out.Rules, "no readiness rule evaluates %s", typ) + }) + } +} + +func TestEmptyDNSTargetAndComponentCountAsUnset(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + res types.Resource + fail bool + }{ + { + name: "terraform shape", + res: types.Resource{ + ComponentId: aws.String(""), DnsTargetResource: &types.DNSTargetResource{}, + ReadinessScopes: []string{"arn:aws:route53-recovery-readiness::123456789012:cell/c"}, + ResourceArn: aws.String(queueARN("dbg")), + }, + }, + {name: "empty dns only", res: types.Resource{DnsTargetResource: &types.DNSTargetResource{}}, fail: true}, + { + name: "real dns and arn", + res: types.Resource{ + DnsTargetResource: &types.DNSTargetResource{DomainName: aws.String("a.example.com")}, + ResourceArn: aws.String(queueARN("dbg")), + }, + fail: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + f := newFixture(t) + ctx := t.Context() + + _, err := f.client.CreateCell(ctx, &sdk.CreateCellInput{CellName: aws.String("c")}) + require.NoError(t, err) + + out, err := f.client.CreateResourceSet(ctx, &sdk.CreateResourceSetInput{ + ResourceSetName: aws.String("rs"), ResourceSetType: aws.String("AWS::SQS::Queue"), + Resources: []types.Resource{tt.res}, + }) + if tt.fail { + require.Error(t, err) + + return + } + + require.NoError(t, err) + + got, err := f.client.GetResourceSet(ctx, &sdk.GetResourceSetInput{ResourceSetName: aws.String("rs")}) + require.NoError(t, err) + require.Len(t, got.Resources, 1) + assert.Nil(t, got.Resources[0].DnsTargetResource) + assert.Empty(t, aws.ToString(got.Resources[0].ComponentId)) + assert.Len(t, out.Resources, 1) + assert.Nil(t, out.Resources[0].DnsTargetResource) + + _, err = f.client.UpdateResourceSet(ctx, &sdk.UpdateResourceSetInput{ + ResourceSetName: aws.String("rs"), ResourceSetType: aws.String("AWS::SQS::Queue"), + Resources: []types.Resource{tt.res}, + }) + require.NoError(t, err) + }) + } +} diff --git a/services/route53recoveryreadiness/extract_headers_test.go b/services/route53recoveryreadiness/extract_headers_test.go new file mode 100644 index 0000000000..72913b1a64 --- /dev/null +++ b/services/route53recoveryreadiness/extract_headers_test.go @@ -0,0 +1,44 @@ +package route53recoveryreadiness_test + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/labstack/echo/v5" + "github.com/stretchr/testify/assert" + + svc "github.com/blackbirdworks/gopherstack/services/route53recoveryreadiness" +) + +func TestExtractIgnoresCredentialHeaders(t *testing.T) { + t.Parallel() + + h := svc.NewHandler(svc.NewInMemoryBackend(testAccount)) + + tests := []struct { + headers map[string]string + name string + }{ + {nil, "none"}, + {map[string]string{ + "Authorization": "AWS4-HMAC-SHA256 Credential=AKIASECRET/20260101/us-east-1/s3/aws4_request", + }, "sigv4"}, + {map[string]string{"X-Amz-Security-Token": "secret-token"}, "token"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/cells/c1", nil) + for k, v := range tt.headers { + req.Header.Set(k, v) + } + + c := echo.New().NewContext(req, httptest.NewRecorder()) + assert.Equal(t, "GetCell", h.ExtractOperation(c)) + assert.Equal(t, "c1", h.ExtractResource(c)) + }) + } +} diff --git a/services/route53recoveryreadiness/fixture_test.go b/services/route53recoveryreadiness/fixture_test.go new file mode 100644 index 0000000000..1817820434 --- /dev/null +++ b/services/route53recoveryreadiness/fixture_test.go @@ -0,0 +1,84 @@ +package route53recoveryreadiness_test + +import ( + "errors" + "testing" + + "github.com/aws/aws-sdk-go-v2/aws" + awscfg "github.com/aws/aws-sdk-go-v2/config" + "github.com/aws/aws-sdk-go-v2/credentials" + sdk "github.com/aws/aws-sdk-go-v2/service/route53recoveryreadiness" + "github.com/aws/smithy-go" + "github.com/stretchr/testify/require" + + "github.com/blackbirdworks/gopherstack/pkgs/sdktest" + "github.com/blackbirdworks/gopherstack/pkgs/service" + r53 "github.com/blackbirdworks/gopherstack/services/route53" + rr "github.com/blackbirdworks/gopherstack/services/route53recoveryreadiness" + sqsbk "github.com/blackbirdworks/gopherstack/services/sqs" +) + +const ( + testAccount = "123456789012" + testRegion = "us-east-1" +) + +// siblings is a stand-in for *CLI exposing only the sibling services a test wires. +type siblings struct { + sqs service.Registerable + r53 service.Registerable +} + +func (s *siblings) GetCloudWatchHandler() service.Registerable { return nil } +func (s *siblings) GetDynamoDBHandler() service.Registerable { return nil } +func (s *siblings) GetEC2Handler() service.Registerable { return nil } +func (s *siblings) GetELBv2Handler() service.Registerable { return nil } +func (s *siblings) GetLambdaHandler() service.Registerable { return nil } +func (s *siblings) GetRoute53Handler() service.Registerable { return s.r53 } +func (s *siblings) GetSNSHandler() service.Registerable { return nil } +func (s *siblings) GetSQSHandler() service.Registerable { return s.sqs } + +type fixture struct { + backend *rr.InMemoryBackend + handler *rr.Handler + client *sdk.Client + sqs *sqsbk.InMemoryBackend + r53 *r53.InMemoryBackend +} + +func newFixture(t *testing.T) *fixture { + t.Helper() + + sq := sqsbk.NewInMemoryBackendWithConfig(testAccount, testRegion) + t.Cleanup(sq.Close) + rt := r53.NewInMemoryBackend() + + b := rr.NewInMemoryBackend(testAccount) + b.SetAppConfig(&siblings{sqs: sqsbk.NewHandler(sq), r53: r53.NewHandler(rt)}) + + h := rr.NewHandler(b) + url := sdktest.Serve(t, h) + + cfg, err := awscfg.LoadDefaultConfig(t.Context(), + awscfg.WithRegion("us-west-2"), + awscfg.WithCredentialsProvider(credentials.NewStaticCredentialsProvider("test", "test", "")), + ) + require.NoError(t, err) + + return &fixture{ + backend: b, handler: h, sqs: sq, r53: rt, + client: sdk.NewFromConfig(cfg, func(o *sdk.Options) { o.BaseEndpoint = aws.String(url) }), + } +} + +func apiCode(err error) string { + if ae, ok := errors.AsType[smithy.APIError](err); ok { + return ae.ErrorCode() + } + + return "" +} + +func queueARN(name string) string { + return "arn:aws:sqs:" + testRegion + ":" + testAccount + ":" + name +} diff --git a/services/route53recoveryreadiness/groups.go b/services/route53recoveryreadiness/groups.go new file mode 100644 index 0000000000..68963e1fab --- /dev/null +++ b/services/route53recoveryreadiness/groups.go @@ -0,0 +1,120 @@ +package route53recoveryreadiness + +import ( + "slices" + + "github.com/blackbirdworks/gopherstack/pkgs/restroute" +) + +// GroupView is the wire form of a recovery group. +type GroupView struct { + Tags map[string]string `json:"tags,omitempty"` + RecoveryGroupArn string `json:"recoveryGroupArn"` + RecoveryGroupName string `json:"recoveryGroupName"` + Cells []string `json:"cells"` +} + +func groupView(g *RecoveryGroup) GroupView { + return GroupView{ + RecoveryGroupArn: g.ARN, RecoveryGroupName: g.Name, Cells: slices.Clone(g.Cells), Tags: cloneTags(g.Tags), + } +} + +// CreateRecoveryGroup creates a group over top-level cells. +func (b *InMemoryBackend) CreateRecoveryGroup(name string, cells []string, tags map[string]string) (GroupView, error) { + if err := validName("recovery group", name); err != nil { + return GroupView{}, err + } + + b.mu.Lock("CreateRecoveryGroup") + defer b.mu.Unlock() + + if b.groups.Has(name) { + return GroupView{}, restroute.Conflict("Recovery group %s already exists", name) + } + + arn := b.arnFor("recovery-group", name) + + kids, err := b.adopt(arn, cells) + if err != nil { + return GroupView{}, err + } + + g := &RecoveryGroup{ARN: arn, Name: name, Cells: kids, Tags: cloneTags(tags)} + b.groups.Put(g) + + return groupView(g), nil +} + +// GetRecoveryGroup returns a group. +func (b *InMemoryBackend) GetRecoveryGroup(name string) (GroupView, error) { + b.mu.RLock("GetRecoveryGroup") + defer b.mu.RUnlock() + + g, err := b.getGroup(name) + if err != nil { + return GroupView{}, err + } + + return groupView(g), nil +} + +// ListRecoveryGroups lists groups. +func (b *InMemoryBackend) ListRecoveryGroups(token string, maxResults int) ([]GroupView, string, error) { + b.mu.RLock("ListRecoveryGroups") + defer b.mu.RUnlock() + + pg, err := restroute.Paginate(sorted(b.groups.All(), func(g *RecoveryGroup) string { return g.Name }), + token, maxResults, listDefault, listMax) + if err != nil { + return nil, "", err + } + + out := make([]GroupView, 0, len(pg.Data)) + for _, g := range pg.Data { + out = append(out, groupView(g)) + } + + return out, pg.Next, nil +} + +// UpdateRecoveryGroup replaces a group's cell list. +func (b *InMemoryBackend) UpdateRecoveryGroup(name string, cells []string) (GroupView, error) { + b.mu.Lock("UpdateRecoveryGroup") + defer b.mu.Unlock() + + g, err := b.getGroup(name) + if err != nil { + return GroupView{}, err + } + + if err = b.attachChildren(g.ARN, "", cells); err != nil { + return GroupView{}, err + } + + b.setParent(g.Cells, "") + b.setParent(cells, g.ARN) + g.Cells = slices.Clone(cells) + + if g.Cells == nil { + g.Cells = []string{} + } + + return groupView(g), nil +} + +// DeleteRecoveryGroup deletes a group; its cells become top-level again. +func (b *InMemoryBackend) DeleteRecoveryGroup(name string) error { + b.mu.Lock("DeleteRecoveryGroup") + defer b.mu.Unlock() + + g, err := b.getGroup(name) + if err != nil { + return err + } + + b.setParent(g.Cells, "") + b.groups.Delete(name) + + return nil +} diff --git a/services/route53recoveryreadiness/handler.go b/services/route53recoveryreadiness/handler.go new file mode 100644 index 0000000000..bfee1627a1 --- /dev/null +++ b/services/route53recoveryreadiness/handler.go @@ -0,0 +1,201 @@ +package route53recoveryreadiness + +import ( + "context" + "net/http" + "slices" + + "github.com/labstack/echo/v5" + + "github.com/blackbirdworks/gopherstack/pkgs/httputils" + "github.com/blackbirdworks/gopherstack/pkgs/restroute" + "github.com/blackbirdworks/gopherstack/pkgs/service" +) + +const ( + signingName = "route53-recovery-readiness" + uaMarker = "api/route53recoveryreadiness" + matchPriority = 87 + + pathCell = "/cells/{CellName}" + pathGroup = "/recoverygroups/{RecoveryGroupName}" + pathSet = "/resourcesets/{ResourceSetName}" + pathCheck = "/readinesschecks/{ReadinessCheckName}" + pathTags = "/tags/{ResourceArn}" +) + +// Handler serves the Route 53 Recovery Readiness API. +type Handler struct { + Backend *InMemoryBackend + server *restroute.Server + DefaultRegion string +} + +// NewHandler creates the handler. +func NewHandler(backend *InMemoryBackend) *Handler { + h := &Handler{Backend: backend} + h.server = &restroute.Server{Router: restroute.New(routes()), Ops: h.ops()} + + return h +} + +// Name returns the service name. +func (h *Handler) Name() string { return "Route53RecoveryReadiness" } + +// Reset clears all state. +func (h *Handler) Reset() { h.Backend.Reset() } + +// Snapshot implements persistence.Persistable. +func (h *Handler) Snapshot(ctx context.Context) []byte { return h.Backend.Snapshot(ctx) } + +// Restore implements persistence.Persistable. +func (h *Handler) Restore(ctx context.Context, data []byte) error { + return h.Backend.Restore(ctx, data) +} + +// GetSupportedOperations lists every implemented operation. +func (h *Handler) GetSupportedOperations() []string { + ops := make([]string, 0, len(h.server.Ops)) + for op := range h.server.Ops { + ops = append(ops, op) + } + + slices.Sort(ops) + + return ops +} + +// ChaosServiceName returns the lowercase service name for fault rules. +func (h *Handler) ChaosServiceName() string { return signingName } + +// ChaosOperations returns the fault-injectable operations. +func (h *Handler) ChaosOperations() []string { return h.GetSupportedOperations() } + +// ChaosRegions returns the regions served. +func (h *Handler) ChaosRegions() []string { return []string{h.DefaultRegion} } + +// RouteMatcher matches by SigV4 signing name (or SDK user agent) plus a known path shape. +func (h *Handler) RouteMatcher() service.Matcher { + return func(c *echo.Context) bool { + r := c.Request() + if !h.server.Router.Handles(restroute.EscapedPath(r)) { + return false + } + + return httputils.ExtractServiceFromRequest(r) == signingName || + service.MatchesUserAgentMarker(r.Header, uaMarker) + } +} + +// MatchPriority returns the routing priority. +func (h *Handler) MatchPriority() int { return matchPriority } + +// ExtractOperation returns the operation name for metrics. +func (h *Handler) ExtractOperation(c *echo.Context) string { + op, _ := h.server.Operation(c.Request()) + if op == "" { + return "Unknown" + } + + return op +} + +// ExtractResource returns the first path label for metrics. +func (h *Handler) ExtractResource(c *echo.Context) string { + _, params := h.server.Operation(c.Request()) + for _, v := range params { + return v + } + + return "" +} + +// Handler returns the Echo handler. +func (h *Handler) Handler() echo.HandlerFunc { return h.server.Handler() } + +func routes() []restroute.Route { + g, p, d, u := http.MethodGet, http.MethodPost, http.MethodDelete, http.MethodPut + + return []restroute.Route{ + {Method: p, Path: "/cells", Op: "CreateCell"}, + {Method: g, Path: "/cells", Op: "ListCells"}, + {Method: g, Path: pathCell, Op: "GetCell"}, + {Method: u, Path: pathCell, Op: "UpdateCell"}, + {Method: d, Path: pathCell, Op: "DeleteCell"}, + {Method: g, Path: "/cellreadiness/{CellName}", Op: "GetCellReadinessSummary"}, + {Method: p, Path: "/recoverygroups", Op: "CreateRecoveryGroup"}, + {Method: g, Path: "/recoverygroups", Op: "ListRecoveryGroups"}, + {Method: g, Path: pathGroup, Op: "GetRecoveryGroup"}, + {Method: u, Path: pathGroup, Op: "UpdateRecoveryGroup"}, + {Method: d, Path: pathGroup, Op: "DeleteRecoveryGroup"}, + { + Method: g, + Path: "/recoverygroups/{RecoveryGroupName}/architectureRecommendations", + Op: "GetArchitectureRecommendations", + }, + {Method: g, Path: "/recoverygroupreadiness/{RecoveryGroupName}", Op: "GetRecoveryGroupReadinessSummary"}, + {Method: p, Path: "/resourcesets", Op: "CreateResourceSet"}, + {Method: g, Path: "/resourcesets", Op: "ListResourceSets"}, + {Method: g, Path: pathSet, Op: "GetResourceSet"}, + {Method: u, Path: pathSet, Op: "UpdateResourceSet"}, + {Method: d, Path: pathSet, Op: "DeleteResourceSet"}, + {Method: p, Path: "/readinesschecks", Op: "CreateReadinessCheck"}, + {Method: g, Path: "/readinesschecks", Op: "ListReadinessChecks"}, + {Method: g, Path: pathCheck, Op: "GetReadinessCheck"}, + {Method: u, Path: pathCheck, Op: "UpdateReadinessCheck"}, + {Method: d, Path: pathCheck, Op: "DeleteReadinessCheck"}, + {Method: g, Path: "/readinesschecks/{ReadinessCheckName}/status", Op: "GetReadinessCheckStatus"}, + { + Method: g, Path: "/readinesschecks/{ReadinessCheckName}/resource/{ResourceIdentifier}/status", + Op: "GetReadinessCheckResourceStatus", + }, + {Method: p, Path: "/crossaccountauthorizations", Op: "CreateCrossAccountAuthorization"}, + {Method: g, Path: "/crossaccountauthorizations", Op: "ListCrossAccountAuthorizations"}, + { + Method: d, + Path: "/crossaccountauthorizations/{CrossAccountAuthorization}", + Op: "DeleteCrossAccountAuthorization", + }, + {Method: g, Path: "/rules", Op: "ListRules"}, + {Method: g, Path: pathTags, Op: "ListTagsForResources"}, + {Method: p, Path: pathTags, Op: "TagResource"}, + {Method: d, Path: pathTags, Op: "UntagResource"}, + } +} + +func (h *Handler) ops() map[string]restroute.OpFunc { + return map[string]restroute.OpFunc{ + "CreateCell": h.createCell, + "GetCell": h.getCell, + "ListCells": h.listCells, + "UpdateCell": h.updateCell, + "DeleteCell": h.deleteCell, + "GetCellReadinessSummary": h.cellSummary, + "CreateRecoveryGroup": h.createGroup, + "GetRecoveryGroup": h.getGroup, + "ListRecoveryGroups": h.listGroups, + "UpdateRecoveryGroup": h.updateGroup, + "DeleteRecoveryGroup": h.deleteGroup, + "GetArchitectureRecommendations": h.recommendations, + "GetRecoveryGroupReadinessSummary": h.groupSummary, + "CreateResourceSet": h.createSet, + "GetResourceSet": h.getSet, + "ListResourceSets": h.listSets, + "UpdateResourceSet": h.updateSet, + "DeleteResourceSet": h.deleteSet, + "CreateReadinessCheck": h.createCheck, + "GetReadinessCheck": h.getCheck, + "ListReadinessChecks": h.listChecks, + "UpdateReadinessCheck": h.updateCheck, + "DeleteReadinessCheck": h.deleteCheck, + "GetReadinessCheckStatus": h.checkStatus, + "GetReadinessCheckResourceStatus": h.resourceStatus, + "CreateCrossAccountAuthorization": h.createAuth, + "ListCrossAccountAuthorizations": h.listAuths, + "DeleteCrossAccountAuthorization": h.deleteAuth, + "ListRules": h.listRules, + "ListTagsForResources": h.listTags, + "TagResource": h.tagResource, + "UntagResource": h.untagResource, + } +} diff --git a/services/route53recoveryreadiness/handler_ops.go b/services/route53recoveryreadiness/handler_ops.go new file mode 100644 index 0000000000..c6d9fc4fdd --- /dev/null +++ b/services/route53recoveryreadiness/handler_ops.go @@ -0,0 +1,394 @@ +package route53recoveryreadiness + +import ( + "context" + + "github.com/blackbirdworks/gopherstack/pkgs/restroute" +) + +type listQuery struct { + token string + max int +} + +func listParams(req *restroute.Request) (listQuery, error) { + m, err := restroute.MaxResultsQuery(req.Query, "maxResults") + if err != nil { + return listQuery{}, err + } + + return listQuery{token: req.Query.Get("nextToken"), max: m}, nil +} + +func listOut[T any](key string, items []T, next string) map[string]any { + if items == nil { + items = []T{} + } + + out := map[string]any{key: items} + if next != "" { + out["nextToken"] = next + } + + return out +} + +type namedBody struct { + Tags map[string]string `json:"tags"` + CellName string `json:"cellName"` + RecoveryGroupName string `json:"recoveryGroupName"` + ResourceSetName string `json:"resourceSetName"` + ResourceSetType string `json:"resourceSetType"` + ReadinessCheckName string `json:"readinessCheckName"` + Cells []string `json:"cells"` + Resources []Resource `json:"resources"` +} + +func decodeBody(req *restroute.Request) (namedBody, error) { + var in namedBody + + return in, req.Decode(&in) +} + +func (h *Handler) createCell(_ context.Context, req *restroute.Request) (any, int, error) { + in, err := decodeBody(req) + if err != nil { + return nil, 0, err + } + + v, err := h.Backend.CreateCell(in.CellName, in.Cells, in.Tags) + + return v, 0, err +} + +func (h *Handler) getCell(_ context.Context, req *restroute.Request) (any, int, error) { + v, err := h.Backend.GetCell(req.Params["CellName"]) + + return v, 0, err +} + +func (h *Handler) listCells(_ context.Context, req *restroute.Request) (any, int, error) { + q, err := listParams(req) + if err != nil { + return nil, 0, err + } + + items, next, err := h.Backend.ListCells(q.token, q.max) + + return listOut("cells", items, next), 0, err +} + +func (h *Handler) updateCell(_ context.Context, req *restroute.Request) (any, int, error) { + in, err := decodeBody(req) + if err != nil { + return nil, 0, err + } + + v, err := h.Backend.UpdateCell(req.Params["CellName"], in.Cells) + + return v, 0, err +} + +func (h *Handler) deleteCell(_ context.Context, req *restroute.Request) (any, int, error) { + return nil, 0, h.Backend.DeleteCell(req.Params["CellName"]) +} + +func (h *Handler) summaryOut(readiness string, checks []CheckSummary, next string) map[string]any { + out := listOut("readinessChecks", checks, next) + out["readiness"] = readiness + + return out +} + +func (h *Handler) cellSummary(ctx context.Context, req *restroute.Request) (any, int, error) { + q, err := listParams(req) + if err != nil { + return nil, 0, err + } + + r, checks, next, err := h.Backend.CellReadinessSummary(ctx, req.Params["CellName"], q.token, q.max) + if err != nil { + return nil, 0, err + } + + return h.summaryOut(r, checks, next), 0, nil +} + +func (h *Handler) createGroup(_ context.Context, req *restroute.Request) (any, int, error) { + in, err := decodeBody(req) + if err != nil { + return nil, 0, err + } + + v, err := h.Backend.CreateRecoveryGroup(in.RecoveryGroupName, in.Cells, in.Tags) + + return v, 0, err +} + +func (h *Handler) getGroup(_ context.Context, req *restroute.Request) (any, int, error) { + v, err := h.Backend.GetRecoveryGroup(req.Params["RecoveryGroupName"]) + + return v, 0, err +} + +func (h *Handler) listGroups(_ context.Context, req *restroute.Request) (any, int, error) { + q, err := listParams(req) + if err != nil { + return nil, 0, err + } + + items, next, err := h.Backend.ListRecoveryGroups(q.token, q.max) + + return listOut("recoveryGroups", items, next), 0, err +} + +func (h *Handler) updateGroup(_ context.Context, req *restroute.Request) (any, int, error) { + in, err := decodeBody(req) + if err != nil { + return nil, 0, err + } + + v, err := h.Backend.UpdateRecoveryGroup(req.Params["RecoveryGroupName"], in.Cells) + + return v, 0, err +} + +func (h *Handler) deleteGroup(_ context.Context, req *restroute.Request) (any, int, error) { + return nil, 0, h.Backend.DeleteRecoveryGroup(req.Params["RecoveryGroupName"]) +} + +func (h *Handler) groupSummary(ctx context.Context, req *restroute.Request) (any, int, error) { + q, err := listParams(req) + if err != nil { + return nil, 0, err + } + + r, checks, next, err := h.Backend.RecoveryGroupReadinessSummary( + ctx, + req.Params["RecoveryGroupName"], + q.token, + q.max, + ) + if err != nil { + return nil, 0, err + } + + return h.summaryOut(r, checks, next), 0, nil +} + +func (h *Handler) recommendations(_ context.Context, req *restroute.Request) (any, int, error) { + q, err := listParams(req) + if err != nil { + return nil, 0, err + } + + at, texts, next, err := h.Backend.ArchitectureRecommendations(req.Params["RecoveryGroupName"], q.token, q.max) + if err != nil { + return nil, 0, err + } + + recs := make([]map[string]string, 0, len(texts)) + for _, t := range texts { + recs = append(recs, map[string]string{"recommendationText": t}) + } + + out := listOut("recommendations", recs, next) + out["lastAuditTimestamp"] = ts(at) + + return out, 0, nil +} + +func (h *Handler) createSet(_ context.Context, req *restroute.Request) (any, int, error) { + in, err := decodeBody(req) + if err != nil { + return nil, 0, err + } + + v, err := h.Backend.CreateResourceSet(in.ResourceSetName, in.ResourceSetType, in.Resources, in.Tags) + + return v, 0, err +} + +func (h *Handler) getSet(_ context.Context, req *restroute.Request) (any, int, error) { + v, err := h.Backend.GetResourceSet(req.Params["ResourceSetName"]) + + return v, 0, err +} + +func (h *Handler) listSets(_ context.Context, req *restroute.Request) (any, int, error) { + q, err := listParams(req) + if err != nil { + return nil, 0, err + } + + items, next, err := h.Backend.ListResourceSets(q.token, q.max) + + return listOut("resourceSets", items, next), 0, err +} + +func (h *Handler) updateSet(_ context.Context, req *restroute.Request) (any, int, error) { + in, err := decodeBody(req) + if err != nil { + return nil, 0, err + } + + v, err := h.Backend.UpdateResourceSet(req.Params["ResourceSetName"], in.ResourceSetType, in.Resources) + + return v, 0, err +} + +func (h *Handler) deleteSet(_ context.Context, req *restroute.Request) (any, int, error) { + return nil, 0, h.Backend.DeleteResourceSet(req.Params["ResourceSetName"]) +} + +func (h *Handler) createCheck(_ context.Context, req *restroute.Request) (any, int, error) { + in, err := decodeBody(req) + if err != nil { + return nil, 0, err + } + + v, err := h.Backend.CreateReadinessCheck(in.ReadinessCheckName, in.ResourceSetName, in.Tags) + + return v, 0, err +} + +func (h *Handler) getCheck(_ context.Context, req *restroute.Request) (any, int, error) { + v, err := h.Backend.GetReadinessCheck(req.Params["ReadinessCheckName"]) + + return v, 0, err +} + +func (h *Handler) listChecks(_ context.Context, req *restroute.Request) (any, int, error) { + q, err := listParams(req) + if err != nil { + return nil, 0, err + } + + items, next, err := h.Backend.ListReadinessChecks(q.token, q.max) + + return listOut("readinessChecks", items, next), 0, err +} + +func (h *Handler) updateCheck(_ context.Context, req *restroute.Request) (any, int, error) { + in, err := decodeBody(req) + if err != nil { + return nil, 0, err + } + + v, err := h.Backend.UpdateReadinessCheck(req.Params["ReadinessCheckName"], in.ResourceSetName) + + return v, 0, err +} + +func (h *Handler) deleteCheck(_ context.Context, req *restroute.Request) (any, int, error) { + return nil, 0, h.Backend.DeleteReadinessCheck(req.Params["ReadinessCheckName"]) +} + +func (h *Handler) checkStatus(ctx context.Context, req *restroute.Request) (any, int, error) { + q, err := listParams(req) + if err != nil { + return nil, 0, err + } + + r, msgs, res, next, err := h.Backend.ReadinessCheckStatus(ctx, req.Params["ReadinessCheckName"], q.token, q.max) + if err != nil { + return nil, 0, err + } + + out := listOut("resources", res, next) + out["readiness"] = r + + if len(msgs) > 0 { + out["messages"] = msgs + } + + return out, 0, nil +} + +func (h *Handler) resourceStatus(ctx context.Context, req *restroute.Request) (any, int, error) { + q, err := listParams(req) + if err != nil { + return nil, 0, err + } + + r, rules, next, err := h.Backend.ReadinessCheckResourceStatus( + ctx, req.Params["ReadinessCheckName"], req.Params["ResourceIdentifier"], q.token, q.max) + if err != nil { + return nil, 0, err + } + + out := listOut("rules", rules, next) + out["readiness"] = r + + return out, 0, nil +} + +func (h *Handler) createAuth(_ context.Context, req *restroute.Request) (any, int, error) { + var in struct { + CrossAccountAuthorization string `json:"crossAccountAuthorization"` + } + if err := req.Decode(&in); err != nil { + return nil, 0, err + } + + err := h.Backend.CreateCrossAccountAuthorization(in.CrossAccountAuthorization) + + return map[string]string{"crossAccountAuthorization": in.CrossAccountAuthorization}, 0, err +} + +func (h *Handler) listAuths(_ context.Context, req *restroute.Request) (any, int, error) { + q, err := listParams(req) + if err != nil { + return nil, 0, err + } + + items, next, err := h.Backend.ListCrossAccountAuthorizations(q.token, q.max) + + return listOut("crossAccountAuthorizations", items, next), 0, err +} + +func (h *Handler) deleteAuth(_ context.Context, req *restroute.Request) (any, int, error) { + return nil, 0, h.Backend.DeleteCrossAccountAuthorization(req.Params["CrossAccountAuthorization"]) +} + +func (h *Handler) listRules(_ context.Context, req *restroute.Request) (any, int, error) { + q, err := listParams(req) + if err != nil { + return nil, 0, err + } + + items, next, err := h.Backend.ListRules(req.Query.Get("resourceType"), q.token, q.max) + + return listOut("rules", items, next), 0, err +} + +func (h *Handler) listTags(_ context.Context, req *restroute.Request) (any, int, error) { + t, err := h.Backend.ListTagsForResource(req.Params["ResourceArn"]) + if t == nil { + t = map[string]string{} + } + + return map[string]any{"tags": t}, 0, err +} + +func (h *Handler) tagResource(_ context.Context, req *restroute.Request) (any, int, error) { + in, err := decodeBody(req) + if err != nil { + return nil, 0, err + } + + if len(in.Tags) == 0 { + return nil, 0, restroute.Validation("tags is required") + } + + return nil, 0, h.Backend.TagResource(req.Params["ResourceArn"], in.Tags) +} + +func (h *Handler) untagResource(_ context.Context, req *restroute.Request) (any, int, error) { + keys := req.Query["tagKeys"] + if len(keys) == 0 { + return nil, 0, restroute.Validation("tagKeys is required") + } + + return nil, 0, h.Backend.UntagResource(req.Params["ResourceArn"], keys) +} diff --git a/services/route53recoveryreadiness/leak_main_test.go b/services/route53recoveryreadiness/leak_main_test.go new file mode 100644 index 0000000000..d352ab44c2 --- /dev/null +++ b/services/route53recoveryreadiness/leak_main_test.go @@ -0,0 +1,9 @@ +package route53recoveryreadiness_test + +import ( + "testing" + + "github.com/blackbirdworks/gopherstack/pkgs/testleak" +) + +func TestMain(m *testing.M) { testleak.VerifyTestMain(m) } diff --git a/services/route53recoveryreadiness/misc_test.go b/services/route53recoveryreadiness/misc_test.go new file mode 100644 index 0000000000..50b1aa7d2f --- /dev/null +++ b/services/route53recoveryreadiness/misc_test.go @@ -0,0 +1,183 @@ +package route53recoveryreadiness_test + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/aws/aws-sdk-go-v2/aws" + sdk "github.com/aws/aws-sdk-go-v2/service/route53recoveryreadiness" + "github.com/aws/aws-sdk-go-v2/service/route53recoveryreadiness/types" + "github.com/labstack/echo/v5" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/blackbirdworks/gopherstack/pkgs/sdkcheck" + rr "github.com/blackbirdworks/gopherstack/services/route53recoveryreadiness" +) + +func TestSDKCompleteness(t *testing.T) { + t.Parallel() + + sdkcheck.CheckCompleteness( + t, + &sdk.Client{}, + rr.NewHandler(rr.NewInMemoryBackend(testAccount)).GetSupportedOperations(), + []string{}, + ) +} + +func TestPersistenceRoundTrip(t *testing.T) { + t.Parallel() + + f := newFixture(t) + ctx := t.Context() + + c, err := f.client.CreateCell( + ctx, + &sdk.CreateCellInput{CellName: aws.String("c"), Tags: map[string]string{"k": "v"}}, + ) + require.NoError(t, err) + _, err = f.client.CreateRecoveryGroup( + ctx, + &sdk.CreateRecoveryGroupInput{RecoveryGroupName: aws.String("g"), Cells: []string{aws.ToString(c.CellArn)}}, + ) + require.NoError(t, err) + rc := f.checkOf(t, "AWS::SQS::Queue", types.Resource{ResourceArn: aws.String(queueARN("q"))}) + _, err = f.client.CreateCrossAccountAuthorization( + ctx, + &sdk.CreateCrossAccountAuthorizationInput{CrossAccountAuthorization: aws.String("arn:aws:iam::9:root")}, + ) + require.NoError(t, err) + + snap := f.handler.Snapshot(ctx) + require.NotEmpty(t, snap) + + restored := rr.NewHandler(rr.NewInMemoryBackend(testAccount)) + require.NoError(t, restored.Restore(ctx, snap)) + + cell, err := restored.Backend.GetCell("c") + require.NoError(t, err) + assert.Equal(t, map[string]string{"k": "v"}, cell.Tags) + assert.Len(t, cell.ParentReadinessScopes, 1, "the parent link survives a restore") + + _, err = restored.Backend.GetReadinessCheck(rc) + require.NoError(t, err) + + auths, _, err := restored.Backend.ListCrossAccountAuthorizations("", 0) + require.NoError(t, err) + assert.Equal(t, []string{"arn:aws:iam::9:root"}, auths) + + require.NoError(t, restored.Restore(ctx, []byte(`{"version":999}`))) + _, err = restored.Backend.GetCell("c") + assert.Error(t, err) +} + +func TestReadinessWithoutSiblingsIsUnknown(t *testing.T) { + t.Parallel() + + b := rr.NewInMemoryBackend(testAccount) + _, err := b.CreateResourceSet("rs", "AWS::SQS::Queue", []rr.Resource{{ResourceArn: queueARN("q")}}, nil) + require.NoError(t, err) + _, err = b.CreateReadinessCheck("rc", "rs", nil) + require.NoError(t, err) + + readiness, _, resources, _, err := b.ReadinessCheckStatus(t.Context(), "rc", "", 0) + require.NoError(t, err) + assert.Equal(t, "UNKNOWN", readiness, "without the owning service there is no basis for READY") + require.Len(t, resources, 1) +} + +func TestRouting(t *testing.T) { + t.Parallel() + + h := rr.NewHandler(rr.NewInMemoryBackend(testAccount)) + + tests := []struct { + name, method, path, auth, wantOp string + wantMatch bool + }{ + {"create cell", http.MethodPost, "/cells", "route53-recovery-readiness", "CreateCell", true}, + { + "cell summary", + http.MethodGet, + "/cellreadiness/c1", + "route53-recovery-readiness", + "GetCellReadinessSummary", + true, + }, + { + "resource status", + http.MethodGet, + "/readinesschecks/rc/resource/arn%3Aaws%3Asqs%3A%3A1%3Aq/status", + "route53-recovery-readiness", + "GetReadinessCheckResourceStatus", + true, + }, + { + "group recommendations", + http.MethodGet, + "/recoverygroups/g/architectureRecommendations", + "route53-recovery-readiness", + "GetArchitectureRecommendations", + true, + }, + {"tags", http.MethodGet, "/tags/arn%3Aaws%3Ax", "route53-recovery-readiness", "ListTagsForResources", true}, + {"same path other service", http.MethodPost, "/cells", "route53-recovery-control-config", "", false}, + {"unknown path", http.MethodGet, "/nothing", "route53-recovery-readiness", "", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + req := httptest.NewRequestWithContext(t.Context(), tt.method, tt.path, nil) + req.Header.Set( + "Authorization", + "AWS4-HMAC-SHA256 Credential=test/20260101/us-east-1/"+tt.auth+"/aws4_request", + ) + c := echo.New().NewContext(req, httptest.NewRecorder()) + + assert.Equal(t, tt.wantMatch, h.RouteMatcher()(c)) + + if tt.wantMatch { + assert.Equal(t, tt.wantOp, h.ExtractOperation(c)) + } + }) + } +} + +func TestErrorBodyShape(t *testing.T) { + t.Parallel() + + h := rr.NewHandler(rr.NewInMemoryBackend(testAccount)) + + tests := []struct { + name, method, path, body, wantCode string + wantStatus int + }{ + {"not found", http.MethodGet, "/cells/none", "", "ResourceNotFoundException", http.StatusNotFound}, + {"bad json", http.MethodPost, "/cells", "{", "ValidationException", http.StatusBadRequest}, + {"unknown operation", http.MethodPost, "/", "{}", "UnknownOperationException", http.StatusNotFound}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + req := httptest.NewRequestWithContext(t.Context(), tt.method, tt.path, strings.NewReader(tt.body)) + rec := httptest.NewRecorder() + require.NoError(t, h.Handler()(echo.New().NewContext(req, rec))) + assert.Equal(t, tt.wantStatus, rec.Code) + assert.Equal(t, tt.wantCode, rec.Header().Get("X-Amzn-Errortype")) + + var body map[string]string + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &body)) + assert.NotEmpty(t, body["message"]) + assert.Equal(t, body["message"], body["Message"]) + assert.False(t, strings.HasPrefix(body["message"], tt.wantCode)) + }) + } +} diff --git a/services/route53recoveryreadiness/models.go b/services/route53recoveryreadiness/models.go new file mode 100644 index 0000000000..2b4827834a --- /dev/null +++ b/services/route53recoveryreadiness/models.go @@ -0,0 +1,105 @@ +package route53recoveryreadiness + +const ( + arnService = "route53-recovery-readiness" + + readinessReady = "READY" + readinessNotReady = "NOT_READY" + readinessUnknown = "UNKNOWN" + readinessNotAuthorized = "NOT_AUTHORIZED" + + typeDNSTarget = "AWS::Route53RecoveryReadiness::DNSTargetResource" + typeELBv2 = "AWS::ElasticLoadBalancingV2::LoadBalancer" + typeHealthCheck = "AWS::Route53::HealthCheck" + typeSQS = "AWS::SQS::Queue" + typeSNS = "AWS::SNS::Topic" + typeLambda = "AWS::Lambda::Function" + typeVPC = "AWS::EC2::VPC" + typeAlarm = "AWS::CloudWatch::Alarm" + typeTable = "AWS::DynamoDB::Table" + + typeAPIGWStage = "AWS::ApiGateway::Stage" + typeAPIGWV2Stage = "AWS::ApiGatewayV2::Stage" + typeASG = "AWS::AutoScaling::AutoScalingGroup" + typeCGW = "AWS::EC2::CustomerGateway" + typeVolume = "AWS::EC2::Volume" + typeELBClassic = "AWS::ElasticLoadBalancing::LoadBalancer" + typeMSK = "AWS::MSK::Cluster" + typeRDSCluster = "AWS::RDS::DBCluster" + typeSNSSub = "AWS::SNS::Subscription" + typeVPNConn = "AWS::EC2::VPNConnection" + typeVPNGateway = "AWS::EC2::VPNGateway" +) + +// Cell is a failover unit that may nest other cells. +type Cell struct { + Tags map[string]string `json:"tags,omitempty"` + ARN string `json:"arn"` + Name string `json:"name"` + Parent string `json:"parent,omitempty"` + Cells []string `json:"cells"` +} + +// RecoveryGroup is the top-level grouping of cells. +type RecoveryGroup struct { + Tags map[string]string `json:"tags,omitempty"` + ARN string `json:"arn"` + Name string `json:"name"` + Cells []string `json:"cells"` +} + +// NLBResource and R53Resource identify a DNS target. +type NLBResource struct { + Arn string `json:"arn,omitempty"` +} + +// R53Resource is a Route 53 record target. +type R53Resource struct { + DomainName string `json:"domainName,omitempty"` + RecordSetID string `json:"recordSetId,omitempty"` +} + +// TargetResource is the target of a DNS target resource. +type TargetResource struct { + NLB *NLBResource `json:"nLBResource,omitempty"` + R53 *R53Resource `json:"r53Resource,omitempty"` +} + +// DNSTarget describes a DNS record as a readiness resource. +type DNSTarget struct { + Target *TargetResource `json:"targetResource,omitempty"` + DomainName string `json:"domainName,omitempty"` + HostedZoneArn string `json:"hostedZoneArn,omitempty"` + RecordSetID string `json:"recordSetId,omitempty"` + RecordType string `json:"recordType,omitempty"` +} + +// Resource is one member of a resource set. +type Resource struct { + DNS *DNSTarget `json:"dnsTargetResource,omitempty"` + ComponentID string `json:"componentId,omitempty"` + ResourceArn string `json:"resourceArn,omitempty"` + ReadinessScopes []string `json:"readinessScopes,omitempty"` +} + +// ResourceSet is a typed collection of resources. +type ResourceSet struct { + Tags map[string]string `json:"tags,omitempty"` + ARN string `json:"arn"` + Name string `json:"name"` + Type string `json:"type"` + Resources []Resource `json:"resources"` +} + +// ReadinessCheck binds a resource set to continuous evaluation. +type ReadinessCheck struct { + Tags map[string]string `json:"tags,omitempty"` + ARN string `json:"arn"` + Name string `json:"name"` + ResourceSet string `json:"resourceSet"` +} + +// Authorization is a cross-account authorization string. +type Authorization struct { + Value string `json:"value"` +} diff --git a/services/route53recoveryreadiness/persistence.go b/services/route53recoveryreadiness/persistence.go new file mode 100644 index 0000000000..169f339698 --- /dev/null +++ b/services/route53recoveryreadiness/persistence.go @@ -0,0 +1,65 @@ +package route53recoveryreadiness + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/blackbirdworks/gopherstack/pkgs/lockmetrics" + "github.com/blackbirdworks/gopherstack/pkgs/logger" + "github.com/blackbirdworks/gopherstack/pkgs/persistence" +) + +const snapshotVersion = 1 + +type backendSnapshot struct { + Tables map[string]json.RawMessage `json:"tables"` + Version int `json:"version"` +} + +// Snapshot serialises the backend state. It implements persistence.Persistable. +func (b *InMemoryBackend) Snapshot(ctx context.Context) []byte { + b.mu.RLock("Snapshot") + defer b.mu.RUnlock() + + tables, err := b.registry.SnapshotAll() + if err != nil { + logger.Load(ctx).WarnContext(ctx, "route53recoveryreadiness: snapshot table marshal failed", "error", err) + + return nil + } + + return persistence.MarshalSnapshot( + ctx, + "route53recoveryreadiness", + backendSnapshot{Version: snapshotVersion, Tables: tables}, + ) +} + +// Restore loads a snapshot; an incompatible version starts empty. It implements persistence.Persistable. +func (b *InMemoryBackend) Restore(ctx context.Context, data []byte) error { + var snap backendSnapshot + + if err := persistence.UnmarshalSnapshot(ctx, "route53recoveryreadiness", data, &snap); err != nil { + return err + } + + if b.mu == nil { + b.mu = lockmetrics.New("route53recoveryreadiness") + } + + b.mu.Lock("Restore") + defer b.mu.Unlock() + + if snap.Version != snapshotVersion { + b.registry.ResetAll() + + return nil + } + + if err := b.registry.RestoreAll(snap.Tables); err != nil { + return fmt.Errorf("route53recoveryreadiness: restore snapshot tables: %w", err) + } + + return nil +} diff --git a/services/route53recoveryreadiness/provider.go b/services/route53recoveryreadiness/provider.go new file mode 100644 index 0000000000..5c31282064 --- /dev/null +++ b/services/route53recoveryreadiness/provider.go @@ -0,0 +1,36 @@ +// Package route53recoveryreadiness emulates Route 53 Application Recovery Controller readiness checks. +package route53recoveryreadiness + +import ( + "errors" + + "github.com/blackbirdworks/gopherstack/pkgs/service" +) + +// ErrNilAppContext is returned by Init when no AppContext is supplied. +var ErrNilAppContext = errors.New("route53recoveryreadiness: AppContext must not be nil") + +// Provider implements service.Provider. +type Provider struct{} + +// Name returns the provider name. +func (p *Provider) Name() string { return "Route53RecoveryReadiness" } + +// Init builds the backend and handler. +// +//nolint:ireturn,nolintlint // architecturally required to return interface +func (p *Provider) Init(ctx *service.AppContext) (service.Registerable, error) { + if ctx == nil { + return nil, ErrNilAppContext + } + + accountID, region := service.AccountRegionOrDefault(ctx) + + backend := NewInMemoryBackend(accountID) + backend.SetAppConfig(ctx.Config) + + handler := NewHandler(backend) + handler.DefaultRegion = region + + return handler, nil +} diff --git a/services/route53recoveryreadiness/readiness_test.go b/services/route53recoveryreadiness/readiness_test.go new file mode 100644 index 0000000000..54a456404c --- /dev/null +++ b/services/route53recoveryreadiness/readiness_test.go @@ -0,0 +1,398 @@ +package route53recoveryreadiness_test + +import ( + "testing" + + "github.com/aws/aws-sdk-go-v2/aws" + sdk "github.com/aws/aws-sdk-go-v2/service/route53recoveryreadiness" + "github.com/aws/aws-sdk-go-v2/service/route53recoveryreadiness/types" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + r53 "github.com/blackbirdworks/gopherstack/services/route53" + sqsbk "github.com/blackbirdworks/gopherstack/services/sqs" +) + +// checkOf creates a resource set of one type, a readiness check over it and returns the check name. +func (f *fixture) checkOf(t *testing.T, typ string, resources ...types.Resource) string { + t.Helper() + + ctx := t.Context() + _, err := f.client.CreateResourceSet(ctx, &sdk.CreateResourceSetInput{ + ResourceSetName: aws.String("rs"), ResourceSetType: aws.String(typ), Resources: resources, + }) + require.NoError(t, err) + _, err = f.client.CreateReadinessCheck( + ctx, + &sdk.CreateReadinessCheckInput{ReadinessCheckName: aws.String("rc"), ResourceSetName: aws.String("rs")}, + ) + require.NoError(t, err) + + return "rc" +} + +func (f *fixture) readiness(t *testing.T, check string) types.Readiness { + t.Helper() + + out, err := f.client.GetReadinessCheckStatus( + t.Context(), + &sdk.GetReadinessCheckStatusInput{ReadinessCheckName: aws.String(check)}, + ) + require.NoError(t, err) + + return out.Readiness +} + +func (f *fixture) rules(t *testing.T, check, resource string) map[string]types.Readiness { + t.Helper() + + out, err := f.client.GetReadinessCheckResourceStatus(t.Context(), &sdk.GetReadinessCheckResourceStatusInput{ + ReadinessCheckName: aws.String(check), ResourceIdentifier: aws.String(resource), + }) + require.NoError(t, err) + + got := map[string]types.Readiness{} + for _, r := range out.Rules { + got[aws.ToString(r.RuleId)] = r.Readiness + } + + return got +} + +func TestReadinessFollowsSiblingState(t *testing.T) { + t.Parallel() + + t.Run("sqs queue", func(t *testing.T) { + t.Parallel() + + f := newFixture(t) + q := queueARN("orders") + rc := f.checkOf(t, "AWS::SQS::Queue", types.Resource{ResourceArn: aws.String(q)}) + + assert.Equal(t, types.ReadinessNotReady, f.readiness(t, rc), "the queue does not exist yet") + assert.Equal(t, map[string]types.Readiness{"SqsQueueExists": types.ReadinessNotReady}, f.rules(t, rc, q)) + + _, err := f.sqs.CreateQueue(&sqsbk.CreateQueueInput{QueueName: "orders"}) + require.NoError(t, err) + + assert.Equal(t, types.ReadinessReady, f.readiness(t, rc)) + assert.Equal(t, map[string]types.Readiness{"SqsQueueExists": types.ReadinessReady}, f.rules(t, rc, q)) + }) + + t.Run("route53 health check", func(t *testing.T) { + t.Parallel() + + f := newFixture(t) + hc, err := f.r53.CreateHealthCheck( + "ref", + r53.HealthCheckConfig{Type: r53.HealthCheckTypeTCP, IPAddress: "192.0.2.1", Port: 80}, + ) + require.NoError(t, err) + + arn := "arn:aws:route53:::healthcheck/" + hc.ID + rc := f.checkOf(t, "AWS::Route53::HealthCheck", types.Resource{ResourceArn: aws.String(arn)}) + + assert.Equal(t, types.ReadinessReady, f.readiness(t, rc)) + + require.NoError(t, f.r53.SetHealthCheckStatus(hc.ID, "Unhealthy")) + assert.Equal(t, types.ReadinessNotReady, f.readiness(t, rc)) + assert.Equal(t, map[string]types.Readiness{ + "Route53HealthCheckExists": types.ReadinessReady, "Route53HealthCheckHealthy": types.ReadinessNotReady, + }, f.rules(t, rc, arn)) + }) + + t.Run("dns target record", func(t *testing.T) { + t.Parallel() + + f := newFixture(t) + zone, err := f.r53.CreateHostedZone("example.com.", "zref", "", false, "", "", "") + require.NoError(t, err) + + dns := &types.DNSTargetResource{ + DomainName: aws.String("app.example.com"), RecordType: aws.String("A"), + HostedZoneArn: aws.String("arn:aws:route53:::hostedzone/" + zone.ID), + } + rc := f.checkOf( + t, + "AWS::Route53RecoveryReadiness::DNSTargetResource", + types.Resource{DnsTargetResource: dns, ComponentId: aws.String("app")}, + ) + assert.Equal(t, types.ReadinessNotReady, f.readiness(t, rc)) + + _, err = f.r53.ChangeResourceRecordSets( + zone.ID, + []r53.Change{{Action: r53.ChangeActionCreate, ResourceRecordSet: r53.ResourceRecordSet{ + Name: "app.example.com.", Type: "A", TTL: 60, Records: []r53.ResourceRecord{{Value: "192.0.2.10"}}, + }}}, + ) + require.NoError(t, err) + + assert.Equal(t, types.ReadinessReady, f.readiness(t, rc)) + assert.Equal( + t, + map[string]types.Readiness{"DnsTargetRecordExists": types.ReadinessReady}, + f.rules(t, rc, "app"), + ) + }) + + t.Run("unreachable sibling is unknown", func(t *testing.T) { + t.Parallel() + + f := newFixture(t) + arn := "arn:aws:kafka:us-east-1:123456789012:cluster/c/1" + rc := f.checkOf(t, "AWS::MSK::Cluster", types.Resource{ResourceArn: aws.String(arn)}) + + out, err := f.client.GetReadinessCheckStatus( + t.Context(), + &sdk.GetReadinessCheckStatusInput{ReadinessCheckName: aws.String(rc)}, + ) + require.NoError(t, err) + assert.Equal(t, types.ReadinessUnknown, out.Readiness) + assert.Equal(t, map[string]types.Readiness{ + "MskClusterExists": types.ReadinessUnknown, "MskClusterActive": types.ReadinessUnknown, + }, f.rules(t, rc, arn)) + }) + + t.Run("cross account needs authorization", func(t *testing.T) { + t.Parallel() + + f := newFixture(t) + arn := "arn:aws:sqs:us-east-1:999999999999:theirs" + rc := f.checkOf(t, "AWS::SQS::Queue", types.Resource{ResourceArn: aws.String(arn)}) + + assert.Equal(t, types.ReadinessNotAuthorized, f.readiness(t, rc)) + + _, err := f.client.CreateCrossAccountAuthorization(t.Context(), &sdk.CreateCrossAccountAuthorizationInput{ + CrossAccountAuthorization: aws.String("arn:aws:iam::999999999999:root"), + }) + require.NoError(t, err) + + assert.Equal( + t, + types.ReadinessNotReady, + f.readiness(t, rc), + "authorized, but the queue is not in this emulator", + ) + }) + + t.Run("authorization must match the account exactly", func(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + auth string + want types.Readiness + }{ + {name: "longer account id", auth: "arn:aws:iam::1999999999999:root", want: types.ReadinessNotAuthorized}, + {name: "other account", auth: "arn:aws:iam::123456789012:root", want: types.ReadinessNotAuthorized}, + {name: "exact arn", auth: "arn:aws:iam::999999999999:root", want: types.ReadinessNotReady}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + f := newFixture(t) + arn := "arn:aws:sqs:us-east-1:999999999999:theirs" + rc := f.checkOf(t, "AWS::SQS::Queue", types.Resource{ResourceArn: aws.String(arn)}) + + _, err := f.client.CreateCrossAccountAuthorization( + t.Context(), + &sdk.CreateCrossAccountAuthorizationInput{CrossAccountAuthorization: aws.String(tt.auth)}, + ) + require.NoError(t, err) + assert.Equal(t, tt.want, f.readiness(t, rc)) + }) + } + }) + + t.Run("empty resource set is unknown", func(t *testing.T) { + t.Parallel() + + f := newFixture(t) + rc := f.checkOf(t, "AWS::SQS::Queue", []types.Resource{}...) + assert.Equal(t, types.ReadinessUnknown, f.readiness(t, rc)) + }) +} + +func TestReadinessSummaries(t *testing.T) { + t.Parallel() + + f := newFixture(t) + ctx := t.Context() + + c1, err := f.client.CreateCell(ctx, &sdk.CreateCellInput{CellName: aws.String("c1")}) + require.NoError(t, err) + c2, err := f.client.CreateCell(ctx, &sdk.CreateCellInput{CellName: aws.String("c2")}) + require.NoError(t, err) + g, err := f.client.CreateRecoveryGroup(ctx, &sdk.CreateRecoveryGroupInput{ + RecoveryGroupName: aws.String("g"), Cells: []string{aws.ToString(c1.CellArn), aws.ToString(c2.CellArn)}, + }) + require.NoError(t, err) + + for _, n := range []string{"q1", "q2"} { + scope := c1.CellArn + if n == "q2" { + scope = c2.CellArn + } + + _, err = f.client.CreateResourceSet(ctx, &sdk.CreateResourceSetInput{ + ResourceSetName: aws.String("rs_" + n), + ResourceSetType: aws.String("AWS::SQS::Queue"), + Resources: []types.Resource{ + {ResourceArn: aws.String(queueARN(n)), ReadinessScopes: []string{aws.ToString(scope)}}, + }, + }) + require.NoError(t, err) + _, err = f.client.CreateReadinessCheck( + ctx, + &sdk.CreateReadinessCheckInput{ + ReadinessCheckName: aws.String("rc_" + n), + ResourceSetName: aws.String("rs_" + n), + }, + ) + require.NoError(t, err) + } + + _, err = f.sqs.CreateQueue(&sqsbk.CreateQueueInput{QueueName: "q1"}) + require.NoError(t, err) + + cell1, err := f.client.GetCellReadinessSummary(ctx, &sdk.GetCellReadinessSummaryInput{CellName: aws.String("c1")}) + require.NoError(t, err) + assert.Equal(t, types.ReadinessReady, cell1.Readiness) + require.Len(t, cell1.ReadinessChecks, 1) + assert.Equal(t, "rc_q1", aws.ToString(cell1.ReadinessChecks[0].ReadinessCheckName)) + + cell2, err := f.client.GetCellReadinessSummary(ctx, &sdk.GetCellReadinessSummaryInput{CellName: aws.String("c2")}) + require.NoError(t, err) + assert.Equal(t, types.ReadinessNotReady, cell2.Readiness) + + group, err := f.client.GetRecoveryGroupReadinessSummary( + ctx, + &sdk.GetRecoveryGroupReadinessSummaryInput{RecoveryGroupName: g.RecoveryGroupName}, + ) + require.NoError(t, err) + assert.Equal(t, types.ReadinessNotReady, group.Readiness, "one not-ready check makes the group not ready") + assert.Len(t, group.ReadinessChecks, 2) + + _, err = f.sqs.CreateQueue(&sqsbk.CreateQueueInput{QueueName: "q2"}) + require.NoError(t, err) + + group, err = f.client.GetRecoveryGroupReadinessSummary( + ctx, + &sdk.GetRecoveryGroupReadinessSummaryInput{RecoveryGroupName: g.RecoveryGroupName}, + ) + require.NoError(t, err) + assert.Equal(t, types.ReadinessReady, group.Readiness) + + empty, err := f.client.CreateCell(ctx, &sdk.CreateCellInput{CellName: aws.String("empty")}) + require.NoError(t, err) + got, err := f.client.GetCellReadinessSummary(ctx, &sdk.GetCellReadinessSummaryInput{CellName: empty.CellName}) + require.NoError(t, err) + assert.Equal(t, types.ReadinessUnknown, got.Readiness, "no check covers the cell, so nothing supports READY") + + _, err = f.client.GetCellReadinessSummary(ctx, &sdk.GetCellReadinessSummaryInput{CellName: aws.String("nope")}) + assert.Equal(t, "ResourceNotFoundException", apiCode(err)) +} + +func TestArchitectureRecommendations(t *testing.T) { + t.Parallel() + + f := newFixture(t) + ctx := t.Context() + + c1, err := f.client.CreateCell(ctx, &sdk.CreateCellInput{CellName: aws.String("c1")}) + require.NoError(t, err) + _, err = f.client.CreateRecoveryGroup( + ctx, + &sdk.CreateRecoveryGroupInput{RecoveryGroupName: aws.String("g"), Cells: []string{aws.ToString(c1.CellArn)}}, + ) + require.NoError(t, err) + + texts := func() []string { + out, rErr := f.client.GetArchitectureRecommendations( + ctx, + &sdk.GetArchitectureRecommendationsInput{RecoveryGroupName: aws.String("g")}, + ) + require.NoError(t, rErr) + require.NotNil(t, out.LastAuditTimestamp) + + s := make([]string, 0, len(out.Recommendations)) + for _, r := range out.Recommendations { + s = append(s, aws.ToString(r.RecommendationText)) + } + + return s + } + + got := texts() + require.Len(t, got, 2) + assert.Contains(t, got[0], "single cell") + assert.Contains(t, got[1], "No resource set is scoped") + + c2, err := f.client.CreateCell(ctx, &sdk.CreateCellInput{CellName: aws.String("c2")}) + require.NoError(t, err) + _, err = f.client.UpdateRecoveryGroup(ctx, &sdk.UpdateRecoveryGroupInput{ + RecoveryGroupName: aws.String("g"), Cells: []string{aws.ToString(c1.CellArn), aws.ToString(c2.CellArn)}, + }) + require.NoError(t, err) + _, err = f.client.CreateResourceSet(ctx, &sdk.CreateResourceSetInput{ + ResourceSetName: aws.String("rs"), + ResourceSetType: aws.String("AWS::SQS::Queue"), + Resources: []types.Resource{ + {ResourceArn: aws.String(queueARN("q")), ReadinessScopes: []string{aws.ToString(c1.CellArn)}}, + }, + }) + require.NoError(t, err) + + got = texts() + require.Len(t, got, 2) + assert.Contains(t, got[0], "Resource set rs") + assert.Contains(t, got[0], "no readiness check") + assert.Contains(t, got[1], "AWS::SQS::Queue") + assert.Contains(t, got[1], aws.ToString(c2.CellArn)) + + _, err = f.client.GetArchitectureRecommendations( + ctx, + &sdk.GetArchitectureRecommendationsInput{RecoveryGroupName: aws.String("nope")}, + ) + assert.Equal(t, "ResourceNotFoundException", apiCode(err)) +} + +func TestListRules(t *testing.T) { + t.Parallel() + + f := newFixture(t) + + all, err := f.client.ListRules(t.Context(), &sdk.ListRulesInput{}) + require.NoError(t, err) + assert.GreaterOrEqual(t, len(all.Rules), 10) + + one, err := f.client.ListRules(t.Context(), &sdk.ListRulesInput{ResourceType: aws.String("AWS::SQS::Queue")}) + require.NoError(t, err) + require.Len(t, one.Rules, 1) + assert.Equal(t, "SqsQueueExists", aws.ToString(one.Rules[0].RuleId)) + assert.NotEmpty(t, aws.ToString(one.Rules[0].RuleDescription)) + + none, err := f.client.ListRules(t.Context(), &sdk.ListRulesInput{ResourceType: aws.String("AWS::Nope::Thing")}) + require.NoError(t, err) + assert.Empty(t, none.Rules) +} + +func TestResourceStatusUnknownIdentifier(t *testing.T) { + t.Parallel() + + f := newFixture(t) + rc := f.checkOf(t, "AWS::SQS::Queue", types.Resource{ResourceArn: aws.String(queueARN("q"))}) + + _, err := f.client.GetReadinessCheckResourceStatus(t.Context(), &sdk.GetReadinessCheckResourceStatusInput{ + ReadinessCheckName: aws.String(rc), ResourceIdentifier: aws.String("arn:aws:sqs:us-east-1:123456789012:other"), + }) + assert.Equal(t, "ResourceNotFoundException", apiCode(err)) + + _, err = f.client.GetReadinessCheckStatus( + t.Context(), + &sdk.GetReadinessCheckStatusInput{ReadinessCheckName: aws.String("none")}, + ) + assert.Equal(t, "ResourceNotFoundException", apiCode(err)) +} diff --git a/services/route53recoveryreadiness/recommendations.go b/services/route53recoveryreadiness/recommendations.go new file mode 100644 index 0000000000..d56531b819 --- /dev/null +++ b/services/route53recoveryreadiness/recommendations.go @@ -0,0 +1,154 @@ +package route53recoveryreadiness + +import ( + "slices" + "time" + + "github.com/blackbirdworks/gopherstack/pkgs/restroute" +) + +// ArchitectureRecommendations derives advice from the group's real cell, resource set and readiness check layout. +func (b *InMemoryBackend) ArchitectureRecommendations( + name, token string, + maxResults int, +) (time.Time, []string, string, error) { + b.mu.RLock("GetArchitectureRecommendations") + defer b.mu.RUnlock() + + g, err := b.getGroup(name) + if err != nil { + return time.Time{}, nil, "", err + } + + scopes := b.descendantScopes(g.ARN, g.Cells) + texts := b.recommendationTexts(g, scopes) + + pg, err := restroute.Paginate(texts, token, maxResults, listDefault, listMax) + if err != nil { + return time.Time{}, nil, "", err + } + + return time.Now().UTC(), append([]string{}, pg.Data...), pg.Next, nil +} + +func (b *InMemoryBackend) recommendationTexts(g *RecoveryGroup, scopes map[string]bool) []string { + var out []string + + switch len(g.Cells) { + case 0: + out = append( + out, + "Recovery group "+g.Name+" has no cells. Add at least two cells, one per failure domain, "+ + "so traffic has somewhere to fail over to.", + ) + case 1: + out = append( + out, + "Recovery group "+g.Name+" has a single cell. "+ + "Add a second cell in a different failure domain to have a failover target.", + ) + } + + typesByCell := map[string]map[string]bool{} + + for _, cellArn := range g.Cells { + typesByCell[cellArn] = map[string]bool{} + } + + checked := map[string]bool{} + for _, c := range b.checks.All() { + checked[c.ResourceSet] = true + } + + for _, s := range sorted(b.sets.All(), func(s *ResourceSet) string { return s.Name }) { + inGroup := false + + for _, r := range s.Resources { + for _, sc := range r.ReadinessScopes { + if !scopes[sc] { + continue + } + + inGroup = true + + if m, top := typesByCell[b.topCell(sc, g)]; top { + m[s.Type] = true + } + } + } + + if inGroup && !checked[s.Name] { + out = append( + out, + "Resource set "+s.Name+" is scoped to recovery group "+g.Name+ + " but has no readiness check; create one to evaluate its readiness.", + ) + } + } + + return append(out, b.coverageGaps(g, typesByCell)...) +} + +// topCell maps a scope ARN to the group's top-level cell that contains it ("" when outside the group). +func (b *InMemoryBackend) topCell(scope string, g *RecoveryGroup) string { + cur := scope + + for range len(g.Cells) + len(b.cells.All()) + 1 { + if slices.Contains(g.Cells, cur) { + return cur + } + + c, ok := b.cellByARN(cur) + if !ok || c.Parent == "" { + return "" + } + + cur = c.Parent + } + + return "" +} + +func (b *InMemoryBackend) coverageGaps(g *RecoveryGroup, typesByCell map[string]map[string]bool) []string { + all := map[string]bool{} + + for _, m := range typesByCell { + for t := range m { + all[t] = true + } + } + + var out []string + + if len(all) == 0 && len(g.Cells) > 0 { + return []string{ + "No resource set is scoped to recovery group " + g.Name + + ". Create resource sets whose resources list its cells in readinessScopes.", + } + } + + for _, t := range sortedKeys(all) { + for _, cellArn := range g.Cells { + if !typesByCell[cellArn][t] { + out = append( + out, + "Resource type "+t+" is present in other cells of "+g.Name+ + " but missing from cell "+cellArn+"; replicate it so the cells are symmetric.", + ) + } + } + } + + return out +} + +func sortedKeys(m map[string]bool) []string { + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + + slices.Sort(keys) + + return keys +} diff --git a/services/route53recoveryreadiness/resolver.go b/services/route53recoveryreadiness/resolver.go new file mode 100644 index 0000000000..3a65c5c709 --- /dev/null +++ b/services/route53recoveryreadiness/resolver.go @@ -0,0 +1,273 @@ +package route53recoveryreadiness + +import ( + "context" + "strings" + + awsarn "github.com/aws/aws-sdk-go-v2/aws/arn" + "github.com/aws/aws-sdk-go-v2/service/dynamodb" + + "github.com/blackbirdworks/gopherstack/pkgs/service" + cwbackend "github.com/blackbirdworks/gopherstack/services/cloudwatch" + ddbbackend "github.com/blackbirdworks/gopherstack/services/dynamodb" + ec2backend "github.com/blackbirdworks/gopherstack/services/ec2" + elbv2backend "github.com/blackbirdworks/gopherstack/services/elbv2" + lambdabackend "github.com/blackbirdworks/gopherstack/services/lambda" + r53backend "github.com/blackbirdworks/gopherstack/services/route53" + snsbackend "github.com/blackbirdworks/gopherstack/services/sns" + sqsbackend "github.com/blackbirdworks/gopherstack/services/sqs" +) + +// siblingServices is matched structurally against *CLI, which cannot be imported here. +type siblingServices interface { + GetCloudWatchHandler() service.Registerable + GetDynamoDBHandler() service.Registerable + GetEC2Handler() service.Registerable + GetELBv2Handler() service.Registerable + GetLambdaHandler() service.Registerable + GetRoute53Handler() service.Registerable + GetSNSHandler() service.Registerable + GetSQSHandler() service.Registerable +} + +// probe is the outcome of one sibling lookup: whether the sibling could answer, and what it found. +type probe struct { + detail string + answered bool + exists bool + healthy bool +} + +type resolver struct{ cfg any } + +func (r resolver) siblings() (siblingServices, bool) { + s, ok := r.cfg.(siblingServices) + + return s, ok +} + +func parseARN(a string) (awsarn.ARN, bool) { + p, err := awsarn.Parse(a) + + return p, err == nil +} + +// lookup dispatches on the resource type; unsupported types answer with answered=false. +func (r resolver) lookup(ctx context.Context, typ, resourceArn string) probe { + p, parsed := parseARN(resourceArn) + if !parsed { + return probe{answered: true, detail: "the resource ARN is not valid"} + } + + if pr, handled := r.lookupMore(ctx, typ, p, resourceArn); handled { + return pr + } + + s, ok := r.siblings() + if !ok { + return probe{} + } + + switch typ { + case typeELBv2: + return r.lookupELBv2(s, p, resourceArn) + case typeHealthCheck: + return r.lookupHealthCheck(s, p) + case typeSQS: + return r.lookupSQS(s, resourceArn) + case typeSNS: + return r.lookupSNS(s, resourceArn) + case typeLambda: + return r.lookupLambda(s, p) + case typeVPC: + return r.lookupVPC(s, p) + case typeAlarm: + return r.lookupAlarm(s, p) + case typeTable: + return r.lookupTable(ctx, s, p) + } + + return probe{} +} + +func (resolver) lookupELBv2(s siblingServices, p awsarn.ARN, full string) probe { + h, ok := s.GetELBv2Handler().(*elbv2backend.Handler) + if !ok || h == nil { + return probe{} + } + + lbs, err := h.BackendFor(p.Region).DescribeLoadBalancers([]string{full}, nil) + if err != nil || len(lbs) == 0 { + return probe{answered: true, detail: "the load balancer does not exist"} + } + + return probe{ + answered: true, + exists: true, + healthy: lbs[0].State.Code == "active", + detail: "load balancer state is " + lbs[0].State.Code, + } +} + +func (resolver) lookupHealthCheck(s siblingServices, p awsarn.ARN) probe { + h, ok := s.GetRoute53Handler().(*r53backend.Handler) + if !ok || h == nil { + return probe{} + } + + id := strings.TrimPrefix(p.Resource, "healthcheck/") + + status, err := h.Backend.GetHealthCheckStatus(id) + if err != nil { + return probe{answered: true, detail: "the health check does not exist"} + } + + return probe{answered: true, exists: true, healthy: status == "Healthy", detail: "health check status is " + status} +} + +func (resolver) lookupSQS(s siblingServices, full string) probe { + h, ok := s.GetSQSHandler().(*sqsbackend.Handler) + if !ok || h == nil { + return probe{} + } + + q, ok := h.Backend.(interface{ QueueExists(string) bool }) + if !ok { + return probe{} + } + + if !q.QueueExists(full) { + return probe{answered: true, detail: "the queue does not exist"} + } + + return probe{answered: true, exists: true, healthy: true} +} + +func (resolver) lookupSNS(s siblingServices, full string) probe { + h, ok := s.GetSNSHandler().(*snsbackend.Handler) + if !ok || h == nil { + return probe{} + } + + if _, err := h.Backend.GetTopicAttributes(full); err != nil { + return probe{answered: true, detail: "the topic does not exist"} + } + + return probe{answered: true, exists: true, healthy: true} +} + +func (resolver) lookupLambda(s siblingServices, p awsarn.ARN) probe { + h, ok := s.GetLambdaHandler().(*lambdabackend.Handler) + if !ok || h == nil { + return probe{} + } + + name := strings.TrimPrefix(p.Resource, "function:") + if i := strings.Index(name, ":"); i >= 0 { + name = name[:i] + } + + if _, err := h.BackendFor(p.Region).GetFunction(name); err != nil { + return probe{answered: true, detail: "the function does not exist"} + } + + return probe{answered: true, exists: true, healthy: true} +} + +func (resolver) lookupVPC(s siblingServices, p awsarn.ARN) probe { + h, ok := s.GetEC2Handler().(*ec2backend.Handler) + if !ok || h == nil { + return probe{} + } + + id := strings.TrimPrefix(p.Resource, "vpc/") + for _, v := range h.BackendFor(p.Region).DescribeVpcs([]string{id}) { + if v.ID == id { + return probe{answered: true, exists: true, healthy: true} + } + } + + return probe{answered: true, detail: "the VPC does not exist"} +} + +func (resolver) lookupAlarm(s siblingServices, p awsarn.ARN) probe { + h, ok := s.GetCloudWatchHandler().(*cwbackend.Handler) + if !ok || h == nil { + return probe{} + } + + name := strings.TrimPrefix(p.Resource, "alarm:") + + metric, _, _, err := h.BackendFor(p.Region).DescribeAlarms([]string{name}, nil, "", "", "", 0, "", "", "") + if err != nil || len(metric.Data) == 0 { + return probe{answered: true, detail: "the alarm does not exist"} + } + + state := metric.Data[0].StateValue + + return probe{answered: true, exists: true, healthy: state != "ALARM", detail: "alarm state is " + state} +} + +func (resolver) lookupTable(ctx context.Context, s siblingServices, p awsarn.ARN) probe { + h, ok := s.GetDynamoDBHandler().(*ddbbackend.DynamoDBHandler) + if !ok || h == nil { + return probe{} + } + + name := strings.TrimPrefix(p.Resource, "table/") + if i := strings.Index(name, "/"); i >= 0 { + name = name[:i] + } + + out, err := h.Backend.DescribeTable(ctx, &dynamodb.DescribeTableInput{TableName: &name}) + if err != nil || out == nil || out.Table == nil { + return probe{answered: true, detail: "the table does not exist"} + } + + status := string(out.Table.TableStatus) + + return probe{answered: true, exists: true, healthy: status == "ACTIVE", detail: "table status is " + status} +} + +func (b *InMemoryBackend) lookupDNS(d *DNSTarget) probe { + s, ok := resolver{cfg: b.appConfig}.siblings() + if !ok { + return probe{} + } + + h, ok := s.GetRoute53Handler().(*r53backend.Handler) + if !ok || h == nil { + return probe{} + } + + zone := d.HostedZoneArn + if i := strings.LastIndex(zone, "/"); i >= 0 { + zone = zone[i+1:] + } + + if zone == "" { + return probe{answered: true, detail: "the DNS target has no hosted zone"} + } + + page, err := h.Backend.ListResourceRecordSets(zone, "", "", "", dnsRecordScan) + if err != nil { + return probe{answered: true, detail: "the hosted zone does not exist"} + } + + want := strings.TrimSuffix(strings.ToLower(d.DomainName), ".") + + for _, rr := range page.Records { + if strings.TrimSuffix(strings.ToLower(rr.Name), ".") != want { + continue + } + + if (d.RecordType == "" || rr.Type == d.RecordType) && + (d.RecordSetID == "" || rr.SetIdentifier == d.RecordSetID) { + return probe{answered: true, exists: true, healthy: true} + } + } + + return probe{answered: true, detail: "no matching record set exists in the hosted zone"} +} + +const dnsRecordScan = 1000 diff --git a/services/route53recoveryreadiness/resolver_more.go b/services/route53recoveryreadiness/resolver_more.go new file mode 100644 index 0000000000..c577546fcf --- /dev/null +++ b/services/route53recoveryreadiness/resolver_more.go @@ -0,0 +1,292 @@ +package route53recoveryreadiness + +import ( + "context" + "strings" + + awsarn "github.com/aws/aws-sdk-go-v2/aws/arn" + + "github.com/blackbirdworks/gopherstack/pkgs/awsmeta" + "github.com/blackbirdworks/gopherstack/pkgs/service" + apigwbackend "github.com/blackbirdworks/gopherstack/services/apigateway" + apigwv2backend "github.com/blackbirdworks/gopherstack/services/apigatewayv2" + asgbackend "github.com/blackbirdworks/gopherstack/services/autoscaling" + ec2backend "github.com/blackbirdworks/gopherstack/services/ec2" + elbbackend "github.com/blackbirdworks/gopherstack/services/elb" + kafkabackend "github.com/blackbirdworks/gopherstack/services/kafka" + rdsbackend "github.com/blackbirdworks/gopherstack/services/rds" + snsbackend "github.com/blackbirdworks/gopherstack/services/sns" +) + +const ( + stateAvailable = "available" + stateInUse = "in-use" + stateActive = "ACTIVE" + lifecycleInSvc = "InService" + statusDeleting = "Delete in progress" + suffixAfterSlash = "/" +) + +// sibling resolves a sibling handler of type *H from cfg at call time; get picks the accessor off cfg. +func sibling[P any, H any](cfg any, get func(P) service.Registerable) (*H, bool) { + p, ok := cfg.(P) + if !ok { + return nil, false + } + + h, ok := any(get(p)).(*H) + + return h, ok && h != nil +} + +type ( + apigwSiblings interface{ GetAPIGatewayHandler() service.Registerable } + apigwV2Siblings interface{ GetAPIGatewayV2Handler() service.Registerable } + asgSiblings interface{ GetAutoscalingHandler() service.Registerable } + ec2Siblings interface{ GetEC2Handler() service.Registerable } + elbSiblings interface{ GetELBHandler() service.Registerable } + kafkaSiblings interface{ GetKafkaHandler() service.Registerable } + rdsSiblings interface{ GetRDSHandler() service.Registerable } + snsSiblings interface{ GetSNSHandler() service.Registerable } +) + +func missing(what string) probe { + return probe{answered: true, detail: "the " + what + " does not exist"} +} + +func found(healthy bool, detail string) probe { + return probe{answered: true, exists: true, healthy: healthy, detail: detail} +} + +// lookupMore dispatches the resource types that need a sibling beyond the original set. +func (r resolver) lookupMore(ctx context.Context, typ string, p awsarn.ARN, full string) (probe, bool) { + switch typ { + case typeAPIGWStage: + return r.lookupAPIGWStage(p), true + case typeAPIGWV2Stage: + return r.lookupAPIGWV2Stage(p), true + case typeASG: + return r.lookupASG(p), true + case typeCGW: + return r.lookupCustomerGateway(p), true + case typeVolume: + return r.lookupVolume(p), true + case typeVPNGateway: + return r.lookupVPNGateway(p), true + case typeVPNConn: + return r.lookupVPNConnection(p), true + case typeELBClassic: + return r.lookupClassicELB(ctx, p), true + case typeMSK: + return r.lookupMSK(ctx, full), true + case typeRDSCluster: + return r.lookupRDSCluster(p), true + case typeSNSSub: + return r.lookupSNSSubscription(full), true + default: + return probe{}, false + } +} + +func lastSegment(resource string) string { + return resource[strings.LastIndex(resource, suffixAfterSlash)+1:] +} + +func (r resolver) lookupAPIGWStage(p awsarn.ARN) probe { + h, ok := sibling[apigwSiblings, apigwbackend.Handler](r.cfg, apigwSiblings.GetAPIGatewayHandler) + if !ok { + return probe{} + } + + parts := strings.Split(strings.Trim(p.Resource, suffixAfterSlash), suffixAfterSlash) + if len(parts) != 4 || parts[0] != "restapis" || parts[2] != "stages" { + return probe{answered: true, detail: "the stage ARN is not valid"} + } + + if _, err := h.BackendFor(p.Region).GetStage(parts[1], parts[3]); err != nil { + return missing("stage") + } + + return found(true, "") +} + +func (r resolver) lookupAPIGWV2Stage(p awsarn.ARN) probe { + h, ok := sibling[apigwV2Siblings, apigwv2backend.Handler](r.cfg, apigwV2Siblings.GetAPIGatewayV2Handler) + if !ok { + return probe{} + } + + parts := strings.Split(strings.Trim(p.Resource, suffixAfterSlash), suffixAfterSlash) + if len(parts) != 4 || parts[0] != "apis" || parts[2] != "stages" { + return probe{answered: true, detail: "the stage ARN is not valid"} + } + + if _, err := h.BackendFor(p.Region).GetStage(parts[1], parts[3]); err != nil { + return missing("stage") + } + + return found(true, "") +} + +func (r resolver) lookupASG(p awsarn.ARN) probe { + h, ok := sibling[asgSiblings, asgbackend.Handler](r.cfg, asgSiblings.GetAutoscalingHandler) + if !ok { + return probe{} + } + + _, name, _ := strings.Cut(p.Resource, "autoScalingGroupName/") + + groups, err := h.BackendFor(p.Region).DescribeAutoScalingGroups([]string{name}, nil) + if err != nil || len(groups) == 0 { + return missing("Auto Scaling group") + } + + g := groups[0] + inService := int32(0) + + for i := range g.Instances { + if g.Instances[i].LifecycleState == lifecycleInSvc { + inService++ + } + } + + met := g.Status != statusDeleting && inService >= g.MinSize + + return found(met, "group status is "+g.Status) +} + +func (r resolver) ec2(region string) (ec2backend.Backend, bool) { + h, ok := sibling[ec2Siblings, ec2backend.Handler](r.cfg, ec2Siblings.GetEC2Handler) + if !ok { + return nil, false + } + + return h.BackendFor(region), true +} + +func (r resolver) lookupCustomerGateway(p awsarn.ARN) probe { + b, ok := r.ec2(p.Region) + if !ok { + return probe{} + } + + gws, err := b.DescribeCustomerGateways([]string{lastSegment(p.Resource)}) + if err != nil || len(gws) == 0 { + return missing("customer gateway") + } + + return found(gws[0].State == stateAvailable, "customer gateway state is "+gws[0].State) +} + +func (r resolver) lookupVolume(p awsarn.ARN) probe { + b, ok := r.ec2(p.Region) + if !ok { + return probe{} + } + + vols := b.DescribeVolumes([]string{lastSegment(p.Resource)}) + if len(vols) == 0 { + return missing("volume") + } + + state := vols[0].State + + return found(state == stateAvailable || state == stateInUse, "volume state is "+state) +} + +func (r resolver) lookupVPNGateway(p awsarn.ARN) probe { + b, ok := r.ec2(p.Region) + if !ok { + return probe{} + } + + id := lastSegment(p.Resource) + + for _, g := range b.DescribeVpnGateways([]string{id}) { + if g.VpnGatewayID == id { + return found(g.State == stateAvailable, "VPN gateway state is "+g.State) + } + } + + return missing("VPN gateway") +} + +func (r resolver) lookupVPNConnection(p awsarn.ARN) probe { + b, ok := r.ec2(p.Region) + if !ok { + return probe{} + } + + id := lastSegment(p.Resource) + + for _, c := range b.DescribeVpnConnections([]string{id}) { + if c.VpnConnectionID == id { + return found(c.State == stateAvailable, "VPN connection state is "+c.State) + } + } + + return missing("VPN connection") +} + +func (r resolver) lookupClassicELB(ctx context.Context, p awsarn.ARN) probe { + h, ok := sibling[elbSiblings, elbbackend.Handler](r.cfg, elbSiblings.GetELBHandler) + if !ok { + return probe{} + } + + name := lastSegment(p.Resource) + + lbs, err := h.Backend.DescribeLoadBalancers(awsmeta.WithRegion(ctx, p.Region), []string{name}) + if err != nil || len(lbs) == 0 { + return missing("load balancer") + } + + return found(true, "") +} + +func (r resolver) lookupMSK(ctx context.Context, full string) probe { + h, ok := sibling[kafkaSiblings, kafkabackend.Handler](r.cfg, kafkaSiblings.GetKafkaHandler) + if !ok { + return probe{} + } + + c, err := h.Backend.DescribeCluster(ctx, full) + if err != nil || c == nil { + return missing("cluster") + } + + return found(c.State == stateActive, "cluster state is "+c.State) +} + +func (r resolver) lookupRDSCluster(p awsarn.ARN) probe { + h, ok := sibling[rdsSiblings, rdsbackend.Handler](r.cfg, rdsSiblings.GetRDSHandler) + if !ok { + return probe{} + } + + id := strings.TrimPrefix(p.Resource, "cluster:") + + cs, err := h.BackendFor(p.Region).DescribeDBClusters(id) + if err != nil || len(cs) == 0 { + return missing("DB cluster") + } + + return found(cs[0].Status == stateAvailable, "DB cluster status is "+cs[0].Status) +} + +func (r resolver) lookupSNSSubscription(full string) probe { + h, ok := sibling[snsSiblings, snsbackend.Handler](r.cfg, snsSiblings.GetSNSHandler) + if !ok { + return probe{} + } + + attrs, err := h.Backend.GetSubscriptionAttributes(full) + if err != nil { + return missing("subscription") + } + + return found( + attrs["PendingConfirmation"] != "true", + "subscription pending confirmation is "+attrs["PendingConfirmation"], + ) +} diff --git a/services/route53recoveryreadiness/sets.go b/services/route53recoveryreadiness/sets.go new file mode 100644 index 0000000000..5f1033f4c7 --- /dev/null +++ b/services/route53recoveryreadiness/sets.go @@ -0,0 +1,207 @@ +package route53recoveryreadiness + +import ( + "slices" + "strings" + + "github.com/blackbirdworks/gopherstack/pkgs/restroute" +) + +// resourceSetTypes are the documented ResourceSetType values (route53recoveryreadiness api_op_CreateResourceSet.go). +func resourceSetTypes() []string { + return []string{ + typeAPIGWStage, typeAPIGWV2Stage, typeASG, + typeAlarm, typeCGW, typeTable, typeVolume, + typeELBClassic, typeELBv2, typeLambda, + typeMSK, typeRDSCluster, typeHealthCheck, typeSQS, typeSNS, + typeSNSSub, typeVPC, typeVPNConn, typeVPNGateway, typeDNSTarget, + } +} + +// SetView is the wire form of a resource set. +type SetView struct { + Tags map[string]string `json:"tags,omitempty"` + ResourceSetArn string `json:"resourceSetArn"` + ResourceSetName string `json:"resourceSetName"` + ResourceSetType string `json:"resourceSetType"` + Resources []Resource `json:"resources"` +} + +func setView(s *ResourceSet) SetView { + return SetView{ + ResourceSetArn: s.ARN, ResourceSetName: s.Name, ResourceSetType: s.Type, + Resources: slices.Clone(s.Resources), Tags: cloneTags(s.Tags), + } +} + +func normalizeResources(typ string, in []Resource) ([]Resource, error) { + out := make([]Resource, 0, len(in)) + ids := map[string]bool{} + + for i, raw := range in { + r, err := checkResource(typ, i, raw) + if err != nil { + return nil, err + } + + id := r.identifier() + if ids[id] { + return nil, restroute.Validation("Resource %s appears more than once", id) + } + + ids[id] = true + out = append(out, r) + } + + return out, nil +} + +// checkResource validates one resource against the set type and fills in a generated component ID for DNS targets. +func checkResource(typ string, i int, r Resource) (Resource, error) { + if r.DNS != nil && r.DNS.empty() { + r.DNS = nil + } + + hasArn, hasDNS := r.ResourceArn != "", r.DNS != nil + + switch { + case hasArn == hasDNS: + return r, restroute.Validation("Resource %d must set exactly one of resourceArn or dnsTargetResource", i) + case typ == typeDNSTarget && !hasDNS: + return r, restroute.Validation("Resources of type %s must set dnsTargetResource", typeDNSTarget) + case typ != typeDNSTarget && !hasArn: + return r, restroute.Validation("Resources of type %s must set resourceArn", typ) + case hasArn && !strings.HasPrefix(r.ResourceArn, "arn:"): + return r, restroute.Validation("resourceArn %q is not a valid ARN", r.ResourceArn) + case hasDNS && r.DNS.DomainName == "": + return r, restroute.Validation("dnsTargetResource.domainName is required") + } + + if r.ComponentID == "" && hasDNS { + r.ComponentID = r.DNS.DomainName + "-" + r.DNS.RecordType + "-" + r.DNS.RecordSetID + } + + return r, nil +} + +// empty reports a dnsTargetResource with no members set, which clients send for "no DNS target". +func (d *DNSTarget) empty() bool { + return d.DomainName == "" && d.HostedZoneArn == "" && d.RecordSetID == "" && d.RecordType == "" && + (d.Target == nil || (d.Target.NLB == nil && d.Target.R53 == nil)) +} + +func validateSetType(typ string) error { + if !slices.Contains(resourceSetTypes(), typ) { + return restroute.Validation("ResourceSetType %q is not supported", typ) + } + + return nil +} + +// CreateResourceSet creates a resource set. +func (b *InMemoryBackend) CreateResourceSet( + name, typ string, + resources []Resource, + tags map[string]string, +) (SetView, error) { + if err := validName("resource set", name); err != nil { + return SetView{}, err + } + + if err := validateSetType(typ); err != nil { + return SetView{}, err + } + + res, err := normalizeResources(typ, resources) + if err != nil { + return SetView{}, err + } + + b.mu.Lock("CreateResourceSet") + defer b.mu.Unlock() + + if b.sets.Has(name) { + return SetView{}, restroute.Conflict("Resource set %s already exists", name) + } + + s := &ResourceSet{ARN: b.arnFor("resource-set", name), Name: name, Type: typ, Resources: res, Tags: cloneTags(tags)} + b.sets.Put(s) + + return setView(s), nil +} + +// GetResourceSet returns a resource set. +func (b *InMemoryBackend) GetResourceSet(name string) (SetView, error) { + b.mu.RLock("GetResourceSet") + defer b.mu.RUnlock() + + s, err := b.getSet(name) + if err != nil { + return SetView{}, err + } + + return setView(s), nil +} + +// ListResourceSets lists resource sets. +func (b *InMemoryBackend) ListResourceSets(token string, maxResults int) ([]SetView, string, error) { + b.mu.RLock("ListResourceSets") + defer b.mu.RUnlock() + + pg, err := restroute.Paginate(sorted(b.sets.All(), func(s *ResourceSet) string { return s.Name }), + token, maxResults, listDefault, listMax) + if err != nil { + return nil, "", err + } + + out := make([]SetView, 0, len(pg.Data)) + for _, s := range pg.Data { + out = append(out, setView(s)) + } + + return out, pg.Next, nil +} + +// UpdateResourceSet replaces a resource set's type and resources. +func (b *InMemoryBackend) UpdateResourceSet(name, typ string, resources []Resource) (SetView, error) { + if err := validateSetType(typ); err != nil { + return SetView{}, err + } + + res, err := normalizeResources(typ, resources) + if err != nil { + return SetView{}, err + } + + b.mu.Lock("UpdateResourceSet") + defer b.mu.Unlock() + + s, err := b.getSet(name) + if err != nil { + return SetView{}, err + } + + s.Type, s.Resources = typ, res + + return setView(s), nil +} + +// DeleteResourceSet deletes a resource set no readiness check uses. +func (b *InMemoryBackend) DeleteResourceSet(name string) error { + b.mu.Lock("DeleteResourceSet") + defer b.mu.Unlock() + + if _, err := b.getSet(name); err != nil { + return err + } + + for _, c := range b.checks.All() { + if c.ResourceSet == name { + return restroute.Conflict("Resource set %s is used by readiness check %s", name, c.Name) + } + } + + b.sets.Delete(name) + + return nil +} diff --git a/services/route53recoveryreadiness/store.go b/services/route53recoveryreadiness/store.go new file mode 100644 index 0000000000..86db567a55 --- /dev/null +++ b/services/route53recoveryreadiness/store.go @@ -0,0 +1,136 @@ +package route53recoveryreadiness + +import ( + "maps" + "regexp" + "slices" + "sort" + + "github.com/blackbirdworks/gopherstack/pkgs/arn" + "github.com/blackbirdworks/gopherstack/pkgs/lockmetrics" + "github.com/blackbirdworks/gopherstack/pkgs/restroute" + "github.com/blackbirdworks/gopherstack/pkgs/store" +) + +const ( + listDefault = 100 + listMax = 1000 + maxTags = 50 +) + +var nameRE = regexp.MustCompile(`^[a-zA-Z0-9_]{1,64}$`) + +// InMemoryBackend holds Route 53 Recovery Readiness state. +type InMemoryBackend struct { + appConfig any + registry *store.Registry + cells *store.Table[Cell] + groups *store.Table[RecoveryGroup] + sets *store.Table[ResourceSet] + checks *store.Table[ReadinessCheck] + auths *store.Table[Authorization] + mu *lockmetrics.RWMutex + accountID string +} + +// NewInMemoryBackend creates an empty backend for accountID. +func NewInMemoryBackend(accountID string) *InMemoryBackend { + b := &InMemoryBackend{ + registry: store.NewRegistry(), + mu: lockmetrics.New("route53recoveryreadiness"), + accountID: accountID, + } + + b.cells = store.Register(b.registry, "cells", store.New(func(v *Cell) string { return v.Name })) + b.groups = store.Register(b.registry, "groups", store.New(func(v *RecoveryGroup) string { return v.Name })) + b.sets = store.Register(b.registry, "sets", store.New(func(v *ResourceSet) string { return v.Name })) + b.checks = store.Register(b.registry, "checks", store.New(func(v *ReadinessCheck) string { return v.Name })) + b.auths = store.Register(b.registry, "auths", store.New(func(v *Authorization) string { return v.Value })) + + return b +} + +// Reset clears all state. +func (b *InMemoryBackend) Reset() { + b.mu.Lock("Reset") + defer b.mu.Unlock() + + b.registry.ResetAll() +} + +// SetAppConfig records service.AppContext.Config so siblings are resolved at call time. +func (b *InMemoryBackend) SetAppConfig(cfg any) { b.appConfig = cfg } + +func (b *InMemoryBackend) arnFor(kind, name string) string { + return arn.BuildGlobal(arnService, "", b.accountID, kind+"/"+name) +} + +func validName(kind, name string) error { + if !nameRE.MatchString(name) { + return restroute.Validation("%s name must be 1-64 characters of letters, digits and underscores", kind) + } + + return nil +} + +func sorted[T any](items []*T, key func(*T) string) []*T { + out := slices.Clone(items) + sort.SliceStable(out, func(i, j int) bool { return key(out[i]) < key(out[j]) }) + + return out +} + +func cloneTags(t map[string]string) map[string]string { + if len(t) == 0 { + return nil + } + + return maps.Clone(t) +} + +func (b *InMemoryBackend) getCell(name string) (*Cell, error) { + c, ok := b.cells.Get(name) + if !ok { + return nil, restroute.NotFound("Cell %s was not found", name) + } + + return c, nil +} + +func (b *InMemoryBackend) getGroup(name string) (*RecoveryGroup, error) { + g, ok := b.groups.Get(name) + if !ok { + return nil, restroute.NotFound("Recovery group %s was not found", name) + } + + return g, nil +} + +func (b *InMemoryBackend) getSet(name string) (*ResourceSet, error) { + s, ok := b.sets.Get(name) + if !ok { + return nil, restroute.NotFound("Resource set %s was not found", name) + } + + return s, nil +} + +func (b *InMemoryBackend) getCheck(name string) (*ReadinessCheck, error) { + c, ok := b.checks.Get(name) + if !ok { + return nil, restroute.NotFound("Readiness check %s was not found", name) + } + + return c, nil +} + +// cellByARN resolves a cell ARN. +func (b *InMemoryBackend) cellByARN(a string) (*Cell, bool) { + for _, c := range b.cells.All() { + if c.ARN == a { + return c, true + } + } + + return nil, false +} diff --git a/services/route53recoveryreadiness/summary.go b/services/route53recoveryreadiness/summary.go new file mode 100644 index 0000000000..c0bbc4f07e --- /dev/null +++ b/services/route53recoveryreadiness/summary.go @@ -0,0 +1,273 @@ +package route53recoveryreadiness + +import ( + "context" + "slices" + "time" + + "github.com/blackbirdworks/gopherstack/pkgs/restroute" +) + +// Message is a readiness message. +type Message struct { + MessageText string `json:"messageText"` +} + +// ResourceResult is one resource's readiness in a check. +type ResourceResult struct { + LastCheckedTimestamp string `json:"lastCheckedTimestamp"` + Readiness string `json:"readiness"` + ComponentID string `json:"componentId,omitempty"` + ResourceArn string `json:"resourceArn,omitempty"` +} + +// RuleResult is one rule's readiness for a resource. +type RuleResult struct { + LastCheckedTimestamp string `json:"lastCheckedTimestamp"` + Readiness string `json:"readiness"` + RuleID string `json:"ruleId"` + Messages []Message `json:"messages"` +} + +// CheckSummary is one check's readiness in a cell or recovery group summary. +type CheckSummary struct { + Readiness string `json:"readiness"` + ReadinessCheckName string `json:"readinessCheckName"` +} + +// RuleInfo describes an evaluated rule. +type RuleInfo struct { + ResourceType string `json:"resourceType"` + RuleDescription string `json:"ruleDescription"` + RuleID string `json:"ruleId"` +} + +func ts(t time.Time) string { return t.UTC().Format(time.RFC3339) } + +// ReadinessCheckStatus evaluates every resource of the check now. +func (b *InMemoryBackend) ReadinessCheckStatus( + ctx context.Context, + name, token string, + maxResults int, +) (string, []Message, []ResourceResult, string, error) { + in, err := b.inputsFor(name) + if err != nil { + return "", nil, nil, "", err + } + + outs := b.evaluateAll(ctx, in) + + var msgs []Message + + if i := slices.IndexFunc(outs, func(o resourceOutcome) bool { return o.Note != "" }); i >= 0 { + msgs = append(msgs, Message{MessageText: outs[i].Note}) + } + + if len(outs) == 0 { + msgs = append(msgs, Message{MessageText: "the resource set has no resources to evaluate"}) + } + + pg, err := restroute.Paginate(outs, token, maxResults, listDefault, listMax) + if err != nil { + return "", nil, nil, "", err + } + + res := make([]ResourceResult, 0, len(pg.Data)) + for _, o := range pg.Data { + res = append(res, ResourceResult{ + LastCheckedTimestamp: ts( + o.At, + ), + Readiness: o.Readiness, + ComponentID: o.Resource.ComponentID, + ResourceArn: o.Resource.ResourceArn, + }) + } + + return readinessOf(outs), msgs, res, pg.Next, nil +} + +// ReadinessCheckResourceStatus evaluates one resource of the check, identified by ARN or component ID. +func (b *InMemoryBackend) ReadinessCheckResourceStatus( + ctx context.Context, name, identifier, token string, maxResults int, +) (string, []RuleResult, string, error) { + in, err := b.inputsFor(name) + if err != nil { + return "", nil, "", err + } + + idx := slices.IndexFunc(in.resources, func(r Resource) bool { return r.identifier() == identifier }) + if idx < 0 { + return "", nil, "", restroute.NotFound("Resource %s is not part of readiness check %s", identifier, name) + } + + in.resources = in.resources[idx : idx+1] + out := b.evaluateAll(ctx, in)[0] + + pg, err := restroute.Paginate(out.Rules, token, maxResults, listDefault, listMax) + if err != nil { + return "", nil, "", err + } + + rules := make([]RuleResult, 0, len(pg.Data)) + + for _, r := range pg.Data { + msgs := make([]Message, 0, len(r.Messages)) + for _, m := range r.Messages { + msgs = append(msgs, Message{MessageText: m}) + } + + rules = append( + rules, + RuleResult{LastCheckedTimestamp: ts(r.At), Readiness: r.Readiness, RuleID: r.ID, Messages: msgs}, + ) + } + + return out.Readiness, rules, pg.Next, nil +} + +// descendantScopes returns the ARN of a cell or group plus every cell below it. +func (b *InMemoryBackend) descendantScopes(root string, children []string) map[string]bool { + scopes := map[string]bool{root: true} + queue := slices.Clone(children) + + for len(queue) > 0 { + a := queue[0] + queue = queue[1:] + + if scopes[a] { + continue + } + + scopes[a] = true + + if c, ok := b.cellByARN(a); ok { + queue = append(queue, c.Cells...) + } + } + + return scopes +} + +type scopedInputs struct { + name string + in checkInputs +} + +// scopedChecks copies, under the lock, every check that has a resource scoped to one of scopes. +func (b *InMemoryBackend) scopedChecks(scopes map[string]bool) []scopedInputs { + var out []scopedInputs + + for _, c := range sorted(b.checks.All(), func(c *ReadinessCheck) string { return c.Name }) { + s, ok := b.sets.Get(c.ResourceSet) + if !ok { + continue + } + + in := checkInputs{typ: s.Type} + + for _, r := range s.Resources { + if slices.ContainsFunc(r.ReadinessScopes, func(sc string) bool { return scopes[sc] }) { + in.resources = append(in.resources, r) + } + } + + if len(in.resources) == 0 { + continue + } + + for _, a := range b.auths.All() { + in.auths = append(in.auths, a.Value) + } + + out = append(out, scopedInputs{name: c.Name, in: in}) + } + + return out +} + +func (b *InMemoryBackend) summarize( + ctx context.Context, + checks []scopedInputs, + token string, + maxResults int, +) (string, []CheckSummary, string, error) { + pg, err := restroute.Paginate(checks, token, maxResults, listDefault, listMax) + if err != nil { + return "", nil, "", err + } + + all := make([]string, 0, len(checks)) + page := make([]CheckSummary, 0, len(pg.Data)) + + for _, c := range checks { + all = append(all, readinessOf(b.evaluateAll(ctx, c.in))) + } + + for i := range pg.Data { + idx := slices.IndexFunc(checks, func(c scopedInputs) bool { return c.name == pg.Data[i].name }) + page = append(page, CheckSummary{Readiness: all[idx], ReadinessCheckName: pg.Data[i].name}) + } + + return combine(all), page, pg.Next, nil +} + +// CellReadinessSummary summarises the checks covering a cell and the cells below it. +func (b *InMemoryBackend) CellReadinessSummary( + ctx context.Context, + name, token string, + maxResults int, +) (string, []CheckSummary, string, error) { + b.mu.RLock("CellReadinessSummary") + + c, err := b.getCell(name) + if err != nil { + b.mu.RUnlock() + + return "", nil, "", err + } + + checks := b.scopedChecks(b.descendantScopes(c.ARN, c.Cells)) + b.mu.RUnlock() + + return b.summarize(ctx, checks, token, maxResults) +} + +// RecoveryGroupReadinessSummary summarises the checks covering a group and its cells. +func (b *InMemoryBackend) RecoveryGroupReadinessSummary( + ctx context.Context, + name, token string, + maxResults int, +) (string, []CheckSummary, string, error) { + b.mu.RLock("RecoveryGroupReadinessSummary") + + g, err := b.getGroup(name) + if err != nil { + b.mu.RUnlock() + + return "", nil, "", err + } + + checks := b.scopedChecks(b.descendantScopes(g.ARN, g.Cells)) + b.mu.RUnlock() + + return b.summarize(ctx, checks, token, maxResults) +} + +// ListRules lists the rules this emulator evaluates, optionally for one resource type. +func (b *InMemoryBackend) ListRules(resourceType, token string, maxResults int) ([]RuleInfo, string, error) { + var all []RuleInfo + + for _, d := range ruleCatalog() { + if resourceType == "" || d.Type == resourceType { + all = append(all, RuleInfo{ResourceType: d.Type, RuleDescription: d.Description, RuleID: d.ID}) + } + } + + pg, err := restroute.Paginate(all, token, maxResults, listDefault, listMax) + if err != nil { + return nil, "", err + } + + return append([]RuleInfo{}, pg.Data...), pg.Next, nil +} diff --git a/services/route53recoveryreadiness/tags.go b/services/route53recoveryreadiness/tags.go new file mode 100644 index 0000000000..766c6610ac --- /dev/null +++ b/services/route53recoveryreadiness/tags.go @@ -0,0 +1,136 @@ +package route53recoveryreadiness + +import ( + "maps" + + "github.com/blackbirdworks/gopherstack/pkgs/restroute" +) + +func (b *InMemoryBackend) tagsFor(arn string) (*map[string]string, error) { + for _, c := range b.cells.All() { + if c.ARN == arn { + return &c.Tags, nil + } + } + + for _, g := range b.groups.All() { + if g.ARN == arn { + return &g.Tags, nil + } + } + + for _, s := range b.sets.All() { + if s.ARN == arn { + return &s.Tags, nil + } + } + + for _, c := range b.checks.All() { + if c.ARN == arn { + return &c.Tags, nil + } + } + + return nil, restroute.NotFound("Resource %s was not found", arn) +} + +// ListTagsForResource returns a resource's tags. +func (b *InMemoryBackend) ListTagsForResource(arn string) (map[string]string, error) { + b.mu.RLock("ListTagsForResources") + defer b.mu.RUnlock() + + t, err := b.tagsFor(arn) + if err != nil { + return nil, err + } + + return maps.Clone(*t), nil +} + +// TagResource adds or overwrites tags. +func (b *InMemoryBackend) TagResource(arn string, tags map[string]string) error { + b.mu.Lock("TagResource") + defer b.mu.Unlock() + + t, err := b.tagsFor(arn) + if err != nil { + return err + } + + merged := maps.Clone(*t) + if merged == nil { + merged = map[string]string{} + } + + maps.Copy(merged, tags) + + if len(merged) > maxTags { + return restroute.Validation("A resource can have at most %d tags", maxTags) + } + + *t = merged + + return nil +} + +// UntagResource removes tags by key. +func (b *InMemoryBackend) UntagResource(arn string, keys []string) error { + b.mu.Lock("UntagResource") + defer b.mu.Unlock() + + t, err := b.tagsFor(arn) + if err != nil { + return err + } + + for _, k := range keys { + delete(*t, k) + } + + return nil +} + +// CreateCrossAccountAuthorization records an authorization (idempotent). +func (b *InMemoryBackend) CreateCrossAccountAuthorization(value string) error { + if value == "" { + return restroute.Validation("crossAccountAuthorization is required") + } + + b.mu.Lock("CreateCrossAccountAuthorization") + defer b.mu.Unlock() + + b.auths.Put(&Authorization{Value: value}) + + return nil +} + +// DeleteCrossAccountAuthorization removes an authorization. +func (b *InMemoryBackend) DeleteCrossAccountAuthorization(value string) error { + b.mu.Lock("DeleteCrossAccountAuthorization") + defer b.mu.Unlock() + + if !b.auths.Delete(value) { + return restroute.NotFound("Cross-account authorization %s was not found", value) + } + + return nil +} + +// ListCrossAccountAuthorizations lists authorizations. +func (b *InMemoryBackend) ListCrossAccountAuthorizations(token string, maxResults int) ([]string, string, error) { + b.mu.RLock("ListCrossAccountAuthorizations") + defer b.mu.RUnlock() + + pg, err := restroute.Paginate(sorted(b.auths.All(), func(a *Authorization) string { return a.Value }), + token, maxResults, listDefault, listMax) + if err != nil { + return nil, "", err + } + + out := make([]string, 0, len(pg.Data)) + for _, a := range pg.Data { + out = append(out, a.Value) + } + + return out, pg.Next, nil +} diff --git a/services/route53recoveryreadiness/topology_test.go b/services/route53recoveryreadiness/topology_test.go new file mode 100644 index 0000000000..46ea172bab --- /dev/null +++ b/services/route53recoveryreadiness/topology_test.go @@ -0,0 +1,463 @@ +package route53recoveryreadiness_test + +import ( + "testing" + + "github.com/aws/aws-sdk-go-v2/aws" + sdk "github.com/aws/aws-sdk-go-v2/service/route53recoveryreadiness" + "github.com/aws/aws-sdk-go-v2/service/route53recoveryreadiness/types" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCellAndRecoveryGroupHierarchy(t *testing.T) { + t.Parallel() + + tests := []struct { + run func(t *testing.T, f *fixture) + name string + }{ + {name: "nested cells record their parent", run: func(t *testing.T, f *fixture) { + t.Helper() + + ctx := t.Context() + child, err := f.client.CreateCell(ctx, &sdk.CreateCellInput{CellName: aws.String("child")}) + require.NoError(t, err) + assert.Equal(t, "arn:aws:route53-recovery-readiness::123456789012:cell/child", aws.ToString(child.CellArn)) + assert.Empty(t, child.ParentReadinessScopes) + + parent, err := f.client.CreateCell(ctx, &sdk.CreateCellInput{ + CellName: aws.String( + "parent", + ), + Cells: []string{aws.ToString(child.CellArn)}, + Tags: map[string]string{"t": "1"}, + }) + require.NoError(t, err) + assert.Equal(t, []string{aws.ToString(child.CellArn)}, parent.Cells) + + got, err := f.client.GetCell(ctx, &sdk.GetCellInput{CellName: aws.String("child")}) + require.NoError(t, err) + assert.Equal(t, []string{aws.ToString(parent.CellArn)}, got.ParentReadinessScopes) + + _, err = f.client.DeleteCell(ctx, &sdk.DeleteCellInput{CellName: aws.String("child")}) + assert.Equal(t, "ConflictException", apiCode(err), "a nested cell cannot be deleted") + + _, err = f.client.DeleteCell(ctx, &sdk.DeleteCellInput{CellName: aws.String("parent")}) + require.NoError(t, err) + + got, err = f.client.GetCell(ctx, &sdk.GetCellInput{CellName: aws.String("child")}) + require.NoError(t, err) + assert.Empty(t, got.ParentReadinessScopes, "deleting the parent frees the child") + }}, + {name: "a cell has one parent", run: func(t *testing.T, f *fixture) { + t.Helper() + + ctx := t.Context() + child, err := f.client.CreateCell(ctx, &sdk.CreateCellInput{CellName: aws.String("child")}) + require.NoError(t, err) + _, err = f.client.CreateCell( + ctx, + &sdk.CreateCellInput{CellName: aws.String("p1"), Cells: []string{aws.ToString(child.CellArn)}}, + ) + require.NoError(t, err) + _, err = f.client.CreateCell( + ctx, + &sdk.CreateCellInput{CellName: aws.String("p2"), Cells: []string{aws.ToString(child.CellArn)}}, + ) + assert.Equal(t, "ConflictException", apiCode(err)) + }}, + {name: "cycles are rejected", run: func(t *testing.T, f *fixture) { + t.Helper() + + ctx := t.Context() + a, err := f.client.CreateCell(ctx, &sdk.CreateCellInput{CellName: aws.String("a")}) + require.NoError(t, err) + b, err := f.client.CreateCell( + ctx, + &sdk.CreateCellInput{CellName: aws.String("b"), Cells: []string{aws.ToString(a.CellArn)}}, + ) + require.NoError(t, err) + _, err = f.client.UpdateCell( + ctx, + &sdk.UpdateCellInput{CellName: aws.String("a"), Cells: []string{aws.ToString(b.CellArn)}}, + ) + assert.Equal(t, "ValidationException", apiCode(err)) + _, err = f.client.UpdateCell( + ctx, + &sdk.UpdateCellInput{CellName: aws.String("a"), Cells: []string{aws.ToString(a.CellArn)}}, + ) + assert.Equal(t, "ValidationException", apiCode(err)) + }}, + {name: "update replaces the child list", run: func(t *testing.T, f *fixture) { + t.Helper() + + ctx := t.Context() + c1, _ := f.client.CreateCell(ctx, &sdk.CreateCellInput{CellName: aws.String("c1")}) + c2, _ := f.client.CreateCell(ctx, &sdk.CreateCellInput{CellName: aws.String("c2")}) + _, err := f.client.CreateCell( + ctx, + &sdk.CreateCellInput{CellName: aws.String("p"), Cells: []string{aws.ToString(c1.CellArn)}}, + ) + require.NoError(t, err) + + out, err := f.client.UpdateCell( + ctx, + &sdk.UpdateCellInput{CellName: aws.String("p"), Cells: []string{aws.ToString(c2.CellArn)}}, + ) + require.NoError(t, err) + assert.Equal(t, []string{aws.ToString(c2.CellArn)}, out.Cells) + + got, err := f.client.GetCell(ctx, &sdk.GetCellInput{CellName: aws.String("c1")}) + require.NoError(t, err) + assert.Empty(t, got.ParentReadinessScopes) + }}, + {name: "duplicate and invalid names and unknown children", run: func(t *testing.T, f *fixture) { + t.Helper() + + ctx := t.Context() + _, err := f.client.CreateCell(ctx, &sdk.CreateCellInput{CellName: aws.String("x")}) + require.NoError(t, err) + _, err = f.client.CreateCell(ctx, &sdk.CreateCellInput{CellName: aws.String("x")}) + assert.Equal(t, "ConflictException", apiCode(err)) + _, err = f.client.CreateCell(ctx, &sdk.CreateCellInput{CellName: aws.String("bad name!")}) + assert.Equal(t, "ValidationException", apiCode(err)) + _, err = f.client.CreateCell( + ctx, + &sdk.CreateCellInput{ + CellName: aws.String("y"), + Cells: []string{"arn:aws:route53-recovery-readiness::123456789012:cell/none"}, + }, + ) + assert.Equal(t, "ResourceNotFoundException", apiCode(err)) + _, err = f.client.GetCell(ctx, &sdk.GetCellInput{CellName: aws.String("none")}) + assert.Equal(t, "ResourceNotFoundException", apiCode(err)) + }}, + {name: "recovery group owns top-level cells", run: func(t *testing.T, f *fixture) { + t.Helper() + + ctx := t.Context() + c1, _ := f.client.CreateCell(ctx, &sdk.CreateCellInput{CellName: aws.String("c1")}) + c2, _ := f.client.CreateCell(ctx, &sdk.CreateCellInput{CellName: aws.String("c2")}) + + g, err := f.client.CreateRecoveryGroup(ctx, &sdk.CreateRecoveryGroupInput{ + RecoveryGroupName: aws.String("g"), Cells: []string{aws.ToString(c1.CellArn), aws.ToString(c2.CellArn)}, + }) + require.NoError(t, err) + assert.Equal( + t, + "arn:aws:route53-recovery-readiness::123456789012:recovery-group/g", + aws.ToString(g.RecoveryGroupArn), + ) + + got, err := f.client.GetCell(ctx, &sdk.GetCellInput{CellName: aws.String("c1")}) + require.NoError(t, err) + assert.Equal(t, []string{aws.ToString(g.RecoveryGroupArn)}, got.ParentReadinessScopes) + + _, err = f.client.CreateRecoveryGroup(ctx, &sdk.CreateRecoveryGroupInput{ + RecoveryGroupName: aws.String("g2"), Cells: []string{aws.ToString(c1.CellArn)}, + }) + assert.Equal(t, "ConflictException", apiCode(err)) + + upd, err := f.client.UpdateRecoveryGroup(ctx, &sdk.UpdateRecoveryGroupInput{ + RecoveryGroupName: aws.String("g"), Cells: []string{aws.ToString(c2.CellArn)}, + }) + require.NoError(t, err) + assert.Equal(t, []string{aws.ToString(c2.CellArn)}, upd.Cells) + + _, err = f.client.DeleteCell(ctx, &sdk.DeleteCellInput{CellName: aws.String("c2")}) + assert.Equal(t, "ConflictException", apiCode(err)) + + _, err = f.client.DeleteRecoveryGroup( + ctx, + &sdk.DeleteRecoveryGroupInput{RecoveryGroupName: aws.String("g")}, + ) + require.NoError(t, err) + _, err = f.client.DeleteCell(ctx, &sdk.DeleteCellInput{CellName: aws.String("c2")}) + require.NoError(t, err) + }}, + {name: "lists paginate and sort", run: func(t *testing.T, f *fixture) { + t.Helper() + + ctx := t.Context() + for _, n := range []string{"c3", "c1", "c2"} { + _, err := f.client.CreateCell(ctx, &sdk.CreateCellInput{CellName: aws.String(n)}) + require.NoError(t, err) + } + + p := sdk.NewListCellsPaginator(f.client, &sdk.ListCellsInput{MaxResults: aws.Int32(2)}) + var names []string + + for p.HasMorePages() { + pg, err := p.NextPage(ctx) + require.NoError(t, err) + + for _, c := range pg.Cells { + names = append(names, aws.ToString(c.CellName)) + } + } + + assert.Equal(t, []string{"c1", "c2", "c3"}, names) + + _, err := f.client.ListCells(ctx, &sdk.ListCellsInput{NextToken: aws.String("!!not-a-token")}) + assert.Equal(t, "ValidationException", apiCode(err)) + }}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + tt.run(t, newFixture(t)) + }) + } +} + +func TestResourceSetsAndChecks(t *testing.T) { + t.Parallel() + + arnRes := func(a string) []types.Resource { return []types.Resource{{ResourceArn: aws.String(a)}} } + + tests := []struct { + run func(t *testing.T, f *fixture) + name string + }{ + {name: "resource set round trip", run: func(t *testing.T, f *fixture) { + t.Helper() + + ctx := t.Context() + out, err := f.client.CreateResourceSet(ctx, &sdk.CreateResourceSetInput{ + ResourceSetName: aws.String("rs"), ResourceSetType: aws.String("AWS::SQS::Queue"), + Resources: []types.Resource{{ResourceArn: aws.String(queueARN("q")), ReadinessScopes: []string{"s"}}}, + Tags: map[string]string{"k": "v"}, + }) + require.NoError(t, err) + assert.Equal( + t, + "arn:aws:route53-recovery-readiness::123456789012:resource-set/rs", + aws.ToString(out.ResourceSetArn), + ) + + got, err := f.client.GetResourceSet(ctx, &sdk.GetResourceSetInput{ResourceSetName: aws.String("rs")}) + require.NoError(t, err) + require.Len(t, got.Resources, 1) + assert.Equal(t, queueARN("q"), aws.ToString(got.Resources[0].ResourceArn)) + assert.Equal(t, []string{"s"}, got.Resources[0].ReadinessScopes) + + upd, err := f.client.UpdateResourceSet(ctx, &sdk.UpdateResourceSetInput{ + ResourceSetName: aws.String( + "rs", + ), + ResourceSetType: aws.String("AWS::SNS::Topic"), + Resources: arnRes("arn:aws:sns:us-east-1:123456789012:t"), + }) + require.NoError(t, err) + assert.Equal(t, "AWS::SNS::Topic", aws.ToString(upd.ResourceSetType)) + + list, err := f.client.ListResourceSets(ctx, &sdk.ListResourceSetsInput{}) + require.NoError(t, err) + assert.Len(t, list.ResourceSets, 1) + }}, + {name: "resource set validation", run: func(t *testing.T, f *fixture) { + t.Helper() + + ctx := t.Context() + bad := []*sdk.CreateResourceSetInput{ + { + ResourceSetName: aws.String("a"), + ResourceSetType: aws.String("AWS::Nope::Thing"), + Resources: arnRes("arn:aws:x:::y"), + }, + { + ResourceSetName: aws.String("b"), + ResourceSetType: aws.String("AWS::SQS::Queue"), + Resources: arnRes("not-an-arn"), + }, + { + ResourceSetName: aws.String("c"), + ResourceSetType: aws.String("AWS::SQS::Queue"), + Resources: []types.Resource{ + {ResourceArn: aws.String(queueARN("q"))}, {ResourceArn: aws.String(queueARN("q"))}, + }, + }, + { + ResourceSetName: aws.String("d"), + ResourceSetType: aws.String("AWS::Route53RecoveryReadiness::DNSTargetResource"), + Resources: arnRes(queueARN("q")), + }, + { + ResourceSetName: aws.String("e"), + ResourceSetType: aws.String("AWS::SQS::Queue"), + Resources: []types.Resource{ + {DnsTargetResource: &types.DNSTargetResource{DomainName: aws.String("x.example.com")}}, + }, + }, + } + for _, in := range bad { + _, err := f.client.CreateResourceSet(ctx, in) + assert.Equal(t, "ValidationException", apiCode(err), aws.ToString(in.ResourceSetName)) + } + }}, + {name: "dns target gets a component id", run: func(t *testing.T, f *fixture) { + t.Helper() + + out, err := f.client.CreateResourceSet(t.Context(), &sdk.CreateResourceSetInput{ + ResourceSetName: aws.String( + "dns", + ), + ResourceSetType: aws.String("AWS::Route53RecoveryReadiness::DNSTargetResource"), + Resources: []types.Resource{{DnsTargetResource: &types.DNSTargetResource{ + DomainName: aws.String( + "app.example.com", + ), + RecordType: aws.String("A"), + RecordSetId: aws.String("primary"), + HostedZoneArn: aws.String("arn:aws:route53:::hostedzone/Z1"), + }}}, + }) + require.NoError(t, err) + assert.NotEmpty(t, aws.ToString(out.Resources[0].ComponentId)) + }}, + {name: "readiness check lifecycle", run: func(t *testing.T, f *fixture) { + t.Helper() + + ctx := t.Context() + for _, n := range []string{"rs1", "rs2"} { + _, err := f.client.CreateResourceSet(ctx, &sdk.CreateResourceSetInput{ + ResourceSetName: aws.String( + n, + ), + ResourceSetType: aws.String("AWS::SQS::Queue"), + Resources: arnRes(queueARN(n)), + }) + require.NoError(t, err) + } + + _, err := f.client.CreateReadinessCheck( + ctx, + &sdk.CreateReadinessCheckInput{ + ReadinessCheckName: aws.String("rc"), + ResourceSetName: aws.String("nope"), + }, + ) + assert.Equal(t, "ResourceNotFoundException", apiCode(err)) + + out, err := f.client.CreateReadinessCheck( + ctx, + &sdk.CreateReadinessCheckInput{ + ReadinessCheckName: aws.String("rc"), + ResourceSetName: aws.String("rs1"), + }, + ) + require.NoError(t, err) + assert.Equal(t, "rs1", aws.ToString(out.ResourceSet)) + + _, err = f.client.CreateReadinessCheck( + ctx, + &sdk.CreateReadinessCheckInput{ + ReadinessCheckName: aws.String("rc"), + ResourceSetName: aws.String("rs1"), + }, + ) + assert.Equal(t, "ConflictException", apiCode(err)) + + _, err = f.client.DeleteResourceSet(ctx, &sdk.DeleteResourceSetInput{ResourceSetName: aws.String("rs1")}) + assert.Equal(t, "ConflictException", apiCode(err), "a resource set used by a check cannot be deleted") + + upd, err := f.client.UpdateReadinessCheck( + ctx, + &sdk.UpdateReadinessCheckInput{ + ReadinessCheckName: aws.String("rc"), + ResourceSetName: aws.String("rs2"), + }, + ) + require.NoError(t, err) + assert.Equal(t, "rs2", aws.ToString(upd.ResourceSet)) + + list, err := f.client.ListReadinessChecks(ctx, &sdk.ListReadinessChecksInput{}) + require.NoError(t, err) + assert.Len(t, list.ReadinessChecks, 1) + + _, err = f.client.DeleteReadinessCheck( + ctx, + &sdk.DeleteReadinessCheckInput{ReadinessCheckName: aws.String("rc")}, + ) + require.NoError(t, err) + _, err = f.client.DeleteResourceSet(ctx, &sdk.DeleteResourceSetInput{ResourceSetName: aws.String("rs1")}) + require.NoError(t, err) + }}, + {name: "tags", run: func(t *testing.T, f *fixture) { + t.Helper() + + ctx := t.Context() + c, err := f.client.CreateCell( + ctx, + &sdk.CreateCellInput{CellName: aws.String("c"), Tags: map[string]string{"a": "1"}}, + ) + require.NoError(t, err) + + _, err = f.client.TagResource( + ctx, + &sdk.TagResourceInput{ResourceArn: c.CellArn, Tags: map[string]string{"b": "2"}}, + ) + require.NoError(t, err) + _, err = f.client.UntagResource( + ctx, + &sdk.UntagResourceInput{ResourceArn: c.CellArn, TagKeys: []string{"a"}}, + ) + require.NoError(t, err) + + out, err := f.client.ListTagsForResources(ctx, &sdk.ListTagsForResourcesInput{ResourceArn: c.CellArn}) + require.NoError(t, err) + assert.Equal(t, map[string]string{"b": "2"}, out.Tags) + + _, err = f.client.TagResource( + ctx, + &sdk.TagResourceInput{ + ResourceArn: aws.String("arn:aws:route53-recovery-readiness::123456789012:cell/none"), + Tags: map[string]string{"x": "y"}, + }, + ) + assert.Equal(t, "ResourceNotFoundException", apiCode(err)) + }}, + {name: "cross account authorizations", run: func(t *testing.T, f *fixture) { + t.Helper() + + ctx := t.Context() + for range 2 { + _, err := f.client.CreateCrossAccountAuthorization( + ctx, + &sdk.CreateCrossAccountAuthorizationInput{ + CrossAccountAuthorization: aws.String("arn:aws:iam::999999999999:root"), + }, + ) + require.NoError(t, err) + } + + list, err := f.client.ListCrossAccountAuthorizations(ctx, &sdk.ListCrossAccountAuthorizationsInput{}) + require.NoError(t, err) + assert.Equal(t, []string{"arn:aws:iam::999999999999:root"}, list.CrossAccountAuthorizations) + + _, err = f.client.DeleteCrossAccountAuthorization( + ctx, + &sdk.DeleteCrossAccountAuthorizationInput{ + CrossAccountAuthorization: aws.String("arn:aws:iam::999999999999:root"), + }, + ) + require.NoError(t, err) + _, err = f.client.DeleteCrossAccountAuthorization( + ctx, + &sdk.DeleteCrossAccountAuthorizationInput{ + CrossAccountAuthorization: aws.String("arn:aws:iam::999999999999:root"), + }, + ) + assert.Equal(t, "ResourceNotFoundException", apiCode(err)) + }}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + tt.run(t, newFixture(t)) + }) + } +} diff --git a/services/sns/delivery_test.go b/services/sns/delivery_test.go index f5337a227a..9f84c44f05 100644 --- a/services/sns/delivery_test.go +++ b/services/sns/delivery_test.go @@ -383,8 +383,15 @@ func TestHTTPSSubscriptionDelivery(t *testing.T) { })) defer ts.Close() + client := ts.Client() + tr, ok := client.Transport.(*http.Transport) + require.True(t, ok) + + tr.DisableKeepAlives = true + t.Cleanup(tr.CloseIdleConnections) + b := newA1679Backend(t) - b.SetHTTPDeliveryClient(ts.Client()) + b.SetHTTPDeliveryClient(client) tp, err := b.CreateTopic("https-topic", nil) require.NoError(t, err) diff --git a/test/e2e/arc_test.go b/test/e2e/arc_test.go new file mode 100644 index 0000000000..a71664c841 --- /dev/null +++ b/test/e2e/arc_test.go @@ -0,0 +1,63 @@ +//go:build e2e + +package e2e_test + +import ( + "net/http/httptest" + "testing" + + "github.com/mxschmitt/playwright-go" + "github.com/stretchr/testify/require" +) + +// TestARCDashboards verifies each Application Recovery Controller dashboard page renders its header and tabs. +func TestARCDashboards(t *testing.T) { + tests := []struct { + name string + route string + heading string + tab string + }{ + {"recovery_control", "route53recoverycontrolconfig", "Route 53 Recovery Control", "Safety Rules"}, + {"recovery_cluster", "route53recoverycluster", "Route 53 Recovery Cluster", ""}, + {"recovery_readiness", "route53recoveryreadiness", "Route 53 Recovery Readiness", "Readiness Checks"}, + {"zonal_shift", "arczonalshift", "ARC Zonal Shift", "Practice Runs"}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + stack := newStack(t) + + server := httptest.NewServer(stack.Echo) + defer server.Close() + + context, err := browser.NewContext() + require.NoError(t, err) + defer context.Close() + + page, err := context.NewPage() + require.NoError(t, err) + defer page.Close() + + defer func() { + if t.Failed() { + saveScreenshot(t, page, "TestARCDashboards_"+tc.name) + } + }() + + _, err = page.Goto(server.URL + "/dashboard/" + tc.route) + require.NoError(t, err) + + visible := playwright.LocatorWaitForOptions{ + State: playwright.WaitForSelectorStateVisible, + Timeout: playwright.Float(60000), + } + + require.NoError(t, page.Locator("h1:has-text('"+tc.heading+"')").WaitFor(visible)) + + if tc.tab != "" { + require.NoError(t, page.Locator("[role=tab]:has-text('"+tc.tab+"')").WaitFor(visible)) + } + }) + } +} diff --git a/test/terraform/arc_recovery_test.go b/test/terraform/arc_recovery_test.go new file mode 100644 index 0000000000..de73d46a4b --- /dev/null +++ b/test/terraform/arc_recovery_test.go @@ -0,0 +1,507 @@ +package terraform_test + +import ( + "context" + "testing" + + "github.com/aws/aws-sdk-go-v2/aws" + arczonalshiftsvc "github.com/aws/aws-sdk-go-v2/service/arczonalshift" + arczstypes "github.com/aws/aws-sdk-go-v2/service/arczonalshift/types" + route53svc "github.com/aws/aws-sdk-go-v2/service/route53" + r53types "github.com/aws/aws-sdk-go-v2/service/route53/types" + rccsvc "github.com/aws/aws-sdk-go-v2/service/route53recoverycontrolconfig" + rcctypes "github.com/aws/aws-sdk-go-v2/service/route53recoverycontrolconfig/types" + rrsvc "github.com/aws/aws-sdk-go-v2/service/route53recoveryreadiness" + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// arcControlConfigUpdate builds the update overlay. Provider v5 re-reads a safety rule after +// UpdateSafetyRule via DescribeControlPanel and fails against real AWS too, so rules only update in v6. +func arcControlConfigUpdate(suffix string, rulesUpdatable bool) map[string]any { + update := map[string]any{ + "PanelName": "arc-panel2-" + suffix, + "SecondaryName": "arc-secondary2-" + suffix, + "Env": "prod", + } + + if rulesUpdatable { + update["WaitMs"] = 6000 + update["AssertionName"] = "arc-at-least-two-" + suffix + update["GatingName"] = "arc-gate-secondary2-" + suffix + } + + return update +} + +// arcControlConfigTags returns the tag map for arn from the recovery control config API. +func arcControlConfigTags(ctx context.Context, t *testing.T, arn string) map[string]string { + t.Helper() + + client := rccsvc.NewFromConfig(megaConfig(t), func(o *rccsvc.Options) { + o.BaseEndpoint = aws.String(endpoint) + }) + + out, err := client.ListTagsForResource(ctx, &rccsvc.ListTagsForResourceInput{ResourceArn: aws.String(arn)}) + require.NoError(t, err) + + return out.Tags +} + +// TestTerraform_ARCRecoveryControlConfig provisions an ARC cluster, control panel, routing controls, +// assertion and gating safety rules, and a RECOVERY_CONTROL health check, then updates in place. +func TestTerraform_ARCRecoveryControlConfig(t *testing.T) { + t.Parallel() + + tests := []struct { + providerFn func(addr string) string + name string + fixture string + tagged bool // v6 only: tags, network type and updatable safety rules + }{ + {name: "provider_v5", fixture: "arc-recovery-control-config"}, + { + name: "provider_v6_tags", + fixture: "arc-recovery-control-config-v6", + providerFn: arczonalshiftProviderBlock, + tagged: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + suffix := uuid.NewString()[:8] + + runTFTest(t, tfTestCase{ + name: tc.name, + fixture: tc.fixture, + providerFn: tc.providerFn, + noDrift: true, + setup: func(t *testing.T, _ string) map[string]any { + t.Helper() + + return map[string]any{ + "Suffix": suffix, + "PanelName": "arc-panel-" + suffix, + "SecondaryName": "arc-secondary-" + suffix, + "WaitMs": 5000, + "Threshold": 1, + "AssertionName": "arc-at-least-one-" + suffix, + "GatingName": "arc-gate-secondary-" + suffix, + "Env": "dev", + } + }, + update: arcControlConfigUpdate(suffix, tc.tagged), + verify: func(t *testing.T, ctx context.Context, vars map[string]any) { + t.Helper() + + verifyARCControlConfig(ctx, t, vars, tc.tagged) + verifyARCRecoveryControlHealthCheck(ctx, t, suffix) + }, + verifyUpdate: func(t *testing.T, ctx context.Context, vars map[string]any) { + t.Helper() + + verifyARCControlConfig(ctx, t, vars, tc.tagged) + }, + }) + }) + } +} + +// TestTerraform_ARCReadiness provisions the readiness cell, recovery group, resource set and +// readiness check resources, then updates their tags. +func TestTerraform_ARCReadiness(t *testing.T) { + t.Parallel() + + tests := []struct { + verify func(ctx context.Context, t *testing.T, vars map[string]any) + name string + fixture string + }{ + { + name: "cells_and_recovery_group", + fixture: "arc-readiness-cells-and-recovery-group", + verify: verifyARCReadinessCellsAndGroup, + }, + { + name: "resource_set_and_readiness_check", + fixture: "arc-readiness-resource-set-and-check", + verify: verifyARCReadinessResourceSetAndCheck, + }, + { + name: "dns_target_resource_set", + fixture: "arc-readiness-dns-target-resource", + verify: verifyARCReadinessDNSTarget, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + runTFTest(t, tfTestCase{ + name: tc.name, + fixture: tc.fixture, + noDrift: true, + setup: func(t *testing.T, _ string) map[string]any { + t.Helper() + + return map[string]any{"Suffix": uuid.NewString()[:8], "Env": "dev"} + }, + update: map[string]any{"Env": "prod"}, + verify: func(t *testing.T, ctx context.Context, vars map[string]any) { + t.Helper() + tc.verify(ctx, t, vars) + }, + verifyUpdate: func(t *testing.T, ctx context.Context, vars map[string]any) { + t.Helper() + tc.verify(ctx, t, vars) + }, + }) + }) + } +} + +// TestTerraform_ARCAutoshiftObserverNotification toggles the account-level observer notification status. +func TestTerraform_ARCAutoshiftObserverNotification(t *testing.T) { + t.Parallel() + + verify := func(want arczstypes.AutoshiftObserverNotificationStatus) func(*testing.T, context.Context, map[string]any) { + return func(t *testing.T, ctx context.Context, _ map[string]any) { + t.Helper() + + client := arczonalshiftsvc.NewFromConfig(megaConfig(t), func(o *arczonalshiftsvc.Options) { + o.BaseEndpoint = aws.String(endpoint) + }) + + got, err := client.GetAutoshiftObserverNotificationStatus(ctx, + &arczonalshiftsvc.GetAutoshiftObserverNotificationStatusInput{}) + require.NoError(t, err) + assert.Equal(t, want, got.Status) + } + } + + runTFTest(t, tfTestCase{ + name: "enabled_then_disabled", + fixture: "arc-autoshift-observer-notification", + providerFn: arczonalshiftProviderBlock, + noDrift: true, + setup: func(*testing.T, string) map[string]any { return map[string]any{"Status": "ENABLED"} }, + update: map[string]any{"Status": "DISABLED"}, + verify: verify(arczstypes.AutoshiftObserverNotificationStatusEnabled), + verifyUpdate: verify(arczstypes.AutoshiftObserverNotificationStatusDisabled), + }) +} + +func verifyARCControlConfig(ctx context.Context, t *testing.T, vars map[string]any, tagged bool) { + t.Helper() + + suffix := vars["Suffix"].(string) + panelName := vars["PanelName"].(string) + secondaryName := vars["SecondaryName"].(string) + waitMs := int32(vars["WaitMs"].(int)) + threshold := int32(vars["Threshold"].(int)) + wantTags := map[string]string{"env": vars["Env"].(string)} + + client := rccsvc.NewFromConfig(megaConfig(t), func(o *rccsvc.Options) { + o.BaseEndpoint = aws.String(endpoint) + }) + + clusters, err := client.ListClusters(ctx, &rccsvc.ListClustersInput{}) + require.NoError(t, err) + + var clusterARN string + + for _, c := range clusters.Clusters { + if aws.ToString(c.Name) == "arc-cluster-"+suffix { + clusterARN = aws.ToString(c.ClusterArn) + + assert.Equal(t, rcctypes.StatusDeployed, c.Status) + + if tagged { + assert.Equal(t, rcctypes.NetworkTypeIpv4, c.NetworkType) + } + } + } + + require.NotEmpty(t, clusterARN, "cluster should be listed") + + if tagged { + assert.Equal(t, wantTags, arcControlConfigTags(ctx, t, clusterARN)) + } + + panels, err := client.ListControlPanels(ctx, &rccsvc.ListControlPanelsInput{}) + require.NoError(t, err) + + var panelARN string + + for _, p := range panels.ControlPanels { + if aws.ToString(p.Name) == panelName { + panelARN = aws.ToString(p.ControlPanelArn) + + assert.Equal(t, clusterARN, aws.ToString(p.ClusterArn)) + assert.Equal(t, int32(2), aws.ToInt32(p.RoutingControlCount)) + } + } + + require.NotEmpty(t, panelARN, "control panel should be listed") + + if tagged { + assert.Equal(t, wantTags, arcControlConfigTags(ctx, t, panelARN)) + } + + controls, err := client.ListRoutingControls(ctx, &rccsvc.ListRoutingControlsInput{ + ControlPanelArn: aws.String(panelARN), + }) + require.NoError(t, err) + + names := map[string]string{} + for _, rc := range controls.RoutingControls { + names[aws.ToString(rc.Name)] = aws.ToString(rc.RoutingControlArn) + } + + require.Contains(t, names, "arc-primary-"+suffix) + require.Contains(t, names, secondaryName) + + rules, err := client.ListSafetyRules(ctx, &rccsvc.ListSafetyRulesInput{ControlPanelArn: aws.String(panelARN)}) + require.NoError(t, err) + + var sawAssertion, sawGating bool + + for _, r := range rules.SafetyRules { + if as := r.ASSERTION; as != nil && aws.ToString(as.Name) == vars["AssertionName"].(string) { + sawAssertion = true + + assert.ElementsMatch(t, []string{names["arc-primary-"+suffix], names[secondaryName]}, as.AssertedControls) + assert.Equal(t, waitMs, aws.ToInt32(as.WaitPeriodMs)) + assert.Equal(t, rcctypes.RuleTypeAtleast, as.RuleConfig.Type) + assert.Equal(t, threshold, aws.ToInt32(as.RuleConfig.Threshold)) + assert.False(t, aws.ToBool(as.RuleConfig.Inverted)) + + if tagged { + assert.Equal(t, wantTags, arcControlConfigTags(ctx, t, aws.ToString(as.SafetyRuleArn))) + } + } + + if g := r.GATING; g != nil && aws.ToString(g.Name) == vars["GatingName"].(string) { + sawGating = true + + assert.Equal(t, []string{names["arc-primary-"+suffix]}, g.GatingControls) + assert.Equal(t, []string{names[secondaryName]}, g.TargetControls) + assert.Equal(t, waitMs, aws.ToInt32(g.WaitPeriodMs)) + assert.Equal(t, rcctypes.RuleTypeOr, g.RuleConfig.Type) + assert.True(t, aws.ToBool(g.RuleConfig.Inverted)) + } + } + + assert.True(t, sawAssertion, "assertion safety rule should be listed") + assert.True(t, sawGating, "gating safety rule should be listed") +} + +func verifyARCRecoveryControlHealthCheck(ctx context.Context, t *testing.T, suffix string) { + t.Helper() + + cc := rccsvc.NewFromConfig(megaConfig(t), func(o *rccsvc.Options) { + o.BaseEndpoint = aws.String(endpoint) + }) + r53 := route53svc.NewFromConfig(megaConfig(t), func(o *route53svc.Options) { + o.BaseEndpoint = aws.String(endpoint) + }) + + panels, err := cc.ListControlPanels(ctx, &rccsvc.ListControlPanelsInput{}) + require.NoError(t, err) + + var primaryARN string + + for _, p := range panels.ControlPanels { + if aws.ToString(p.Name) != "arc-panel-"+suffix { + continue + } + + controls, listErr := cc.ListRoutingControls(ctx, &rccsvc.ListRoutingControlsInput{ + ControlPanelArn: p.ControlPanelArn, + }) + require.NoError(t, listErr) + + for _, rc := range controls.RoutingControls { + if aws.ToString(rc.Name) == "arc-primary-"+suffix { + primaryARN = aws.ToString(rc.RoutingControlArn) + } + } + } + + require.NotEmpty(t, primaryARN) + + checks, err := r53.ListHealthChecks(ctx, &route53svc.ListHealthChecksInput{}) + require.NoError(t, err) + + var found bool + + for _, hc := range checks.HealthChecks { + if hc.HealthCheckConfig != nil && hc.HealthCheckConfig.Type == r53types.HealthCheckTypeRecoveryControl && + aws.ToString(hc.HealthCheckConfig.RoutingControlArn) == primaryARN { + found = true + } + } + + assert.True(t, found, "RECOVERY_CONTROL health check for the primary routing control should be listed") +} + +func verifyARCReadinessCellsAndGroup(ctx context.Context, t *testing.T, vars map[string]any) { + t.Helper() + + suffix := vars["Suffix"].(string) + wantTags := map[string]string{"env": vars["Env"].(string)} + + client := rrsvc.NewFromConfig(megaConfig(t), func(o *rrsvc.Options) { + o.BaseEndpoint = aws.String(endpoint) + }) + + west, err := client.GetCell(ctx, &rrsvc.GetCellInput{CellName: aws.String("arc_west_" + suffix)}) + require.NoError(t, err) + + nested, err := client.GetCell(ctx, &rrsvc.GetCellInput{CellName: aws.String("arc_west_az_" + suffix)}) + require.NoError(t, err) + assert.Equal(t, []string{aws.ToString(nested.CellArn)}, west.Cells) + assert.Equal(t, []string{aws.ToString(west.CellArn)}, nested.ParentReadinessScopes) + assert.Equal(t, wantTags, west.Tags) + + east, err := client.GetCell(ctx, &rrsvc.GetCellInput{CellName: aws.String("arc_east_" + suffix)}) + require.NoError(t, err) + assert.Equal(t, wantTags, east.Tags) + + group, err := client.GetRecoveryGroup(ctx, &rrsvc.GetRecoveryGroupInput{ + RecoveryGroupName: aws.String("arc_group_" + suffix), + }) + require.NoError(t, err) + assert.ElementsMatch(t, []string{aws.ToString(west.CellArn), aws.ToString(east.CellArn)}, group.Cells) + assert.Equal(t, wantTags, group.Tags) +} + +func verifyARCReadinessResourceSetAndCheck(ctx context.Context, t *testing.T, vars map[string]any) { + t.Helper() + + suffix := vars["Suffix"].(string) + wantTags := map[string]string{"env": vars["Env"].(string)} + + client := rrsvc.NewFromConfig(megaConfig(t), func(o *rrsvc.Options) { + o.BaseEndpoint = aws.String(endpoint) + }) + + west, err := client.GetCell(ctx, &rrsvc.GetCellInput{CellName: aws.String("arc_west_" + suffix)}) + require.NoError(t, err) + + set, err := client.GetResourceSet(ctx, &rrsvc.GetResourceSetInput{ + ResourceSetName: aws.String("arc_queues_" + suffix), + }) + require.NoError(t, err) + assert.Equal(t, "AWS::SQS::Queue", aws.ToString(set.ResourceSetType)) + assert.Equal(t, wantTags, set.Tags) + require.Len(t, set.Resources, 1) + assert.Contains(t, aws.ToString(set.Resources[0].ResourceArn), "arc-orders-"+suffix) + assert.Equal(t, []string{aws.ToString(west.CellArn)}, set.Resources[0].ReadinessScopes) + + check, err := client.GetReadinessCheck(ctx, &rrsvc.GetReadinessCheckInput{ + ReadinessCheckName: aws.String("arc_check_" + suffix), + }) + require.NoError(t, err) + assert.Equal(t, "arc_queues_"+suffix, aws.ToString(check.ResourceSet)) + assert.Equal(t, wantTags, check.Tags) + + status, err := client.GetReadinessCheckStatus(ctx, &rrsvc.GetReadinessCheckStatusInput{ + ReadinessCheckName: aws.String("arc_check_" + suffix), + }) + require.NoError(t, err) + assert.NotEmpty(t, status.Readiness) +} + +func verifyARCReadinessDNSTarget(ctx context.Context, t *testing.T, vars map[string]any) { + t.Helper() + + suffix := vars["Suffix"].(string) + + client := rrsvc.NewFromConfig(megaConfig(t), func(o *rrsvc.Options) { + o.BaseEndpoint = aws.String(endpoint) + }) + + set, err := client.GetResourceSet(ctx, &rrsvc.GetResourceSetInput{ResourceSetName: aws.String("arc_dns_" + suffix)}) + require.NoError(t, err) + assert.Equal(t, "AWS::Route53RecoveryReadiness::DNSTargetResource", aws.ToString(set.ResourceSetType)) + assert.Equal(t, map[string]string{"env": vars["Env"].(string)}, set.Tags) + require.Len(t, set.Resources, 1) + + dns := set.Resources[0].DnsTargetResource + require.NotNil(t, dns) + assert.Equal(t, "app.arc-"+suffix+".example.com", aws.ToString(dns.DomainName)) + assert.Equal(t, "A", aws.ToString(dns.RecordType)) + assert.Equal(t, "primary", aws.ToString(dns.RecordSetId)) + require.NotNil(t, dns.TargetResource) + require.NotNil(t, dns.TargetResource.R53Resource) + assert.Equal(t, "secondary", aws.ToString(dns.TargetResource.R53Resource.RecordSetId)) +} + +// TestTerraform_ARCZonalAutoshiftConfiguration provisions a zonal autoshift configuration with +// outcome and blocking alarms and maintenance windows on an Application Load Balancer, then updates it. +func TestTerraform_ARCZonalAutoshiftConfiguration(t *testing.T) { + t.Parallel() + + type verifyFn = func(*testing.T, context.Context, map[string]any) + + verify := func(status arczstypes.ZonalAutoshiftStatus, blockedWindow string) verifyFn { + return func(t *testing.T, ctx context.Context, vars map[string]any) { + t.Helper() + + client := arczonalshiftsvc.NewFromConfig(megaConfig(t), func(o *arczonalshiftsvc.Options) { + o.BaseEndpoint = aws.String(endpoint) + }) + + resources, err := client.ListManagedResources(ctx, &arczonalshiftsvc.ListManagedResourcesInput{}) + require.NoError(t, err) + + var arn string + + for _, r := range resources.Items { + if aws.ToString(r.Name) == "arczs-"+vars["Suffix"].(string) { + arn = aws.ToString(r.Arn) + + assert.Equal(t, status, r.ZonalAutoshiftStatus) + } + } + + require.NotEmpty(t, arn, "load balancer should be a managed resource") + + got, err := client.GetManagedResource(ctx, &arczonalshiftsvc.GetManagedResourceInput{ + ResourceIdentifier: aws.String(arn), + }) + require.NoError(t, err) + require.NotNil(t, got.PracticeRunConfiguration) + assert.Len(t, got.PracticeRunConfiguration.OutcomeAlarms, 1) + assert.Len(t, got.PracticeRunConfiguration.BlockingAlarms, 1) + assert.Equal(t, []string{blockedWindow}, got.PracticeRunConfiguration.BlockedWindows) + assert.Equal(t, []string{"2030-12-25"}, got.PracticeRunConfiguration.BlockedDates) + } + } + + runTFTest(t, tfTestCase{ + name: "enabled_with_blocking_alarms", + fixture: "arc-zonal-autoshift-configuration", + providerFn: arczonalshiftProviderBlock, + noDrift: true, + setup: func(t *testing.T, _ string) map[string]any { + t.Helper() + + vars := vpcCIDRVars(t) + vars["Suffix"] = uuid.NewString()[:8] + vars["Status"] = "ENABLED" + vars["BlockedWindow"] = "Tue:00:00-Tue:04:00" + + return vars + }, + update: map[string]any{"Status": "DISABLED", "BlockedWindow": "Wed:00:00-Wed:04:00"}, + verify: verify(arczstypes.ZonalAutoshiftStatusEnabled, "Tue:00:00-Tue:04:00"), + verifyUpdate: verify(arczstypes.ZonalAutoshiftStatusDisabled, "Wed:00:00-Wed:04:00"), + }) +} diff --git a/test/terraform/drift_test.go b/test/terraform/drift_test.go index a4745b6b38..8d216fc988 100644 --- a/test/terraform/drift_test.go +++ b/test/terraform/drift_test.go @@ -48,9 +48,28 @@ func tofuEnv() []string { "TF_IN_AUTOMATION=1", "TF_PLUGIN_CACHE_DIR="+tofuProviderCacheDir, "TF_PLUGIN_CACHE_MAY_BREAK_DEPENDENCY_LOCK_FILE=true", + "AWS_ENDPOINT_URL="+endpoint, ) } +// assertNoTofuChanges fails the test if a fresh plan in dir still reports changes. +func assertNoTofuChanges(ctx context.Context, t *testing.T, tofuBin, dir string) { + t.Helper() + + assert.False(t, runTofuPlan(ctx, t, tofuBin, dir).HasChanges, "plan after apply must be empty") +} + +// reapplyTofu rewrites main.tf in an already-initialised dir and applies it. +func reapplyTofu(ctx context.Context, t *testing.T, tofuBin, dir, hcl string) { + t.Helper() + + require.NoError(t, os.WriteFile(filepath.Join(dir, "main.tf"), []byte(hcl), 0o644)) + + out, err := execTofuCmd(ctx, t, tofuBin, dir, "apply", "-auto-approve", "-no-color") + t.Logf("tofu apply (update):\n%s", out) + require.NoError(t, err, "tofu update apply failed") +} + // execTofuCmd runs a single OpenTofu sub-command inside dir and returns the combined // stdout+stderr output together with any process error. It uses CommandContext so // the subprocess is cancelled promptly when the test context expires. diff --git a/test/terraform/fixtures/arc-autoshift-observer-notification.tf b/test/terraform/fixtures/arc-autoshift-observer-notification.tf new file mode 100644 index 0000000000..46b13d6ca8 --- /dev/null +++ b/test/terraform/fixtures/arc-autoshift-observer-notification.tf @@ -0,0 +1,3 @@ +resource "aws_arczonalshift_autoshift_observer_notification_status" "arc" { + status = "{{.Status}}" +} diff --git a/test/terraform/fixtures/arc-readiness-cells-and-recovery-group.tf b/test/terraform/fixtures/arc-readiness-cells-and-recovery-group.tf new file mode 100644 index 0000000000..f2fb52a301 --- /dev/null +++ b/test/terraform/fixtures/arc-readiness-cells-and-recovery-group.tf @@ -0,0 +1,32 @@ +resource "aws_route53recoveryreadiness_cell" "west_az" { + cell_name = "arc_west_az_{{.Suffix}}" +} + +resource "aws_route53recoveryreadiness_cell" "west" { + cell_name = "arc_west_{{.Suffix}}" + cells = [aws_route53recoveryreadiness_cell.west_az.arn] + + tags = { + env = "{{.Env}}" + } +} + +resource "aws_route53recoveryreadiness_cell" "east" { + cell_name = "arc_east_{{.Suffix}}" + + tags = { + env = "{{.Env}}" + } +} + +resource "aws_route53recoveryreadiness_recovery_group" "arc" { + recovery_group_name = "arc_group_{{.Suffix}}" + cells = [ + aws_route53recoveryreadiness_cell.west.arn, + aws_route53recoveryreadiness_cell.east.arn, + ] + + tags = { + env = "{{.Env}}" + } +} diff --git a/test/terraform/fixtures/arc-readiness-dns-target-resource.tf b/test/terraform/fixtures/arc-readiness-dns-target-resource.tf new file mode 100644 index 0000000000..bc2e25caa5 --- /dev/null +++ b/test/terraform/fixtures/arc-readiness-dns-target-resource.tf @@ -0,0 +1,34 @@ +resource "aws_route53_zone" "arc" { + name = "arc-{{.Suffix}}.example.com" +} + +resource "aws_route53recoveryreadiness_cell" "west" { + cell_name = "arc_dns_west_{{.Suffix}}" +} + +resource "aws_route53recoveryreadiness_resource_set" "dns" { + resource_set_name = "arc_dns_{{.Suffix}}" + resource_set_type = "AWS::Route53RecoveryReadiness::DNSTargetResource" + + resources { + dns_target_resource { + domain_name = "app.arc-{{.Suffix}}.example.com" + hosted_zone_arn = aws_route53_zone.arc.arn + record_type = "A" + record_set_id = "primary" + + target_resource { + r53_resource { + domain_name = "app2.arc-{{.Suffix}}.example.com" + record_set_id = "secondary" + } + } + } + + readiness_scopes = [aws_route53recoveryreadiness_cell.west.arn] + } + + tags = { + env = "{{.Env}}" + } +} diff --git a/test/terraform/fixtures/arc-readiness-resource-set-and-check.tf b/test/terraform/fixtures/arc-readiness-resource-set-and-check.tf new file mode 100644 index 0000000000..2b833897fb --- /dev/null +++ b/test/terraform/fixtures/arc-readiness-resource-set-and-check.tf @@ -0,0 +1,42 @@ +resource "aws_sqs_queue" "arc" { + name = "arc-orders-{{.Suffix}}" +} + +resource "aws_route53recoveryreadiness_cell" "west" { + cell_name = "arc_west_{{.Suffix}}" +} + +resource "aws_route53recoveryreadiness_cell" "east" { + cell_name = "arc_east_{{.Suffix}}" +} + +resource "aws_route53recoveryreadiness_recovery_group" "arc" { + recovery_group_name = "arc_group_{{.Suffix}}" + cells = [ + aws_route53recoveryreadiness_cell.west.arn, + aws_route53recoveryreadiness_cell.east.arn, + ] +} + +resource "aws_route53recoveryreadiness_resource_set" "arc" { + resource_set_name = "arc_queues_{{.Suffix}}" + resource_set_type = "AWS::SQS::Queue" + + resources { + resource_arn = aws_sqs_queue.arc.arn + readiness_scopes = [aws_route53recoveryreadiness_cell.west.arn] + } + + tags = { + env = "{{.Env}}" + } +} + +resource "aws_route53recoveryreadiness_readiness_check" "arc" { + readiness_check_name = "arc_check_{{.Suffix}}" + resource_set_name = aws_route53recoveryreadiness_resource_set.arc.resource_set_name + + tags = { + env = "{{.Env}}" + } +} diff --git a/test/terraform/fixtures/arc-recovery-control-config-v6.tf b/test/terraform/fixtures/arc-recovery-control-config-v6.tf new file mode 100644 index 0000000000..61740dbb80 --- /dev/null +++ b/test/terraform/fixtures/arc-recovery-control-config-v6.tf @@ -0,0 +1,74 @@ +resource "aws_route53recoverycontrolconfig_cluster" "arc" { + name = "arc-cluster-{{.Suffix}}" + network_type = "IPV4" + + tags = { + env = "{{.Env}}" + } +} + +resource "aws_route53recoverycontrolconfig_control_panel" "arc" { + name = "{{.PanelName}}" + cluster_arn = aws_route53recoverycontrolconfig_cluster.arc.arn + + tags = { + env = "{{.Env}}" + } +} + +resource "aws_route53recoverycontrolconfig_routing_control" "primary" { + name = "arc-primary-{{.Suffix}}" + cluster_arn = aws_route53recoverycontrolconfig_cluster.arc.arn + control_panel_arn = aws_route53recoverycontrolconfig_control_panel.arc.arn +} + +resource "aws_route53recoverycontrolconfig_routing_control" "secondary" { + name = "{{.SecondaryName}}" + cluster_arn = aws_route53recoverycontrolconfig_cluster.arc.arn + control_panel_arn = aws_route53recoverycontrolconfig_control_panel.arc.arn +} + +resource "aws_route53recoverycontrolconfig_safety_rule" "assertion" { + name = "{{.AssertionName}}" + control_panel_arn = aws_route53recoverycontrolconfig_control_panel.arc.arn + wait_period_ms = {{.WaitMs}} + + tags = { + env = "{{.Env}}" + } + + asserted_controls = [ + aws_route53recoverycontrolconfig_routing_control.primary.arn, + aws_route53recoverycontrolconfig_routing_control.secondary.arn, + ] + + rule_config { + inverted = false + threshold = {{.Threshold}} + type = "ATLEAST" + } +} + +resource "aws_route53recoverycontrolconfig_safety_rule" "gating" { + name = "{{.GatingName}}" + control_panel_arn = aws_route53recoverycontrolconfig_control_panel.arc.arn + wait_period_ms = {{.WaitMs}} + + tags = { + env = "{{.Env}}" + } + + gating_controls = [aws_route53recoverycontrolconfig_routing_control.primary.arn] + target_controls = [aws_route53recoverycontrolconfig_routing_control.secondary.arn] + + rule_config { + inverted = true + threshold = 1 + type = "OR" + } +} + +resource "aws_route53_health_check" "arc" { + type = "RECOVERY_CONTROL" + routing_control_arn = aws_route53recoverycontrolconfig_routing_control.primary.arn +} diff --git a/test/terraform/fixtures/arc-recovery-control-config.tf b/test/terraform/fixtures/arc-recovery-control-config.tf new file mode 100644 index 0000000000..bc9cb84732 --- /dev/null +++ b/test/terraform/fixtures/arc-recovery-control-config.tf @@ -0,0 +1,57 @@ +resource "aws_route53recoverycontrolconfig_cluster" "arc" { + name = "arc-cluster-{{.Suffix}}" +} + +resource "aws_route53recoverycontrolconfig_control_panel" "arc" { + name = "{{.PanelName}}" + cluster_arn = aws_route53recoverycontrolconfig_cluster.arc.arn +} + +resource "aws_route53recoverycontrolconfig_routing_control" "primary" { + name = "arc-primary-{{.Suffix}}" + cluster_arn = aws_route53recoverycontrolconfig_cluster.arc.arn + control_panel_arn = aws_route53recoverycontrolconfig_control_panel.arc.arn +} + +resource "aws_route53recoverycontrolconfig_routing_control" "secondary" { + name = "{{.SecondaryName}}" + cluster_arn = aws_route53recoverycontrolconfig_cluster.arc.arn + control_panel_arn = aws_route53recoverycontrolconfig_control_panel.arc.arn +} + +resource "aws_route53recoverycontrolconfig_safety_rule" "assertion" { + name = "{{.AssertionName}}" + control_panel_arn = aws_route53recoverycontrolconfig_control_panel.arc.arn + wait_period_ms = {{.WaitMs}} + + asserted_controls = [ + aws_route53recoverycontrolconfig_routing_control.primary.arn, + aws_route53recoverycontrolconfig_routing_control.secondary.arn, + ] + + rule_config { + inverted = false + threshold = {{.Threshold}} + type = "ATLEAST" + } +} + +resource "aws_route53recoverycontrolconfig_safety_rule" "gating" { + name = "{{.GatingName}}" + control_panel_arn = aws_route53recoverycontrolconfig_control_panel.arc.arn + wait_period_ms = {{.WaitMs}} + + gating_controls = [aws_route53recoverycontrolconfig_routing_control.primary.arn] + target_controls = [aws_route53recoverycontrolconfig_routing_control.secondary.arn] + + rule_config { + inverted = true + threshold = 1 + type = "OR" + } +} + +resource "aws_route53_health_check" "arc" { + type = "RECOVERY_CONTROL" + routing_control_arn = aws_route53recoverycontrolconfig_routing_control.primary.arn +} diff --git a/test/terraform/fixtures/arc-zonal-autoshift-configuration.tf b/test/terraform/fixtures/arc-zonal-autoshift-configuration.tf new file mode 100644 index 0000000000..48f7a1ffd4 --- /dev/null +++ b/test/terraform/fixtures/arc-zonal-autoshift-configuration.tf @@ -0,0 +1,61 @@ +resource "aws_vpc" "arczs" { + cidr_block = "{{.VPCCidr}}" +} + +resource "aws_subnet" "arczs_a" { + vpc_id = aws_vpc.arczs.id + cidr_block = "{{.SubnetCidrA}}" + availability_zone = "us-east-1a" +} + +resource "aws_subnet" "arczs_b" { + vpc_id = aws_vpc.arczs.id + cidr_block = "{{.SubnetCidrB}}" + availability_zone = "us-east-1b" +} + +resource "aws_lb" "arczs" { + name = "arczs-{{.Suffix}}" + internal = true + load_balancer_type = "application" + subnets = [aws_subnet.arczs_a.id, aws_subnet.arczs_b.id] +} + +resource "aws_cloudwatch_metric_alarm" "arczs_outcome" { + alarm_name = "arczs-outcome-{{.Suffix}}" + comparison_operator = "GreaterThanOrEqualToThreshold" + evaluation_periods = 1 + metric_name = "TargetResponseTime" + namespace = "AWS/ApplicationELB" + period = 60 + statistic = "Average" + threshold = 1 +} + +resource "aws_cloudwatch_metric_alarm" "arczs_blocking" { + alarm_name = "arczs-blocking-{{.Suffix}}" + comparison_operator = "GreaterThanOrEqualToThreshold" + evaluation_periods = 1 + metric_name = "HTTPCode_Target_5XX_Count" + namespace = "AWS/ApplicationELB" + period = 60 + statistic = "Sum" + threshold = 5 +} + +resource "aws_arczonalshift_zonal_autoshift_configuration" "arczs" { + resource_arn = aws_lb.arczs.arn + zonal_autoshift_status = "{{.Status}}" + blocked_windows = ["{{.BlockedWindow}}"] + blocked_dates = ["2030-12-25"] + + outcome_alarms { + alarm_identifier = aws_cloudwatch_metric_alarm.arczs_outcome.arn + type = "CLOUDWATCH" + } + + blocking_alarms { + alarm_identifier = aws_cloudwatch_metric_alarm.arczs_blocking.arn + type = "CLOUDWATCH" + } +} diff --git a/test/terraform/terraform_test.go b/test/terraform/terraform_test.go index bb440531df..f33aa632cf 100644 --- a/test/terraform/terraform_test.go +++ b/test/terraform/terraform_test.go @@ -11,6 +11,7 @@ import ( "hash/fnv" "io" "log/slog" + "maps" "net/http" "os" "os/exec" @@ -608,6 +609,39 @@ provider "aws" { `, addr) } +// arczonalshiftProviderBlock pins provider v6: aws_arczonalshift_zonal_autoshift_configuration +// does not exist in v5. +func arczonalshiftProviderBlock(addr string) string { + return fmt.Sprintf(`terraform { + required_providers { + aws = { + source = "hashicorp/aws" + version = "~> 6.0" + } + } + required_version = ">= 1.0" +} + +provider "aws" { + region = "us-east-1" + access_key = "test" + secret_key = "test" + skip_credentials_validation = true + skip_metadata_api_check = true + skip_requesting_account_id = true + skip_region_validation = true + + endpoints { + arczonalshift = %[1]q + cloudwatch = %[1]q + ec2 = %[1]q + elbv2 = %[1]q + sts = %[1]q + } +} +`, addr) +} + // ensureTofuBinary returns the path to the tofu binary, downloading it if // necessary. The download happens at most once per test run. func ensureTofuBinary(t *testing.T) string { @@ -985,8 +1019,13 @@ type tfTestCase struct { providerFn func(addr string) string setup func(t *testing.T, dir string) map[string]any verify func(t *testing.T, ctx context.Context, vars map[string]any) - name string - fixture string + // update overlays vars and re-applies the fixture to exercise Update ops. + update map[string]any + verifyUpdate func(t *testing.T, ctx context.Context, vars map[string]any) + name string + fixture string + // noDrift asserts an empty plan after every apply. + noDrift bool } // runTFTest is the common runner shared by all per-service Terraform tests. @@ -1011,6 +1050,26 @@ func runTFTest(t *testing.T, tc tfTestCase) { if tc.verify != nil { tc.verify(t, ctx, vars) } + + if tc.noDrift { + assertNoTofuChanges(ctx, t, tofuBin, dir) + } + + if tc.update == nil { + return + } + + updated := maps.Clone(vars) + maps.Copy(updated, tc.update) + reapplyTofu(ctx, t, tofuBin, dir, provFn(endpoint)+renderFixture(t, tc.fixture, updated)) + + if tc.verifyUpdate != nil { + tc.verifyUpdate(t, ctx, updated) + } + + if tc.noDrift { + assertNoTofuChanges(ctx, t, tofuBin, dir) + } } // runTfTestWithEndpoint is a specialized runner for tests using setupEndpoint. diff --git a/testdata/routing/corpus.tsv b/testdata/routing/corpus.tsv index caf787ae8a..53da69712c 100644 --- a/testdata/routing/corpus.tsv +++ b/testdata/routing/corpus.tsv @@ -1051,6 +1051,45 @@ POST localhost:4566 /v1/apis/xapiid appsync User-Agent:aws-sdk-go-v2/1.41.0 u POST localhost:4566 /v1/apis/xapiid/types/xtypena/resolvers/xfieldn appsync User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/appsync#1.0.0 AppSync AppSync POST localhost:4566 /v1/mergedApis/xmerged/sourceApiAssociations/xassoci appsync User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/appsync#1.0.0 AppSync AppSync POST localhost:4566 /v1/apis/xapiid/types/xtypena appsync User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/appsync#1.0.0 AppSync AppSync +DELETE localhost:4566 /practiceruns/xzonals arc-zonal-shift User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/arczonalshift#1.0.0 ARCZonalShift ARCZonalShift +DELETE localhost:4566 /practiceruns/xzonals execute-api User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/arczonalshift#1.0.0 ARCZonalShift ARCZonalShift +DELETE arc-zonal-shift.us-east-1.amazonaws.com /practiceruns/xzonals arc-zonal-shift User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/arczonalshift#1.0.0 ARCZonalShift ARCZonalShift +DELETE localhost:4566 /practiceruns/xzonals?X-Amz-Credential=AKIA%2F20260101%2Fus-east-1%2Farc-zonal-shift%2Faws4_request&X-Amz-Signature=00 User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/arczonalshift#1.0.0 ARCZonalShift ARCZonalShift +DELETE localhost:4566 /practiceruns/xzonals S3 S3 +DELETE localhost:4566 /zonalshifts/xzonals arc-zonal-shift User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/arczonalshift#1.0.0 ARCZonalShift ARCZonalShift +DELETE localhost:4566 /zonalshifts/xzonals execute-api User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/arczonalshift#1.0.0 ARCZonalShift ARCZonalShift +DELETE arc-zonal-shift.us-east-1.amazonaws.com /zonalshifts/xzonals arc-zonal-shift User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/arczonalshift#1.0.0 ARCZonalShift ARCZonalShift +DELETE localhost:4566 /zonalshifts/xzonals?X-Amz-Credential=AKIA%2F20260101%2Fus-east-1%2Farc-zonal-shift%2Faws4_request&X-Amz-Signature=00 User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/arczonalshift#1.0.0 ARCZonalShift ARCZonalShift +DELETE localhost:4566 /zonalshifts/xzonals S3 S3 +POST localhost:4566 /configuration arc-zonal-shift User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/arczonalshift#1.0.0 ARCZonalShift ARCZonalShift +POST localhost:4566 /configuration execute-api User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/arczonalshift#1.0.0 ARCZonalShift ARCZonalShift +POST arc-zonal-shift.us-east-1.amazonaws.com /configuration arc-zonal-shift User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/arczonalshift#1.0.0 ARCZonalShift ARCZonalShift +POST localhost:4566 /configuration?X-Amz-Credential=AKIA%2F20260101%2Fus-east-1%2Farc-zonal-shift%2Faws4_request&X-Amz-Signature=00 User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/arczonalshift#1.0.0 ARCZonalShift ARCZonalShift +POST localhost:4566 /configuration Omics Omics +DELETE localhost:4566 /configuration/xresour arc-zonal-shift User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/arczonalshift#1.0.0 ARCZonalShift ARCZonalShift +DELETE localhost:4566 /configuration/xresour execute-api User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/arczonalshift#1.0.0 ARCZonalShift ARCZonalShift +DELETE arc-zonal-shift.us-east-1.amazonaws.com /configuration/xresour arc-zonal-shift User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/arczonalshift#1.0.0 ARCZonalShift ARCZonalShift +DELETE localhost:4566 /configuration/xresour?X-Amz-Credential=AKIA%2F20260101%2Fus-east-1%2Farc-zonal-shift%2Faws4_request&X-Amz-Signature=00 User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/arczonalshift#1.0.0 ARCZonalShift ARCZonalShift +DELETE localhost:4566 /configuration/xresour Omics Omics +GET localhost:4566 /autoshift-observer-notification arc-zonal-shift User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/arczonalshift#1.0.0 ARCZonalShift ARCZonalShift +GET localhost:4566 /autoshift-observer-notification S3 S3 +GET localhost:4566 /managedresources/xresour arc-zonal-shift User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/arczonalshift#1.0.0 ARCZonalShift ARCZonalShift +GET localhost:4566 /managedresources/xresour S3 S3 +GET localhost:4566 /autoshifts arc-zonal-shift User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/arczonalshift#1.0.0 ARCZonalShift ARCZonalShift +GET localhost:4566 /autoshifts S3 S3 +GET localhost:4566 /managedresources arc-zonal-shift User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/arczonalshift#1.0.0 ARCZonalShift ARCZonalShift +GET localhost:4566 /managedresources S3 S3 +GET localhost:4566 /zonalshifts arc-zonal-shift User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/arczonalshift#1.0.0 ARCZonalShift ARCZonalShift +GET localhost:4566 /zonalshifts S3 S3 +POST localhost:4566 /practiceruns arc-zonal-shift User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/arczonalshift#1.0.0 ARCZonalShift ARCZonalShift +POST localhost:4566 /practiceruns S3 S3 +POST localhost:4566 /zonalshifts arc-zonal-shift User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/arczonalshift#1.0.0 ARCZonalShift ARCZonalShift +POST localhost:4566 /zonalshifts S3 S3 +PUT localhost:4566 /autoshift-observer-notification arc-zonal-shift User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/arczonalshift#1.0.0 ARCZonalShift ARCZonalShift +PUT localhost:4566 /autoshift-observer-notification S3 S3 +PATCH localhost:4566 /configuration/xresour arc-zonal-shift User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/arczonalshift#1.0.0 ARCZonalShift ARCZonalShift +PUT localhost:4566 /managedresources/xresour arc-zonal-shift User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/arczonalshift#1.0.0 ARCZonalShift ARCZonalShift +PATCH localhost:4566 /zonalshifts/xzonals arc-zonal-shift User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/arczonalshift#1.0.0 ARCZonalShift ARCZonalShift POST localhost:4566 / athena application/x-amz-json-1.1 AmazonAthena.BatchGetNamedQuery {} User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/athena#1.0.0 Athena Athena POST localhost:4566 / application/x-amz-json-1.1 AmazonAthena.BatchGetNamedQuery {} Athena Athena POST localhost:4566 / execute-api application/x-amz-json-1.1 AmazonAthena.BatchGetNamedQuery {} User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/athena#1.0.0 Athena Athena @@ -12404,6 +12443,131 @@ POST localhost:4566 /2013-04-01/hostedzone/xid route53 User-Agent:aws-sdk-go- POST localhost:4566 /2013-04-01/hostedzone/xhosted/features route53 User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/route53#1.0.0 Route53 Route53 POST localhost:4566 /2013-04-01/trafficpolicy/xid/xversio route53 User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/route53#1.0.0 Route53 Route53 POST localhost:4566 /2013-04-01/trafficpolicyinstance/xid route53 User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/route53#1.0.0 Route53 Route53 +POST localhost:4566 / route53-recovery-cluster application/x-amz-json-1.0 ToggleCustomerAPI.GetRoutingControlState {} User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/route53recoverycluster#1.0.0 Route53RecoveryCluster Route53RecoveryCluster +POST localhost:4566 / application/x-amz-json-1.0 ToggleCustomerAPI.GetRoutingControlState {} Route53RecoveryCluster Route53RecoveryCluster +POST localhost:4566 / execute-api application/x-amz-json-1.0 ToggleCustomerAPI.GetRoutingControlState {} User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/route53recoverycluster#1.0.0 Route53RecoveryCluster Route53RecoveryCluster +POST route53-recovery-cluster.us-east-1.amazonaws.com / route53-recovery-cluster application/x-amz-json-1.0 ToggleCustomerAPI.GetRoutingControlState {} User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/route53recoverycluster#1.0.0 Route53RecoveryCluster Route53RecoveryCluster +POST localhost:4566 /?X-Amz-Credential=AKIA%2F20260101%2Fus-east-1%2Froute53-recovery-cluster%2Faws4_request&X-Amz-Signature=00 application/x-amz-json-1.0 ToggleCustomerAPI.GetRoutingControlState {} User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/route53recoverycluster#1.0.0 Route53RecoveryCluster Route53RecoveryCluster +POST localhost:4566 / route53-recovery-cluster application/x-amz-json-1.0 ToggleCustomerAPI.ListRoutingControls {} User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/route53recoverycluster#1.0.0 Route53RecoveryCluster Route53RecoveryCluster +POST localhost:4566 / application/x-amz-json-1.0 ToggleCustomerAPI.ListRoutingControls {} Route53RecoveryCluster Route53RecoveryCluster +POST localhost:4566 / execute-api application/x-amz-json-1.0 ToggleCustomerAPI.ListRoutingControls {} User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/route53recoverycluster#1.0.0 Route53RecoveryCluster Route53RecoveryCluster +POST route53-recovery-cluster.us-east-1.amazonaws.com / route53-recovery-cluster application/x-amz-json-1.0 ToggleCustomerAPI.ListRoutingControls {} User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/route53recoverycluster#1.0.0 Route53RecoveryCluster Route53RecoveryCluster +POST localhost:4566 /?X-Amz-Credential=AKIA%2F20260101%2Fus-east-1%2Froute53-recovery-cluster%2Faws4_request&X-Amz-Signature=00 application/x-amz-json-1.0 ToggleCustomerAPI.ListRoutingControls {} User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/route53recoverycluster#1.0.0 Route53RecoveryCluster Route53RecoveryCluster +POST localhost:4566 / route53-recovery-cluster application/x-amz-json-1.0 ToggleCustomerAPI.UpdateRoutingControlState {} User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/route53recoverycluster#1.0.0 Route53RecoveryCluster Route53RecoveryCluster +POST localhost:4566 / application/x-amz-json-1.0 ToggleCustomerAPI.UpdateRoutingControlState {} Route53RecoveryCluster Route53RecoveryCluster +POST localhost:4566 / execute-api application/x-amz-json-1.0 ToggleCustomerAPI.UpdateRoutingControlState {} User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/route53recoverycluster#1.0.0 Route53RecoveryCluster Route53RecoveryCluster +POST route53-recovery-cluster.us-east-1.amazonaws.com / route53-recovery-cluster application/x-amz-json-1.0 ToggleCustomerAPI.UpdateRoutingControlState {} User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/route53recoverycluster#1.0.0 Route53RecoveryCluster Route53RecoveryCluster +POST localhost:4566 /?X-Amz-Credential=AKIA%2F20260101%2Fus-east-1%2Froute53-recovery-cluster%2Faws4_request&X-Amz-Signature=00 application/x-amz-json-1.0 ToggleCustomerAPI.UpdateRoutingControlState {} User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/route53recoverycluster#1.0.0 Route53RecoveryCluster Route53RecoveryCluster +POST localhost:4566 / route53-recovery-cluster application/x-amz-json-1.0 ToggleCustomerAPI.UpdateRoutingControlStates {} User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/route53recoverycluster#1.0.0 Route53RecoveryCluster Route53RecoveryCluster +POST localhost:4566 / application/x-amz-json-1.0 ToggleCustomerAPI.UpdateRoutingControlStates {} Route53RecoveryCluster Route53RecoveryCluster +POST localhost:4566 / execute-api application/x-amz-json-1.0 ToggleCustomerAPI.UpdateRoutingControlStates {} User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/route53recoverycluster#1.0.0 Route53RecoveryCluster Route53RecoveryCluster +POST route53-recovery-cluster.us-east-1.amazonaws.com / route53-recovery-cluster application/x-amz-json-1.0 ToggleCustomerAPI.UpdateRoutingControlStates {} User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/route53recoverycluster#1.0.0 Route53RecoveryCluster Route53RecoveryCluster +POST localhost:4566 /?X-Amz-Credential=AKIA%2F20260101%2Fus-east-1%2Froute53-recovery-cluster%2Faws4_request&X-Amz-Signature=00 application/x-amz-json-1.0 ToggleCustomerAPI.UpdateRoutingControlStates {} User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/route53recoverycluster#1.0.0 Route53RecoveryCluster Route53RecoveryCluster +POST localhost:4566 /cluster route53-recovery-control-config User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/route53recoverycontrolconfig#1.0.0 Route53RecoveryControlConfig Route53RecoveryControlConfig +POST localhost:4566 /cluster execute-api User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/route53recoverycontrolconfig#1.0.0 Route53RecoveryControlConfig Route53RecoveryControlConfig +POST route53-recovery-control-config.us-east-1.amazonaws.com /cluster route53-recovery-control-config User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/route53recoverycontrolconfig#1.0.0 Route53RecoveryControlConfig Route53RecoveryControlConfig +POST localhost:4566 /cluster?X-Amz-Credential=AKIA%2F20260101%2Fus-east-1%2Froute53-recovery-control-config%2Faws4_request&X-Amz-Signature=00 User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/route53recoverycontrolconfig#1.0.0 Route53RecoveryControlConfig Route53RecoveryControlConfig +POST localhost:4566 /cluster DSQL DSQL +POST localhost:4566 /controlpanel route53-recovery-control-config User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/route53recoverycontrolconfig#1.0.0 Route53RecoveryControlConfig Route53RecoveryControlConfig +POST localhost:4566 /controlpanel execute-api User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/route53recoverycontrolconfig#1.0.0 Route53RecoveryControlConfig Route53RecoveryControlConfig +POST route53-recovery-control-config.us-east-1.amazonaws.com /controlpanel route53-recovery-control-config User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/route53recoverycontrolconfig#1.0.0 Route53RecoveryControlConfig Route53RecoveryControlConfig +POST localhost:4566 /controlpanel?X-Amz-Credential=AKIA%2F20260101%2Fus-east-1%2Froute53-recovery-control-config%2Faws4_request&X-Amz-Signature=00 User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/route53recoverycontrolconfig#1.0.0 Route53RecoveryControlConfig Route53RecoveryControlConfig +POST localhost:4566 /controlpanel S3 S3 +POST localhost:4566 /routingcontrol route53-recovery-control-config User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/route53recoverycontrolconfig#1.0.0 Route53RecoveryControlConfig Route53RecoveryControlConfig +POST localhost:4566 /routingcontrol execute-api User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/route53recoverycontrolconfig#1.0.0 Route53RecoveryControlConfig Route53RecoveryControlConfig +POST route53-recovery-control-config.us-east-1.amazonaws.com /routingcontrol route53-recovery-control-config User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/route53recoverycontrolconfig#1.0.0 Route53RecoveryControlConfig Route53RecoveryControlConfig +POST localhost:4566 /routingcontrol?X-Amz-Credential=AKIA%2F20260101%2Fus-east-1%2Froute53-recovery-control-config%2Faws4_request&X-Amz-Signature=00 User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/route53recoverycontrolconfig#1.0.0 Route53RecoveryControlConfig Route53RecoveryControlConfig +POST localhost:4566 /routingcontrol S3 S3 +POST localhost:4566 /safetyrule route53-recovery-control-config User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/route53recoverycontrolconfig#1.0.0 Route53RecoveryControlConfig Route53RecoveryControlConfig +POST localhost:4566 /safetyrule execute-api User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/route53recoverycontrolconfig#1.0.0 Route53RecoveryControlConfig Route53RecoveryControlConfig +POST route53-recovery-control-config.us-east-1.amazonaws.com /safetyrule route53-recovery-control-config User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/route53recoverycontrolconfig#1.0.0 Route53RecoveryControlConfig Route53RecoveryControlConfig +POST localhost:4566 /safetyrule?X-Amz-Credential=AKIA%2F20260101%2Fus-east-1%2Froute53-recovery-control-config%2Faws4_request&X-Amz-Signature=00 User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/route53recoverycontrolconfig#1.0.0 Route53RecoveryControlConfig Route53RecoveryControlConfig +POST localhost:4566 /safetyrule S3 S3 +DELETE localhost:4566 /cluster/xcluste route53-recovery-control-config User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/route53recoverycontrolconfig#1.0.0 Route53RecoveryControlConfig Route53RecoveryControlConfig +DELETE localhost:4566 /cluster/xcluste DSQL DSQL +DELETE localhost:4566 /controlpanel/xcontro route53-recovery-control-config User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/route53recoverycontrolconfig#1.0.0 Route53RecoveryControlConfig Route53RecoveryControlConfig +DELETE localhost:4566 /controlpanel/xcontro S3 S3 +DELETE localhost:4566 /routingcontrol/xroutin route53-recovery-control-config User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/route53recoverycontrolconfig#1.0.0 Route53RecoveryControlConfig Route53RecoveryControlConfig +DELETE localhost:4566 /routingcontrol/xroutin S3 S3 +DELETE localhost:4566 /safetyrule/xsafety route53-recovery-control-config User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/route53recoverycontrolconfig#1.0.0 Route53RecoveryControlConfig Route53RecoveryControlConfig +DELETE localhost:4566 /safetyrule/xsafety S3 S3 +GET localhost:4566 /cluster/xcluste route53-recovery-control-config User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/route53recoverycontrolconfig#1.0.0 Route53RecoveryControlConfig Route53RecoveryControlConfig +GET localhost:4566 /cluster/xcluste DSQL DSQL +GET localhost:4566 /controlpanel/xcontro route53-recovery-control-config User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/route53recoverycontrolconfig#1.0.0 Route53RecoveryControlConfig Route53RecoveryControlConfig +GET localhost:4566 /controlpanel/xcontro S3 S3 +GET localhost:4566 /routingcontrol/xroutin route53-recovery-control-config User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/route53recoverycontrolconfig#1.0.0 Route53RecoveryControlConfig Route53RecoveryControlConfig +GET localhost:4566 /routingcontrol/xroutin S3 S3 +GET localhost:4566 /safetyrule/xsafety route53-recovery-control-config User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/route53recoverycontrolconfig#1.0.0 Route53RecoveryControlConfig Route53RecoveryControlConfig +GET localhost:4566 /safetyrule/xsafety S3 S3 +GET localhost:4566 /resourcePolicy/xresour route53-recovery-control-config User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/route53recoverycontrolconfig#1.0.0 Route53RecoveryControlConfig Route53RecoveryControlConfig +GET localhost:4566 /routingcontrol/xroutin/associatedRoute53HealthChecks route53-recovery-control-config User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/route53recoverycontrolconfig#1.0.0 Route53RecoveryControlConfig Route53RecoveryControlConfig +GET localhost:4566 /cluster route53-recovery-control-config User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/route53recoverycontrolconfig#1.0.0 Route53RecoveryControlConfig Route53RecoveryControlConfig +GET localhost:4566 /controlpanels route53-recovery-control-config User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/route53recoverycontrolconfig#1.0.0 Route53RecoveryControlConfig Route53RecoveryControlConfig +GET localhost:4566 /controlpanel/xcontro/routingcontrols route53-recovery-control-config User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/route53recoverycontrolconfig#1.0.0 Route53RecoveryControlConfig Route53RecoveryControlConfig +GET localhost:4566 /controlpanel/xcontro/safetyrules route53-recovery-control-config User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/route53recoverycontrolconfig#1.0.0 Route53RecoveryControlConfig Route53RecoveryControlConfig +GET localhost:4566 /tags/xresour route53-recovery-control-config User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/route53recoverycontrolconfig#1.0.0 Route53RecoveryControlConfig Route53RecoveryControlConfig +POST localhost:4566 /tags/xresour route53-recovery-control-config User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/route53recoverycontrolconfig#1.0.0 Route53RecoveryControlConfig Route53RecoveryControlConfig +DELETE localhost:4566 /tags/xresour route53-recovery-control-config User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/route53recoverycontrolconfig#1.0.0 Route53RecoveryControlConfig Route53RecoveryControlConfig +PUT localhost:4566 /cluster route53-recovery-control-config User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/route53recoverycontrolconfig#1.0.0 Route53RecoveryControlConfig Route53RecoveryControlConfig +PUT localhost:4566 /controlpanel route53-recovery-control-config User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/route53recoverycontrolconfig#1.0.0 Route53RecoveryControlConfig Route53RecoveryControlConfig +PUT localhost:4566 /routingcontrol route53-recovery-control-config User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/route53recoverycontrolconfig#1.0.0 Route53RecoveryControlConfig Route53RecoveryControlConfig +PUT localhost:4566 /safetyrule route53-recovery-control-config User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/route53recoverycontrolconfig#1.0.0 Route53RecoveryControlConfig Route53RecoveryControlConfig +POST localhost:4566 /cells route53-recovery-readiness User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/route53recoveryreadiness#1.0.0 Route53RecoveryReadiness Route53RecoveryReadiness +POST localhost:4566 /cells execute-api User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/route53recoveryreadiness#1.0.0 Route53RecoveryReadiness Route53RecoveryReadiness +POST route53-recovery-readiness.us-east-1.amazonaws.com /cells route53-recovery-readiness User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/route53recoveryreadiness#1.0.0 Route53RecoveryReadiness Route53RecoveryReadiness +POST localhost:4566 /cells?X-Amz-Credential=AKIA%2F20260101%2Fus-east-1%2Froute53-recovery-readiness%2Faws4_request&X-Amz-Signature=00 User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/route53recoveryreadiness#1.0.0 Route53RecoveryReadiness Route53RecoveryReadiness +POST localhost:4566 /cells S3 S3 +POST localhost:4566 /crossaccountauthorizations route53-recovery-readiness User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/route53recoveryreadiness#1.0.0 Route53RecoveryReadiness Route53RecoveryReadiness +POST localhost:4566 /crossaccountauthorizations execute-api User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/route53recoveryreadiness#1.0.0 Route53RecoveryReadiness Route53RecoveryReadiness +POST route53-recovery-readiness.us-east-1.amazonaws.com /crossaccountauthorizations route53-recovery-readiness User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/route53recoveryreadiness#1.0.0 Route53RecoveryReadiness Route53RecoveryReadiness +POST localhost:4566 /crossaccountauthorizations?X-Amz-Credential=AKIA%2F20260101%2Fus-east-1%2Froute53-recovery-readiness%2Faws4_request&X-Amz-Signature=00 User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/route53recoveryreadiness#1.0.0 Route53RecoveryReadiness Route53RecoveryReadiness +POST localhost:4566 /crossaccountauthorizations S3 S3 +POST localhost:4566 /readinesschecks route53-recovery-readiness User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/route53recoveryreadiness#1.0.0 Route53RecoveryReadiness Route53RecoveryReadiness +POST localhost:4566 /readinesschecks execute-api User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/route53recoveryreadiness#1.0.0 Route53RecoveryReadiness Route53RecoveryReadiness +POST route53-recovery-readiness.us-east-1.amazonaws.com /readinesschecks route53-recovery-readiness User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/route53recoveryreadiness#1.0.0 Route53RecoveryReadiness Route53RecoveryReadiness +POST localhost:4566 /readinesschecks?X-Amz-Credential=AKIA%2F20260101%2Fus-east-1%2Froute53-recovery-readiness%2Faws4_request&X-Amz-Signature=00 User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/route53recoveryreadiness#1.0.0 Route53RecoveryReadiness Route53RecoveryReadiness +POST localhost:4566 /readinesschecks S3 S3 +POST localhost:4566 /recoverygroups route53-recovery-readiness User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/route53recoveryreadiness#1.0.0 Route53RecoveryReadiness Route53RecoveryReadiness +POST localhost:4566 /recoverygroups execute-api User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/route53recoveryreadiness#1.0.0 Route53RecoveryReadiness Route53RecoveryReadiness +POST route53-recovery-readiness.us-east-1.amazonaws.com /recoverygroups route53-recovery-readiness User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/route53recoveryreadiness#1.0.0 Route53RecoveryReadiness Route53RecoveryReadiness +POST localhost:4566 /recoverygroups?X-Amz-Credential=AKIA%2F20260101%2Fus-east-1%2Froute53-recovery-readiness%2Faws4_request&X-Amz-Signature=00 User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/route53recoveryreadiness#1.0.0 Route53RecoveryReadiness Route53RecoveryReadiness +POST localhost:4566 /recoverygroups S3 S3 +POST localhost:4566 /resourcesets route53-recovery-readiness User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/route53recoveryreadiness#1.0.0 Route53RecoveryReadiness Route53RecoveryReadiness +POST localhost:4566 /resourcesets S3 S3 +DELETE localhost:4566 /cells/xcellna route53-recovery-readiness User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/route53recoveryreadiness#1.0.0 Route53RecoveryReadiness Route53RecoveryReadiness +DELETE localhost:4566 /cells/xcellna S3 S3 +DELETE localhost:4566 /crossaccountauthorizations/xcrossa route53-recovery-readiness User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/route53recoveryreadiness#1.0.0 Route53RecoveryReadiness Route53RecoveryReadiness +DELETE localhost:4566 /crossaccountauthorizations/xcrossa S3 S3 +DELETE localhost:4566 /readinesschecks/xreadin route53-recovery-readiness User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/route53recoveryreadiness#1.0.0 Route53RecoveryReadiness Route53RecoveryReadiness +DELETE localhost:4566 /readinesschecks/xreadin S3 S3 +DELETE localhost:4566 /recoverygroups/xrecove route53-recovery-readiness User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/route53recoveryreadiness#1.0.0 Route53RecoveryReadiness Route53RecoveryReadiness +DELETE localhost:4566 /recoverygroups/xrecove S3 S3 +DELETE localhost:4566 /resourcesets/xresour route53-recovery-readiness User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/route53recoveryreadiness#1.0.0 Route53RecoveryReadiness Route53RecoveryReadiness +DELETE localhost:4566 /resourcesets/xresour S3 S3 +GET localhost:4566 /recoverygroups/xrecove/architectureRecommendations route53-recovery-readiness User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/route53recoveryreadiness#1.0.0 Route53RecoveryReadiness Route53RecoveryReadiness +GET localhost:4566 /recoverygroups/xrecove/architectureRecommendations S3 S3 +GET localhost:4566 /cells/xcellna route53-recovery-readiness User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/route53recoveryreadiness#1.0.0 Route53RecoveryReadiness Route53RecoveryReadiness +GET localhost:4566 /cells/xcellna S3 S3 +GET localhost:4566 /cellreadiness/xcellna route53-recovery-readiness User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/route53recoveryreadiness#1.0.0 Route53RecoveryReadiness Route53RecoveryReadiness +GET localhost:4566 /readinesschecks/xreadin route53-recovery-readiness User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/route53recoveryreadiness#1.0.0 Route53RecoveryReadiness Route53RecoveryReadiness +GET localhost:4566 /readinesschecks/xreadin/resource/xresour/status route53-recovery-readiness User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/route53recoveryreadiness#1.0.0 Route53RecoveryReadiness Route53RecoveryReadiness +GET localhost:4566 /readinesschecks/xreadin/status route53-recovery-readiness User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/route53recoveryreadiness#1.0.0 Route53RecoveryReadiness Route53RecoveryReadiness +GET localhost:4566 /recoverygroups/xrecove route53-recovery-readiness User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/route53recoveryreadiness#1.0.0 Route53RecoveryReadiness Route53RecoveryReadiness +GET localhost:4566 /recoverygroupreadiness/xrecove route53-recovery-readiness User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/route53recoveryreadiness#1.0.0 Route53RecoveryReadiness Route53RecoveryReadiness +GET localhost:4566 /resourcesets/xresour route53-recovery-readiness User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/route53recoveryreadiness#1.0.0 Route53RecoveryReadiness Route53RecoveryReadiness +GET localhost:4566 /cells route53-recovery-readiness User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/route53recoveryreadiness#1.0.0 Route53RecoveryReadiness Route53RecoveryReadiness +GET localhost:4566 /crossaccountauthorizations route53-recovery-readiness User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/route53recoveryreadiness#1.0.0 Route53RecoveryReadiness Route53RecoveryReadiness +GET localhost:4566 /readinesschecks route53-recovery-readiness User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/route53recoveryreadiness#1.0.0 Route53RecoveryReadiness Route53RecoveryReadiness +GET localhost:4566 /recoverygroups route53-recovery-readiness User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/route53recoveryreadiness#1.0.0 Route53RecoveryReadiness Route53RecoveryReadiness +GET localhost:4566 /resourcesets route53-recovery-readiness User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/route53recoveryreadiness#1.0.0 Route53RecoveryReadiness Route53RecoveryReadiness +GET localhost:4566 /rules route53-recovery-readiness User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/route53recoveryreadiness#1.0.0 Route53RecoveryReadiness Route53RecoveryReadiness +GET localhost:4566 /tags/xresour route53-recovery-readiness User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/route53recoveryreadiness#1.0.0 Route53RecoveryReadiness Route53RecoveryReadiness +POST localhost:4566 /tags/xresour route53-recovery-readiness User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/route53recoveryreadiness#1.0.0 Route53RecoveryReadiness Route53RecoveryReadiness +DELETE localhost:4566 /tags/xresour route53-recovery-readiness User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/route53recoveryreadiness#1.0.0 Route53RecoveryReadiness Route53RecoveryReadiness +PUT localhost:4566 /cells/xcellna route53-recovery-readiness User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/route53recoveryreadiness#1.0.0 Route53RecoveryReadiness Route53RecoveryReadiness +PUT localhost:4566 /readinesschecks/xreadin route53-recovery-readiness User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/route53recoveryreadiness#1.0.0 Route53RecoveryReadiness Route53RecoveryReadiness +PUT localhost:4566 /recoverygroups/xrecove route53-recovery-readiness User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/route53recoveryreadiness#1.0.0 Route53RecoveryReadiness Route53RecoveryReadiness +PUT localhost:4566 /resourcesets/xresour route53-recovery-readiness User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/route53recoveryreadiness#1.0.0 Route53RecoveryReadiness Route53RecoveryReadiness POST localhost:4566 / route53resolver application/x-amz-json-1.1 Route53Resolver.AssociateFirewallRuleGroup {} User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/route53resolver#1.0.0 Route53Resolver Route53Resolver POST localhost:4566 / application/x-amz-json-1.1 Route53Resolver.AssociateFirewallRuleGroup {} Route53Resolver Route53Resolver POST localhost:4566 / execute-api application/x-amz-json-1.1 Route53Resolver.AssociateFirewallRuleGroup {} User-Agent:aws-sdk-go-v2/1.41.0 ua/2.1 os/linux lang/go#1.26 api/route53resolver#1.0.0 Route53Resolver Route53Resolver diff --git a/ui/package-lock.json b/ui/package-lock.json index 6aac2055b8..2451259435 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -24,6 +24,7 @@ "@aws-sdk/client-apprunner": "3.1146.0", "@aws-sdk/client-appstream": "3.1146.0", "@aws-sdk/client-appsync": "3.1146.0", + "@aws-sdk/client-arc-zonal-shift": "3.1146.0", "@aws-sdk/client-athena": "3.1146.0", "@aws-sdk/client-auto-scaling": "3.1146.0", "@aws-sdk/client-backup": "3.1146.0", @@ -133,6 +134,9 @@ "@aws-sdk/client-resource-groups-tagging-api": "3.1146.0", "@aws-sdk/client-rolesanywhere": "3.1146.0", "@aws-sdk/client-route-53": "3.1146.0", + "@aws-sdk/client-route53-recovery-cluster": "3.1146.0", + "@aws-sdk/client-route53-recovery-control-config": "3.1146.0", + "@aws-sdk/client-route53-recovery-readiness": "3.1146.0", "@aws-sdk/client-route53resolver": "3.1146.0", "@aws-sdk/client-s3": "3.1146.0", "@aws-sdk/client-s3-control": "3.1146.0", @@ -676,6 +680,25 @@ "node": ">=20.0.0" } }, + "node_modules/@aws-sdk/client-arc-zonal-shift": { + "version": "3.1146.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-arc-zonal-shift/-/client-arc-zonal-shift-3.1146.0.tgz", + "integrity": "sha512-gC8jAtC4RMMydK0VanCt54GIf9ShKJkmObDHlYSAKoF8kUHik6ehM4ilgRHOiCC52Me3LyxzQzzH8Qq6BQU02A==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.978.1", + "@aws-sdk/credential-provider-node": "^3.972.84", + "@aws-sdk/types": "^3.974.6", + "@smithy/core": "^3.35.0", + "@smithy/fetch-http-handler": "^5.8.0", + "@smithy/node-http-handler": "^4.12.1", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/@aws-sdk/client-athena": { "version": "3.1146.0", "resolved": "https://registry.npmjs.org/@aws-sdk/client-athena/-/client-athena-3.1146.0.tgz", @@ -2832,6 +2855,63 @@ "node": ">=20.0.0" } }, + "node_modules/@aws-sdk/client-route53-recovery-cluster": { + "version": "3.1146.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-route53-recovery-cluster/-/client-route53-recovery-cluster-3.1146.0.tgz", + "integrity": "sha512-DCffTmcfG6Iu3lJMjbFq+oP94ry8qCNWf+HRFEBaJ6T1JBMv1w0lFQCDm/turdJ7hFRc1Pz0+kMl34JdYiJE7Q==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.978.1", + "@aws-sdk/credential-provider-node": "^3.972.84", + "@aws-sdk/types": "^3.974.6", + "@smithy/core": "^3.35.0", + "@smithy/fetch-http-handler": "^5.8.0", + "@smithy/node-http-handler": "^4.12.1", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/client-route53-recovery-control-config": { + "version": "3.1146.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-route53-recovery-control-config/-/client-route53-recovery-control-config-3.1146.0.tgz", + "integrity": "sha512-XNtO0m8M3qMFfjcVjojnelPnfe4a18+pdPLOKnk97XSq/ZRpvXtWkY9EnJ+EAIe0yxO9wBAugjH+6vM06aqGwg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.978.1", + "@aws-sdk/credential-provider-node": "^3.972.84", + "@aws-sdk/types": "^3.974.6", + "@smithy/core": "^3.35.0", + "@smithy/fetch-http-handler": "^5.8.0", + "@smithy/node-http-handler": "^4.12.1", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/client-route53-recovery-readiness": { + "version": "3.1146.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-route53-recovery-readiness/-/client-route53-recovery-readiness-3.1146.0.tgz", + "integrity": "sha512-I6YVhAXjgII66K2BJD3HFBmMGa+9b6H9XN2uYFfUfmoCr+oeJyAGWH4BUPOlS594fKAJ9Bu/jougPRGrfLCiOA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.978.1", + "@aws-sdk/credential-provider-node": "^3.972.84", + "@aws-sdk/types": "^3.974.6", + "@smithy/core": "^3.35.0", + "@smithy/fetch-http-handler": "^5.8.0", + "@smithy/node-http-handler": "^4.12.1", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/@aws-sdk/client-route53resolver": { "version": "3.1146.0", "resolved": "https://registry.npmjs.org/@aws-sdk/client-route53resolver/-/client-route53resolver-3.1146.0.tgz", diff --git a/ui/package.json b/ui/package.json index 819535da72..dc49de3e08 100644 --- a/ui/package.json +++ b/ui/package.json @@ -39,6 +39,7 @@ "@aws-sdk/client-apprunner": "3.1146.0", "@aws-sdk/client-appstream": "3.1146.0", "@aws-sdk/client-appsync": "3.1146.0", + "@aws-sdk/client-arc-zonal-shift": "3.1146.0", "@aws-sdk/client-athena": "3.1146.0", "@aws-sdk/client-auto-scaling": "3.1146.0", "@aws-sdk/client-backup": "3.1146.0", @@ -148,6 +149,9 @@ "@aws-sdk/client-resource-groups-tagging-api": "3.1146.0", "@aws-sdk/client-rolesanywhere": "3.1146.0", "@aws-sdk/client-route-53": "3.1146.0", + "@aws-sdk/client-route53-recovery-cluster": "3.1146.0", + "@aws-sdk/client-route53-recovery-control-config": "3.1146.0", + "@aws-sdk/client-route53-recovery-readiness": "3.1146.0", "@aws-sdk/client-route53resolver": "3.1146.0", "@aws-sdk/client-s3": "3.1146.0", "@aws-sdk/client-s3-control": "3.1146.0", diff --git a/ui/src/lib/arc.test.ts b/ui/src/lib/arc.test.ts new file mode 100644 index 0000000000..19a16adb5d --- /dev/null +++ b/ui/src/lib/arc.test.ts @@ -0,0 +1,117 @@ +import { describe, it, expect, vi } from "vitest"; +import { + describeError, + endpointPath, + isApiError, + RESOURCE_SET_TYPE_LIST, + statusBadgeClass, + withClusterEndpoints, +} from "./arc"; + +const clients: { path: string; region: string }[] = []; +vi.mock("#lib/aws-client.ts", () => ({ + getRoute53RecoveryClusterClient: (path: string, region: string) => { + const c = { path, region }; + clients.push(c); + return c; + }, +})); + +describe("arc helpers", () => { + it.each([ + [ + "http://h:1/route53-recovery-cluster/abc/us-west-2", + "/route53-recovery-cluster/abc/us-west-2", + ], + [ + "http://h:1/route53-recovery-cluster/abc/us-west-2/", + "/route53-recovery-cluster/abc/us-west-2", + ], + [undefined, ""], + ])("endpointPath(%s)", (input, want) => { + expect(endpointPath(input)).toBe(want); + }); + + it("describeError includes code and status", () => { + const e = Object.assign(new Error("blocked"), { + name: "ConflictException", + $metadata: { httpStatusCode: 409 }, + }); + expect(describeError(e)).toBe("ConflictException (HTTP 409): blocked"); + expect(isApiError(e)).toBe(true); + expect(isApiError(new TypeError("Failed to fetch"))).toBe(false); + }); + + it("statusBadgeClass maps states", () => { + expect(statusBadgeClass("READY")).toContain("green"); + expect(statusBadgeClass("NOT_READY")).toContain("amber"); + expect(statusBadgeClass("FAILED")).toContain("red"); + expect(statusBadgeClass("x")).toContain("gray"); + }); + + it("withClusterEndpoints prefers the region, fails over on network errors only", async () => { + clients.length = 0; + const eps = [ + { Endpoint: "http://h/p/c/us-east-1", Region: "us-east-1" }, + { Endpoint: "http://h/p/c/us-west-2", Region: "us-west-2" }, + ]; + const seen: string[] = []; + const out = await withClusterEndpoints(eps, "us-west-2", (c) => { + const cl = c as unknown as { region: string }; + seen.push(cl.region); + if (cl.region === "us-west-2") { + return Promise.reject(new TypeError("Failed to fetch")); + } + return Promise.resolve("ok"); + }); + expect(out).toBe("ok"); + expect(seen).toEqual(["us-west-2", "us-east-1"]); + + const conflict = Object.assign(new Error("no"), { + name: "ConflictException", + $metadata: { httpStatusCode: 409 }, + }); + const tried: string[] = []; + await expect( + withClusterEndpoints(eps, "us-east-1", (c) => { + tried.push((c as unknown as { region: string }).region); + return Promise.reject(conflict); + }), + ).rejects.toBe(conflict); + expect(tried).toEqual(["us-east-1"]); + + await expect(withClusterEndpoints([], "us-east-1", () => Promise.resolve(1))).rejects.toThrow( + "no endpoints", + ); + }); +}); + +describe("RESOURCE_SET_TYPE_LIST", () => { + it("matches the backend's accepted resource set types", () => { + // Source: services/route53recoveryreadiness/sets.go resourceSetTypes() and its type* consts. + const backend = [ + "AWS::ApiGateway::Stage", + "AWS::ApiGatewayV2::Stage", + "AWS::AutoScaling::AutoScalingGroup", + "AWS::CloudWatch::Alarm", + "AWS::EC2::CustomerGateway", + "AWS::DynamoDB::Table", + "AWS::EC2::Volume", + "AWS::ElasticLoadBalancing::LoadBalancer", + "AWS::ElasticLoadBalancingV2::LoadBalancer", + "AWS::Lambda::Function", + "AWS::MSK::Cluster", + "AWS::RDS::DBCluster", + "AWS::Route53::HealthCheck", + "AWS::SQS::Queue", + "AWS::SNS::Topic", + "AWS::SNS::Subscription", + "AWS::EC2::VPC", + "AWS::EC2::VPNConnection", + "AWS::EC2::VPNGateway", + "AWS::Route53RecoveryReadiness::DNSTargetResource", + ]; + expect(backend).toHaveLength(20); + expect(RESOURCE_SET_TYPE_LIST.toSorted()).toEqual(backend.toSorted()); + }); +}); diff --git a/ui/src/lib/arc.ts b/ui/src/lib/arc.ts new file mode 100644 index 0000000000..a4ba55b827 --- /dev/null +++ b/ui/src/lib/arc.ts @@ -0,0 +1,107 @@ +import type { ClusterEndpoint } from "@aws-sdk/client-route53-recovery-control-config"; +import type { Route53RecoveryClusterClient } from "@aws-sdk/client-route53-recovery-cluster"; +import { getRoute53RecoveryClusterClient } from "#lib/aws-client.ts"; + +export function describeError(e: unknown): string { + if (e && typeof e === "object") { + const rec = e as { name?: unknown; message?: unknown; $metadata?: { httpStatusCode?: number } }; + const name = rec.name ? String(rec.name) : "Error"; + const message = rec.message ? String(rec.message) : String(e); + const status = rec.$metadata?.httpStatusCode; + return status ? `${name} (HTTP ${status}): ${message}` : `${name}: ${message}`; + } + return String(e); +} + +export function isApiError(e: unknown): boolean { + const status = (e as { $metadata?: { httpStatusCode?: number } } | null)?.$metadata + ?.httpStatusCode; + return typeof status === "number" && status > 0; +} + +export function endpointPath(endpoint: string | undefined): string { + if (!endpoint) { + return ""; + } + try { + return new URL(endpoint).pathname.replace(/\/+$/, ""); + } catch { + return endpoint.replace(/\/+$/, ""); + } +} + +// Tries the preferred region's endpoint first, then the rest, moving on only when the +// request never reached the service; an API-level answer (e.g. ConflictException) is final. +export async function withClusterEndpoints( + endpoints: ClusterEndpoint[], + preferredRegion: string, + call: (client: Route53RecoveryClusterClient) => Promise, +): Promise { + const usable = endpoints.filter((e) => e.Endpoint && e.Region); + if (usable.length === 0) { + throw new Error("Cluster has no endpoints"); + } + const ordered = [ + ...usable.filter((e) => e.Region === preferredRegion), + ...usable.filter((e) => e.Region !== preferredRegion), + ]; + let last: unknown; + for (const ep of ordered) { + try { + return await call( + getRoute53RecoveryClusterClient(endpointPath(ep.Endpoint), ep.Region as string), + ); + } catch (e) { + if (isApiError(e)) { + throw e; + } + last = e; + } + } + throw last; +} + +export function statusBadgeClass(status: string | undefined): string { + switch (status) { + case "DEPLOYED": + case "READY": + case "ACTIVE": + case "ENABLED": + case "On": + return "bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400"; + case "PENDING": + case "PENDING_DELETION": + case "NOT_READY": + case "SKIPPED": + case "UNKNOWN": + return "bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-400"; + case "FAILED": + case "CANCELED": + return "bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-400"; + default: + return "bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400"; + } +} + +export const RESOURCE_SET_TYPE_LIST: string[] = [ + "AWS::ApiGateway::Stage", + "AWS::ApiGatewayV2::Stage", + "AWS::AutoScaling::AutoScalingGroup", + "AWS::CloudWatch::Alarm", + "AWS::DynamoDB::Table", + "AWS::EC2::CustomerGateway", + "AWS::EC2::Volume", + "AWS::EC2::VPC", + "AWS::EC2::VPNConnection", + "AWS::EC2::VPNGateway", + "AWS::ElasticLoadBalancing::LoadBalancer", + "AWS::ElasticLoadBalancingV2::LoadBalancer", + "AWS::Lambda::Function", + "AWS::MSK::Cluster", + "AWS::RDS::DBCluster", + "AWS::Route53::HealthCheck", + "AWS::SNS::Subscription", + "AWS::SNS::Topic", + "AWS::Route53RecoveryReadiness::DNSTargetResource", + "AWS::SQS::Queue", +]; diff --git a/ui/src/lib/aws-client.ts b/ui/src/lib/aws-client.ts index dde40ea381..3f5d87d0a4 100644 --- a/ui/src/lib/aws-client.ts +++ b/ui/src/lib/aws-client.ts @@ -88,6 +88,10 @@ import { PersonalizeRuntimeClient } from "@aws-sdk/client-personalize-runtime"; import { QuickSightClient } from "@aws-sdk/client-quicksight"; import { RolesAnywhereClient } from "@aws-sdk/client-rolesanywhere"; import { WorkMailClient } from "@aws-sdk/client-workmail"; +import { Route53RecoveryControlConfigClient } from "@aws-sdk/client-route53-recovery-control-config"; +import { Route53RecoveryClusterClient } from "@aws-sdk/client-route53-recovery-cluster"; +import { Route53RecoveryReadinessClient } from "@aws-sdk/client-route53-recovery-readiness"; +import { ARCZonalShiftClient } from "@aws-sdk/client-arc-zonal-shift"; import { S3Client } from "@aws-sdk/client-s3"; import { DynamoDBClient } from "@aws-sdk/client-dynamodb"; import { regionProvider } from "#lib/region.svelte.ts"; @@ -792,3 +796,29 @@ import { DynamoDBStreamsClient } from "@aws-sdk/client-dynamodb-streams"; export function getDynamoDBStreamsClient(region?: string): DynamoDBStreamsClient { return new DynamoDBStreamsClient(clientConfig(region)); } + +export function getRoute53RecoveryControlConfigClient( + region?: string, +): Route53RecoveryControlConfigClient { + return new Route53RecoveryControlConfigClient(clientConfig(region)); +} + +export function getRoute53RecoveryReadinessClient(region?: string): Route53RecoveryReadinessClient { + return new Route53RecoveryReadinessClient(clientConfig(region)); +} + +export function getARCZonalShiftClient(region?: string): ARCZonalShiftClient { + return new ARCZonalShiftClient(clientConfig(region)); +} + +// The cluster data plane lives under a per-cluster, per-region path on this server, +// so the host the control plane reports is replaced by the dashboard origin. +export function getRoute53RecoveryClusterClient( + endpointPath: string, + region: string, +): Route53RecoveryClusterClient { + return new Route53RecoveryClusterClient({ + ...clientConfig(region), + endpoint: `${endpointURL()}${endpointPath}`, + }); +} diff --git a/ui/src/lib/nav.ts b/ui/src/lib/nav.ts index d5cd134f9b..f27caabc83 100644 --- a/ui/src/lib/nav.ts +++ b/ui/src/lib/nav.ts @@ -167,6 +167,10 @@ export const implementedDashboardRouteIds = new Set([ "resourcegroups", "resourcegroupstaggingapi", "swf", + "route53recoverycontrolconfig", + "route53recoverycluster", + "route53recoveryreadiness", + "arczonalshift", ]); // The 31 most commonly used AWS services shown in the sidebar (routes flagged `common: true`). @@ -651,6 +655,30 @@ export const sidebarCategories: DashboardCategory[] = [ }, { id: "transfer", href: "/dashboard/transfer", label: "Transfer Family", icon: "transfer" }, { id: "appmesh", href: "/dashboard/appmesh", label: "App Mesh", icon: "appmesh" }, + { + id: "route53recoverycontrolconfig", + href: "/dashboard/route53recoverycontrolconfig", + label: "ARC Recovery Control", + icon: "route53", + }, + { + id: "route53recoverycluster", + href: "/dashboard/route53recoverycluster", + label: "ARC Recovery Cluster", + icon: "route53", + }, + { + id: "route53recoveryreadiness", + href: "/dashboard/route53recoveryreadiness", + label: "ARC Recovery Readiness", + icon: "route53", + }, + { + id: "arczonalshift", + href: "/dashboard/arczonalshift", + label: "ARC Zonal Shift", + icon: "arczonalshift", + }, ], }, { diff --git a/ui/src/lib/tab-loader.svelte.ts b/ui/src/lib/tab-loader.svelte.ts index 345fc87c2b..af0e68d6f7 100644 --- a/ui/src/lib/tab-loader.svelte.ts +++ b/ui/src/lib/tab-loader.svelte.ts @@ -20,6 +20,8 @@ export type TabLoader = { load(tab: K, force?: boolean): Promise; /** Force a reload of a tab regardless of whether it is already loaded. */ refresh(tab: K): Promise; + /** Force-reload every tab that has loaded, plus `include` (e.g. the active tab) even if it has not. */ + refreshLoaded(include?: K): Promise; isLoading(tab: K): boolean; getError(tab: K): string | null; isLoaded(tab: K): boolean; @@ -126,6 +128,13 @@ export function createTabLoader( return load(tab, true); } + async function refreshLoaded(include?: K): Promise { + const tabs = (Object.keys(fetchers) as K[]).filter( + (t) => t === include || untrack(() => stateFor(t).loaded), + ); + await Promise.all(tabs.map((t) => load(t, true))); + } + function isLoading(tab: K): boolean { return stateFor(tab).loading; } @@ -138,5 +147,5 @@ export function createTabLoader( return stateFor(tab).loaded; } - return { load, refresh, isLoading, getError, isLoaded }; + return { load, refresh, refreshLoaded, isLoading, getError, isLoaded }; } diff --git a/ui/src/lib/tab-loader.test.ts b/ui/src/lib/tab-loader.test.ts index 784d6b68d7..b49c22dd96 100644 --- a/ui/src/lib/tab-loader.test.ts +++ b/ui/src/lib/tab-loader.test.ts @@ -67,6 +67,18 @@ describe("createTabLoader", () => { expect(loader.isLoaded("a")).toBe(true); }); + it("refreshLoaded refetches only tabs that have loaded", async () => { + const a = vi.fn(async () => {}); + const b = vi.fn(async () => {}); + const loader = createTabLoader({ a, b }); + + await loader.load("a"); + await loader.refreshLoaded(); + + expect(a).toHaveBeenCalledTimes(2); + expect(b).not.toHaveBeenCalled(); + }); + it("captures errors into per-tab error state instead of throwing", async () => { const fetcher = vi.fn().mockRejectedValue(new Error("boom")); const loader = createTabLoader({ a: fetcher }); diff --git a/ui/src/routes/arczonalshift/+page.svelte b/ui/src/routes/arczonalshift/+page.svelte new file mode 100644 index 0000000000..c6f2be2358 --- /dev/null +++ b/ui/src/routes/arczonalshift/+page.svelte @@ -0,0 +1,473 @@ + + +{#snippet badge(value: string | undefined)} + {value ?? '—'} +{/snippet} + +{#snippet shiftTable(rows: ZonalShiftSummary[], empty: string, practice: boolean)} + {#if rows.length === 0} +
{empty}
+ {:else} +
+ + + {#if practice}{/if} + + + + {#each rows as s (s.zonalShiftId)} + + + + + + {#if practice}{/if} + + + + + + {/each} + +
Shift IDResourceAway fromStatusOutcomeStartedExpiresComment
{s.zonalShiftId}{resourceName(s.resourceIdentifier)}{s.awayFrom}{@render badge(s.status)}{s.practiceRunOutcome ?? '—'}{formatDate(s.startTime)}{formatDate(s.expiryTime)}{s.comment || '—'} + {#if s.status === 'ACTIVE'} + + {/if} +
+ {/if} +{/snippet} + +
+ tabLoader.refresh(activeTab)} + color="sky" + /> + +
+
+ + +
+
+ {#if activeTabError} + + {/if} + + {#if tabLoader.isLoading(activeTab)} +
Loading...
+ {:else if activeTab === 'resources'} + {#if fResources.length === 0} +
No managed resources found
+ {:else} +
+ + + + + + {#each fResources as r (r.arn)} + + + + + + + + + {/each} + +
ResourceZones (weight)Active shiftsAutoshiftPractice runsActions
{r.name}
{r.arn}
+ {#each r.availabilityZones ?? [] as az (az)} +
{az} ({r.appliedWeights?.[az] ?? 1})
+ {/each} +
{(r.zonalShifts ?? []).length}{@render badge(r.zonalAutoshiftStatus)}{@render badge(r.practiceRunStatus)} +
+ + + +
+
+ {/if} + {:else if activeTab === 'shifts'} + {@render shiftTable(fShifts, 'No zonal shifts found', false)} + {:else if activeTab === 'practice'} + {@render shiftTable(fPractice, 'No practice runs found', true)} + {:else if autoshifts.length === 0} +
No autoshifts found
+ {:else} +
+ + + + + {#each autoshifts as a (`${a.awayFrom}-${a.startTime}`)} + + + + + {/each} + +
Away fromStatusStartedEnded
{a.awayFrom}{@render badge(a.status)}{formatDate(a.startTime)}{formatDate(a.endTime)}
+ {/if} +
+
+
+ +{#snippet errorLine()} + {#if modalError}

{modalError}

{/if} +{/snippet} + + + {#snippet children()} +
+

{shiftResource?.name}

+ + + {#if shiftMode === 'zonal'} + + + {/if} + + + {@render errorLine()} +
+ {/snippet} + {#snippet footer()} + + + {/snippet} +
+ + + {#snippet children()} +
+

{configResource?.name}

+ + + + +
+ + + {#if existingConfig} + + {/if} +
+ {@render errorLine()} +
+ {/snippet} + {#snippet footer()} + + + {/snippet} +
diff --git a/ui/src/routes/arczonalshift/page.test.ts b/ui/src/routes/arczonalshift/page.test.ts new file mode 100644 index 0000000000..19a1bfada4 --- /dev/null +++ b/ui/src/routes/arczonalshift/page.test.ts @@ -0,0 +1,167 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen, fireEvent, waitFor } from "@testing-library/svelte"; +import { DEFAULT_REGION, setStoredRegion } from "#lib/region.svelte.ts"; +import Page from "./+page.svelte"; + +type Handler = (input: Record) => unknown; +const h: Record = {}; + +vi.mock("#lib/aws-client.ts", () => ({ + getARCZonalShiftClient: () => ({ + send: (cmd: { constructor: { name: string }; input: Record }) => { + const fn = h[cmd.constructor.name]; + if (!fn) return Promise.reject(new Error(`unexpected ${cmd.constructor.name}`)); + try { + return Promise.resolve(fn(cmd.input)); + } catch (e) { + return Promise.reject(e); + } + }, + }), +})); +const confirmDestructive = vi.fn().mockResolvedValue(true); +vi.mock("#lib/confirm-dialog.ts", () => ({ + confirmDestructive: (...a: unknown[]) => confirmDestructive(...a), +})); +vi.mock("svelte-sonner", () => ({ toast: { success: vi.fn(), error: vi.fn() } })); + +const lb = "arn:aws:elasticloadbalancing:us-west-2:0:loadbalancer/app/api-alb/1"; +const shift = (id: string, type: string, extra = {}) => ({ + zonalShiftId: id, + resourceIdentifier: lb, + awayFrom: "usw2-az1", + status: "ACTIVE", + comment: "c", + shiftType: type, + startTime: new Date("2026-01-01T00:00:00Z"), + expiryTime: new Date("2026-01-01T02:00:00Z"), + ...extra, +}); + +describe("ARC Zonal Shift page", () => { + beforeEach(() => { + for (const k of Object.keys(h)) delete h[k]; + h.ListManagedResourcesCommand = () => ({ + items: [ + { + arn: lb, + name: "api-alb", + availabilityZones: ["usw2-az1", "usw2-az2"], + appliedWeights: { "usw2-az1": 0, "usw2-az2": 1 }, + zonalShifts: [{ appliedStatus: "APPLIED" }], + zonalAutoshiftStatus: "DISABLED", + practiceRunStatus: "ENABLED", + }, + ], + }); + h.ListZonalShiftsCommand = () => ({ + items: [ + shift("zs-1", "ZONAL_SHIFT"), + shift("pr-1", "PRACTICE_RUN", { practiceRunOutcome: "PENDING" }), + ], + }); + h.ListAutoshiftsCommand = () => ({ items: [] }); + confirmDestructive.mockClear(); + }); + + it("lists managed resources with zones and weights", async () => { + render(Page); + await waitFor(() => expect(screen.getByText("api-alb")).toBeInTheDocument()); + expect(screen.getByText("usw2-az1 (0)")).toBeInTheDocument(); + expect(screen.getByText("usw2-az2 (1)")).toBeInTheDocument(); + }); + + it.each([ + ["Zonal Shifts", "zs-1", "pr-1"], + ["Practice Runs", "pr-1", "zs-1"], + ])("%s tab filters by shift type", async (tab, shown, hidden) => { + render(Page); + await waitFor(() => screen.getByText("api-alb")); + await fireEvent.click(screen.getByRole("tab", { name: tab })); + await waitFor(() => expect(screen.getByText(shown)).toBeInTheDocument()); + expect(screen.queryByText(hidden)).not.toBeInTheDocument(); + }); + + it("starts a zonal shift", async () => { + const started: Record[] = []; + h.StartZonalShiftCommand = (i) => { + started.push(i); + return {}; + }; + render(Page); + await waitFor(() => screen.getByText("api-alb")); + await fireEvent.click(screen.getByLabelText("Start zonal shift for api-alb")); + await fireEvent.input(screen.getByLabelText("Comment"), { target: { value: "evacuate" } }); + await fireEvent.click(screen.getByRole("button", { name: "Start" })); + await waitFor(() => + expect(started).toEqual([ + { resourceIdentifier: lb, awayFrom: "usw2-az1", expiresIn: "1h", comment: "evacuate" }, + ]), + ); + }); + + it("starts a practice run", async () => { + const started: Record[] = []; + h.StartPracticeRunCommand = (i) => { + started.push(i); + return {}; + }; + render(Page); + await waitFor(() => screen.getByText("api-alb")); + await fireEvent.click(screen.getByLabelText("Start practice run for api-alb")); + await fireEvent.input(screen.getByLabelText("Comment"), { target: { value: "drill" } }); + await fireEvent.click(screen.getByRole("button", { name: "Start" })); + await waitFor(() => + expect(started).toEqual([{ resourceIdentifier: lb, awayFrom: "usw2-az1", comment: "drill" }]), + ); + }); + + it("cancels an active zonal shift after confirmation", async () => { + const canceled: Record[] = []; + h.CancelZonalShiftCommand = (i) => { + canceled.push(i); + return {}; + }; + render(Page); + await waitFor(() => screen.getByText("api-alb")); + await fireEvent.click(screen.getByRole("tab", { name: "Zonal Shifts" })); + await waitFor(() => screen.getByLabelText("Cancel zonal shift zs-1")); + await fireEvent.click(screen.getByLabelText("Cancel zonal shift zs-1")); + await waitFor(() => expect(canceled).toEqual([{ zonalShiftId: "zs-1" }])); + }); + + it("shows a load error with the AWS error code", async () => { + h.ListManagedResourcesCommand = () => { + throw Object.assign(new Error("denied"), { + name: "AccessDeniedException", + $metadata: { httpStatusCode: 403 }, + }); + }; + render(Page); + await waitFor(() => + expect(screen.getByText("AccessDeniedException (HTTP 403): denied")).toBeInTheDocument(), + ); + }); + + it("reloads every loaded tab when the region changes", async () => { + let shiftLists = 0; + const list = h.ListZonalShiftsCommand; + h.ListZonalShiftsCommand = (input) => { + shiftLists += 1; + return list(input); + }; + render(Page); + await waitFor(() => screen.getByText("api-alb")); + await fireEvent.click(screen.getByRole("tab", { name: "Zonal Shifts" })); + await waitFor(() => screen.getByText("zs-1")); + await fireEvent.click(screen.getByRole("tab", { name: "Managed Resources" })); + expect(shiftLists).toBe(1); + + setStoredRegion("us-west-2"); + try { + await waitFor(() => expect(shiftLists).toBe(2)); + } finally { + setStoredRegion(DEFAULT_REGION); + } + }); +}); diff --git a/ui/src/routes/route53recoverycluster/+page.svelte b/ui/src/routes/route53recoverycluster/+page.svelte new file mode 100644 index 0000000000..6dc91f919d --- /dev/null +++ b/ui/src/routes/route53recoverycluster/+page.svelte @@ -0,0 +1,238 @@ + + +
+ + +
+
+
+ + + + + {#if cluster} + {cluster.Status} + {/if} +
+ +
+ +
+ {#if cluster} +

+ {endpointPath(endpoints.find((e) => e.Region === endpointRegion)?.Endpoint)} +

+ {/if} + {#if loadError} + + {/if} + {#if flipError} + + {/if} + {#if loading} +
Loading...
+ {:else if filtered.length === 0} +
No routing controls found
+ {:else} +
+ + + + + + + + + {#each filtered as rc (rc.RoutingControlArn)} + + + + + + + {/each} + +
Routing controlControl panelStateChange state
+
{rc.RoutingControlName}
+
{rc.RoutingControlArn}
+
{rc.ControlPanelName} + {rc.RoutingControlState ?? 'Unknown'} + + +
+
+ {/if} +
+
+
diff --git a/ui/src/routes/route53recoverycluster/page.test.ts b/ui/src/routes/route53recoverycluster/page.test.ts new file mode 100644 index 0000000000..e7a7ed139b --- /dev/null +++ b/ui/src/routes/route53recoverycluster/page.test.ts @@ -0,0 +1,107 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen, fireEvent, waitFor } from "@testing-library/svelte"; +import Page from "./+page.svelte"; + +type Handler = (input: Record) => unknown; +const cfg: Record = {}; +const data: Record = {}; +const regionsUsed: string[] = []; + +function dispatch(table: Record) { + return (cmd: { constructor: { name: string }; input: Record }) => { + const h = table[cmd.constructor.name]; + if (!h) return Promise.reject(new Error(`unexpected ${cmd.constructor.name}`)); + try { + return Promise.resolve(h(cmd.input)); + } catch (e) { + return Promise.reject(e); + } + }; +} + +vi.mock("#lib/aws-client.ts", () => ({ + getRoute53RecoveryControlConfigClient: () => ({ send: dispatch(cfg) }), + getRoute53RecoveryClusterClient: (_p: string, region: string) => { + regionsUsed.push(region); + return { send: dispatch(data) }; + }, +})); +vi.mock("svelte-sonner", () => ({ toast: { success: vi.fn(), error: vi.fn() } })); + +const rc = "arn:aws:route53-recovery-control::000000000000:controlpanel/p/routingcontrol/r1"; + +describe("Route 53 Recovery Cluster page", () => { + beforeEach(() => { + for (const t of [cfg, data]) for (const k of Object.keys(t)) delete t[k]; + regionsUsed.length = 0; + cfg.ListClustersCommand = () => ({ + Clusters: [ + { + ClusterArn: "arn:c", + Name: "prod", + Status: "DEPLOYED", + ClusterEndpoints: [ + { Endpoint: "http://h/route53-recovery-cluster/c/us-east-1", Region: "us-east-1" }, + { Endpoint: "http://h/route53-recovery-cluster/c/us-west-2", Region: "us-west-2" }, + ], + }, + ], + }); + }); + + it("reads routing control state from the cluster endpoint", async () => { + data.ListRoutingControlsCommand = () => ({ + RoutingControls: [ + { + RoutingControlArn: rc, + RoutingControlName: "east", + ControlPanelName: "panel", + RoutingControlState: "On", + }, + ], + }); + render(Page); + await waitFor(() => expect(screen.getByTestId("state-east")).toHaveTextContent("On")); + expect(regionsUsed.length).toBeGreaterThan(0); + }); + + it("surfaces ConflictException from UpdateRoutingControlState", async () => { + data.ListRoutingControlsCommand = () => ({ + RoutingControls: [ + { RoutingControlArn: rc, RoutingControlName: "east", RoutingControlState: "On" }, + ], + }); + data.UpdateRoutingControlStateCommand = () => { + throw Object.assign(new Error("blocked by rule"), { + name: "ConflictException", + $metadata: { httpStatusCode: 409 }, + }); + }; + render(Page); + await waitFor(() => screen.getByLabelText("Turn east Off")); + await fireEvent.click(screen.getByLabelText("Turn east Off")); + await waitFor(() => + expect(screen.getByTestId("flip-error")).toHaveTextContent( + "ConflictException (HTTP 409): blocked by rule", + ), + ); + }); + + it("shows an empty state when there are no clusters", async () => { + cfg.ListClustersCommand = () => ({ Clusters: [] }); + render(Page); + await waitFor(() => expect(screen.getByText("No routing controls found")).toBeInTheDocument()); + }); + + it("lists every page of clusters", async () => { + cfg.ListClustersCommand = (input) => + input.NextToken + ? { Clusters: [{ ClusterArn: "arn:c2", Name: "second", ClusterEndpoints: [] }] } + : { + Clusters: [{ ClusterArn: "arn:c1", Name: "first", ClusterEndpoints: [] }], + NextToken: "p2", + }; + render(Page); + await waitFor(() => expect(screen.getByRole("option", { name: "second" })).toBeInTheDocument()); + }); +}); diff --git a/ui/src/routes/route53recoverycontrolconfig/+page.svelte b/ui/src/routes/route53recoverycontrolconfig/+page.svelte new file mode 100644 index 0000000000..281687703b --- /dev/null +++ b/ui/src/routes/route53recoverycontrolconfig/+page.svelte @@ -0,0 +1,902 @@ + + +
+ + {#snippet actions()} + {#if activeTab === 'clusters'} + + {:else if activeTab === 'panels'} + + {:else if activeTab === 'controls'} + + {:else} + + {/if} + {/snippet} + + +
+
+ + +
+ +
+ {#if activeTab === 'controls' || activeTab === 'rules'} +
+ + +
+ {/if} + + {#if activeTabError} + + {/if} + + {#if activeTab === 'controls' && flipError} + + {/if} + + {#if activeTab === 'controls' && statesError} + + {/if} + + {#if tabLoader.isLoading(activeTab)} +
Loading...
+ {:else if activeTab === 'clusters'} + {#if filteredClusters.length === 0} +
No clusters found
+ {:else} +
+ + + + + + + + + {#each filteredClusters as c (c.ClusterArn)} + + + + + + + + + {#if expandedCluster === c.ClusterArn} + + + + {/if} + {/each} + +
NameStatusNetworkRegionEndpoints
+
{c.Name}
+
{c.ClusterArn}
+
+ {c.Status ?? '—'} + {c.NetworkType ?? '—'} + + + +
+ + + + + + {#each c.ClusterEndpoints ?? [] as ep (ep.Region)} + + {/each} + +
RegionEndpoint
{ep.Region}{ep.Endpoint}
+
+
+ {/if} + {:else if activeTab === 'panels'} + {#if filteredPanels.length === 0} +
No control panels found
+ {:else} +
+ + + + + + + + + {#each filteredPanels as p (p.ControlPanelArn)} + + + + + + + + + {/each} + +
NameStatusRouting controlsDefaultCluster
+
{p.Name}
+
{p.ControlPanelArn}
+
+ {p.Status ?? '—'} + {p.RoutingControlCount ?? 0}{p.DefaultControlPanel ? 'Yes' : 'No'}{clusters.find((c) => c.ClusterArn === p.ClusterArn)?.Name ?? p.ClusterArn} + +
+
+ {/if} + {:else if activeTab === 'controls'} + {#if filteredControls.length === 0} +
No routing controls found
+ {:else} +
+ + + + + + + + + {#each filteredControls as rc (rc.RoutingControlArn)} + {@const state = states[rc.RoutingControlArn ?? '']} + + + + + + + + {/each} + +
NameConfig statusLive stateChange state
+
{rc.Name}
+
{rc.RoutingControlArn}
+
+ {rc.Status ?? '—'} + + {state || 'Unknown'} + + + + +
+
+ {/if} + {:else if filteredRules.length === 0} +
No safety rules found
+ {:else} +
+ + + + + + + + + {#each filteredRules as r (r.arn)} + + + + + + + + + + {/each} + +
NameKindStatusRuleControlsWait
+
{r.name}
+
{r.arn}
+
{r.kind === 'ASSERTION' ? 'Assertion' : 'Gating'} + {r.status ?? '—'} + {r.type}{r.type === 'ATLEAST' ? ` ${r.threshold}` : ''}{r.inverted ? ' (inverted)' : ''} + {r.kind === 'GATING' ? 'Gating: ' : 'Asserted: '}{r.controls.map(shortName).join(', ')} + {#if r.kind === 'GATING'}
Targets: {r.targets.map(shortName).join(', ')}{/if} +
{r.waitMs ?? 0} ms + +
+
+ {/if} +
+
+
+ +{#snippet errorLine()} + {#if modalError} +

{modalError}

+ {/if} +{/snippet} + + + {#snippet children()} +
+ + + + + {@render errorLine()} +
+ {/snippet} + {#snippet footer()} + + + {/snippet} +
+ + + {#snippet children()} +
+ + + + + {@render errorLine()} +
+ {/snippet} + {#snippet footer()} + + + {/snippet} +
+ + + {#snippet children()} +
+ + + {@render errorLine()} +
+ {/snippet} + {#snippet footer()} + + + {/snippet} +
+ + + {#snippet children()} +
+ + + + +
+
+ + +
+
+ + +
+
+ + + +
+ {ruleKind === 'ASSERTION' ? 'Asserted controls' : 'Gating controls'} + {#each controls as rc (rc.RoutingControlArn)} + + {/each} +
+ {#if ruleKind === 'GATING'} +
+ Target controls + {#each controls as rc (rc.RoutingControlArn)} + + {/each} +
+ {/if} + {@render errorLine()} +
+ {/snippet} + {#snippet footer()} + + + {/snippet} +
diff --git a/ui/src/routes/route53recoverycontrolconfig/page.test.ts b/ui/src/routes/route53recoverycontrolconfig/page.test.ts new file mode 100644 index 0000000000..6ecef82653 --- /dev/null +++ b/ui/src/routes/route53recoverycontrolconfig/page.test.ts @@ -0,0 +1,283 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen, fireEvent, waitFor } from "@testing-library/svelte"; +import { DEFAULT_REGION, setStoredRegion } from "#lib/region.svelte.ts"; +import Page from "./+page.svelte"; + +type Handler = (input: Record) => unknown; +const configHandlers: Record = {}; +const clusterHandlers: Record = {}; +const clusterRegions: string[] = []; + +function dispatch(table: Record) { + return (cmd: { constructor: { name: string }; input: Record }) => { + const h = table[cmd.constructor.name]; + if (!h) { + return Promise.reject(new Error(`unexpected command ${cmd.constructor.name}`)); + } + try { + return Promise.resolve(h(cmd.input)); + } catch (e) { + return Promise.reject(e); + } + }; +} + +vi.mock("#lib/aws-client.ts", () => ({ + getRoute53RecoveryControlConfigClient: () => ({ send: dispatch(configHandlers) }), + getRoute53RecoveryClusterClient: (_path: string, region: string) => { + clusterRegions.push(region); + return { send: dispatch(clusterHandlers) }; + }, +})); + +const confirmDestructive = vi.fn().mockResolvedValue(true); +vi.mock("#lib/confirm-dialog.ts", () => ({ + confirmDestructive: (...args: unknown[]) => confirmDestructive(...args), +})); +vi.mock("svelte-sonner", () => ({ toast: { success: vi.fn(), error: vi.fn() } })); + +const clusterArn = "arn:aws:route53-recovery-control::000000000000:cluster/c1"; +const panelArn = "arn:aws:route53-recovery-control::000000000000:controlpanel/p1"; +const rcEast = `${panelArn}/routingcontrol/east`; +const rcWest = `${panelArn}/routingcontrol/west`; + +const cluster = { + ClusterArn: clusterArn, + Name: "prod-cluster", + Status: "DEPLOYED", + NetworkType: "IPV4", + ClusterEndpoints: [ + { Endpoint: "http://h/route53-recovery-cluster/c1/us-east-1", Region: "us-east-1" }, + { Endpoint: "http://h/route53-recovery-cluster/c1/us-west-2", Region: "us-west-2" }, + ], +}; +const panel = { + ControlPanelArn: panelArn, + ClusterArn: clusterArn, + Name: "app-panel", + Status: "DEPLOYED", + RoutingControlCount: 2, + DefaultControlPanel: false, +}; + +function seed(states: Record) { + configHandlers.ListClustersCommand = () => ({ Clusters: [cluster] }); + configHandlers.ListControlPanelsCommand = () => ({ ControlPanels: [panel] }); + configHandlers.ListRoutingControlsCommand = () => ({ + RoutingControls: [ + { + RoutingControlArn: rcEast, + Name: "east-active", + Status: "DEPLOYED", + ControlPanelArn: panelArn, + }, + { + RoutingControlArn: rcWest, + Name: "west-active", + Status: "DEPLOYED", + ControlPanelArn: panelArn, + }, + ], + }); + configHandlers.ListSafetyRulesCommand = () => ({ + SafetyRules: [ + { + ASSERTION: { + SafetyRuleArn: `${panelArn}/safetyrule/a1`, + Name: "at-least-one", + Status: "DEPLOYED", + AssertedControls: [rcEast, rcWest], + RuleConfig: { Type: "ATLEAST", Threshold: 1, Inverted: false }, + WaitPeriodMs: 0, + }, + }, + { + GATING: { + SafetyRuleArn: `${panelArn}/safetyrule/g1`, + Name: "gate-west", + Status: "DEPLOYED", + GatingControls: [rcEast], + TargetControls: [rcWest], + RuleConfig: { Type: "AND", Threshold: 1, Inverted: false }, + WaitPeriodMs: 5, + }, + }, + ], + }); + clusterHandlers.ListRoutingControlsCommand = () => ({ + RoutingControls: Object.entries(states).map(([arn, s]) => ({ + RoutingControlArn: arn, + RoutingControlState: s, + })), + }); +} + +async function openTab(name: string) { + await fireEvent.click(screen.getByRole("tab", { name })); +} + +describe("Route 53 Recovery Control page", () => { + beforeEach(() => { + for (const t of [configHandlers, clusterHandlers]) { + for (const k of Object.keys(t)) delete t[k]; + } + clusterRegions.length = 0; + confirmDestructive.mockClear(); + }); + + it("lists clusters with status and endpoint count", async () => { + seed({}); + render(Page); + await waitFor(() => expect(screen.getByText("prod-cluster")).toBeInTheDocument()); + expect(screen.getByText("DEPLOYED")).toBeInTheDocument(); + await fireEvent.click(screen.getByLabelText("Toggle endpoints for prod-cluster")); + expect(screen.getByText("http://h/route53-recovery-cluster/c1/us-west-2")).toBeInTheDocument(); + }); + + it("shows live routing control state read through the data plane", async () => { + seed({ [rcEast]: "On", [rcWest]: "Off" }); + render(Page); + await waitFor(() => screen.getByText("prod-cluster")); + await openTab("Routing Controls"); + await waitFor(() => expect(screen.getByTestId("state-east-active")).toHaveTextContent("On")); + expect(screen.getByTestId("state-west-active")).toHaveTextContent("Off"); + }); + + it("flips a routing control through the data plane", async () => { + seed({ [rcEast]: "On", [rcWest]: "Off" }); + const updates: Record[] = []; + clusterHandlers.UpdateRoutingControlStateCommand = (input) => { + updates.push(input); + return {}; + }; + render(Page); + await waitFor(() => screen.getByText("prod-cluster")); + await openTab("Routing Controls"); + await waitFor(() => screen.getByLabelText("Turn west-active On")); + await fireEvent.click(screen.getByLabelText("Turn west-active On")); + await waitFor(() => + expect(updates).toEqual([{ RoutingControlArn: rcWest, RoutingControlState: "On" }]), + ); + }); + + it("shows the ConflictException when a safety rule blocks the flip", async () => { + seed({ [rcEast]: "Off", [rcWest]: "On" }); + clusterHandlers.UpdateRoutingControlStateCommand = () => { + throw Object.assign(new Error("assertion rule at-least-one blocks this change"), { + name: "ConflictException", + $metadata: { httpStatusCode: 409 }, + }); + }; + render(Page); + await waitFor(() => screen.getByText("prod-cluster")); + await openTab("Routing Controls"); + await waitFor(() => screen.getByLabelText("Turn west-active Off")); + await fireEvent.click(screen.getByLabelText("Turn west-active Off")); + await waitFor(() => + expect(screen.getByTestId("flip-error")).toHaveTextContent( + "ConflictException (HTTP 409): assertion rule at-least-one blocks this change", + ), + ); + expect(screen.getByTestId("state-west-active")).toHaveTextContent("On"); + }); + + it("keeps loaded panels and does not refetch clusters when switching tabs", async () => { + seed({}); + let clusterLists = 0; + configHandlers.ListClustersCommand = () => { + clusterLists += 1; + return { Clusters: [cluster] }; + }; + render(Page); + await waitFor(() => screen.getByText("prod-cluster")); + await openTab("Safety Rules"); + await waitFor(() => screen.getByText("at-least-one")); + expect(screen.getByRole("option", { name: "app-panel" })).toBeInTheDocument(); + expect(clusterLists).toBe(1); + }); + + it("renders assertion and gating safety rules", async () => { + seed({}); + render(Page); + await waitFor(() => screen.getByText("prod-cluster")); + await openTab("Safety Rules"); + await waitFor(() => expect(screen.getByText("at-least-one")).toBeInTheDocument()); + expect(screen.getByText("gate-west")).toBeInTheDocument(); + expect(screen.getByText("Assertion")).toBeInTheDocument(); + expect(screen.getByText("Gating")).toBeInTheDocument(); + }); + + it("creates a cluster from the modal", async () => { + seed({}); + const created: Record[] = []; + configHandlers.CreateClusterCommand = (input) => { + created.push(input); + return {}; + }; + render(Page); + await waitFor(() => screen.getByText("prod-cluster")); + await fireEvent.click(screen.getByRole("button", { name: /Create cluster/ })); + await fireEvent.input(screen.getByLabelText("Cluster name"), { target: { value: "dr" } }); + await fireEvent.click(screen.getByRole("button", { name: "Create" })); + await waitFor(() => expect(created).toEqual([{ ClusterName: "dr", NetworkType: "IPV4" }])); + }); + + it("deletes a cluster only after confirmation", async () => { + seed({}); + const deleted: Record[] = []; + configHandlers.DeleteClusterCommand = (input) => { + deleted.push(input); + return {}; + }; + render(Page); + await waitFor(() => screen.getByText("prod-cluster")); + confirmDestructive.mockResolvedValueOnce(false); + await fireEvent.click(screen.getByLabelText("Delete cluster prod-cluster")); + expect(deleted).toEqual([]); + await fireEvent.click(screen.getByLabelText("Delete cluster prod-cluster")); + await waitFor(() => expect(deleted).toEqual([{ ClusterArn: clusterArn }])); + }); + + it("resolves safety rule control names across paginated routing controls", async () => { + seed({}); + configHandlers.ListRoutingControlsCommand = (input) => + input.NextToken + ? { RoutingControls: [{ RoutingControlArn: rcWest, Name: "west-active" }] } + : { + RoutingControls: [{ RoutingControlArn: rcEast, Name: "east-active" }], + NextToken: "page2", + }; + render(Page); + await waitFor(() => screen.getByText("prod-cluster")); + await openTab("Safety Rules"); + await waitFor(() => screen.getByText("at-least-one")); + expect(screen.getByText(/Asserted: east-active, west-active/)).toBeInTheDocument(); + }); + + it("reloads every loaded tab when the region changes", async () => { + seed({}); + const calls = { clusters: 0, rules: 0 }; + configHandlers.ListClustersCommand = () => { + calls.clusters += 1; + return { Clusters: [cluster] }; + }; + const rules = configHandlers.ListSafetyRulesCommand; + configHandlers.ListSafetyRulesCommand = (input) => { + calls.rules += 1; + return rules(input); + }; + render(Page); + await waitFor(() => screen.getByText("prod-cluster")); + await openTab("Safety Rules"); + await waitFor(() => screen.getByText("at-least-one")); + expect(calls).toEqual({ clusters: 1, rules: 1 }); + + setStoredRegion("us-west-2"); + try { + await waitFor(() => expect(calls.rules).toBe(2)); + await waitFor(() => expect(calls.clusters).toBe(2)); + } finally { + setStoredRegion(DEFAULT_REGION); + } + }); +}); diff --git a/ui/src/routes/route53recoveryreadiness/+page.svelte b/ui/src/routes/route53recoveryreadiness/+page.svelte new file mode 100644 index 0000000000..c4cc1b076a --- /dev/null +++ b/ui/src/routes/route53recoveryreadiness/+page.svelte @@ -0,0 +1,561 @@ + + +{#snippet badge(value: string | undefined)} + {value ?? '—'} +{/snippet} + +{#snippet delBtn(label: string, onclick: () => void)} + +{/snippet} + +
+ tabLoader.refresh(activeTab)} + color="teal" + > + {#snippet actions()} + {#if activeTab === 'groups'} + + {:else if activeTab === 'cells'} + + {:else if activeTab === 'sets'} + + {:else} + + {/if} + {/snippet} + + +
+
+ + +
+
+ {#if activeTabError} + + {/if} + + {#if tabLoader.isLoading(activeTab)} +
Loading...
+ {:else if activeTab === 'groups'} + {#if fGroups.length === 0} +
No recovery groups found
+ {:else} +
+ + + + + {#each fGroups as g (g.RecoveryGroupArn)} + + + + + + + {/each} + +
NameReadinessCells
{g.RecoveryGroupName}
{g.RecoveryGroupArn}
{@render badge(readiness[`group:${g.RecoveryGroupName}`])}{(g.Cells ?? []).map(cellName).join(', ') || '—'}{@render delBtn(`Delete recovery group ${g.RecoveryGroupName}`, () => delGroup(g))}
+ {/if} + {:else if activeTab === 'cells'} + {#if fCells.length === 0} +
No cells found
+ {:else} +
+ + + + + {#each fCells as c (c.CellArn)} + + + + + + + + {/each} + +
NameReadinessNested cellsParent groups
{c.CellName}
{c.CellArn}
{@render badge(readiness[`cell:${c.CellName}`])}{(c.Cells ?? []).map(cellName).join(', ') || '—'}{(c.ParentReadinessScopes ?? []).join(', ') || '—'}{@render delBtn(`Delete cell ${c.CellName}`, () => delCell(c))}
+ {/if} + {:else if activeTab === 'sets'} + {#if fSets.length === 0} +
No resource sets found
+ {:else} +
+ + + + + {#each fSets as s (s.ResourceSetArn)} + + + + + + + {/each} + +
NameTypeResources
{s.ResourceSetName}
{s.ResourceSetArn}
{s.ResourceSetType} + {#each s.Resources ?? [] as r (r.ResourceArn ?? r.ComponentId)} +
{r.ResourceArn ?? r.ComponentId} + scopes: {(r.ReadinessScopes ?? []).map(cellName).join(', ') || '—'}
+ {/each} +
{@render delBtn(`Delete resource set ${s.ResourceSetName}`, () => delSet(s))}
+ {/if} + {:else if fChecks.length === 0} +
No readiness checks found
+ {:else} +
+ + + + + {#each fChecks as c (c.ReadinessCheckArn)} + + + + + + + {/each} + +
NameReadinessResource set
{c.ReadinessCheckName}
{c.ReadinessCheckArn}
{@render badge(readiness[`check:${c.ReadinessCheckName}`])}{c.ResourceSet} +
+ + {@render delBtn(`Delete readiness check ${c.ReadinessCheckName}`, () => delCheck(c))} +
+
+ {/if} +
+
+
+ +{#snippet errorLine()} + {#if modalError}

{modalError}

{/if} +{/snippet} + + + {#snippet children()} +
+ + + {@render errorLine()} +
+ {/snippet} + {#snippet footer()} + + + {/snippet} +
+ + + {#snippet children()} +
+ + +
+ Cells + {#each cells as c (c.CellArn)} + + {/each} +
+ {@render errorLine()} +
+ {/snippet} + {#snippet footer()} + + + {/snippet} +
+ + + {#snippet children()} +
+ + + + + + + + + {@render errorLine()} +
+ {/snippet} + {#snippet footer()} + + + {/snippet} +
+ + + {#snippet children()} +
+ + + + + {@render errorLine()} +
+ {/snippet} + {#snippet footer()} + + + {/snippet} +
+ + + {#snippet children()} + {#if detailCheck} +
+

{detailCheck.ReadinessCheckName} {@render badge(detailReadiness)}

+ {#if detailError}

{detailError}

{/if} + {#each detailResources as r (r.ResourceArn ?? r.ComponentId)} +
+
{r.ResourceArn ?? r.ComponentId}
+
{@render badge(r.Readiness)} checked {formatDate(r.LastCheckedTimestamp)}
+
+ {/each} + {#each detailMessages as m, i (i)} +

{m.MessageText}

+ {/each} +
+ {/if} + {/snippet} +
diff --git a/ui/src/routes/route53recoveryreadiness/page.test.ts b/ui/src/routes/route53recoveryreadiness/page.test.ts new file mode 100644 index 0000000000..8d95fe029a --- /dev/null +++ b/ui/src/routes/route53recoveryreadiness/page.test.ts @@ -0,0 +1,172 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen, fireEvent, waitFor } from "@testing-library/svelte"; +import { DEFAULT_REGION, setStoredRegion } from "#lib/region.svelte.ts"; +import Page from "./+page.svelte"; + +type Handler = (input: Record) => unknown; +const h: Record = {}; + +vi.mock("#lib/aws-client.ts", () => ({ + getRoute53RecoveryReadinessClient: () => ({ + send: (cmd: { constructor: { name: string }; input: Record }) => { + const fn = h[cmd.constructor.name]; + if (!fn) return Promise.reject(new Error(`unexpected ${cmd.constructor.name}`)); + try { + return Promise.resolve(fn(cmd.input)); + } catch (e) { + return Promise.reject(e); + } + }, + }), +})); +const confirmDestructive = vi.fn().mockResolvedValue(true); +vi.mock("#lib/confirm-dialog.ts", () => ({ + confirmDestructive: (...a: unknown[]) => confirmDestructive(...a), +})); +vi.mock("svelte-sonner", () => ({ toast: { success: vi.fn(), error: vi.fn() } })); + +const cellArn = "arn:aws:route53-recovery-readiness::0:cell/cell_west"; + +describe("Route 53 Recovery Readiness page", () => { + beforeEach(() => { + for (const k of Object.keys(h)) delete h[k]; + h.ListCellsCommand = () => ({ + Cells: [{ CellArn: cellArn, CellName: "cell_west", Cells: [], ParentReadinessScopes: [] }], + }); + h.ListRecoveryGroupsCommand = () => ({ + RecoveryGroups: [ + { RecoveryGroupArn: "arn:g", RecoveryGroupName: "app_rg", Cells: [cellArn] }, + ], + }); + h.GetRecoveryGroupReadinessSummaryCommand = () => ({ Readiness: "NOT_READY" }); + h.GetCellReadinessSummaryCommand = () => ({ Readiness: "READY" }); + h.ListResourceSetsCommand = () => ({ + ResourceSets: [ + { + ResourceSetArn: "arn:rs", + ResourceSetName: "orders_queues", + ResourceSetType: "AWS::SQS::Queue", + Resources: [ + { ResourceArn: "arn:aws:sqs:us-west-2:0:orders", ReadinessScopes: [cellArn] }, + ], + }, + ], + }); + h.ListReadinessChecksCommand = () => ({ + ReadinessChecks: [ + { + ReadinessCheckArn: "arn:rc", + ReadinessCheckName: "orders_check", + ResourceSet: "orders_queues", + }, + ], + }); + h.GetReadinessCheckStatusCommand = () => ({ + Readiness: "READY", + Resources: [ + { + ResourceArn: "arn:aws:sqs:us-west-2:0:orders", + Readiness: "READY", + LastCheckedTimestamp: new Date("2026-01-01T00:00:00Z"), + }, + ], + }); + confirmDestructive.mockClear(); + }); + + it("shows recovery group readiness status", async () => { + render(Page); + await waitFor(() => + expect(screen.getByTestId("readiness-group-app_rg")).toHaveTextContent("NOT_READY"), + ); + expect(screen.getAllByText("cell_west").length).toBeGreaterThan(0); + }); + + it.each([ + ["Cells", "No cells found", "readiness-cell-cell_west", "READY"], + ["Readiness Checks", "No readiness checks found", "readiness-check-orders_check", "READY"], + ])("%s tab shows readiness", async (tab, _empty, testId, want) => { + render(Page); + await waitFor(() => screen.getByText("app_rg")); + await fireEvent.click(screen.getByRole("tab", { name: tab })); + await waitFor(() => expect(screen.getByTestId(testId)).toHaveTextContent(want)); + }); + + it("lists resource sets with type and scopes", async () => { + render(Page); + await waitFor(() => screen.getByText("app_rg")); + await fireEvent.click(screen.getByRole("tab", { name: "Resource Sets" })); + await waitFor(() => expect(screen.getByText("orders_queues")).toBeInTheDocument()); + expect(screen.getAllByText("AWS::SQS::Queue").length).toBeGreaterThan(0); + }); + + it("opens the readiness check detail with resource status", async () => { + render(Page); + await waitFor(() => screen.getByText("app_rg")); + await fireEvent.click(screen.getByRole("tab", { name: "Readiness Checks" })); + await waitFor(() => screen.getByLabelText("View readiness check orders_check")); + await fireEvent.click(screen.getByLabelText("View readiness check orders_check")); + await waitFor(() => + expect(screen.getAllByText("arn:aws:sqs:us-west-2:0:orders").length).toBeGreaterThan(0), + ); + }); + + it("creates a cell", async () => { + const created: Record[] = []; + h.CreateCellCommand = (i) => { + created.push(i); + return {}; + }; + render(Page); + await waitFor(() => screen.getByText("app_rg")); + await fireEvent.click(screen.getByRole("tab", { name: "Cells" })); + await waitFor(() => screen.getByTestId("readiness-cell-cell_west")); + await fireEvent.click(screen.getByRole("button", { name: /Create cell/ })); + await fireEvent.input(screen.getByLabelText("Cell name"), { target: { value: "cell_east" } }); + await fireEvent.click(screen.getByRole("button", { name: "Create" })); + await waitFor(() => expect(created).toEqual([{ CellName: "cell_east" }])); + }); + + it("deletes a recovery group after confirmation", async () => { + const deleted: Record[] = []; + h.DeleteRecoveryGroupCommand = (i) => { + deleted.push(i); + return {}; + }; + render(Page); + await waitFor(() => screen.getByText("app_rg")); + await fireEvent.click(screen.getByLabelText("Delete recovery group app_rg")); + await waitFor(() => expect(deleted).toEqual([{ RecoveryGroupName: "app_rg" }])); + }); + + it("reloads loaded tabs and clears stale readiness when the region changes", async () => { + let cellLists = 0; + const cells = h.ListCellsCommand; + h.ListCellsCommand = (input) => { + cellLists += 1; + return cells(input); + }; + render(Page); + await waitFor(() => screen.getByText("app_rg")); + await fireEvent.click(screen.getByRole("tab", { name: "Cells" })); + await waitFor(() => + expect(screen.getByTestId("readiness-cell-cell_west")).toHaveTextContent("READY"), + ); + await fireEvent.click(screen.getByRole("tab", { name: "Recovery Groups" })); + const before = cellLists; + + h.GetCellReadinessSummaryCommand = () => new Promise(() => {}); + h.GetRecoveryGroupReadinessSummaryCommand = () => new Promise(() => {}); + setStoredRegion("us-west-2"); + try { + await waitFor(() => expect(cellLists).toBeGreaterThan(before)); + await waitFor(() => + expect(screen.getByTestId("readiness-group-app_rg")).not.toHaveTextContent("NOT_READY"), + ); + await fireEvent.click(screen.getByRole("tab", { name: "Cells" })); + expect(screen.getByTestId("readiness-cell-cell_west")).not.toHaveTextContent("READY"); + } finally { + setStoredRegion(DEFAULT_REGION); + } + }); +});