From bcc2c19a9f1c80588a6b0518f12841dc9ca89d09 Mon Sep 17 00:00:00 2001 From: Witness Patrol Date: Tue, 13 Oct 2026 19:05:32 -0500 Subject: [PATCH 01/11] feat(arc): Application Recovery Controller services - route53-recovery-control-config: clusters (PENDING/DEPLOYED, five regional endpoints that route back to this server), control panels (default panel), routing controls, ASSERTION/GATING safety rules (ATLEAST/AND/OR, Inverted, WaitPeriodMs) with AWS's delete conflicts. - route53-recovery-cluster: Get/List/UpdateRoutingControlState(s) via the cluster endpoints from DescribeCluster; every update evaluates the safety rules atomically (ConflictException names the violated rule; SafetyRulesToOverride honoured). - route53-recovery-readiness: recovery groups, cells, resource sets and readiness checks evaluated live against the owning services (ELB, Route 53 health checks, SQS, SNS, Lambda, VPC, CloudWatch alarms, DynamoDB, DNS records); UNKNOWN for unevaluated types. - arc-zonal-shift: zonal shifts on ALBs/NLBs with expiry, conflicts and appliedStatus precedence; practice runs with blocking/outcome alarms and windows (--lifecycle-arczonalshift). - route53: RECOVERY_CONTROL health checks follow routing control state, so failover answers follow control flips. - iot: GET /rules no longer claims requests signed for another service. Adds the four SDK modules (core SDK and smithy-go patch bumps they require). Co-Authored-By: Claude Opus 5.5 (1M context) --- .badges/operations.svg | 6 +- .badges/parity.svg | 6 +- .badges/services.svg | 6 +- .beads/issues.jsonl | 6 + README.md | 6 + cli.go | 42 ++ cli_arc_recovery_test.go | 321 +++++++++++ cli_lifecycle.go | 19 + cmd/gendocs/categories.go | 85 +-- go.mod | 12 +- go.sum | 16 + .../testdata/snapshot_inventory.json | 107 ++++ pkgs/restroute/paginate.go | 36 ++ pkgs/restroute/restroute.go | 136 +++++ pkgs/restroute/restroute_test.go | 239 +++++++++ pkgs/restroute/serve.go | 143 +++++ pkgs/sdktest/serve.go | 31 ++ services/_PROTOCOLS.md | 4 + services/arczonalshift/PARITY.md | 41 ++ services/arczonalshift/README.md | 30 ++ services/arczonalshift/cross_service.go | 171 ++++++ services/arczonalshift/errors.go | 31 ++ services/arczonalshift/fixture_test.go | 92 ++++ services/arczonalshift/handler.go | 158 ++++++ services/arczonalshift/handler_ops.go | 203 +++++++ services/arczonalshift/leak_main_test.go | 9 + services/arczonalshift/managed.go | 227 ++++++++ services/arczonalshift/misc_test.go | 167 ++++++ services/arczonalshift/models.go | 67 +++ services/arczonalshift/persistence.go | 61 +++ services/arczonalshift/practice.go | 378 +++++++++++++ services/arczonalshift/practice_test.go | 502 ++++++++++++++++++ services/arczonalshift/provider.go | 36 ++ services/arczonalshift/shifts.go | 331 ++++++++++++ services/arczonalshift/shifts_test.go | 296 +++++++++++ services/arczonalshift/store.go | 100 ++++ services/arczonalshift/time_test.go | 161 ++++++ services/iot/handler_routing.go | 10 +- services/iot/route_scope_rules_test.go | 45 ++ services/route53/cross_service.go | 78 +++ services/route53/health_checks.go | 2 +- services/route53/provider.go | 1 + services/route53/recovery_control_test.go | 194 +++++++ services/route53/routing.go | 7 +- services/route53/store.go | 1 + services/route53recoverycluster/PARITY.md | 28 + services/route53recoverycluster/README.md | 19 + .../route53recoverycluster/dataplane_test.go | 109 ++++ .../route53recoverycluster/fixture_test.go | 67 +++ services/route53recoverycluster/handler.go | 174 ++++++ .../route53recoverycluster/leak_main_test.go | 9 + services/route53recoverycluster/misc_test.go | 110 ++++ services/route53recoverycluster/ops.go | 161 ++++++ services/route53recoverycluster/provider.go | 32 ++ .../route53recoverycluster/safety_test.go | 241 +++++++++ .../route53recoverycontrolconfig/PARITY.md | 51 ++ .../route53recoverycontrolconfig/README.md | 26 + .../route53recoverycontrolconfig/clusters.go | 207 ++++++++ .../controlpanels.go | 180 +++++++ .../cross_service.go | 38 ++ .../fixture_test.go | 72 +++ .../route53recoverycontrolconfig/handler.go | 178 +++++++ .../handler_ops.go | 353 ++++++++++++ .../leak_main_test.go | 9 + .../lifecycle_test.go | 112 ++++ .../route53recoverycontrolconfig/misc_test.go | 170 ++++++ .../route53recoverycontrolconfig/models.go | 74 +++ .../route53recoverycontrolconfig/ops_test.go | 479 +++++++++++++++++ .../persistence.go | 69 +++ .../route53recoverycontrolconfig/provider.go | 36 ++ .../routingcontrols.go | 162 ++++++ .../route53recoverycontrolconfig/rules.go | 238 +++++++++ .../rules_test.go | 216 ++++++++ .../route53recoverycontrolconfig/state.go | 320 +++++++++++ .../state_test.go | 311 +++++++++++ .../route53recoverycontrolconfig/store.go | 171 ++++++ services/route53recoverycontrolconfig/tags.go | 103 ++++ services/route53recoverycontrolconfig/wire.go | 98 ++++ services/route53recoveryreadiness/PARITY.md | 58 ++ services/route53recoveryreadiness/README.md | 24 + services/route53recoveryreadiness/cells.go | 204 +++++++ services/route53recoveryreadiness/checks.go | 110 ++++ services/route53recoveryreadiness/evaluate.go | 245 +++++++++ .../route53recoveryreadiness/fixture_test.go | 84 +++ services/route53recoveryreadiness/groups.go | 120 +++++ services/route53recoveryreadiness/handler.go | 201 +++++++ .../route53recoveryreadiness/handler_ops.go | 394 ++++++++++++++ .../leak_main_test.go | 9 + .../route53recoveryreadiness/misc_test.go | 183 +++++++ services/route53recoveryreadiness/models.go | 93 ++++ .../route53recoveryreadiness/persistence.go | 65 +++ services/route53recoveryreadiness/provider.go | 36 ++ .../readiness_test.go | 367 +++++++++++++ .../recommendations.go | 154 ++++++ services/route53recoveryreadiness/resolver.go | 269 ++++++++++ services/route53recoveryreadiness/sets.go | 197 +++++++ services/route53recoveryreadiness/store.go | 136 +++++ services/route53recoveryreadiness/summary.go | 277 ++++++++++ services/route53recoveryreadiness/tags.go | 136 +++++ .../route53recoveryreadiness/topology_test.go | 463 ++++++++++++++++ testdata/routing/corpus.tsv | 164 ++++++ 101 files changed, 13176 insertions(+), 56 deletions(-) create mode 100644 cli_arc_recovery_test.go create mode 100644 pkgs/restroute/paginate.go create mode 100644 pkgs/restroute/restroute.go create mode 100644 pkgs/restroute/restroute_test.go create mode 100644 pkgs/restroute/serve.go create mode 100644 pkgs/sdktest/serve.go create mode 100644 services/arczonalshift/PARITY.md create mode 100644 services/arczonalshift/README.md create mode 100644 services/arczonalshift/cross_service.go create mode 100644 services/arczonalshift/errors.go create mode 100644 services/arczonalshift/fixture_test.go create mode 100644 services/arczonalshift/handler.go create mode 100644 services/arczonalshift/handler_ops.go create mode 100644 services/arczonalshift/leak_main_test.go create mode 100644 services/arczonalshift/managed.go create mode 100644 services/arczonalshift/misc_test.go create mode 100644 services/arczonalshift/models.go create mode 100644 services/arczonalshift/persistence.go create mode 100644 services/arczonalshift/practice.go create mode 100644 services/arczonalshift/practice_test.go create mode 100644 services/arczonalshift/provider.go create mode 100644 services/arczonalshift/shifts.go create mode 100644 services/arczonalshift/shifts_test.go create mode 100644 services/arczonalshift/store.go create mode 100644 services/arczonalshift/time_test.go create mode 100644 services/iot/route_scope_rules_test.go create mode 100644 services/route53/cross_service.go create mode 100644 services/route53/recovery_control_test.go create mode 100644 services/route53recoverycluster/PARITY.md create mode 100644 services/route53recoverycluster/README.md create mode 100644 services/route53recoverycluster/dataplane_test.go create mode 100644 services/route53recoverycluster/fixture_test.go create mode 100644 services/route53recoverycluster/handler.go create mode 100644 services/route53recoverycluster/leak_main_test.go create mode 100644 services/route53recoverycluster/misc_test.go create mode 100644 services/route53recoverycluster/ops.go create mode 100644 services/route53recoverycluster/provider.go create mode 100644 services/route53recoverycluster/safety_test.go create mode 100644 services/route53recoverycontrolconfig/PARITY.md create mode 100644 services/route53recoverycontrolconfig/README.md create mode 100644 services/route53recoverycontrolconfig/clusters.go create mode 100644 services/route53recoverycontrolconfig/controlpanels.go create mode 100644 services/route53recoverycontrolconfig/cross_service.go create mode 100644 services/route53recoverycontrolconfig/fixture_test.go create mode 100644 services/route53recoverycontrolconfig/handler.go create mode 100644 services/route53recoverycontrolconfig/handler_ops.go create mode 100644 services/route53recoverycontrolconfig/leak_main_test.go create mode 100644 services/route53recoverycontrolconfig/lifecycle_test.go create mode 100644 services/route53recoverycontrolconfig/misc_test.go create mode 100644 services/route53recoverycontrolconfig/models.go create mode 100644 services/route53recoverycontrolconfig/ops_test.go create mode 100644 services/route53recoverycontrolconfig/persistence.go create mode 100644 services/route53recoverycontrolconfig/provider.go create mode 100644 services/route53recoverycontrolconfig/routingcontrols.go create mode 100644 services/route53recoverycontrolconfig/rules.go create mode 100644 services/route53recoverycontrolconfig/rules_test.go create mode 100644 services/route53recoverycontrolconfig/state.go create mode 100644 services/route53recoverycontrolconfig/state_test.go create mode 100644 services/route53recoverycontrolconfig/store.go create mode 100644 services/route53recoverycontrolconfig/tags.go create mode 100644 services/route53recoverycontrolconfig/wire.go create mode 100644 services/route53recoveryreadiness/PARITY.md create mode 100644 services/route53recoveryreadiness/README.md create mode 100644 services/route53recoveryreadiness/cells.go create mode 100644 services/route53recoveryreadiness/checks.go create mode 100644 services/route53recoveryreadiness/evaluate.go create mode 100644 services/route53recoveryreadiness/fixture_test.go create mode 100644 services/route53recoveryreadiness/groups.go create mode 100644 services/route53recoveryreadiness/handler.go create mode 100644 services/route53recoveryreadiness/handler_ops.go create mode 100644 services/route53recoveryreadiness/leak_main_test.go create mode 100644 services/route53recoveryreadiness/misc_test.go create mode 100644 services/route53recoveryreadiness/models.go create mode 100644 services/route53recoveryreadiness/persistence.go create mode 100644 services/route53recoveryreadiness/provider.go create mode 100644 services/route53recoveryreadiness/readiness_test.go create mode 100644 services/route53recoveryreadiness/recommendations.go create mode 100644 services/route53recoveryreadiness/resolver.go create mode 100644 services/route53recoveryreadiness/sets.go create mode 100644 services/route53recoveryreadiness/store.go create mode 100644 services/route53recoveryreadiness/summary.go create mode 100644 services/route53recoveryreadiness/tags.go create mode 100644 services/route53recoveryreadiness/topology_test.go 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..1948cd34c4 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 + 166 A · 7 B · 4 C + 166 A · 7 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 3997563ab1..eb724b096c 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -1,6 +1,9 @@ {"_type":"issue","id":"gopherstack-3js4","title":"dynamodb: DeleteTable leaves fisReplicationPaused keyed by a name-deterministic ARN, so a recreated table starts replication-paused","status":"closed","priority":0,"issue_type":"bug","created_at":"2026-09-04T07:54:20Z","updated_at":"2026-09-04T08:00:46Z","closed_at":"2026-09-04T08:00:46Z","close_reason":"fixed and regression-tested on chore/parity-sweep-2026-09-03","labels":["parity-campaign"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"gopherstack-wnmd","title":"lambda: DeleteFunction leaves ~17 side maps including resource-policy permissions and aliases; a recreated function inherits them","status":"closed","priority":0,"issue_type":"bug","created_at":"2026-09-04T07:54:19Z","updated_at":"2026-09-04T08:00:45Z","started_at":"2026-09-04T07:54:30Z","closed_at":"2026-09-04T08:00:45Z","close_reason":"fixed: DeleteFunction now reuses deleteFunctionMapsLocked; verified via the real HTTP handler that a recreated function no longer inherits the resource policy, aliases or concurrency","labels":["parity-campaign"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"gopherstack-gmc5","title":"HANDOFF 2026-08-06: uncommitted in-flight work on chore/parity-upgrade","description":"Session ended mid-flight. 18 commits pushed on chore/parity-upgrade (PR #2414). The working tree has UNCOMMITTED work that survives on disk — do not discard it.\n\nUNCOMMITTED, VERIFIED COMPLETE (safe to commit after re-running gates):\n- services/grafana (12 files) — B to A. New test/integration/grafana_test.go, real cross-service validation via a new cross_service.go (captures ctx.Config at Provider.Init, resolves sibling handlers lazily on first request, no cli.go edits — REUSE THIS PATTERN for outposts/mgn/resiliencehub), chaos-driven FAILED/DEGRADED transitions, ListVersions moved to structural_gaps. Unit gates verified green by the main thread.\n- services/networkmanager (15 files) — gap to A. New test/integration/networkmanager_test.go, cross-service validation against EC2/DirectConnect, a real single-hop TGW route-analysis walk replacing a hardcoded NOT_CONNECTED, and a real core-network policy diff engine. Unit gates verified green.\n\nUNCOMMITTED, MID-EDIT (an agent was still working when the session ended — REVIEW BEFORE TRUSTING):\n- services/bedrockagent, services/cleanrooms, pkgs/httputils, cli.go, test/integration/tag_routing_test.go\n\nTHE BLOCKER — read this before committing anything above.\nTestIntegration_Grafana_WorkspaceLifecycle/Tags FAILS (grafana_test.go:521, TagResource should succeed). Root cause is a router bug class, NOT grafana:\nSeveral services' RouteMatcher do an unguarded strings.HasPrefix(path, \"/tags/\") with no SigV4 service-scope guard, so they swallow other services' tag requests. Confirmed in services/bedrockagent/handler.go:229-234 and services/cleanrooms/handler.go:312-323. A previous pass had masked this by escalating networkManagerMatchPriority to 88; that escalation was reverted (correctly) which un-masked cleanrooms. Do NOT re-escalate priority — cleanrooms beats grafana regardless of networkmanager's priority.\nCorrect fix, in progress: guard each prefix fallback so it does not match when ExtractServiceFromRequest names a different known service; sweep EVERY service RouteMatcher for the same pattern (check /resourcepolicy, /flows, /agents, /prompts too); extend test/integration/tag_routing_test.go to cover every service serving /tags/ in ONE binary run. A shared helper (service.PrefixMatcherWithScopeGuard) was proposed so the convention cannot be skipped. Tracked as gopherstack-sokq.\nThis class is invisible when services test alone — every one passes in isolation.\n\nNEXT SERVICES for the all-services-A program (2 herdr tabs max, sonnet, see the herdr-delegate skill): outposts (gopherstack-b9mg), mgn (gopherstack-xd34), resiliencehub (gopherstack-lxs2). directconnect already landed in 198990e82.\n\nPROCESS NOTE: the directconnect agent committed despite an explicit instruction not to. Main thread commits; re-state that in every brief and verify with git log.","status":"closed","priority":0,"issue_type":"task","owner":"blackbird7181@gmail.com","created_at":"2026-08-06T19:27:57Z","created_by":"Witness Patrol","updated_at":"2026-08-07T05:29:00Z","closed_at":"2026-08-07T05:29:00Z","close_reason":"Handoff complete. All work it described has landed and pushed: grafana and networkmanager reached A, the router prefix-collision fix (ef896bcf1), and the follow-on services. Nothing uncommitted remains.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"gopherstack-cfam2","title":"perf(s3): stream MD5 for UploadPart instead of hashing a buffered copy","description":"PGO 2026-10-12: md5 in UploadPart = ~4.5-6% CPU. Hash while reading the body (single pass), avoid extra copies; ETag and checksum behaviour unchanged.","notes":"Investigated 2026-10-13: UploadPart and PutObject already hash in a single streaming MultiWriter pass; md5 cost is the ETag floor. Added Content-MD5/ETag guard test.","status":"closed","priority":1,"issue_type":"task","owner":"blackbird7181@gmail.com","created_at":"2026-10-13T03:49:23Z","created_by":"Witness Patrol","updated_at":"2026-10-13T04:24:59Z","closed_at":"2026-10-13T04:24:59Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"gopherstack-6dyao","title":"perf(dynamodb): cache sorted scan order instead of re-sorting each Scan","description":"PGO 2026-10-12: sortScanResultsSized via storeScanOrder = ~4.7% CPU. Cache sorted order per table/index, invalidate on writes; identical results/pagination.","status":"closed","priority":1,"issue_type":"task","owner":"blackbird7181@gmail.com","created_at":"2026-10-13T03:49:22Z","created_by":"Witness Patrol","updated_at":"2026-10-13T04:30:24Z","closed_at":"2026-10-13T04:30:24Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"gopherstack-xd3vh","title":"perf(sns): avoid RSA-signing every notification on Publish","description":"PGO 2026-10-12: sns notificationSigner.signSHA1 (RSA PKCS1v15) = ~4.5% total CPU. Sign once per message per signature version and reuse across subscribers, and only when a delivery needs the signature; keep wire output identical.","notes":"Investigated 2026-10-13: Publish already signs once per distinct body and skips signing when no consumer needs it; remaining cost is one RSA-2048 sign per publish (AWS-faithful key size). Only lever is a 1024-bit key (rejected: fidelity/CodeQL). Added BenchmarkE2E_SNS + signature-verification guard test.","status":"closed","priority":1,"issue_type":"task","owner":"blackbird7181@gmail.com","created_at":"2026-10-13T03:49:18Z","created_by":"Witness Patrol","updated_at":"2026-10-13T04:24:57Z","closed_at":"2026-10-13T04:24:57Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"gopherstack-8r9ey","title":"Athena integration test DDL/DML now FAILED under SQL engine","description":"CI integration-tests(0): TestIntegration_Athena_DDLAndDMLQueryLifecycle athena_test.go:175 expected SUCCEEDED got FAILED after the SQLite engine (f67e74448/7401e0e93). Find which statement fails (StateChangeReason), decide if engine should accept it (AWS-valid) or test is unrealistic; fix with regression test.","status":"closed","priority":1,"issue_type":"bug","owner":"blackbird7181@gmail.com","created_at":"2026-10-12T05:16:45Z","created_by":"Witness Patrol","updated_at":"2026-10-12T06:27:13Z","closed_at":"2026-10-12T06:27:13Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} {"_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} @@ -314,6 +317,7 @@ {"_type":"issue","id":"gopherstack-rnd","title":"EventBridge Pipes AWS-accuracy audit (GH#1818)","status":"closed","priority":1,"issue_type":"task","owner":"blackbird7181@gmail.com","created_at":"2026-05-29T21:27:34Z","created_by":"mayor","updated_at":"2026-07-26T14:59:24Z","closed_at":"2026-07-26T14:59:24Z","close_reason":"Superseded: pipes audited 2026-07-24 (5d5b2188), overall A. Execution gaps closed; only remaining gap is a proven impossibility (no in-repo Kafka/AMQP broker).","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"gopherstack-o2j","title":"OpenSearch AWS-accuracy audit (GH#1817)","status":"closed","priority":1,"issue_type":"task","owner":"blackbird7181@gmail.com","created_at":"2026-05-29T21:27:29Z","created_by":"mayor","updated_at":"2026-07-26T14:59:23Z","closed_at":"2026-07-26T14:59:23Z","close_reason":"Superseded: opensearch audited 2026-07-25 (acb2e23f9, parity-4), overall A-, gaps documented. Issue predates the parity campaigns.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"gopherstack-g3y","title":"EC2 batch-4 audit: VPC endpoints, TGW, NACL, Route Tables, NAT Gateway. Real stateful emulation, 2k+ lines.","status":"closed","priority":1,"issue_type":"task","owner":"blackbird7181@gmail.com","created_at":"2026-05-29T10:49:37Z","created_by":"mayor","updated_at":"2026-07-26T14:59:24Z","closed_at":"2026-07-26T14:59:24Z","close_reason":"Superseded: all five families exist and ec2 audited 2026-07-25 (parity-4) at overall A. Remaining EC2 field-diff work tracked in gopherstack-8pce.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"gopherstack-nwr62","title":"DynamoDB Scan: ExclusiveStartKey of a deleted item restarts from the beginning","description":"Real DynamoDB resumes after the key's position. A test (TestScanPaging_StartKeyOfDeletedItemRestartsLikeLinearPath) currently locks in the wrong behaviour. Fix with binary-search insertion point; update test; regression test.","status":"closed","priority":2,"issue_type":"bug","owner":"blackbird7181@gmail.com","created_at":"2026-10-13T04:18:57Z","created_by":"Witness Patrol","updated_at":"2026-10-13T20:59:57Z","closed_at":"2026-10-13T20:59:57Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"gopherstack-cefu7.51","title":"ID shape audit: uuid.NewString()[:N] IDs vs SDK patterns","description":"Transfer IDs violated SDK patterns. Same construction remains in glue(10), efs(5), opensearch(4), codeartifact(4), mq/ecs/cloudfront(2), ssm, rds, kafka, eks, kinesis. Audit each against the pinned SDK pattern; add a guard where patterns are machine-readable.","status":"open","priority":2,"issue_type":"task","owner":"blackbird7181@gmail.com","created_at":"2026-10-12T19:56:20Z","created_by":"Witness Patrol","updated_at":"2026-10-12T19:56:20Z","dependencies":[{"issue_id":"gopherstack-cefu7.51","depends_on_id":"gopherstack-cefu7","type":"parent-child","created_at":"2026-10-12T14:56:19Z","created_by":"Witness Patrol","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"gopherstack-cefu7.50","title":"Organizations: async CreateAccount (IN_PROGRESS -\u003e SUCCEEDED)","description":"CreateAccount completes synchronously; model the async status with an opt-in delay.","status":"open","priority":2,"issue_type":"task","owner":"blackbird7181@gmail.com","created_at":"2026-10-12T19:56:17Z","created_by":"Witness Patrol","updated_at":"2026-10-12T19:56:17Z","dependencies":[{"issue_id":"gopherstack-cefu7.50","depends_on_id":"gopherstack-cefu7","type":"parent-child","created_at":"2026-10-12T14:56:17Z","created_by":"Witness Patrol","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"gopherstack-cefu7.49","title":"IoT: MQTT broker port configurable; jobs MQTT topics; policy doc validation","description":"Broker hardcoded to 1883 (collides with a running instance); $aws/things/+/jobs/* topics not served; policy document structure not validated.","status":"open","priority":2,"issue_type":"task","owner":"blackbird7181@gmail.com","created_at":"2026-10-12T19:56:14Z","created_by":"Witness Patrol","updated_at":"2026-10-12T19:56:14Z","dependencies":[{"issue_id":"gopherstack-cefu7.49","depends_on_id":"gopherstack-cefu7","type":"parent-child","created_at":"2026-10-12T14:56:14Z","created_by":"Witness Patrol","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} @@ -1539,6 +1543,8 @@ {"_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-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} {"_type":"issue","id":"gopherstack-cefu7.14","title":"Web console covering all supported clouds","description":"Extend the dashboard to browse/manage resources for AWS plus Azure/GCP/OCI once those exist.","status":"open","priority":3,"issue_type":"feature","owner":"blackbird7181@gmail.com","created_at":"2026-10-11T14:33:44Z","created_by":"Witness Patrol","updated_at":"2026-10-11T14:33:44Z","dependencies":[{"issue_id":"gopherstack-cefu7.14","depends_on_id":"gopherstack-cefu7","type":"parent-child","created_at":"2026-10-11T09:33:44Z","created_by":"Witness Patrol","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"gopherstack-cefu7.13","title":"Standalone CLI to manage local cloud emulators","description":"CLI for start/stop/status/reset/snapshot of local emulators across clouds.","status":"open","priority":3,"issue_type":"feature","owner":"blackbird7181@gmail.com","created_at":"2026-10-11T14:33:41Z","created_by":"Witness Patrol","updated_at":"2026-10-11T14:33:41Z","dependencies":[{"issue_id":"gopherstack-cefu7.13","depends_on_id":"gopherstack-cefu7","type":"parent-child","created_at":"2026-10-11T09:33:40Z","created_by":"Witness Patrol","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} diff --git a/README.md b/README.md index 6eb686ca91..b5e4c9d855 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) | B | 15 | 1 gap; 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) | B | 32 | 2 gaps | | [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..104c8686c9 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"] @@ -4393,6 +4431,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_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/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 cf851b6a67..9f6d63e8be 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\"`", @@ -25431,6 +25458,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..bd1739bf2a --- /dev/null +++ b/pkgs/restroute/restroute_test.go @@ -0,0 +1,239 @@ +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/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: "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..649831aeb4 --- /dev/null +++ b/pkgs/restroute/serve.go @@ -0,0 +1,143 @@ +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 { + 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..56577b00f8 --- /dev/null +++ b/services/arczonalshift/PARITY.md @@ -0,0 +1,41 @@ +--- +service: arczonalshift +sdk_module: aws-sdk-go-v2/service/arczonalshift@v1.34.1 +last_audit_commit: a65519ce2 +last_audit_date: 2026-10-13 +overall: B +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 of 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: + - StartZonalShift/ListManagedResources: Auto Scaling groups and EKS clusters are supported resource types in AWS but are not listed as managed resources here +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 the ELBv2 Application/Network Load Balancers of the request region, read live; their zones come from the load balancer and are reported as zone IDs (`use1-az1`). +- 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..c663f1ad18 --- /dev/null +++ b/services/arczonalshift/README.md @@ -0,0 +1,30 @@ + +# ARC Zonal Shift + +**Parity grade: B** · SDK `aws-sdk-go-v2/service/arczonalshift@v1.34.1` · last audited 2026-10-13 (`a65519ce2`) + +## Coverage + +| Metric | Value | +| --- | --- | +| PARITY entries audited | 15 (15 ok) | +| Feature families | 2 (2 ok) | +| Known gaps | 1 | +| Structural gaps (can't be emulated) | 1 | +| Deferred items | 0 | +| Resource leaks | clean | + +### Known gaps + +- StartZonalShift/ListManagedResources: Auto Scaling groups and EKS clusters are supported resource types in AWS but are not listed as managed resources here + +### 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..59a36cdb59 --- /dev/null +++ b/services/arczonalshift/cross_service.go @@ -0,0 +1,171 @@ +package arczonalshift + +import ( + "strconv" + "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" + 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) { + 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 load balancers of region. +func (b *InMemoryBackend) listManaged(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 len(name) < len("x-y-1a") { + return name + } + + letter := name[len(name)-1] + region := name[:len(name)-1] + parts := strings.Split(region, "-") + + if letter < 'a' || letter > 'z' || len(parts) < 3 || region[len(region)-1] < '0' || region[len(region)-1] > '9' { + return name + } + + var sb strings.Builder + + sb.WriteString(parts[0]) + + for _, p := range parts[1 : len(parts)-1] { + switch p { + case "northeast": + sb.WriteString("ne") + case "northwest": + sb.WriteString("nw") + case "southeast": + sb.WriteString("se") + case "southwest": + sb.WriteString("sw") + default: + sb.WriteString(p[:1]) + } + } + + sb.WriteString(parts[len(parts)-1]) + sb.WriteString("-az") + sb.WriteString(strconv.Itoa(int(letter-'a') + 1)) + + return sb.String() +} 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/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/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..71d5285fab --- /dev/null +++ b/services/arczonalshift/practice.go @@ -0,0 +1,378 @@ +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 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..4f093954e9 --- /dev/null +++ b/services/arczonalshift/practice_test.go @@ -0,0 +1,502 @@ +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" + + 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)) +} 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/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..79fb0f9473 --- /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("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..1e81db5270 --- /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: a65519ce2 +last_audit_date: 2026-10-13 +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, 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..dcbf6be0e1 --- /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-13 (`a65519ce2`) + +## 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..c129cfbe47 --- /dev/null +++ b/services/route53recoverycluster/handler.go @@ -0,0 +1,174 @@ +// 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 { + _, op, _ := strings.Cut(httputils.HeaderValue(c.Request().Header, "X-Amz-Target"), ".") + if op == "" { + return "Unknown" + } + + return op +} + +// 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..0f790a5d64 --- /dev/null +++ b/services/route53recoverycluster/misc_test.go @@ -0,0 +1,110 @@ +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"], + ) + }) + } +} 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..150e6cd4e6 --- /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: a65519ce2 +last_audit_date: 2026-10-13 +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"} + 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; policies only arrive through RAM sharing, which is not emulated, so an existing resource returns no Policy"} + 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: resource policies are created by RAM resource sharing, which is not emulated +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-route53recoverycontrolconfig` (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..5a41a8496a --- /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-13 (`a65519ce2`) + +## 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: resource policies are created by RAM resource sharing, which is not emulated + +## 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..231326e618 --- /dev/null +++ b/services/route53recoverycontrolconfig/clusters.go @@ -0,0 +1,207 @@ +package route53recoverycontrolconfig + +import ( + "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 +} + +// endpointBase is the externally reachable URL of this server; cluster endpoints are paths below it. +func (b *InMemoryBackend) endpointBase() string { + if ep, ok := b.appConfig.(interface{ GetEndpoint() string }); ok { + return strings.TrimRight(ep.GetEndpoint(), "/") + } + + return "http://localhost:4566" +} + +func (b *InMemoryBackend) clusterView(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() + endpointPrefix + idOf(c.ARN) + "/" + region, + Region: region, + }) + } + } + + return v +} + +// CreateCluster creates a cluster together with its default control panel. +func (b *InMemoryBackend) CreateCluster(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(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(cluster), nil +} + +// DescribeCluster returns one cluster. +func (b *InMemoryBackend) DescribeCluster(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(c), nil +} + +// ListClusters lists clusters. +func (b *InMemoryBackend) ListClusters(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(c)) + } + + return out, pg.Next, nil +} + +// UpdateCluster changes the network type. +func (b *InMemoryBackend) UpdateCluster(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(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..b7811396a3 --- /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.getCluster(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/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..94f43a6427 --- /dev/null +++ b/services/route53recoverycontrolconfig/handler.go @@ -0,0 +1,178 @@ +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 { return h.server.Handler() } + +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..f9184113a8 --- /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(_ 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(in.ClusterName, in.NetworkType, in.ClientToken, in.Tags) + + return map[string]any{keyCluster: c}, 0, err +} + +func (h *Handler) describeCluster(_ context.Context, req *restroute.Request) (any, int, error) { + c, err := h.Backend.DescribeCluster(req.Params["ClusterArn"]) + + return map[string]any{keyCluster: c}, 0, err +} + +func (h *Handler) listClusters(_ 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(q.token, q.max) + + return pageOut("Clusters", items, next), 0, err +} + +func (h *Handler) updateCluster(_ 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(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..a4b24a4f76 --- /dev/null +++ b/services/route53recoverycontrolconfig/lifecycle_test.go @@ -0,0 +1,112 @@ +package route53recoverycontrolconfig_test + +import ( + "testing" + "testing/synctest" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + 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("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(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("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(cl.ClusterArn) + if tt.wantGone { + require.Error(t, err) + + list, _, lErr := b.ListClusters("", 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("", 0) + require.NoError(t, err) + assert.Len(t, list, 1) + }) + }) + } +} diff --git a/services/route53recoverycontrolconfig/misc_test.go b/services/route53recoverycontrolconfig/misc_test.go new file mode 100644 index 0000000000..fb4cdaa9a2 --- /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(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..ec6c501e0f --- /dev/null +++ b/services/route53recoverycontrolconfig/provider.go @@ -0,0 +1,36 @@ +// Package route53recoverycontrolconfig emulates the Route 53 Application Recovery Controller control plane. +package route53recoverycontrolconfig + +import ( + "errors" + + "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") + +// 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) + + 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..a51f8a886c --- /dev/null +++ b/services/route53recoverycontrolconfig/routingcontrols.go @@ -0,0 +1,162 @@ +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.getCluster(clusterArn); err != nil { + return wireRoutingControl{}, err + } + + if panelArn == "" { + def, err := b.defaultPanelOf(clusterArn) + if err != nil { + return wireRoutingControl{}, err + } + + panelArn = def + } + + panel, err := b.getPanel(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 + } + + for _, r := range b.rulesIn(c.PanelARN) { + 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..922704553a --- /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.getPanel(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..274ca725a0 --- /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), "renamed") + + _, 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..0129ca3d16 --- /dev/null +++ b/services/route53recoverycontrolconfig/state.go @@ -0,0 +1,320 @@ +package route53recoverycontrolconfig + +import ( + "fmt" + "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 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..2e5b27a5dc --- /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("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("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..89622b2f02 --- /dev/null +++ b/services/route53recoverycontrolconfig/store.go @@ -0,0 +1,171 @@ +package route53recoverycontrolconfig + +import ( + "maps" + "slices" + "sort" + "strings" + "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 + 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 +} + +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..c440948c87 --- /dev/null +++ b/services/route53recoveryreadiness/PARITY.md @@ -0,0 +1,58 @@ +--- +service: route53recoveryreadiness +sdk_module: aws-sdk-go-v2/service/route53recoveryreadiness@v1.34.4 +last_audit_commit: a65519ce2 +last_audit_date: 2026-10-13 +overall: B +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; UNKNOWN when no rule evaluates the type or 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: + - ListRules: the rule IDs and descriptions are gopherstack-defined; the real ARC rule catalog is not published in the SDK, so parity of IDs is unverifiable + - GetReadinessCheckStatus: resource types without an evaluator (API Gateway stages, Auto Scaling groups, EC2 volumes and gateways, ELB classic, MSK, RDS clusters, SNS subscriptions) report UNKNOWN; each needs a sibling lookup +structural_gaps: [] +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). Anything else, or a sibling that is not reachable, is UNKNOWN with a message. 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..e0ccca529d --- /dev/null +++ b/services/route53recoveryreadiness/README.md @@ -0,0 +1,24 @@ + +# Route 53 Recovery Readiness + +**Parity grade: B** · SDK `aws-sdk-go-v2/service/route53recoveryreadiness@v1.34.4` · last audited 2026-10-13 (`a65519ce2`) + +## Coverage + +| Metric | Value | +| --- | --- | +| PARITY entries audited | 32 (32 ok) | +| Feature families | 2 (2 ok) | +| Known gaps | 2 | +| Deferred items | 0 | +| Resource leaks | clean | + +### Known gaps + +- ListRules: the rule IDs and descriptions are gopherstack-defined; the real ARC rule catalog is not published in the SDK, so parity of IDs is unverifiable +- GetReadinessCheckStatus: resource types without an evaluator (API Gateway stages, Auto Scaling groups, EC2 volumes and gateways, ELB classic, MSK, RDS clusters, SNS subscriptions) report UNKNOWN; each needs a sibling lookup + +## 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..54d4100917 --- /dev/null +++ b/services/route53recoveryreadiness/evaluate.go @@ -0,0 +1,245 @@ +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}, + } +} + +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 strings.Contains(a, p.AccountID) }), p.AccountID +} + +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/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..aeaba3eee2 --- /dev/null +++ b/services/route53recoveryreadiness/models.go @@ -0,0 +1,93 @@ +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" +) + +// 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..d3fc891d14 --- /dev/null +++ b/services/route53recoveryreadiness/readiness_test.go @@ -0,0 +1,367 @@ +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("unsupported resource type 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) + require.NotEmpty(t, out.Messages) + assert.Contains(t, aws.ToString(out.Messages[0].MessageText), "AWS::MSK::Cluster") + assert.Empty(t, 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("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::MSK::Cluster")}) + 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..a5bb262d1e --- /dev/null +++ b/services/route53recoveryreadiness/resolver.go @@ -0,0 +1,269 @@ +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 { + s, ok := r.siblings() + if !ok { + return probe{} + } + + p, parsed := parseARN(resourceArn) + if !parsed { + return probe{answered: true, detail: "the resource ARN is not valid"} + } + + 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/sets.go b/services/route53recoveryreadiness/sets.go new file mode 100644 index 0000000000..47d25053e8 --- /dev/null +++ b/services/route53recoveryreadiness/sets.go @@ -0,0 +1,197 @@ +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{ + "AWS::ApiGateway::Stage", "AWS::ApiGatewayV2::Stage", "AWS::AutoScaling::AutoScalingGroup", + typeAlarm, "AWS::EC2::CustomerGateway", typeTable, "AWS::EC2::Volume", + "AWS::ElasticLoadBalancing::LoadBalancer", typeELBv2, typeLambda, + "AWS::MSK::Cluster", "AWS::RDS::DBCluster", typeHealthCheck, typeSQS, typeSNS, + "AWS::SNS::Subscription", typeVPC, "AWS::EC2::VPNConnection", "AWS::EC2::VPNGateway", 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) { + 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 +} + +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..acc9a7fb16 --- /dev/null +++ b/services/route53recoveryreadiness/summary.go @@ -0,0 +1,277 @@ +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 + + for _, o := range outs { + if o.Note != "" { + msgs = append(msgs, Message{MessageText: o.Note}) + + break + } + } + + 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/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 From 10b89afb7b1c4b51fb7a7f3ee2f7a7bb12b1a7ff Mon Sep 17 00:00:00 2001 From: Witness Patrol Date: Tue, 13 Oct 2026 19:06:15 -0500 Subject: [PATCH 02/11] chore(bd): close gopherstack-7bxwn Co-Authored-By: Claude Opus 5.5 (1M context) --- .beads/issues.jsonl | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index eb724b096c..3407240fa0 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} From 80b7bb8b336063e0b35eabcbb14e5a99c34d81b5 Mon Sep 17 00:00:00 2001 From: Witness Patrol Date: Wed, 14 Oct 2026 22:41:26 -0500 Subject: [PATCH 03/11] fix(route53recoverycluster): metrics operation name from known ops only ExtractOperation returned a substring of X-Amz-Target read through the shared header helper, which CodeQL tracks as sensitive header data flowing into the request-logging calls (go/clear-text-logging). It now returns only a constant from the supported-operation list, or "Unknown". Guard tests for all four ARC services assert operation and resource labels don't change when credential headers are present. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01EFp5KU8QNMfPqXVavWVtMT --- .../arczonalshift/extract_headers_test.go | 44 +++++++++++++++++++ services/route53recoverycluster/handler.go | 10 +++-- services/route53recoverycluster/misc_test.go | 35 +++++++++++++++ .../extract_headers_test.go | 44 +++++++++++++++++++ .../extract_headers_test.go | 44 +++++++++++++++++++ 5 files changed, 173 insertions(+), 4 deletions(-) create mode 100644 services/arczonalshift/extract_headers_test.go create mode 100644 services/route53recoverycontrolconfig/extract_headers_test.go create mode 100644 services/route53recoveryreadiness/extract_headers_test.go 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/route53recoverycluster/handler.go b/services/route53recoverycluster/handler.go index c129cfbe47..ff75d707b4 100644 --- a/services/route53recoverycluster/handler.go +++ b/services/route53recoverycluster/handler.go @@ -87,12 +87,14 @@ func (h *Handler) MatchPriority() int { return matchPriority } // ExtractOperation returns the operation name for metrics. func (h *Handler) ExtractOperation(c *echo.Context) string { - _, op, _ := strings.Cut(httputils.HeaderValue(c.Request().Header, "X-Amz-Target"), ".") - if op == "" { - return "Unknown" + target := c.Request().Header.Get("X-Amz-Target") + for _, op := range h.GetSupportedOperations() { + if target == targetPrefix+op { + return op + } } - return op + return "Unknown" } // ExtractResource returns no resource label; operations name routing controls in the body. diff --git a/services/route53recoverycluster/misc_test.go b/services/route53recoverycluster/misc_test.go index 0f790a5d64..127e6aee29 100644 --- a/services/route53recoverycluster/misc_test.go +++ b/services/route53recoverycluster/misc_test.go @@ -108,3 +108,38 @@ func TestErrorBodyShape(t *testing.T) { }) } } + +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/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/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)) + }) + } +} From 023f04d88290244b7723f3a1df8f0ba271abde06 Mon Sep 17 00:00:00 2001 From: Witness Patrol Date: Wed, 14 Oct 2026 23:37:47 -0500 Subject: [PATCH 04/11] feat(arc): readiness for every resource-set type, ASG/EKS zonal shift, caller-host cluster endpoints - route53-recovery-readiness: evaluators for all 20 SDK resource-set types (API Gateway v1/v2 stages, Auto Scaling groups, EC2 customer gateways/volumes/VPN gateways/VPN connections, classic ELB, MSK, RDS clusters, SNS subscriptions, plus the existing nine), resolved from the owning service at call time. - arc-zonal-shift: Auto Scaling groups with zonal shift enabled in their AZ impairment policy and EKS clusters with zonalShiftConfig enabled are managed resources; disabling it unmanages them. - route53-recovery-control-config: cluster endpoints use the scheme and host the caller reached (plain host[:port] only), so clients behind host.docker.internal get reachable endpoints; ROUTE53_RECOVERY_CONTROL_ENDPOINT overrides. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01EFp5KU8QNMfPqXVavWVtMT --- .badges/parity.svg | 6 +- README.md | 4 +- services/arczonalshift/PARITY.md | 13 +- services/arczonalshift/README.md | 8 +- services/arczonalshift/cross_service.go | 13 +- services/arczonalshift/managed_more.go | 221 +++++++++++ .../arczonalshift/managed_resources_test.go | 241 ++++++++++++ services/route53/recovery_control_test.go | 2 +- services/route53recoverycluster/PARITY.md | 6 +- services/route53recoverycluster/README.md | 2 +- .../route53recoverycontrolconfig/PARITY.md | 10 +- .../route53recoverycontrolconfig/README.md | 4 +- .../route53recoverycontrolconfig/clusters.go | 69 +++- .../endpoints_test.go | 106 +++++ .../route53recoverycontrolconfig/handler.go | 12 +- .../handler_ops.go | 16 +- .../lifecycle_test.go | 12 +- .../route53recoverycontrolconfig/misc_test.go | 2 +- .../route53recoverycontrolconfig/provider.go | 5 + .../state_test.go | 4 +- .../route53recoverycontrolconfig/store.go | 22 +- services/route53recoveryreadiness/PARITY.md | 17 +- services/route53recoveryreadiness/README.md | 12 +- services/route53recoveryreadiness/evaluate.go | 22 ++ .../evaluators_test.go | 361 ++++++++++++++++++ services/route53recoveryreadiness/models.go | 12 + .../readiness_test.go | 10 +- services/route53recoveryreadiness/resolver.go | 14 +- .../route53recoveryreadiness/resolver_more.go | 292 ++++++++++++++ services/route53recoveryreadiness/sets.go | 10 +- 30 files changed, 1427 insertions(+), 101 deletions(-) create mode 100644 services/arczonalshift/managed_more.go create mode 100644 services/arczonalshift/managed_resources_test.go create mode 100644 services/route53recoverycontrolconfig/endpoints_test.go create mode 100644 services/route53recoveryreadiness/evaluators_test.go create mode 100644 services/route53recoveryreadiness/resolver_more.go diff --git a/.badges/parity.svg b/.badges/parity.svg index 1948cd34c4..4f38e0435e 100644 --- a/.badges/parity.svg +++ b/.badges/parity.svg @@ -1,4 +1,4 @@ - + @@ -12,7 +12,7 @@ parity parity - 166 A · 7 B · 4 C - 166 A · 7 B · 4 C + 168 A · 5 B · 4 C + 168 A · 5 B · 4 C diff --git a/README.md b/README.md index b5e4c9d855..9cff4478ee 100644 --- a/README.md +++ b/README.md @@ -633,7 +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) | B | 15 | 1 gap; 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 | @@ -643,7 +643,7 @@ Every service links to its own page with a coverage breakdown — audited operat | [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) | B | 32 | 2 gaps | +| [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/services/arczonalshift/PARITY.md b/services/arczonalshift/PARITY.md index 56577b00f8..da1b895b3d 100644 --- a/services/arczonalshift/PARITY.md +++ b/services/arczonalshift/PARITY.md @@ -1,15 +1,15 @@ --- service: arczonalshift sdk_module: aws-sdk-go-v2/service/arczonalshift@v1.34.1 -last_audit_commit: a65519ce2 -last_audit_date: 2026-10-13 -overall: B +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 of the request region"} + 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} @@ -24,8 +24,7 @@ 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: - - StartZonalShift/ListManagedResources: Auto Scaling groups and EKS clusters are supported resource types in AWS but are not listed as managed resources here +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: [] @@ -35,7 +34,7 @@ leaks: {status: clean, note: "no goroutines or janitors; expiry and lifecycle ar ## Notes - Protocol: REST-JSON (schema-serde client), lowerCamel members, epoch-seconds timestamps, SigV4 signing name `arc-zonal-shift`. -- Managed resources are the ELBv2 Application/Network Load Balancers of the request region, read live; their zones come from the load balancer and are reported as zone IDs (`use1-az1`). +- 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 index c663f1ad18..bc7869db9a 100644 --- a/services/arczonalshift/README.md +++ b/services/arczonalshift/README.md @@ -1,7 +1,7 @@ # ARC Zonal Shift -**Parity grade: B** · SDK `aws-sdk-go-v2/service/arczonalshift@v1.34.1` · last audited 2026-10-13 (`a65519ce2`) +**Parity grade: A** · SDK `aws-sdk-go-v2/service/arczonalshift@v1.34.1` · last audited 2026-10-14 (`80b7bb8b3`) ## Coverage @@ -9,15 +9,11 @@ | --- | --- | | PARITY entries audited | 15 (15 ok) | | Feature families | 2 (2 ok) | -| Known gaps | 1 | +| Known gaps | none | | Structural gaps (can't be emulated) | 1 | | Deferred items | 0 | | Resource leaks | clean | -### Known gaps - -- StartZonalShift/ListManagedResources: Auto Scaling groups and EKS clusters are supported resource types in AWS but are not listed as managed resources here - ### 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. diff --git a/services/arczonalshift/cross_service.go b/services/arczonalshift/cross_service.go index 59a36cdb59..0f0cf491dc 100644 --- a/services/arczonalshift/cross_service.go +++ b/services/arczonalshift/cross_service.go @@ -59,6 +59,10 @@ func supportedLB(arn string) (awsarn.ARN, bool) { // 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( @@ -81,8 +85,15 @@ func (b *InMemoryBackend) managedFor(arn string) (managedResource, error) { return toManaged(&lbs[0]), nil } -// listManaged returns the load balancers of region. +// 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 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..3301840951 --- /dev/null +++ b/services/arczonalshift/managed_resources_test.go @@ -0,0 +1,241 @@ +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)) +} diff --git a/services/route53/recovery_control_test.go b/services/route53/recovery_control_test.go index 79fb0f9473..295e304318 100644 --- a/services/route53/recovery_control_test.go +++ b/services/route53/recovery_control_test.go @@ -37,7 +37,7 @@ func newRecoveryWorld(t *testing.T, wire bool) *recoveryWorld { t.Helper() control := cc.NewInMemoryBackend("123456789012") - cl, err := control.CreateCluster("c", "", "", nil) + cl, err := control.CreateCluster(t.Context(), "c", "", "", nil) require.NoError(t, err) rcOut, err := control.CreateRoutingControl(cl.ClusterArn, "", "primary-up", "") require.NoError(t, err) diff --git a/services/route53recoverycluster/PARITY.md b/services/route53recoverycluster/PARITY.md index 1e81db5270..173146b718 100644 --- a/services/route53recoverycluster/PARITY.md +++ b/services/route53recoverycluster/PARITY.md @@ -1,8 +1,8 @@ --- service: route53recoverycluster sdk_module: aws-sdk-go-v2/service/route53recoverycluster@v1.36.4 -last_audit_commit: a65519ce2 -last_audit_date: 2026-10-13 +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"} @@ -23,6 +23,6 @@ leaks: {status: clean, note: "no goroutines or janitors; expiry and lifecycle ar - 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, so the data plane is reachable through the normal router. A control that belongs to another cluster is ResourceNotFound on that endpoint. +- 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 index dcbf6be0e1..04394e1dc4 100644 --- a/services/route53recoverycluster/README.md +++ b/services/route53recoverycluster/README.md @@ -1,7 +1,7 @@ # Route 53 Recovery Cluster -**Parity grade: A** · SDK `aws-sdk-go-v2/service/route53recoverycluster@v1.36.4` · last audited 2026-10-13 (`a65519ce2`) +**Parity grade: A** · SDK `aws-sdk-go-v2/service/route53recoverycluster@v1.36.4` · last audited 2026-10-14 (`80b7bb8b3`) ## Coverage diff --git a/services/route53recoverycontrolconfig/PARITY.md b/services/route53recoverycontrolconfig/PARITY.md index 150e6cd4e6..4d0a1e1fe0 100644 --- a/services/route53recoverycontrolconfig/PARITY.md +++ b/services/route53recoverycontrolconfig/PARITY.md @@ -1,11 +1,11 @@ --- service: route53recoverycontrolconfig sdk_module: aws-sdk-go-v2/service/route53recoverycontrolconfig@v1.40.4 -last_audit_commit: a65519ce2 -last_audit_date: 2026-10-13 +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"} + 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} @@ -26,7 +26,7 @@ ops: 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; policies only arrive through RAM sharing, which is not emulated, so an existing resource returns no Policy"} + 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} @@ -36,7 +36,7 @@ families: gaps: [] items_still_open: [] structural_gaps: - - GetResourcePolicy cannot return a policy: resource policies are created by RAM resource sharing, which is not emulated + - 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"} --- diff --git a/services/route53recoverycontrolconfig/README.md b/services/route53recoverycontrolconfig/README.md index 5a41a8496a..2a5f739dc4 100644 --- a/services/route53recoverycontrolconfig/README.md +++ b/services/route53recoverycontrolconfig/README.md @@ -1,7 +1,7 @@ # Route 53 Recovery Control Config -**Parity grade: A** · SDK `aws-sdk-go-v2/service/route53recoverycontrolconfig@v1.40.4` · last audited 2026-10-13 (`a65519ce2`) +**Parity grade: A** · SDK `aws-sdk-go-v2/service/route53recoverycontrolconfig@v1.40.4` · last audited 2026-10-14 (`80b7bb8b3`) ## Coverage @@ -18,7 +18,7 @@ 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: resource policies are created by RAM resource sharing, which is not emulated +- 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 diff --git a/services/route53recoverycontrolconfig/clusters.go b/services/route53recoverycontrolconfig/clusters.go index 231326e618..860ce16e57 100644 --- a/services/route53recoverycontrolconfig/clusters.go +++ b/services/route53recoverycontrolconfig/clusters.go @@ -1,6 +1,8 @@ package route53recoverycontrolconfig import ( + "context" + "net/url" "strings" "time" @@ -38,8 +40,35 @@ func idOf(arn string) string { return arn } -// endpointBase is the externally reachable URL of this server; cluster endpoints are paths below it. -func (b *InMemoryBackend) endpointBase() string { +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(), "/") } @@ -47,7 +76,13 @@ func (b *InMemoryBackend) endpointBase() string { return "http://localhost:4566" } -func (b *InMemoryBackend) clusterView(c *Cluster) wireCluster { +// 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, @@ -59,7 +94,7 @@ func (b *InMemoryBackend) clusterView(c *Cluster) wireCluster { if v.Status == statusDeployed { for _, region := range clusterRegions() { v.ClusterEndpoints = append(v.ClusterEndpoints, wireEndpoint{ - Endpoint: b.endpointBase() + endpointPrefix + idOf(c.ARN) + "/" + region, + Endpoint: b.endpointBase(ctx) + endpointPrefix + idOf(c.ARN) + "/" + region, Region: region, }) } @@ -69,7 +104,11 @@ func (b *InMemoryBackend) clusterView(c *Cluster) wireCluster { } // CreateCluster creates a cluster together with its default control panel. -func (b *InMemoryBackend) CreateCluster(name, networkType, token string, tags map[string]string) (wireCluster, error) { +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 } @@ -87,7 +126,7 @@ func (b *InMemoryBackend) CreateCluster(name, networkType, token string, tags ma if existing, ok := b.idempotent("cluster", token); ok { if c, err := b.getCluster(existing); err == nil { - return b.clusterView(c), nil + return b.clusterView(ctx, c), nil } } @@ -102,11 +141,11 @@ func (b *InMemoryBackend) CreateCluster(name, networkType, token string, tags ma }) b.rememberToken("cluster", token, cluster.ARN) - return b.clusterView(cluster), nil + return b.clusterView(ctx, cluster), nil } // DescribeCluster returns one cluster. -func (b *InMemoryBackend) DescribeCluster(arn string) (wireCluster, error) { +func (b *InMemoryBackend) DescribeCluster(ctx context.Context, arn string) (wireCluster, error) { b.mu.RLock("DescribeCluster") defer b.mu.RUnlock() @@ -115,11 +154,15 @@ func (b *InMemoryBackend) DescribeCluster(arn string) (wireCluster, error) { return wireCluster{}, err } - return b.clusterView(c), nil + return b.clusterView(ctx, c), nil } // ListClusters lists clusters. -func (b *InMemoryBackend) ListClusters(token string, maxResults int) ([]wireCluster, string, error) { +func (b *InMemoryBackend) ListClusters( + ctx context.Context, + token string, + maxResults int, +) ([]wireCluster, string, error) { b.mu.RLock("ListClusters") defer b.mu.RUnlock() @@ -139,14 +182,14 @@ func (b *InMemoryBackend) ListClusters(token string, maxResults int) ([]wireClus out := make([]wireCluster, 0, len(pg.Data)) for _, c := range pg.Data { - out = append(out, b.clusterView(c)) + out = append(out, b.clusterView(ctx, c)) } return out, pg.Next, nil } // UpdateCluster changes the network type. -func (b *InMemoryBackend) UpdateCluster(arn, networkType string) (wireCluster, error) { +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") } @@ -161,7 +204,7 @@ func (b *InMemoryBackend) UpdateCluster(arn, networkType string) (wireCluster, e c.NetworkType = networkType - return b.clusterView(c), nil + return b.clusterView(ctx, c), nil } // DeleteCluster removes a cluster; it must not hold custom control panels, routing controls or safety rules. 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/handler.go b/services/route53recoverycontrolconfig/handler.go index 94f43a6427..153e383f5b 100644 --- a/services/route53recoverycontrolconfig/handler.go +++ b/services/route53recoverycontrolconfig/handler.go @@ -112,7 +112,17 @@ func (h *Handler) ExtractResource(c *echo.Context) string { } // Handler returns the Echo handler. -func (h *Handler) Handler() echo.HandlerFunc { return h.server.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{ diff --git a/services/route53recoverycontrolconfig/handler_ops.go b/services/route53recoverycontrolconfig/handler_ops.go index f9184113a8..59feea2198 100644 --- a/services/route53recoverycontrolconfig/handler_ops.go +++ b/services/route53recoverycontrolconfig/handler_ops.go @@ -26,7 +26,7 @@ func listParams(req *restroute.Request) (listQuery, error) { return listQuery{token: req.Query.Get("NextToken"), max: m}, nil } -func (h *Handler) createCluster(_ context.Context, req *restroute.Request) (any, int, error) { +func (h *Handler) createCluster(ctx context.Context, req *restroute.Request) (any, int, error) { var in struct { Tags map[string]string ClusterName string @@ -37,35 +37,35 @@ func (h *Handler) createCluster(_ context.Context, req *restroute.Request) (any, return nil, 0, err } - c, err := h.Backend.CreateCluster(in.ClusterName, in.NetworkType, in.ClientToken, in.Tags) + 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(_ context.Context, req *restroute.Request) (any, int, error) { - c, err := h.Backend.DescribeCluster(req.Params["ClusterArn"]) +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(_ context.Context, req *restroute.Request) (any, int, error) { +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(q.token, q.max) + items, next, err := h.Backend.ListClusters(ctx, q.token, q.max) return pageOut("Clusters", items, next), 0, err } -func (h *Handler) updateCluster(_ context.Context, req *restroute.Request) (any, int, error) { +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(in.ClusterArn, in.NetworkType) + c, err := h.Backend.UpdateCluster(ctx, in.ClusterArn, in.NetworkType) return map[string]any{keyCluster: c}, 0, err } diff --git a/services/route53recoverycontrolconfig/lifecycle_test.go b/services/route53recoverycontrolconfig/lifecycle_test.go index a4b24a4f76..b1a605614d 100644 --- a/services/route53recoverycontrolconfig/lifecycle_test.go +++ b/services/route53recoverycontrolconfig/lifecycle_test.go @@ -40,7 +40,7 @@ func TestActivationDelay(t *testing.T) { b := cc.NewInMemoryBackend(testAccount) b.SetActivationDelay(tt.delay) - cl, err := b.CreateCluster("c", "", "", nil) + cl, err := b.CreateCluster(t.Context(), "c", "", "", nil) require.NoError(t, err) rc, err := b.CreateRoutingControl(cl.ClusterArn, "", "r", "") @@ -48,7 +48,7 @@ func TestActivationDelay(t *testing.T) { time.Sleep(tt.elapsed) - got, err := b.DescribeCluster(cl.ClusterArn) + 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) @@ -82,18 +82,18 @@ func TestPendingDeletion(t *testing.T) { b := cc.NewInMemoryBackend(testAccount) b.SetActivationDelay(time.Minute) - cl, err := b.CreateCluster("c", "", "", nil) + 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(cl.ClusterArn) + got, err := b.DescribeCluster(t.Context(), cl.ClusterArn) if tt.wantGone { require.Error(t, err) - list, _, lErr := b.ListClusters("", 0) + list, _, lErr := b.ListClusters(t.Context(), "", 0) require.NoError(t, lErr) assert.Empty(t, list) @@ -103,7 +103,7 @@ func TestPendingDeletion(t *testing.T) { require.NoError(t, err) assert.Equal(t, tt.wantStatus, got.Status) - list, _, err := b.ListClusters("", 0) + list, _, err := b.ListClusters(t.Context(), "", 0) require.NoError(t, err) assert.Len(t, list, 1) }) diff --git a/services/route53recoverycontrolconfig/misc_test.go b/services/route53recoverycontrolconfig/misc_test.go index fb4cdaa9a2..4be704046e 100644 --- a/services/route53recoverycontrolconfig/misc_test.go +++ b/services/route53recoverycontrolconfig/misc_test.go @@ -64,7 +64,7 @@ func TestPersistenceRoundTrip(t *testing.T) { assert.Equal(t, map[string]string{"k": "v"}, tags) require.NoError(t, restored.Restore(ctx, []byte(`{"version":999}`))) - _, err = restored.Backend.DescribeCluster(topo.cluster) + _, err = restored.Backend.DescribeCluster(t.Context(), topo.cluster) assert.Error(t, err, "an incompatible snapshot starts empty") } diff --git a/services/route53recoverycontrolconfig/provider.go b/services/route53recoverycontrolconfig/provider.go index ec6c501e0f..d7e2d11a93 100644 --- a/services/route53recoverycontrolconfig/provider.go +++ b/services/route53recoverycontrolconfig/provider.go @@ -3,6 +3,7 @@ package route53recoverycontrolconfig import ( "errors" + "os" "github.com/blackbirdworks/gopherstack/pkgs/service" ) @@ -10,6 +11,9 @@ import ( // 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{} @@ -28,6 +32,7 @@ func (p *Provider) Init(ctx *service.AppContext) (service.Registerable, error) { backend := NewInMemoryBackend(accountID) backend.SetAppConfig(ctx.Config) + backend.SetExternalEndpoint(os.Getenv(externalEndpointEnv)) handler := NewHandler(backend) handler.DefaultRegion = region diff --git a/services/route53recoverycontrolconfig/state_test.go b/services/route53recoverycontrolconfig/state_test.go index 2e5b27a5dc..b35719b67a 100644 --- a/services/route53recoverycontrolconfig/state_test.go +++ b/services/route53recoverycontrolconfig/state_test.go @@ -24,7 +24,7 @@ func newWorld(t *testing.T) *world { t.Helper() b := cc.NewInMemoryBackend(testAccount) - cl, err := b.CreateCluster("w", "", "", nil) + cl, err := b.CreateCluster(t.Context(), "w", "", "", nil) require.NoError(t, err) w := &world{b: b, cluster: cl.ClusterArn} @@ -295,7 +295,7 @@ func TestClusterScopedState(t *testing.T) { t.Parallel() w := newWorld(t) - other, err := w.b.CreateCluster("other", "", "", nil) + other, err := w.b.CreateCluster(t.Context(), "other", "", "", nil) require.NoError(t, err) otherID := other.ClusterArn[len(other.ClusterArn)-32:] diff --git a/services/route53recoverycontrolconfig/store.go b/services/route53recoverycontrolconfig/store.go index 89622b2f02..e9642a8d97 100644 --- a/services/route53recoverycontrolconfig/store.go +++ b/services/route53recoverycontrolconfig/store.go @@ -5,6 +5,7 @@ import ( "slices" "sort" "strings" + "sync/atomic" "time" "github.com/google/uuid" @@ -17,16 +18,17 @@ import ( // InMemoryBackend holds the Route 53 ARC control plane: clusters, control panels, routing controls, safety rules. type InMemoryBackend struct { - appConfig any - 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 + 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. diff --git a/services/route53recoveryreadiness/PARITY.md b/services/route53recoveryreadiness/PARITY.md index c440948c87..47aa9d53f2 100644 --- a/services/route53recoveryreadiness/PARITY.md +++ b/services/route53recoveryreadiness/PARITY.md @@ -1,9 +1,9 @@ --- service: route53recoveryreadiness sdk_module: aws-sdk-go-v2/service/route53recoveryreadiness@v1.34.4 -last_audit_commit: a65519ce2 -last_audit_date: 2026-10-13 -overall: B +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} @@ -28,7 +28,7 @@ ops: 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; UNKNOWN when no rule evaluates the type or the sibling is unreachable"} + 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} @@ -41,10 +41,9 @@ 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: - - ListRules: the rule IDs and descriptions are gopherstack-defined; the real ARC rule catalog is not published in the SDK, so parity of IDs is unverifiable - - GetReadinessCheckStatus: resource types without an evaluator (API Gateway stages, Auto Scaling groups, EC2 volumes and gateways, ELB classic, MSK, RDS clusters, SNS subscriptions) report UNKNOWN; each needs a sibling lookup -structural_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"} --- @@ -52,7 +51,7 @@ leaks: {status: clean, note: "no goroutines or janitors; expiry and lifecycle ar ## 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). Anything else, or a sibling that is not reachable, is UNKNOWN with a message. A resource in another account is NOT_AUTHORIZED until a cross-account authorization naming that account exists. +- 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 index e0ccca529d..57b9e07c25 100644 --- a/services/route53recoveryreadiness/README.md +++ b/services/route53recoveryreadiness/README.md @@ -1,7 +1,7 @@ # Route 53 Recovery Readiness -**Parity grade: B** · SDK `aws-sdk-go-v2/service/route53recoveryreadiness@v1.34.4` · last audited 2026-10-13 (`a65519ce2`) +**Parity grade: A** · SDK `aws-sdk-go-v2/service/route53recoveryreadiness@v1.34.4` · last audited 2026-10-14 (`80b7bb8b3`) ## Coverage @@ -9,14 +9,16 @@ | --- | --- | | PARITY entries audited | 32 (32 ok) | | Feature families | 2 (2 ok) | -| Known gaps | 2 | +| Known gaps | none | +| Structural gaps (can't be emulated) | 1 | | Deferred items | 0 | | Resource leaks | clean | -### Known gaps +### Structural gaps -- ListRules: the rule IDs and descriptions are gopherstack-defined; the real ARC rule catalog is not published in the SDK, so parity of IDs is unverifiable -- GetReadinessCheckStatus: resource types without an evaluator (API Gateway stages, Auto Scaling groups, EC2 volumes and gateways, ELB classic, MSK, RDS clusters, SNS subscriptions) report UNKNOWN; each needs a sibling lookup +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 diff --git a/services/route53recoveryreadiness/evaluate.go b/services/route53recoveryreadiness/evaluate.go index 54d4100917..047061ff50 100644 --- a/services/route53recoveryreadiness/evaluate.go +++ b/services/route53recoveryreadiness/evaluate.go @@ -42,6 +42,28 @@ func ruleCatalog() []ruleDef { {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}, } } diff --git a/services/route53recoveryreadiness/evaluators_test.go b/services/route53recoveryreadiness/evaluators_test.go new file mode 100644 index 0000000000..bed4935202 --- /dev/null +++ b/services/route53recoveryreadiness/evaluators_test.go @@ -0,0 +1,361 @@ +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) + }) + } +} diff --git a/services/route53recoveryreadiness/models.go b/services/route53recoveryreadiness/models.go index aeaba3eee2..2b4827834a 100644 --- a/services/route53recoveryreadiness/models.go +++ b/services/route53recoveryreadiness/models.go @@ -17,6 +17,18 @@ const ( 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. diff --git a/services/route53recoveryreadiness/readiness_test.go b/services/route53recoveryreadiness/readiness_test.go index d3fc891d14..551a808427 100644 --- a/services/route53recoveryreadiness/readiness_test.go +++ b/services/route53recoveryreadiness/readiness_test.go @@ -135,7 +135,7 @@ func TestReadinessFollowsSiblingState(t *testing.T) { ) }) - t.Run("unsupported resource type is unknown", func(t *testing.T) { + t.Run("unreachable sibling is unknown", func(t *testing.T) { t.Parallel() f := newFixture(t) @@ -148,9 +148,9 @@ func TestReadinessFollowsSiblingState(t *testing.T) { ) require.NoError(t, err) assert.Equal(t, types.ReadinessUnknown, out.Readiness) - require.NotEmpty(t, out.Messages) - assert.Contains(t, aws.ToString(out.Messages[0].MessageText), "AWS::MSK::Cluster") - assert.Empty(t, f.rules(t, rc, arn)) + 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) { @@ -343,7 +343,7 @@ func TestListRules(t *testing.T) { 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::MSK::Cluster")}) + none, err := f.client.ListRules(t.Context(), &sdk.ListRulesInput{ResourceType: aws.String("AWS::Nope::Thing")}) require.NoError(t, err) assert.Empty(t, none.Rules) } diff --git a/services/route53recoveryreadiness/resolver.go b/services/route53recoveryreadiness/resolver.go index a5bb262d1e..3a65c5c709 100644 --- a/services/route53recoveryreadiness/resolver.go +++ b/services/route53recoveryreadiness/resolver.go @@ -54,16 +54,20 @@ func parseARN(a string) (awsarn.ARN, bool) { // lookup dispatches on the resource type; unsupported types answer with answered=false. func (r resolver) lookup(ctx context.Context, typ, resourceArn string) probe { - s, ok := r.siblings() - if !ok { - return 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) 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 index 47d25053e8..fafd666f14 100644 --- a/services/route53recoveryreadiness/sets.go +++ b/services/route53recoveryreadiness/sets.go @@ -10,11 +10,11 @@ import ( // resourceSetTypes are the documented ResourceSetType values (route53recoveryreadiness api_op_CreateResourceSet.go). func resourceSetTypes() []string { return []string{ - "AWS::ApiGateway::Stage", "AWS::ApiGatewayV2::Stage", "AWS::AutoScaling::AutoScalingGroup", - typeAlarm, "AWS::EC2::CustomerGateway", typeTable, "AWS::EC2::Volume", - "AWS::ElasticLoadBalancing::LoadBalancer", typeELBv2, typeLambda, - "AWS::MSK::Cluster", "AWS::RDS::DBCluster", typeHealthCheck, typeSQS, typeSNS, - "AWS::SNS::Subscription", typeVPC, "AWS::EC2::VPNConnection", "AWS::EC2::VPNGateway", typeDNSTarget, + typeAPIGWStage, typeAPIGWV2Stage, typeASG, + typeAlarm, typeCGW, typeTable, typeVolume, + typeELBClassic, typeELBv2, typeLambda, + typeMSK, typeRDSCluster, typeHealthCheck, typeSQS, typeSNS, + typeSNSSub, typeVPC, typeVPNConn, typeVPNGateway, typeDNSTarget, } } From fbd53449f6e149f10a17fc152d846906ab1b7f84 Mon Sep 17 00:00:00 2001 From: Witness Patrol Date: Wed, 14 Oct 2026 23:55:44 -0500 Subject: [PATCH 05/11] feat(arc): dashboard pages and Terraform fixtures - Dashboard pages for route53-recovery-control-config (clusters and endpoints, control panels, routing controls with live state and flip, safety rules), route53-recovery-cluster (data-plane view per cluster endpoint), route53-recovery-readiness (recovery groups, cells, resource sets, readiness checks) and arc-zonal-shift (managed resources, shifts, practice runs, autoshift). Safety-rule rejections surface as the ConflictException message; endpoint failover retries only on network errors. - Terraform fixtures for clusters, control panels, routing controls, assertion/gating safety rules, a RECOVERY_CONTROL health check, readiness cells/recovery groups/resource sets/checks, and zonal autoshift configuration (provider v6 block; v5 lacks arczonalshift). Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01EFp5KU8QNMfPqXVavWVtMT --- test/e2e/arc_test.go | 63 ++ test/terraform/arc_recovery_test.go | 341 +++++++ .../arc-readiness-cells-and-recovery-group.tf | 15 + .../arc-readiness-resource-set-and-check.tf | 34 + .../fixtures/arc-recovery-control-config.tf | 57 ++ .../arc-zonal-autoshift-configuration.tf | 59 ++ test/terraform/terraform_test.go | 33 + ui/package-lock.json | 80 ++ ui/package.json | 4 + ui/src/lib/arc.test.ts | 86 ++ ui/src/lib/arc.ts | 84 ++ ui/src/lib/aws-client.ts | 30 + ui/src/lib/nav.ts | 28 + ui/src/routes/arczonalshift/+page.svelte | 473 +++++++++ ui/src/routes/arczonalshift/page.test.ts | 144 +++ .../route53recoverycluster/+page.svelte | 232 +++++ .../route53recoverycluster/page.test.ts | 95 ++ .../route53recoverycontrolconfig/+page.svelte | 901 ++++++++++++++++++ .../route53recoverycontrolconfig/page.test.ts | 239 +++++ .../route53recoveryreadiness/+page.svelte | 579 +++++++++++ .../route53recoveryreadiness/page.test.ts | 140 +++ 21 files changed, 3717 insertions(+) create mode 100644 test/e2e/arc_test.go create mode 100644 test/terraform/arc_recovery_test.go create mode 100644 test/terraform/fixtures/arc-readiness-cells-and-recovery-group.tf create mode 100644 test/terraform/fixtures/arc-readiness-resource-set-and-check.tf create mode 100644 test/terraform/fixtures/arc-recovery-control-config.tf create mode 100644 test/terraform/fixtures/arc-zonal-autoshift-configuration.tf create mode 100644 ui/src/lib/arc.test.ts create mode 100644 ui/src/lib/arc.ts create mode 100644 ui/src/routes/arczonalshift/+page.svelte create mode 100644 ui/src/routes/arczonalshift/page.test.ts create mode 100644 ui/src/routes/route53recoverycluster/+page.svelte create mode 100644 ui/src/routes/route53recoverycluster/page.test.ts create mode 100644 ui/src/routes/route53recoverycontrolconfig/+page.svelte create mode 100644 ui/src/routes/route53recoverycontrolconfig/page.test.ts create mode 100644 ui/src/routes/route53recoveryreadiness/+page.svelte create mode 100644 ui/src/routes/route53recoveryreadiness/page.test.ts 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..166e017f24 --- /dev/null +++ b/test/terraform/arc_recovery_test.go @@ -0,0 +1,341 @@ +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" +) + +// TestTerraform_ARCRecoveryControlConfig provisions an ARC cluster, control panel, routing controls, +// assertion and gating safety rules, and a RECOVERY_CONTROL health check. +func TestTerraform_ARCRecoveryControlConfig(t *testing.T) { + t.Parallel() + + suffix := uuid.NewString()[:8] + + verifiers := []struct { + verify func(ctx context.Context, t *testing.T, suffix string) + name string + }{ + {name: "control_config", verify: verifyARCControlConfig}, + {name: "health_check", verify: verifyARCRecoveryControlHealthCheck}, + } + + runTFTest(t, tfTestCase{ + name: "recovery_control_config", + fixture: "arc-recovery-control-config", + setup: func(t *testing.T, _ string) map[string]any { + t.Helper() + + return map[string]any{"Suffix": suffix} + }, + verify: func(t *testing.T, ctx context.Context, _ map[string]any) { + t.Helper() + + for _, tt := range verifiers { + t.Run(tt.name, func(t *testing.T) { + tt.verify(ctx, t, suffix) + }) + } + }, + }) +} + +// TestTerraform_ARCReadiness provisions the readiness cell, recovery group, resource set and +// readiness check resources. +func TestTerraform_ARCReadiness(t *testing.T) { + t.Parallel() + + tests := []struct { + verify func(ctx context.Context, t *testing.T, suffix string) + name string + fixture string + skip 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, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + if tc.skip != "" { + t.Skip(tc.skip) + } + + suffix := uuid.NewString()[:8] + + runTFTest(t, tfTestCase{ + name: tc.name, + fixture: tc.fixture, + setup: func(t *testing.T, _ string) map[string]any { + t.Helper() + + return map[string]any{"Suffix": suffix} + }, + verify: func(t *testing.T, ctx context.Context, _ map[string]any) { + t.Helper() + tc.verify(ctx, t, suffix) + }, + }) + }) + } +} + +func verifyARCControlConfig(ctx context.Context, t *testing.T, suffix string) { + t.Helper() + + 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) + } + } + + require.NotEmpty(t, clusterARN, "cluster should be listed") + + panels, err := client.ListControlPanels(ctx, &rccsvc.ListControlPanelsInput{}) + require.NoError(t, err) + + var panelARN string + + for _, p := range panels.ControlPanels { + if aws.ToString(p.Name) == "arc-panel-"+suffix { + 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") + + 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, "arc-secondary-"+suffix) + + 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) == "arc-at-least-one-"+suffix { + sawAssertion = true + + assert.ElementsMatch(t, []string{names["arc-primary-"+suffix], names["arc-secondary-"+suffix]}, + as.AssertedControls) + assert.Equal(t, int32(5000), aws.ToInt32(as.WaitPeriodMs)) + assert.Equal(t, rcctypes.RuleTypeAtleast, as.RuleConfig.Type) + } + + if g := r.GATING; g != nil && aws.ToString(g.Name) == "arc-gate-secondary-"+suffix { + sawGating = true + + assert.Equal(t, []string{names["arc-primary-"+suffix]}, g.GatingControls) + assert.Equal(t, []string{names["arc-secondary-"+suffix]}, g.TargetControls) + } + } + + 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, suffix string) { + t.Helper() + + 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) + + east, err := client.GetCell(ctx, &rrsvc.GetCellInput{CellName: aws.String("arc_east_" + suffix)}) + require.NoError(t, err) + + 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) +} + +func verifyARCReadinessResourceSetAndCheck(ctx context.Context, t *testing.T, suffix string) { + t.Helper() + + 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)) + 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)) + + status, err := client.GetReadinessCheckStatus(ctx, &rrsvc.GetReadinessCheckStatusInput{ + ReadinessCheckName: aws.String("arc_check_" + suffix), + }) + require.NoError(t, err) + assert.NotEmpty(t, status.Readiness) +} + +// TestTerraform_ARCZonalAutoshiftConfiguration provisions a zonal autoshift configuration with +// outcome and blocking alarms on an Application Load Balancer. +func TestTerraform_ARCZonalAutoshiftConfiguration(t *testing.T) { + t.Parallel() + + tests := []tfTestCase{ + { + name: "enabled_with_blocking_alarms", + fixture: "arc-zonal-autoshift-configuration", + providerFn: arczonalshiftProviderBlock, + setup: func(t *testing.T, _ string) map[string]any { + t.Helper() + + vars := vpcCIDRVars(t) + vars["Suffix"] = uuid.NewString()[:8] + + return vars + }, + verify: 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, arczstypes.ZonalAutoshiftStatusEnabled, 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) + }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + runTFTest(t, tc) + }) + } +} 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..6010af6825 --- /dev/null +++ b/test/terraform/fixtures/arc-readiness-cells-and-recovery-group.tf @@ -0,0 +1,15 @@ +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, + ] +} 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..fe2a0b2cda --- /dev/null +++ b/test/terraform/fixtures/arc-readiness-resource-set-and-check.tf @@ -0,0 +1,34 @@ +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] + } +} + +resource "aws_route53recoveryreadiness_readiness_check" "arc" { + readiness_check_name = "arc_check_{{.Suffix}}" + resource_set_name = aws_route53recoveryreadiness_resource_set.arc.resource_set_name +} 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..0731bdd9d3 --- /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 = "arc-panel-{{.Suffix}}" + 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 = "arc-secondary-{{.Suffix}}" + cluster_arn = aws_route53recoverycontrolconfig_cluster.arc.arn + control_panel_arn = aws_route53recoverycontrolconfig_control_panel.arc.arn +} + +resource "aws_route53recoverycontrolconfig_safety_rule" "assertion" { + name = "arc-at-least-one-{{.Suffix}}" + control_panel_arn = aws_route53recoverycontrolconfig_control_panel.arc.arn + wait_period_ms = 5000 + + asserted_controls = [ + aws_route53recoverycontrolconfig_routing_control.primary.arn, + aws_route53recoverycontrolconfig_routing_control.secondary.arn, + ] + + rule_config { + inverted = false + threshold = 1 + type = "ATLEAST" + } +} + +resource "aws_route53recoverycontrolconfig_safety_rule" "gating" { + name = "arc-gate-secondary-{{.Suffix}}" + control_panel_arn = aws_route53recoverycontrolconfig_control_panel.arc.arn + wait_period_ms = 5000 + + gating_controls = [aws_route53recoverycontrolconfig_routing_control.primary.arn] + target_controls = [aws_route53recoverycontrolconfig_routing_control.secondary.arn] + + rule_config { + inverted = false + threshold = 1 + type = "ATLEAST" + } +} + +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..0432989286 --- /dev/null +++ b/test/terraform/fixtures/arc-zonal-autoshift-configuration.tf @@ -0,0 +1,59 @@ +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 = "ENABLED" + + 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..a8343a1313 100644 --- a/test/terraform/terraform_test.go +++ b/test/terraform/terraform_test.go @@ -608,6 +608,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 { 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..14a5ea418d --- /dev/null +++ b/ui/src/lib/arc.test.ts @@ -0,0 +1,86 @@ +import { describe, it, expect, vi } from "vitest"; +import { + describeError, + endpointPath, + isApiError, + 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", + ); + }); +}); diff --git a/ui/src/lib/arc.ts b/ui/src/lib/arc.ts new file mode 100644 index 0000000000..631fed4d20 --- /dev/null +++ b/ui/src/lib/arc.ts @@ -0,0 +1,84 @@ +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"; + } +} 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/routes/arczonalshift/+page.svelte b/ui/src/routes/arczonalshift/+page.svelte new file mode 100644 index 0000000000..2bb602c6ed --- /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..e342edee30 --- /dev/null +++ b/ui/src/routes/arczonalshift/page.test.ts @@ -0,0 +1,144 @@ +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 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(), + ); + }); +}); diff --git a/ui/src/routes/route53recoverycluster/+page.svelte b/ui/src/routes/route53recoverycluster/+page.svelte new file mode 100644 index 0000000000..7fe16b3f47 --- /dev/null +++ b/ui/src/routes/route53recoverycluster/+page.svelte @@ -0,0 +1,232 @@ + + +
+ + +
+
+
+ + + + + {#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..efb8e18a42 --- /dev/null +++ b/ui/src/routes/route53recoverycluster/page.test.ts @@ -0,0 +1,95 @@ +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()); + }); +}); diff --git a/ui/src/routes/route53recoverycontrolconfig/+page.svelte b/ui/src/routes/route53recoverycontrolconfig/+page.svelte new file mode 100644 index 0000000000..312c35d488 --- /dev/null +++ b/ui/src/routes/route53recoverycontrolconfig/+page.svelte @@ -0,0 +1,901 @@ + + +
+ + {#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..f401bf0b75 --- /dev/null +++ b/ui/src/routes/route53recoverycontrolconfig/page.test.ts @@ -0,0 +1,239 @@ +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 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 }])); + }); +}); diff --git a/ui/src/routes/route53recoveryreadiness/+page.svelte b/ui/src/routes/route53recoveryreadiness/+page.svelte new file mode 100644 index 0000000000..beb3bc998a --- /dev/null +++ b/ui/src/routes/route53recoveryreadiness/+page.svelte @@ -0,0 +1,579 @@ + + +{#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..3f7a252cde --- /dev/null +++ b/ui/src/routes/route53recoveryreadiness/page.test.ts @@ -0,0 +1,140 @@ +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 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" }])); + }); +}); From acae3941b8223d2d024d992edbe9554c5e686f77 Mon Sep 17 00:00:00 2001 From: Witness Patrol Date: Thu, 15 Oct 2026 21:11:20 -0500 Subject: [PATCH 06/11] fix(elbv2,arc): subnet placement in the load balancer's region; accept empty dnsTargetResource - elbv2: placeSubnets resolved subnets by searching every region's EC2 backend and taking the first match, so subnet-default always landed in us-east-1a. It now looks the subnet up in the load balancer's own region, which also fixes zonal shift reporting use1 zone IDs for load balancers in other regions. arc-zonal-shift reuses EC2's AZ to zone-ID mapping instead of its own copy. - route53-recovery-readiness: CreateResourceSet/UpdateResourceSet treat an all-empty dnsTargetResource as unset (the Terraform provider sends one alongside resourceArn) and don't store or echo it back. The resource-set Terraform fixture is no longer skipped. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01EFp5KU8QNMfPqXVavWVtMT --- cli.go | 24 +++++++ cli_elbv2_region_subnet_test.go | 55 ++++++++++++++++ services/arczonalshift/cross_service.go | 39 ++--------- .../arczonalshift/managed_resources_test.go | 40 +++++++++++ services/ec2/az_ids.go | 3 + services/elbv2/crossservice.go | 7 ++ services/elbv2/load_balancers.go | 22 +++++++ .../evaluators_test.go | 66 +++++++++++++++++++ services/route53recoveryreadiness/sets.go | 10 +++ 9 files changed, 231 insertions(+), 35 deletions(-) create mode 100644 cli_elbv2_region_subnet_test.go diff --git a/cli.go b/cli.go index 104c8686c9..3610897721 100644 --- a/cli.go +++ b/cli.go @@ -3664,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 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/services/arczonalshift/cross_service.go b/services/arczonalshift/cross_service.go index 0f0cf491dc..0e757bdf9c 100644 --- a/services/arczonalshift/cross_service.go +++ b/services/arczonalshift/cross_service.go @@ -1,7 +1,6 @@ package arczonalshift import ( - "strconv" "strings" awsarn "github.com/aws/aws-sdk-go-v2/aws/arn" @@ -9,6 +8,7 @@ import ( "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" ) @@ -143,40 +143,9 @@ func (b *InMemoryBackend) alarmState(alarmArn string) (string, bool) { // 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 len(name) < len("x-y-1a") { - return name + if id := ec2backend.AvailabilityZoneID(name); id != "" { + return id } - letter := name[len(name)-1] - region := name[:len(name)-1] - parts := strings.Split(region, "-") - - if letter < 'a' || letter > 'z' || len(parts) < 3 || region[len(region)-1] < '0' || region[len(region)-1] > '9' { - return name - } - - var sb strings.Builder - - sb.WriteString(parts[0]) - - for _, p := range parts[1 : len(parts)-1] { - switch p { - case "northeast": - sb.WriteString("ne") - case "northwest": - sb.WriteString("nw") - case "southeast": - sb.WriteString("se") - case "southwest": - sb.WriteString("sw") - default: - sb.WriteString(p[:1]) - } - } - - sb.WriteString(parts[len(parts)-1]) - sb.WriteString("-az") - sb.WriteString(strconv.Itoa(int(letter-'a') + 1)) - - return sb.String() + return name } diff --git a/services/arczonalshift/managed_resources_test.go b/services/arczonalshift/managed_resources_test.go index 3301840951..4c4027f9c3 100644 --- a/services/arczonalshift/managed_resources_test.go +++ b/services/arczonalshift/managed_resources_test.go @@ -239,3 +239,43 @@ func TestManagedResourceStopsWhenZonalShiftDisabled(t *testing.T) { ) 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/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/route53recoveryreadiness/evaluators_test.go b/services/route53recoveryreadiness/evaluators_test.go index bed4935202..a1d006f264 100644 --- a/services/route53recoveryreadiness/evaluators_test.go +++ b/services/route53recoveryreadiness/evaluators_test.go @@ -359,3 +359,69 @@ func TestEveryResourceSetTypeHasRules(t *testing.T) { }) } } + +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/sets.go b/services/route53recoveryreadiness/sets.go index fafd666f14..5f1033f4c7 100644 --- a/services/route53recoveryreadiness/sets.go +++ b/services/route53recoveryreadiness/sets.go @@ -58,6 +58,10 @@ func normalizeResources(typ string, in []Resource) ([]Resource, error) { // 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 { @@ -80,6 +84,12 @@ func checkResource(typ string, i int, r Resource) (Resource, error) { 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) From d9d09642fb61c3309a3c3a3a2520a95028fb94f7 Mon Sep 17 00:00:00 2001 From: Witness Patrol Date: Thu, 15 Oct 2026 21:17:58 -0500 Subject: [PATCH 07/11] test(sns): don't keep HTTPS delivery connections alive past the test The goroutine-leak check in TestMain flagged an http2 client read loop from TestHTTPSSubscriptionDelivery: ts.Client() keeps connections alive, and under CI's coverage slowdown one could outlive ts.Close(). Disable keep-alives on the test client, as production newDeliveryClient does, and close idle connections on cleanup. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01EFp5KU8QNMfPqXVavWVtMT --- services/sns/delivery_test.go | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) 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) From 8eb657781a91766d5c250f0f372add359096e513 Mon Sep 17 00:00:00 2001 From: Witness Patrol Date: Thu, 15 Oct 2026 22:15:23 -0500 Subject: [PATCH 08/11] fix(ui/arc): paginate routing controls and clusters, full resource-set type list, reload loaded tabs on region change - Safety-rule control names and the Create Safety Rule picker read every page of ListRoutingControls; the cluster page reads every page of ListClusters. - Readiness resource-set types come from one list matching the backend's 20 types (adds AWS::SNS::Subscription and AWS::Route53RecoveryReadiness::DNSTargetResource). - Changing region refreshes every already-loaded tab (new createTabLoader.refreshLoaded) instead of only the active one. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01EFp5KU8QNMfPqXVavWVtMT --- .beads/issues.jsonl | 1 + ui/src/lib/arc.test.ts | 31 +++++++++++++ ui/src/lib/arc.ts | 23 ++++++++++ ui/src/lib/tab-loader.svelte.ts | 11 ++++- ui/src/lib/tab-loader.test.ts | 12 +++++ ui/src/routes/arczonalshift/+page.svelte | 2 +- ui/src/routes/arczonalshift/page.test.ts | 23 ++++++++++ .../route53recoverycluster/+page.svelte | 10 ++++- .../route53recoverycluster/page.test.ts | 12 +++++ .../route53recoverycontrolconfig/+page.svelte | 29 ++++++------ .../route53recoverycontrolconfig/page.test.ts | 44 +++++++++++++++++++ .../route53recoveryreadiness/+page.svelte | 26 ++--------- .../route53recoveryreadiness/page.test.ts | 32 ++++++++++++++ 13 files changed, 216 insertions(+), 40 deletions(-) diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index 3407240fa0..590c980082 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -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/ui/src/lib/arc.test.ts b/ui/src/lib/arc.test.ts index 14a5ea418d..19a16adb5d 100644 --- a/ui/src/lib/arc.test.ts +++ b/ui/src/lib/arc.test.ts @@ -3,6 +3,7 @@ import { describeError, endpointPath, isApiError, + RESOURCE_SET_TYPE_LIST, statusBadgeClass, withClusterEndpoints, } from "./arc"; @@ -84,3 +85,33 @@ describe("arc helpers", () => { ); }); }); + +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 index 631fed4d20..a4ba55b827 100644 --- a/ui/src/lib/arc.ts +++ b/ui/src/lib/arc.ts @@ -82,3 +82,26 @@ export function statusBadgeClass(status: string | undefined): string { 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/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 index 2bb602c6ed..c6f2be2358 100644 --- a/ui/src/routes/arczonalshift/+page.svelte +++ b/ui/src/routes/arczonalshift/+page.svelte @@ -111,7 +111,7 @@ onRegionChange(() => { untrack(() => { - void tabLoader.refresh(activeTab); + void tabLoader.refreshLoaded(activeTab); }); }); diff --git a/ui/src/routes/arczonalshift/page.test.ts b/ui/src/routes/arczonalshift/page.test.ts index e342edee30..19a1bfada4 100644 --- a/ui/src/routes/arczonalshift/page.test.ts +++ b/ui/src/routes/arczonalshift/page.test.ts @@ -1,5 +1,6 @@ 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; @@ -141,4 +142,26 @@ describe("ARC Zonal Shift page", () => { 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 index 7fe16b3f47..6dc91f919d 100644 --- a/ui/src/routes/route53recoverycluster/+page.svelte +++ b/ui/src/routes/route53recoverycluster/+page.svelte @@ -65,8 +65,14 @@ loading = true; loadError = null; try { - const resp = await configClient().send(new ListClustersCommand({})); - clusters = resp.Clusters ?? []; + const all: Cluster[] = []; + let token: string | undefined; + do { + const resp = await configClient().send(new ListClustersCommand({ NextToken: token })); + all.push(...(resp.Clusters ?? [])); + token = resp.NextToken; + } while (token); + clusters = all; if (!clusters.some((c) => c.ClusterArn === selectedCluster)) { selectedCluster = clusters[0]?.ClusterArn ?? ''; } diff --git a/ui/src/routes/route53recoverycluster/page.test.ts b/ui/src/routes/route53recoverycluster/page.test.ts index efb8e18a42..e7a7ed139b 100644 --- a/ui/src/routes/route53recoverycluster/page.test.ts +++ b/ui/src/routes/route53recoverycluster/page.test.ts @@ -92,4 +92,16 @@ describe("Route 53 Recovery Cluster page", () => { 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 index 312c35d488..281687703b 100644 --- a/ui/src/routes/route53recoverycontrolconfig/+page.svelte +++ b/ui/src/routes/route53recoverycontrolconfig/+page.svelte @@ -139,23 +139,27 @@ } } - async function fetchControls(): Promise { - if (panels.length === 0) await fetchPanels(); - if (clusters.length === 0) await fetchClusters(); - if (!selectedPanelArn) { - controls = []; - return; - } + async function listControls(panelArn: string): Promise { const out: RoutingControl[] = []; let token: string | undefined; do { const resp = await client().send( - new ListRoutingControlsCommand({ ControlPanelArn: selectedPanelArn, NextToken: token }) + new ListRoutingControlsCommand({ ControlPanelArn: panelArn, NextToken: token }) ); out.push(...(resp.RoutingControls ?? [])); token = resp.NextToken; } while (token); - controls = out; + return out; + } + + async function fetchControls(): Promise { + if (panels.length === 0) await fetchPanels(); + if (clusters.length === 0) await fetchClusters(); + if (!selectedPanelArn) { + controls = []; + return; + } + controls = await listControls(selectedPanelArn); await loadStates(); } @@ -200,10 +204,7 @@ return; } if (controls.length === 0) { - const resp = await client().send( - new ListRoutingControlsCommand({ ControlPanelArn: selectedPanelArn }) - ); - controls = resp.RoutingControls ?? []; + controls = await listControls(selectedPanelArn); } const out: FlatRule[] = []; let token: string | undefined; @@ -256,7 +257,7 @@ controls = []; rules = []; clusters = []; - void tabLoader.refresh(activeTab); + void tabLoader.refreshLoaded(activeTab); }); }); diff --git a/ui/src/routes/route53recoverycontrolconfig/page.test.ts b/ui/src/routes/route53recoverycontrolconfig/page.test.ts index f401bf0b75..6ecef82653 100644 --- a/ui/src/routes/route53recoverycontrolconfig/page.test.ts +++ b/ui/src/routes/route53recoverycontrolconfig/page.test.ts @@ -1,5 +1,6 @@ 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; @@ -236,4 +237,47 @@ describe("Route 53 Recovery Control page", () => { 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 index beb3bc998a..c4cc1b076a 100644 --- a/ui/src/routes/route53recoveryreadiness/+page.svelte +++ b/ui/src/routes/route53recoveryreadiness/+page.svelte @@ -2,7 +2,7 @@ import { untrack } from 'svelte'; import { onRegionChange, regionalClient } from '#lib/region-effect.svelte.ts'; import { getRoute53RecoveryReadinessClient } from '#lib/aws-client.ts'; - import { describeError, statusBadgeClass } from '#lib/arc.ts'; + import { describeError, RESOURCE_SET_TYPE_LIST, statusBadgeClass } from '#lib/arc.ts'; import { ListCellsCommand, CreateCellCommand, @@ -47,26 +47,7 @@ { id: 'checks', label: 'Readiness Checks' } ]; - const RESOURCE_SET_TYPES = [ - '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::Topic', - 'AWS::SQS::Queue' - ]; + const RESOURCE_SET_TYPES = RESOURCE_SET_TYPE_LIST; const btn = 'flex items-center gap-2 px-3 py-2 rounded-lg bg-teal-600 text-white hover:bg-teal-700 text-sm'; @@ -179,7 +160,8 @@ untrack(() => { cells = []; sets = []; - void tabLoader.refresh(activeTab); + readiness = {}; + void tabLoader.refreshLoaded(activeTab); }); }); diff --git a/ui/src/routes/route53recoveryreadiness/page.test.ts b/ui/src/routes/route53recoveryreadiness/page.test.ts index 3f7a252cde..8d95fe029a 100644 --- a/ui/src/routes/route53recoveryreadiness/page.test.ts +++ b/ui/src/routes/route53recoveryreadiness/page.test.ts @@ -1,5 +1,6 @@ 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; @@ -137,4 +138,35 @@ describe("Route 53 Recovery Readiness page", () => { 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); + } + }); }); From e0d0b9ea706108b709cef1177a5786d405c85e3e Mon Sep 17 00:00:00 2001 From: Witness Patrol Date: Thu, 15 Oct 2026 22:32:59 -0500 Subject: [PATCH 09/11] fix(arc): review feedback on ARC backends - restroute: an oversized request body returns 413 RequestEntityTooLargeException instead of 400 ValidationException. - arc-zonal-shift: StartPracticeRun rejects a second practice run while one is active on the resource (ConflictException, ZonalShiftAlreadyExists). - route53-recovery-control-config: creating a control panel, routing control or safety rule under a PENDING_DELETION parent is a ConflictException. Safety-rule evaluation and the DeleteRoutingControl conflict iterate in a stable order, so the rule named in errors is deterministic (TestSafetyRuleCRUD failed 8/50 before). - route53-recovery-readiness: cross-account authorizations match the account ID exactly instead of by substring. - docs: correct the lifecycle flag to --lifecycle-route53-recovery, with a repo-wide guard that every documented --lifecycle-* flag exists. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01EFp5KU8QNMfPqXVavWVtMT --- cli_lifecycle_docs_test.go | 85 +++++++++++++++++ pkgs/restroute/restroute_test.go | 9 ++ pkgs/restroute/serve.go | 5 + services/arczonalshift/practice.go | 5 + services/arczonalshift/practice_test.go | 50 ++++++++++ .../route53recoverycontrolconfig/PARITY.md | 2 +- .../controlpanels.go | 2 +- .../lifecycle_test.go | 95 +++++++++++++++++++ .../routingcontrols.go | 8 +- .../route53recoverycontrolconfig/rules.go | 2 +- .../rules_test.go | 2 +- .../route53recoverycontrolconfig/state.go | 3 +- .../route53recoverycontrolconfig/store.go | 28 ++++++ services/route53recoveryreadiness/evaluate.go | 11 ++- .../readiness_test.go | 31 ++++++ services/route53recoveryreadiness/summary.go | 8 +- 16 files changed, 331 insertions(+), 15 deletions(-) create mode 100644 cli_lifecycle_docs_test.go 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/pkgs/restroute/restroute_test.go b/pkgs/restroute/restroute_test.go index bd1739bf2a..4fa11941ff 100644 --- a/pkgs/restroute/restroute_test.go +++ b/pkgs/restroute/restroute_test.go @@ -12,6 +12,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/blackbirdworks/gopherstack/pkgs/httputils" "github.com/blackbirdworks/gopherstack/pkgs/restroute" ) @@ -186,6 +187,14 @@ func TestServerDispatchAndErrors(t *testing.T) { 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, diff --git a/pkgs/restroute/serve.go b/pkgs/restroute/serve.go index 649831aeb4..90530ecdde 100644 --- a/pkgs/restroute/serve.go +++ b/pkgs/restroute/serve.go @@ -97,6 +97,11 @@ func (s *Server) Handler() echo.HandlerFunc { 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) } diff --git a/services/arczonalshift/practice.go b/services/arczonalshift/practice.go index 71d5285fab..0b4c9124a5 100644 --- a/services/arczonalshift/practice.go +++ b/services/arczonalshift/practice.go @@ -364,6 +364,11 @@ func (b *InMemoryBackend) StartPracticeRun(resource, awayFrom, comment string) ( 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 } diff --git a/services/arczonalshift/practice_test.go b/services/arczonalshift/practice_test.go index 4f093954e9..1c95024d52 100644 --- a/services/arczonalshift/practice_test.go +++ b/services/arczonalshift/practice_test.go @@ -11,6 +11,7 @@ import ( "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" ) @@ -500,3 +501,52 @@ func TestAutoshiftObserverNotification(t *testing.T) { _, 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/route53recoverycontrolconfig/PARITY.md b/services/route53recoverycontrolconfig/PARITY.md index 4d0a1e1fe0..785defb3e9 100644 --- a/services/route53recoverycontrolconfig/PARITY.md +++ b/services/route53recoverycontrolconfig/PARITY.md @@ -46,6 +46,6 @@ leaks: {status: clean, note: "no goroutines or janitors; expiry and lifecycle ar - 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-route53recoverycontrolconfig` (or the global delay) keeps new resources PENDING and deleted ones PENDING_DELETION for the window; default 0 is instant. +- 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/controlpanels.go b/services/route53recoverycontrolconfig/controlpanels.go index b7811396a3..d5149d5407 100644 --- a/services/route53recoverycontrolconfig/controlpanels.go +++ b/services/route53recoverycontrolconfig/controlpanels.go @@ -64,7 +64,7 @@ func (b *InMemoryBackend) CreateControlPanel( } } - if _, err := b.getCluster(clusterArn); err != nil { + if err := b.activeCluster(clusterArn); err != nil { return wireControlPanel{}, err } diff --git a/services/route53recoverycontrolconfig/lifecycle_test.go b/services/route53recoverycontrolconfig/lifecycle_test.go index b1a605614d..f899a49725 100644 --- a/services/route53recoverycontrolconfig/lifecycle_test.go +++ b/services/route53recoverycontrolconfig/lifecycle_test.go @@ -8,6 +8,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/blackbirdworks/gopherstack/pkgs/restroute" cc "github.com/blackbirdworks/gopherstack/services/route53recoverycontrolconfig" ) @@ -110,3 +111,97 @@ func TestPendingDeletion(t *testing.T) { }) } } + +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/routingcontrols.go b/services/route53recoverycontrolconfig/routingcontrols.go index a51f8a886c..8d435dd123 100644 --- a/services/route53recoverycontrolconfig/routingcontrols.go +++ b/services/route53recoverycontrolconfig/routingcontrols.go @@ -32,7 +32,7 @@ func (b *InMemoryBackend) CreateRoutingControl(clusterArn, panelArn, name, token } } - if _, err := b.getCluster(clusterArn); err != nil { + if err := b.activeCluster(clusterArn); err != nil { return wireRoutingControl{}, err } @@ -45,7 +45,7 @@ func (b *InMemoryBackend) CreateRoutingControl(clusterArn, panelArn, name, token panelArn = def } - panel, err := b.getPanel(panelArn) + panel, err := b.activePanel(panelArn) if err != nil { return wireRoutingControl{}, err } @@ -150,7 +150,9 @@ func (b *InMemoryBackend) DeleteRoutingControl(arn string) error { return err } - for _, r := range b.rulesIn(c.PanelARN) { + 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) } diff --git a/services/route53recoverycontrolconfig/rules.go b/services/route53recoverycontrolconfig/rules.go index 922704553a..43df14725e 100644 --- a/services/route53recoverycontrolconfig/rules.go +++ b/services/route53recoverycontrolconfig/rules.go @@ -112,7 +112,7 @@ func (b *InMemoryBackend) CreateSafetyRule(spec *NewRuleSpec, token string, tags } } - if _, err := b.getPanel(spec.PanelARN); err != nil { + if _, err := b.activePanel(spec.PanelARN); err != nil { return wireRule{}, err } diff --git a/services/route53recoverycontrolconfig/rules_test.go b/services/route53recoverycontrolconfig/rules_test.go index 274ca725a0..75226edb1b 100644 --- a/services/route53recoverycontrolconfig/rules_test.go +++ b/services/route53recoverycontrolconfig/rules_test.go @@ -102,7 +102,7 @@ func TestSafetyRuleCRUD(t *testing.T) { &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), "renamed") + 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) diff --git a/services/route53recoverycontrolconfig/state.go b/services/route53recoverycontrolconfig/state.go index 0129ca3d16..624bf74814 100644 --- a/services/route53recoverycontrolconfig/state.go +++ b/services/route53recoverycontrolconfig/state.go @@ -2,6 +2,7 @@ package route53recoverycontrolconfig import ( "fmt" + "maps" "slices" "time" @@ -288,7 +289,7 @@ func (b *InMemoryBackend) evaluateRules( return ev } - for panel := range panels { + 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 diff --git a/services/route53recoverycontrolconfig/store.go b/services/route53recoverycontrolconfig/store.go index e9642a8d97..a6871fb23f 100644 --- a/services/route53recoverycontrolconfig/store.go +++ b/services/route53recoverycontrolconfig/store.go @@ -122,6 +122,34 @@ func (b *InMemoryBackend) getPanel(arn string) (*ControlPanel, error) { 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) { diff --git a/services/route53recoveryreadiness/evaluate.go b/services/route53recoveryreadiness/evaluate.go index 047061ff50..2eec07e4ae 100644 --- a/services/route53recoveryreadiness/evaluate.go +++ b/services/route53recoveryreadiness/evaluate.go @@ -124,7 +124,16 @@ func (b *InMemoryBackend) authorized(resourceArn string, auths []string) (bool, return true, "" } - return slices.ContainsFunc(auths, func(a string) bool { return strings.Contains(a, p.AccountID) }), p.AccountID + 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 { diff --git a/services/route53recoveryreadiness/readiness_test.go b/services/route53recoveryreadiness/readiness_test.go index 551a808427..54a456404c 100644 --- a/services/route53recoveryreadiness/readiness_test.go +++ b/services/route53recoveryreadiness/readiness_test.go @@ -175,6 +175,37 @@ func TestReadinessFollowsSiblingState(t *testing.T) { ) }) + 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() diff --git a/services/route53recoveryreadiness/summary.go b/services/route53recoveryreadiness/summary.go index acc9a7fb16..c0bbc4f07e 100644 --- a/services/route53recoveryreadiness/summary.go +++ b/services/route53recoveryreadiness/summary.go @@ -59,12 +59,8 @@ func (b *InMemoryBackend) ReadinessCheckStatus( var msgs []Message - for _, o := range outs { - if o.Note != "" { - msgs = append(msgs, Message{MessageText: o.Note}) - - break - } + 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 { From 5562b63d2ad4bd4350479e4a921dae39e9362e80 Mon Sep 17 00:00:00 2001 From: Witness Patrol Date: Thu, 15 Oct 2026 23:04:19 -0500 Subject: [PATCH 10/11] test(terraform): full ARC provider coverage with drift and update checks - Fixtures now cover every route53recoverycontrolconfig_*, route53recoveryreadiness_* and arczonalshift_* resource in provider v5.100 and v6.69 (adds autoshift_observer_notification_status, a DNS-target resource set and a v6 control-config fixture with tags and network_type); neither version has data sources for these. - Opt-in harness fields: noDrift asserts an empty plan after apply and after an update; update/verifyUpdate re-apply a vars overlay and check the backend. ARC cases update names, wait_period_ms, tags, observer status and autoshift windows. - tofuEnv sets AWS_ENDPOINT_URL so plans hit the same endpoint as applies. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01EFp5KU8QNMfPqXVavWVtMT --- test/terraform/arc_recovery_test.go | 344 +++++++++++++----- test/terraform/drift_test.go | 19 + .../arc-autoshift-observer-notification.tf | 3 + .../arc-readiness-cells-and-recovery-group.tf | 17 + .../arc-readiness-dns-target-resource.tf | 34 ++ .../arc-readiness-resource-set-and-check.tf | 8 + .../arc-recovery-control-config-v6.tf | 74 ++++ .../fixtures/arc-recovery-control-config.tf | 18 +- .../arc-zonal-autoshift-configuration.tf | 4 +- test/terraform/terraform_test.go | 30 +- 10 files changed, 450 insertions(+), 101 deletions(-) create mode 100644 test/terraform/fixtures/arc-autoshift-observer-notification.tf create mode 100644 test/terraform/fixtures/arc-readiness-dns-target-resource.tf create mode 100644 test/terraform/fixtures/arc-recovery-control-config-v6.tf diff --git a/test/terraform/arc_recovery_test.go b/test/terraform/arc_recovery_test.go index 166e017f24..de73d46a4b 100644 --- a/test/terraform/arc_recovery_test.go +++ b/test/terraform/arc_recovery_test.go @@ -17,51 +17,109 @@ import ( "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. +// assertion and gating safety rules, and a RECOVERY_CONTROL health check, then updates in place. func TestTerraform_ARCRecoveryControlConfig(t *testing.T) { t.Parallel() - suffix := uuid.NewString()[:8] - - verifiers := []struct { - verify func(ctx context.Context, t *testing.T, suffix string) - name string + tests := []struct { + providerFn func(addr string) string + name string + fixture string + tagged bool // v6 only: tags, network type and updatable safety rules }{ - {name: "control_config", verify: verifyARCControlConfig}, - {name: "health_check", verify: verifyARCRecoveryControlHealthCheck}, + {name: "provider_v5", fixture: "arc-recovery-control-config"}, + { + name: "provider_v6_tags", + fixture: "arc-recovery-control-config-v6", + providerFn: arczonalshiftProviderBlock, + tagged: true, + }, } - runTFTest(t, tfTestCase{ - name: "recovery_control_config", - fixture: "arc-recovery-control-config", - setup: func(t *testing.T, _ string) map[string]any { - t.Helper() + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() - return map[string]any{"Suffix": suffix} - }, - verify: func(t *testing.T, ctx context.Context, _ map[string]any) { - t.Helper() + suffix := uuid.NewString()[:8] - for _, tt := range verifiers { - t.Run(tt.name, func(t *testing.T) { - tt.verify(ctx, t, suffix) - }) - } - }, - }) + 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. +// 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, suffix string) + verify func(ctx context.Context, t *testing.T, vars map[string]any) name string fixture string - skip string }{ { name: "cells_and_recovery_group", @@ -73,38 +131,81 @@ func TestTerraform_ARCReadiness(t *testing.T) { 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() - if tc.skip != "" { - t.Skip(tc.skip) - } - - suffix := uuid.NewString()[:8] - 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": suffix} + return map[string]any{"Suffix": uuid.NewString()[:8], "Env": "dev"} }, - verify: func(t *testing.T, ctx context.Context, _ map[string]any) { + update: map[string]any{"Env": "prod"}, + verify: func(t *testing.T, ctx context.Context, vars map[string]any) { t.Helper() - tc.verify(ctx, t, suffix) + tc.verify(ctx, t, vars) + }, + verifyUpdate: func(t *testing.T, ctx context.Context, vars map[string]any) { + t.Helper() + tc.verify(ctx, t, vars) }, }) }) } } -func verifyARCControlConfig(ctx context.Context, t *testing.T, suffix string) { +// 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) }) @@ -119,18 +220,26 @@ func verifyARCControlConfig(ctx context.Context, t *testing.T, suffix string) { 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) == "arc-panel-"+suffix { + if aws.ToString(p.Name) == panelName { panelARN = aws.ToString(p.ControlPanelArn) assert.Equal(t, clusterARN, aws.ToString(p.ClusterArn)) @@ -140,6 +249,10 @@ func verifyARCControlConfig(ctx context.Context, t *testing.T, suffix string) { 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), }) @@ -151,7 +264,7 @@ func verifyARCControlConfig(ctx context.Context, t *testing.T, suffix string) { } require.Contains(t, names, "arc-primary-"+suffix) - require.Contains(t, names, "arc-secondary-"+suffix) + require.Contains(t, names, secondaryName) rules, err := client.ListSafetyRules(ctx, &rccsvc.ListSafetyRulesInput{ControlPanelArn: aws.String(panelARN)}) require.NoError(t, err) @@ -159,20 +272,28 @@ func verifyARCControlConfig(ctx context.Context, t *testing.T, suffix string) { var sawAssertion, sawGating bool for _, r := range rules.SafetyRules { - if as := r.ASSERTION; as != nil && aws.ToString(as.Name) == "arc-at-least-one-"+suffix { + if as := r.ASSERTION; as != nil && aws.ToString(as.Name) == vars["AssertionName"].(string) { sawAssertion = true - assert.ElementsMatch(t, []string{names["arc-primary-"+suffix], names["arc-secondary-"+suffix]}, - as.AssertedControls) - assert.Equal(t, int32(5000), aws.ToInt32(as.WaitPeriodMs)) + 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) == "arc-gate-secondary-"+suffix { + 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["arc-secondary-"+suffix]}, g.TargetControls) + 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)) } } @@ -229,9 +350,12 @@ func verifyARCRecoveryControlHealthCheck(ctx context.Context, t *testing.T, suff assert.True(t, found, "RECOVERY_CONTROL health check for the primary routing control should be listed") } -func verifyARCReadinessCellsAndGroup(ctx context.Context, t *testing.T, suffix string) { +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) }) @@ -239,19 +363,30 @@ func verifyARCReadinessCellsAndGroup(ctx context.Context, t *testing.T, suffix s 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, suffix string) { +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) }) @@ -264,6 +399,7 @@ func verifyARCReadinessResourceSetAndCheck(ctx context.Context, t *testing.T, su }) 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) @@ -273,6 +409,7 @@ func verifyARCReadinessResourceSetAndCheck(ctx context.Context, t *testing.T, su }) 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), @@ -281,61 +418,90 @@ func verifyARCReadinessResourceSetAndCheck(ctx context.Context, t *testing.T, su 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 on an Application Load Balancer. +// outcome and blocking alarms and maintenance windows on an Application Load Balancer, then updates it. func TestTerraform_ARCZonalAutoshiftConfiguration(t *testing.T) { t.Parallel() - tests := []tfTestCase{ - { - name: "enabled_with_blocking_alarms", - fixture: "arc-zonal-autoshift-configuration", - providerFn: arczonalshiftProviderBlock, - setup: func(t *testing.T, _ string) map[string]any { - t.Helper() - - vars := vpcCIDRVars(t) - vars["Suffix"] = uuid.NewString()[:8] + type verifyFn = func(*testing.T, context.Context, map[string]any) - return vars - }, - verify: func(t *testing.T, ctx context.Context, vars map[string]any) { - t.Helper() + 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) - }) + 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) + resources, err := client.ListManagedResources(ctx, &arczonalshiftsvc.ListManagedResourcesInput{}) + require.NoError(t, err) - var arn string + var arn string - for _, r := range resources.Items { - if aws.ToString(r.Name) == "arczs-"+vars["Suffix"].(string) { - arn = aws.ToString(r.Arn) + for _, r := range resources.Items { + if aws.ToString(r.Name) == "arczs-"+vars["Suffix"].(string) { + arn = aws.ToString(r.Arn) - assert.Equal(t, arczstypes.ZonalAutoshiftStatusEnabled, r.ZonalAutoshiftStatus) - } + assert.Equal(t, status, r.ZonalAutoshiftStatus) } + } - require.NotEmpty(t, arn, "load balancer should be a managed resource") + 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) - }, - }, + 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) + } } - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - runTFTest(t, tc) - }) - } + 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 index 6010af6825..f2fb52a301 100644 --- a/test/terraform/fixtures/arc-readiness-cells-and-recovery-group.tf +++ b/test/terraform/fixtures/arc-readiness-cells-and-recovery-group.tf @@ -1,9 +1,22 @@ +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" { @@ -12,4 +25,8 @@ resource "aws_route53recoveryreadiness_recovery_group" "arc" { 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 index fe2a0b2cda..2b833897fb 100644 --- a/test/terraform/fixtures/arc-readiness-resource-set-and-check.tf +++ b/test/terraform/fixtures/arc-readiness-resource-set-and-check.tf @@ -26,9 +26,17 @@ resource "aws_route53recoveryreadiness_resource_set" "arc" { 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 index 0731bdd9d3..bc9cb84732 100644 --- a/test/terraform/fixtures/arc-recovery-control-config.tf +++ b/test/terraform/fixtures/arc-recovery-control-config.tf @@ -3,7 +3,7 @@ resource "aws_route53recoverycontrolconfig_cluster" "arc" { } resource "aws_route53recoverycontrolconfig_control_panel" "arc" { - name = "arc-panel-{{.Suffix}}" + name = "{{.PanelName}}" cluster_arn = aws_route53recoverycontrolconfig_cluster.arc.arn } @@ -14,15 +14,15 @@ resource "aws_route53recoverycontrolconfig_routing_control" "primary" { } resource "aws_route53recoverycontrolconfig_routing_control" "secondary" { - name = "arc-secondary-{{.Suffix}}" + 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 = "arc-at-least-one-{{.Suffix}}" + name = "{{.AssertionName}}" control_panel_arn = aws_route53recoverycontrolconfig_control_panel.arc.arn - wait_period_ms = 5000 + wait_period_ms = {{.WaitMs}} asserted_controls = [ aws_route53recoverycontrolconfig_routing_control.primary.arn, @@ -31,23 +31,23 @@ resource "aws_route53recoverycontrolconfig_safety_rule" "assertion" { rule_config { inverted = false - threshold = 1 + threshold = {{.Threshold}} type = "ATLEAST" } } resource "aws_route53recoverycontrolconfig_safety_rule" "gating" { - name = "arc-gate-secondary-{{.Suffix}}" + name = "{{.GatingName}}" control_panel_arn = aws_route53recoverycontrolconfig_control_panel.arc.arn - wait_period_ms = 5000 + wait_period_ms = {{.WaitMs}} gating_controls = [aws_route53recoverycontrolconfig_routing_control.primary.arn] target_controls = [aws_route53recoverycontrolconfig_routing_control.secondary.arn] rule_config { - inverted = false + inverted = true threshold = 1 - type = "ATLEAST" + type = "OR" } } diff --git a/test/terraform/fixtures/arc-zonal-autoshift-configuration.tf b/test/terraform/fixtures/arc-zonal-autoshift-configuration.tf index 0432989286..48f7a1ffd4 100644 --- a/test/terraform/fixtures/arc-zonal-autoshift-configuration.tf +++ b/test/terraform/fixtures/arc-zonal-autoshift-configuration.tf @@ -45,7 +45,9 @@ resource "aws_cloudwatch_metric_alarm" "arczs_blocking" { resource "aws_arczonalshift_zonal_autoshift_configuration" "arczs" { resource_arn = aws_lb.arczs.arn - zonal_autoshift_status = "ENABLED" + zonal_autoshift_status = "{{.Status}}" + blocked_windows = ["{{.BlockedWindow}}"] + blocked_dates = ["2030-12-25"] outcome_alarms { alarm_identifier = aws_cloudwatch_metric_alarm.arczs_outcome.arn diff --git a/test/terraform/terraform_test.go b/test/terraform/terraform_test.go index a8343a1313..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" @@ -1018,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. @@ -1044,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. From 003d2d3dc2752d4fcc0b32c37fb6b838b2bc350a Mon Sep 17 00:00:00 2001 From: Witness Patrol Date: Fri, 16 Oct 2026 16:21:53 -0500 Subject: [PATCH 11/11] test(terraform): drop invalid headers on the zonal-autoshift fixture ALB Clears trivy AWS-0052 flagged by CodeFactor. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01EFp5KU8QNMfPqXVavWVtMT --- test/terraform/fixtures/arc-zonal-autoshift-configuration.tf | 2 ++ 1 file changed, 2 insertions(+) diff --git a/test/terraform/fixtures/arc-zonal-autoshift-configuration.tf b/test/terraform/fixtures/arc-zonal-autoshift-configuration.tf index 48f7a1ffd4..fad19d4048 100644 --- a/test/terraform/fixtures/arc-zonal-autoshift-configuration.tf +++ b/test/terraform/fixtures/arc-zonal-autoshift-configuration.tf @@ -19,6 +19,8 @@ resource "aws_lb" "arczs" { internal = true load_balancer_type = "application" subnets = [aws_subnet.arczs_a.id, aws_subnet.arczs_b.id] + + drop_invalid_header_fields = true } resource "aws_cloudwatch_metric_alarm" "arczs_outcome" {