diff --git a/docs/_generated/integ-coverage.json b/docs/_generated/integ-coverage.json index eaf857c2e..de56b8d62 100644 --- a/docs/_generated/integ-coverage.json +++ b/docs/_generated/integ-coverage.json @@ -743,6 +743,7 @@ "rds-dbinstance-backfill", "remove-protection", "rollback-failure-injection", + "sg-circular-dependency", "vpc-lambda", "vpc-lambda-cr-race", "vpc-nat-gateway" @@ -812,6 +813,9 @@ "rollback-failure-injection": [ "l2" ], + "sg-circular-dependency": [ + "l2" + ], "vpc-lambda": [ "l2" ], @@ -847,6 +851,7 @@ "rds-dbinstance-backfill", "remove-protection", "rollback-failure-injection", + "sg-circular-dependency", "vpc-lambda", "vpc-lambda-cr-race", "vpc-nat-gateway" @@ -915,6 +920,9 @@ "rollback-failure-injection": [ "l2" ], + "sg-circular-dependency": [ + "l2" + ], "vpc-lambda": [ "l2" ], @@ -973,6 +981,7 @@ "rds-dbinstance-backfill", "remove-protection", "rollback-failure-injection", + "sg-circular-dependency", "vpc-lambda", "vpc-lambda-cr-race", "vpc-nat-gateway" @@ -1042,6 +1051,9 @@ "rollback-failure-injection": [ "l2" ], + "sg-circular-dependency": [ + "l2" + ], "vpc-lambda": [ "l2" ], @@ -1077,6 +1089,7 @@ "rds-dbinstance-backfill", "remove-protection", "rollback-failure-injection", + "sg-circular-dependency", "vpc-lambda", "vpc-lambda-cr-race", "vpc-nat-gateway" @@ -1146,6 +1159,9 @@ "rollback-failure-injection": [ "l2" ], + "sg-circular-dependency": [ + "l2" + ], "vpc-lambda": [ "l2" ], @@ -1178,6 +1194,7 @@ "rds-dbinstance-backfill", "remove-protection", "rollback-failure-injection", + "sg-circular-dependency", "update-replace", "vpc-lambda", "vpc-lambda-cr-race" @@ -1238,6 +1255,10 @@ "rollback-failure-injection": [ "l2" ], + "sg-circular-dependency": [ + "l2", + "literal" + ], "update-replace": [ "l2", "literal" @@ -1253,11 +1274,15 @@ { "resourceType": "AWS::EC2::SecurityGroupIngress", "integs": [ - "alb" + "alb", + "sg-circular-dependency" ], "signals": { "alb": [ "literal" + ], + "sg-circular-dependency": [ + "literal" ] } }, @@ -1285,6 +1310,7 @@ "rds-dbinstance-backfill", "remove-protection", "rollback-failure-injection", + "sg-circular-dependency", "vpc-lambda", "vpc-lambda-cr-race", "vpc-nat-gateway" @@ -1354,6 +1380,9 @@ "rollback-failure-injection": [ "l2" ], + "sg-circular-dependency": [ + "l2" + ], "vpc-lambda": [ "l2" ], @@ -1400,6 +1429,7 @@ "rds-dbinstance-backfill", "remove-protection", "rollback-failure-injection", + "sg-circular-dependency", "vpc-lambda", "vpc-lambda-cr-race", "vpc-nat-gateway" @@ -1469,6 +1499,9 @@ "rollback-failure-injection": [ "l2" ], + "sg-circular-dependency": [ + "l2" + ], "vpc-lambda": [ "l2" ], @@ -1504,6 +1537,7 @@ "rds-dbinstance-backfill", "remove-protection", "rollback-failure-injection", + "sg-circular-dependency", "vpc-lambda", "vpc-lambda-cr-race", "vpc-nat-gateway" @@ -1574,6 +1608,9 @@ "rollback-failure-injection": [ "l2" ], + "sg-circular-dependency": [ + "l2" + ], "vpc-lambda": [ "l2" ], @@ -1609,6 +1646,7 @@ "rds-dbinstance-backfill", "remove-protection", "rollback-failure-injection", + "sg-circular-dependency", "vpc-lambda", "vpc-lambda-cr-race", "vpc-nat-gateway" @@ -1678,6 +1716,9 @@ "rollback-failure-injection": [ "l2" ], + "sg-circular-dependency": [ + "l2" + ], "vpc-lambda": [ "l2" ], diff --git a/docs/_generated/scenario-coverage.json b/docs/_generated/scenario-coverage.json index 0d46ddf2c..aa3364020 100644 --- a/docs/_generated/scenario-coverage.json +++ b/docs/_generated/scenario-coverage.json @@ -168,6 +168,10 @@ "tag": "s3-asset-deploy", "description": "File/ZIP asset publishing during `cdkd deploy`: a multi-file local directory is zipped + uploaded to the CDK bootstrap asset bucket by `FileAssetPublisher` (content-addressed, skip-if-exists), the Lambda `Code.S3Bucket`/`Code.S3Key` ref is wired to the uploaded object (CodeSize proves it is NOT inline), AND a generic `s3_assets.Asset` upload is read back at runtime via cdkd-resolved bucket/key env vars. Bootstrap-bucket asset objects persist by design across destroy." }, + { + "tag": "sg-circular-dependency", + "description": "Circular Security Group reference (SG-A ingress from SG-B AND SG-B ingress from SG-A) modeled via standalone AWS::EC2::SecurityGroupIngress resources. DAG builder must not raise a false cycle; destroy must revoke both ingress rules BEFORE deleting either SG (SecurityGroup-after-SecurityGroupIngress implicit-delete-dep) or AWS rejects DeleteSecurityGroup with DependencyViolation." + }, { "tag": "state-bucket-region-resolve", "description": "State-bucket S3 clients (state backend + lock manager) auto-detect bucket region via `GetBucketLocation` regardless of caller-profile region." @@ -943,6 +947,13 @@ "annotated": true, "scenarios": [] }, + { + "name": "sg-circular-dependency", + "annotated": true, + "scenarios": [ + "sg-circular-dependency" + ] + }, { "name": "sns-sqs-event", "annotated": true, @@ -1340,6 +1351,13 @@ "s3-asset-deploy" ] }, + { + "scenario": "sg-circular-dependency", + "description": "Circular Security Group reference (SG-A ingress from SG-B AND SG-B ingress from SG-A) modeled via standalone AWS::EC2::SecurityGroupIngress resources. DAG builder must not raise a false cycle; destroy must revoke both ingress rules BEFORE deleting either SG (SecurityGroup-after-SecurityGroupIngress implicit-delete-dep) or AWS rejects DeleteSecurityGroup with DependencyViolation.", + "fixtures": [ + "sg-circular-dependency" + ] + }, { "scenario": "state-bucket-region-resolve", "description": "State-bucket S3 clients (state backend + lock manager) auto-detect bucket region via `GetBucketLocation` regardless of caller-profile region.", diff --git a/docs/changelog-cdkd.md b/docs/changelog-cdkd.md index 3cc154c8f..310014aec 100644 --- a/docs/changelog-cdkd.md +++ b/docs/changelog-cdkd.md @@ -19,6 +19,7 @@ The CLAUDE.md `## Known Limitations` section retains the load-bearing summary **Recently Implemented** (2026-06-13): - ✅ **New `multi-asset` integ stresses the asset-publishing layer when MANY assets of TWO kinds publish concurrently in one deploy** — `tests/integration/multi-asset/**`. The existing `docker-image-asset` and `s3-asset-deploy` fixtures each exercise ONE publisher in isolation (ECR build+push vs a single S3 zip upload), leaving the CONCURRENT multi-asset case — `FileAssetPublisher` + `DockerAssetPublisher` interleaved, ECR + S3 in one run, and several distinct S3 uploads in flight together — without a dedicated regression. The new `CdkdMultiAssetExample` stack (no VPC) publishes **1 ECR image + 4 S3 objects** in a single deploy: a `lambda.DockerImageFunction` (`DockerImageCode.fromImageAsset(docker/, { platform: LINUX_ARM64 })` + `architecture: ARM_64`, matched, to avoid the cross-arch `Runtime.InvalidEntrypoint: ProcessSpawnFailed` trap on Apple-Silicon hosts) goes through `DockerAssetPublisher`; three `lambda.Function`s (`AlphaHandler`/`BetaHandler`/`GammaHandler`, Python 3.12) each `Code.fromAsset('')` produce three DISTINCT `FileAssetPublisher` S3 uploads (distinct content -> distinct content-addressed asset hash, confirmed at synth: 5 file assets + 1 docker image in the manifest); and a generic `s3_assets.Asset` (`asset-data/`) is a 4th S3 upload whose resolved `s3BucketName`/`s3ObjectKey` are threaded into the alpha Lambda via `CONFIG_BUCKET`/`CONFIG_KEY` env (asset-ref intrinsic resolution). The load-bearing correctness proof is that **each Lambda returns its OWN distinct marker** (`...-docker` / `...-alpha` / `...-beta` / `...-gamma`): a cross-wired asset (e.g. the beta ZIP uploaded but cdkd pointed the alpha Lambda's `Code.S3Bucket`/`S3Key` at it) would return the WRONG marker and FAIL — so the markers prove not just that all assets uploaded but that each Lambda was wired to the RIGHT one. `verify.sh` (BSD/macOS-portable, real-rc capture + explicit `[verify] PASS`) gracefully SKIPs (exit 0) when `docker info` fails so it is robust on a Docker-less box but runs in a Docker env; it then deploys (printing which asset/resource failed for triage on a deploy error), asserts the Docker Lambda is `PackageType=Image` with OUR pushed image present in ECR by content-tag (parsed from `Code.ImageUri`), asserts each zip Lambda's `CodeSize > 500` bytes (ran from an uploaded ZIP, not inline), invokes all 4 Lambdas and asserts each distinct marker, asserts the alpha Lambda's generic-asset read-back (`configBytes > 0`), then destroys and asserts clean (all 4 Lambdas gone, OUR pushed ECR image by tag gone via a sweep fallback, state file gone). The shared bootstrap container-assets ECR repo + the bootstrap asset bucket OBJECTS persist by design (cdkd does not own CDK bootstrap infra) and are NOT treated as orphans; the EXIT trap sweeps leftover state, the deployment-events sidecar, and (by tag) the pushed image. New scenario tag `multi-asset` in the canonical taxonomy (`scripts/build-scenario-coverage-matrix.ts`); coverage matrices regenerated. (NOTE: not yet run against real AWS — needs `/run-integ multi-asset` (in a Docker env) before merge.) +- ✅ **New `sg-circular-dependency` integ surfaces create/destroy DAG-ordering bugs with a circular Security Group reference (test-only — no `src/` change)** — `tests/integration/sg-circular-dependency/**`. Models the classic CloudFormation cycle the CFn-safe way: SG-A allows ingress from SG-B AND SG-B allows ingress from SG-A, where each rule is a STANDALONE `AWS::EC2::SecurityGroupIngress` resource (not inline) so the two SGs can exist before the cross-references are added. In CDK, `sgA.addIngressRule(sgB, ...)` + `sgB.addIngressRule(sgA, ...)` against two distinct SG constructs makes CDK emit standalone ingress resources (each `Fn::GetAtt`s `GroupId` on the SG it attaches to and `SourceSecurityGroupId` on the OTHER SG), breaking what would otherwise be a genuine SG-to-SG cycle. The stack is a `natGateways: 0` single-AZ VPC + SG-A + SG-B + the two cross-referencing ingress resources (no EC2 instances). `verify.sh` (BSD/macOS-portable, captures real exit codes, prints an explicit `=== PASS ===` only on full success): **Phase 0** runs `cdkd synth` and asserts >= 2 standalone `AWS::EC2::SecurityGroupIngress` resources each carrying a `SourceSecurityGroupId` (true SG-to-SG cross-ref) so the cycle-breaking shape is confirmed before any AWS call; **Phase 1** deploys (the DAG builder in `src/analyzer/dag-builder.ts` must NOT raise a false `DependencyError` — the standalone ingress resources break the would-be cycle) and asserts both SGs exist with the live cross-reference (SG-A's `IpPermissions[].UserIdGroupPairs[].GroupId` contains SG-B and vice versa); **Phase 2 (the key test)** destroys and asserts 0 errors — if cdkd deletes an SG while its cross-referencing ingress rule is still live, AWS rejects `DeleteSecurityGroup` with `DependencyViolation: resource sg-xxx has a dependent object`, so a wrong delete order fails / orphans here. The post-destroy assertions confirm both SGs + the VPC + the state file are gone. Resources are located by the `cdkd:integ-fixture=sg-circular-dependency` tag (NOT `aws:cdk:path`, which AWS reserves), and the EXIT-trap cleanup revokes-then-deletes both SGs directly (the SAME ordering cdkd must perform) so a destroy-ordering bug never leaks billing resources. The existing `AWS::EC2::SecurityGroup -> AWS::EC2::SecurityGroupIngress` implicit-delete-dep edge in `src/analyzer/implicit-delete-deps.ts` is what this fixture exercises end-to-end on real AWS. New scenario tag `sg-circular-dependency` in the canonical taxonomy (`scripts/build-scenario-coverage-matrix.ts`); integ-coverage + scenario-coverage matrices regenerated. One fix surfaced while authoring: AWS rejects non-ASCII characters (em-dash U+2014) in a SecurityGroup `GroupDescription`, so the two `GroupDescription` strings are ASCII-only (hyphen, not em-dash). Validated green against real AWS (deploy + destroy clean, 0 orphans). - ✅ **Hardened post-destroy assertions across three new behavior-class integ fixtures so a silently-skipped delete cannot pass as clean (issue [#831](https://github.com/go-to-k/cdkd/issues/831), test-only — no `src/` change)** — `tests/integration/{update-replace,destroy-interrupt,deployment-events}/verify.sh`. Code review of the new `update-replace` integ (PR #830) surfaced a hardening gap shared by several of the new fixtures: their post-destroy assertions checked only that `state.json` was gone (and S3 buckets), but did NOT explicitly assert that the named NON-bucket resources (Lambda / IAM Role / SecurityGroup / SNS Topic) were actually deleted from AWS. Per the `feedback_protection_integ_must_instantiate_resource` rule ("state-empty misses an orphan carrying no stack name"), a `state destroy` that silently skips a resource (e.g. an SG delete blocked by a lingering ENI, or an IAM role that is not VPC-bound and orphans independently) would leave a real orphan while the test still passed. The fixtures now assert each named resource is NOT-FOUND in AWS after destroy: **`update-replace`** — the Lambda (`WorkerFn`, `aws lambda get-function-configuration`), IAM Role (`WorkerRole`, `aws iam get-role`), and SecurityGroup (`WorkerSg`, `aws ec2 describe-security-groups`) physical ids captured from state in Phase 1 must each error post-destroy, on top of the two existing bucket checks; plus a belt-and-suspenders `aws s3 rb --force` of the predictable `cdkd-update-replace-{account}-{region}-v1`/`-v2` bucket names in the cleanup trap so a re-run is not blocked after a mid-replacement crash that left a v1/v2 bucket behind. **`destroy-interrupt`** — already resolved the backing Lambda + VPC (covering subnets/SG/ENI implicitly) + each SSM parameter id from state; added EXPLICIT not-found assertions for the CR handler's IAM Role (`aws iam get-role` — NOT VPC-bound, so the VPC-gone assert did NOT cover it) and the Lambda SecurityGroup (`aws ec2 describe-security-groups` — belt-and-suspenders on top of the VPC-gone implication). **`deployment-events`** — already asserted the SSM parameter gone; added an SNS Topic not-found assertion (`aws sns get-topic-attributes` against the deterministic `${STACK}-topic` ARN resolved via `sts get-caller-identity`) plus a matching topic delete in the failure-cleanup path. All three scripts stay BSD/macOS-portable (no `grep -P` / `date -d`), use `jq has()` for boolean probes, capture real exit codes, and print `[verify] PASS` only on full success — following the own-`cdkd:integ-fixture`-tag pattern already used by `rollback-failure-injection` (these three resolve physical ids from cdkd state instead, which is equally reliable and needs no `lib/` change). Resource TYPES are unchanged, so the integ-coverage / scenario-coverage matrices regenerated with no diff. (NOTE: `update-replace` and `deployment-events` need a real-AWS re-run to validate the new assertions; `destroy-interrupt` adds only two cheap not-found checks to an already-passing fixture.) - ✅ **New `s3-asset-deploy` integ exercises the S3 file/ZIP asset-publishing path during a real `cdkd deploy`** — `tests/integration/s3-asset-deploy/**`. Most Lambda fixtures use inline code (`Code.fromInline`) or never assert the asset upload itself, leaving cdkd's `FileAssetPublisher` (S3 zip + upload, content-addressed skip-if-exists) without a dedicated end-to-end regression. The new `CdkdS3AssetDeployExample` stack closes that gap: a Lambda whose code comes from a **local multi-file directory** (`lambda/` — handler + `helpers/` + `vendored/` Python sub-packages, so the asset is a genuine multi-file ZIP, not a trivial single file) forces cdkd to zip the directory + upload it to the CDK bootstrap asset bucket and wire the function's `Code.S3Bucket`/`Code.S3Key` to the uploaded object; AND a generic `s3_assets.Asset` (`asset-data/`) is uploaded to the same bucket with its resolved `s3BucketName`/`s3ObjectKey` threaded into the Lambda as `CONFIG_BUCKET`/`CONFIG_KEY` env vars (synth confirms both are emitted as `Fn::Sub`-backed bucket + literal key refs, exercising cdkd's intrinsic resolver). `verify.sh` (BSD/macOS-portable, real-rc + explicit `[verify] PASS`) deploys, asserts the function's `CodeSize > 500` bytes (proves it ran from the uploaded ZIP, not inline), invokes it and asserts the handler marker (`cdkd-s3-asset-deploy-marker-v1`) plus a non-zero `configBytes` from the generic-asset S3 read-back (proving that upload reached AWS and the bucket/key env wiring resolved), then destroys and asserts the Lambda + state file are gone with 0 errors. The bootstrap-bucket asset OBJECTS persist by design (cdkd does not own / delete the CDK bootstrap bucket) — the script deliberately does NOT assert their absence and notes this. New scenario tag `s3-asset-deploy` in the canonical taxonomy (`scripts/build-scenario-coverage-matrix.ts`); coverage matrices regenerated. (NOTE: not yet run against real AWS — needs `/run-integ s3-asset-deploy` before merge.) - ✅ **New `drift-revert-arrays` integ broadens drift coverage to TAG-heavy / ARRAY-heavy resource types (refs issue [#802](https://github.com/go-to-k/cdkd/issues/802))** — `tests/integration/drift-revert-arrays/` (new fixture; no `src/` change). Issue #802 added `src/analyzer/drift-normalize.ts` (`canonicalizeTagListsDeep` + `canonicalizeIdArraysDeep`) so a benign AWS-side reorder of a tag list (`{Key,Value}[]`) or a resource-id / ARN array no longer surfaces as phantom drift, but the existing `drift-revert` / `drift-revert-vpc` fixtures carry none of those unordered-set array shapes — the canonicalization path had unit coverage only. This fixture (`CdkdDriftArraysExample`) deploys an S3 Bucket, SNS Topic, SQS Queue (each with six user tags), an IAM ManagedPolicy with a multi-statement document carrying multiple `Action[]` (plain scalar arrays — intentionally NOT canonicalized) + multiple `Resource[]` ARN arrays (canonicalized) + six tags, and a VPC (`natGateways: 0`, no NAT cost) + SecurityGroup with four CIDR ingress rules + six tags. `verify.sh` asserts (a) **no false positive on a clean deploy** (`cdkd drift` exit 0 even though AWS reorders the tag lists / ARN arrays on readback), (b) **no false positive on an induced reorder** (`inject-drift.ts reorder` re-PUTs the same six S3 tags reversed; `cdkd drift` still exit 0 — proves `canonicalizeTagListsDeep`), (c) **true drift still detected** (`inject-drift.ts drift` changes a tag VALUE + adds a managed-policy Action + authorizes a new SG ingress rule out of band; `cdkd drift` exit 1), then `cdkd drift --revert -y` reverts and a follow-up `cdkd drift` is clean, and destroy leaves 0 orphans. The script is BSD/macOS-portable (no `grep -P` / `date -d`), captures each `cdkd drift` real exit code, hard-fails with a canonicalizer-naming message, and prints `[verify] PASS` only on full success. The `subnet-…` / `sg-…` resource-id branch of `canonicalizeIdArraysDeep` stays unit-covered (`tests/unit/analyzer/drift-normalize.test.ts`); the integ exercises the ARN branch of the same function end-to-end. New scenario tag `drift-revert-array-canonicalization`. diff --git a/docs/integ-coverage.md b/docs/integ-coverage.md index 7459a4044..1fbffa06f 100644 --- a/docs/integ-coverage.md +++ b/docs/integ-coverage.md @@ -72,19 +72,19 @@ Registered without an integ fixture, with an explicit `// allow-no-integ: [`lambda`](../tests/integration/lambda/) (l2,literal) | | `AWS::DynamoDB::Table` | [`appsync`](../tests/integration/appsync/) (l2)
[`bench-sdk`](../tests/integration/bench-sdk/) (l2)
[`composite-stack`](../tests/integration/composite-stack/) (l2,literal)
[`data-pipeline`](../tests/integration/data-pipeline/) (l2)
[`dynamodb-ondemand`](../tests/integration/dynamodb-ondemand/) (l2)
[`dynamodb-streams`](../tests/integration/dynamodb-streams/) (l2)
[`full-stack-demo`](../tests/integration/full-stack-demo/) (l2)
[`lambda`](../tests/integration/lambda/) (l2)
[`local-invoke-from-cfn-stack`](../tests/integration/local-invoke-from-cfn-stack/) (l2)
[`local-run-task-from-state`](../tests/integration/local-run-task-from-state/) (l2)
[`multi-resource`](../tests/integration/multi-resource/) (l2)
[`multi-stack-deps`](../tests/integration/multi-stack-deps/) (l2)
[`remove-protection`](../tests/integration/remove-protection/) (l2,literal)
[`serverless-api`](../tests/integration/serverless-api/) (l2) | | `AWS::EC2::Instance` | [`ec2-instance`](../tests/integration/ec2-instance/) (l1)
[`remove-protection`](../tests/integration/remove-protection/) (l2,literal) | -| `AWS::EC2::InternetGateway` | [`alb`](../tests/integration/alb/) (l2)
[`alb-advanced`](../tests/integration/alb-advanced/) (l2)
[`batch`](../tests/integration/batch/) (l2)
[`bench-cdk-sample`](../tests/integration/bench-cdk-sample/) (l2)
[`cache-streaming`](../tests/integration/cache-streaming/) (l2)
[`destroy-interrupt`](../tests/integration/destroy-interrupt/) (l2)
[`docdb-neptune`](../tests/integration/docdb-neptune/) (l2)
[`drift-revert-arrays`](../tests/integration/drift-revert-arrays/) (l2)
[`drift-revert-vpc`](../tests/integration/drift-revert-vpc/) (l2)
[`ec2-instance`](../tests/integration/ec2-instance/) (l2)
[`ec2-vpc`](../tests/integration/ec2-vpc/) (l2)
[`ecs-fargate`](../tests/integration/ecs-fargate/) (l2)
[`efs-lambda`](../tests/integration/efs-lambda/) (l2)
[`efs-standalone`](../tests/integration/efs-standalone/) (l2)
[`infra-security`](../tests/integration/infra-security/) (l2)
[`local-start-alb-from-state`](../tests/integration/local-start-alb-from-state/) (l2,literal)
[`multi-stack-deps`](../tests/integration/multi-stack-deps/) (l2)
[`rds-aurora`](../tests/integration/rds-aurora/) (l2)
[`rds-dbinstance-backfill`](../tests/integration/rds-dbinstance-backfill/) (l2)
[`remove-protection`](../tests/integration/remove-protection/) (l2)
[`rollback-failure-injection`](../tests/integration/rollback-failure-injection/) (l2)
[`vpc-lambda`](../tests/integration/vpc-lambda/) (l2)
[`vpc-lambda-cr-race`](../tests/integration/vpc-lambda-cr-race/) (l2)
[`vpc-nat-gateway`](../tests/integration/vpc-nat-gateway/) (l2) | -| `AWS::EC2::NatGateway` | [`alb`](../tests/integration/alb/) (l2)
[`alb-advanced`](../tests/integration/alb-advanced/) (l2)
[`batch`](../tests/integration/batch/) (l2)
[`bench-cdk-sample`](../tests/integration/bench-cdk-sample/) (l2)
[`cache-streaming`](../tests/integration/cache-streaming/) (l2)
[`destroy-interrupt`](../tests/integration/destroy-interrupt/) (l2)
[`docdb-neptune`](../tests/integration/docdb-neptune/) (l2)
[`drift-revert-arrays`](../tests/integration/drift-revert-arrays/) (l2)
[`drift-revert-vpc`](../tests/integration/drift-revert-vpc/) (l2)
[`ec2-instance`](../tests/integration/ec2-instance/) (l2)
[`ec2-vpc`](../tests/integration/ec2-vpc/) (l2)
[`ecs-fargate`](../tests/integration/ecs-fargate/) (l2)
[`efs-lambda`](../tests/integration/efs-lambda/) (l2)
[`efs-standalone`](../tests/integration/efs-standalone/) (l2)
[`infra-security`](../tests/integration/infra-security/) (l2)
[`local-start-alb-from-state`](../tests/integration/local-start-alb-from-state/) (l2)
[`multi-stack-deps`](../tests/integration/multi-stack-deps/) (l2)
[`rds-aurora`](../tests/integration/rds-aurora/) (l2)
[`rds-dbinstance-backfill`](../tests/integration/rds-dbinstance-backfill/) (l2)
[`remove-protection`](../tests/integration/remove-protection/) (l2)
[`rollback-failure-injection`](../tests/integration/rollback-failure-injection/) (l2)
[`vpc-lambda`](../tests/integration/vpc-lambda/) (l2)
[`vpc-lambda-cr-race`](../tests/integration/vpc-lambda-cr-race/) (l2)
[`vpc-nat-gateway`](../tests/integration/vpc-nat-gateway/) (l2,literal) | +| `AWS::EC2::InternetGateway` | [`alb`](../tests/integration/alb/) (l2)
[`alb-advanced`](../tests/integration/alb-advanced/) (l2)
[`batch`](../tests/integration/batch/) (l2)
[`bench-cdk-sample`](../tests/integration/bench-cdk-sample/) (l2)
[`cache-streaming`](../tests/integration/cache-streaming/) (l2)
[`destroy-interrupt`](../tests/integration/destroy-interrupt/) (l2)
[`docdb-neptune`](../tests/integration/docdb-neptune/) (l2)
[`drift-revert-arrays`](../tests/integration/drift-revert-arrays/) (l2)
[`drift-revert-vpc`](../tests/integration/drift-revert-vpc/) (l2)
[`ec2-instance`](../tests/integration/ec2-instance/) (l2)
[`ec2-vpc`](../tests/integration/ec2-vpc/) (l2)
[`ecs-fargate`](../tests/integration/ecs-fargate/) (l2)
[`efs-lambda`](../tests/integration/efs-lambda/) (l2)
[`efs-standalone`](../tests/integration/efs-standalone/) (l2)
[`infra-security`](../tests/integration/infra-security/) (l2)
[`local-start-alb-from-state`](../tests/integration/local-start-alb-from-state/) (l2,literal)
[`multi-stack-deps`](../tests/integration/multi-stack-deps/) (l2)
[`rds-aurora`](../tests/integration/rds-aurora/) (l2)
[`rds-dbinstance-backfill`](../tests/integration/rds-dbinstance-backfill/) (l2)
[`remove-protection`](../tests/integration/remove-protection/) (l2)
[`rollback-failure-injection`](../tests/integration/rollback-failure-injection/) (l2)
[`sg-circular-dependency`](../tests/integration/sg-circular-dependency/) (l2)
[`vpc-lambda`](../tests/integration/vpc-lambda/) (l2)
[`vpc-lambda-cr-race`](../tests/integration/vpc-lambda-cr-race/) (l2)
[`vpc-nat-gateway`](../tests/integration/vpc-nat-gateway/) (l2) | +| `AWS::EC2::NatGateway` | [`alb`](../tests/integration/alb/) (l2)
[`alb-advanced`](../tests/integration/alb-advanced/) (l2)
[`batch`](../tests/integration/batch/) (l2)
[`bench-cdk-sample`](../tests/integration/bench-cdk-sample/) (l2)
[`cache-streaming`](../tests/integration/cache-streaming/) (l2)
[`destroy-interrupt`](../tests/integration/destroy-interrupt/) (l2)
[`docdb-neptune`](../tests/integration/docdb-neptune/) (l2)
[`drift-revert-arrays`](../tests/integration/drift-revert-arrays/) (l2)
[`drift-revert-vpc`](../tests/integration/drift-revert-vpc/) (l2)
[`ec2-instance`](../tests/integration/ec2-instance/) (l2)
[`ec2-vpc`](../tests/integration/ec2-vpc/) (l2)
[`ecs-fargate`](../tests/integration/ecs-fargate/) (l2)
[`efs-lambda`](../tests/integration/efs-lambda/) (l2)
[`efs-standalone`](../tests/integration/efs-standalone/) (l2)
[`infra-security`](../tests/integration/infra-security/) (l2)
[`local-start-alb-from-state`](../tests/integration/local-start-alb-from-state/) (l2)
[`multi-stack-deps`](../tests/integration/multi-stack-deps/) (l2)
[`rds-aurora`](../tests/integration/rds-aurora/) (l2)
[`rds-dbinstance-backfill`](../tests/integration/rds-dbinstance-backfill/) (l2)
[`remove-protection`](../tests/integration/remove-protection/) (l2)
[`rollback-failure-injection`](../tests/integration/rollback-failure-injection/) (l2)
[`sg-circular-dependency`](../tests/integration/sg-circular-dependency/) (l2)
[`vpc-lambda`](../tests/integration/vpc-lambda/) (l2)
[`vpc-lambda-cr-race`](../tests/integration/vpc-lambda-cr-race/) (l2)
[`vpc-nat-gateway`](../tests/integration/vpc-nat-gateway/) (l2,literal) | | `AWS::EC2::NetworkAcl` | [`ec2-vpc`](../tests/integration/ec2-vpc/) (l2) | | `AWS::EC2::NetworkAclEntry` | [`ec2-vpc`](../tests/integration/ec2-vpc/) (literal) | -| `AWS::EC2::Route` | [`alb`](../tests/integration/alb/) (l2)
[`alb-advanced`](../tests/integration/alb-advanced/) (l2)
[`batch`](../tests/integration/batch/) (l2)
[`bench-cdk-sample`](../tests/integration/bench-cdk-sample/) (l2)
[`cache-streaming`](../tests/integration/cache-streaming/) (l2)
[`destroy-interrupt`](../tests/integration/destroy-interrupt/) (l2)
[`docdb-neptune`](../tests/integration/docdb-neptune/) (l2)
[`drift-revert-arrays`](../tests/integration/drift-revert-arrays/) (l2)
[`drift-revert-vpc`](../tests/integration/drift-revert-vpc/) (l2)
[`ec2-instance`](../tests/integration/ec2-instance/) (l2)
[`ec2-vpc`](../tests/integration/ec2-vpc/) (l2)
[`ecs-fargate`](../tests/integration/ecs-fargate/) (l2)
[`efs-lambda`](../tests/integration/efs-lambda/) (l2)
[`efs-standalone`](../tests/integration/efs-standalone/) (l2)
[`infra-security`](../tests/integration/infra-security/) (l2)
[`local-start-alb-from-state`](../tests/integration/local-start-alb-from-state/) (l2,literal)
[`multi-stack-deps`](../tests/integration/multi-stack-deps/) (l2)
[`rds-aurora`](../tests/integration/rds-aurora/) (l2)
[`rds-dbinstance-backfill`](../tests/integration/rds-dbinstance-backfill/) (l2)
[`remove-protection`](../tests/integration/remove-protection/) (l2)
[`rollback-failure-injection`](../tests/integration/rollback-failure-injection/) (l2)
[`vpc-lambda`](../tests/integration/vpc-lambda/) (l2)
[`vpc-lambda-cr-race`](../tests/integration/vpc-lambda-cr-race/) (l2)
[`vpc-nat-gateway`](../tests/integration/vpc-nat-gateway/) (l2) | -| `AWS::EC2::RouteTable` | [`alb`](../tests/integration/alb/) (l2)
[`alb-advanced`](../tests/integration/alb-advanced/) (l2)
[`batch`](../tests/integration/batch/) (l2)
[`bench-cdk-sample`](../tests/integration/bench-cdk-sample/) (l2)
[`cache-streaming`](../tests/integration/cache-streaming/) (l2)
[`destroy-interrupt`](../tests/integration/destroy-interrupt/) (l2)
[`docdb-neptune`](../tests/integration/docdb-neptune/) (l2)
[`drift-revert-arrays`](../tests/integration/drift-revert-arrays/) (l2)
[`drift-revert-vpc`](../tests/integration/drift-revert-vpc/) (l2)
[`ec2-instance`](../tests/integration/ec2-instance/) (l2)
[`ec2-vpc`](../tests/integration/ec2-vpc/) (l2)
[`ecs-fargate`](../tests/integration/ecs-fargate/) (l2)
[`efs-lambda`](../tests/integration/efs-lambda/) (l2)
[`efs-standalone`](../tests/integration/efs-standalone/) (l2)
[`infra-security`](../tests/integration/infra-security/) (l2)
[`local-start-alb-from-state`](../tests/integration/local-start-alb-from-state/) (l2,literal)
[`multi-stack-deps`](../tests/integration/multi-stack-deps/) (l2)
[`rds-aurora`](../tests/integration/rds-aurora/) (l2)
[`rds-dbinstance-backfill`](../tests/integration/rds-dbinstance-backfill/) (l2)
[`remove-protection`](../tests/integration/remove-protection/) (l2)
[`rollback-failure-injection`](../tests/integration/rollback-failure-injection/) (l2)
[`vpc-lambda`](../tests/integration/vpc-lambda/) (l2)
[`vpc-lambda-cr-race`](../tests/integration/vpc-lambda-cr-race/) (l2)
[`vpc-nat-gateway`](../tests/integration/vpc-nat-gateway/) (l2) | -| `AWS::EC2::SecurityGroup` | [`alb`](../tests/integration/alb/) (l2)
[`alb-advanced`](../tests/integration/alb-advanced/) (l2)
[`batch`](../tests/integration/batch/) (l2)
[`bench-cdk-sample`](../tests/integration/bench-cdk-sample/) (l2)
[`cache-streaming`](../tests/integration/cache-streaming/) (l2)
[`destroy-interrupt`](../tests/integration/destroy-interrupt/) (l2)
[`docdb-neptune`](../tests/integration/docdb-neptune/) (l2)
[`drift-revert-arrays`](../tests/integration/drift-revert-arrays/) (l2)
[`drift-revert-vpc`](../tests/integration/drift-revert-vpc/) (l2)
[`ec2-instance`](../tests/integration/ec2-instance/) (l2)
[`ec2-vpc`](../tests/integration/ec2-vpc/) (l2)
[`efs-standalone`](../tests/integration/efs-standalone/) (literal)
[`local-start-alb-from-state`](../tests/integration/local-start-alb-from-state/) (l2,literal)
[`multi-stack-deps`](../tests/integration/multi-stack-deps/) (l2)
[`rds-aurora`](../tests/integration/rds-aurora/) (l2)
[`rds-dbinstance-backfill`](../tests/integration/rds-dbinstance-backfill/) (l2)
[`remove-protection`](../tests/integration/remove-protection/) (l2)
[`rollback-failure-injection`](../tests/integration/rollback-failure-injection/) (l2)
[`update-replace`](../tests/integration/update-replace/) (l2,literal)
[`vpc-lambda`](../tests/integration/vpc-lambda/) (l2)
[`vpc-lambda-cr-race`](../tests/integration/vpc-lambda-cr-race/) (l2) | -| `AWS::EC2::SecurityGroupIngress` | [`alb`](../tests/integration/alb/) (literal) | -| `AWS::EC2::Subnet` | [`alb`](../tests/integration/alb/) (l2)
[`alb-advanced`](../tests/integration/alb-advanced/) (l2)
[`batch`](../tests/integration/batch/) (l2)
[`bench-cdk-sample`](../tests/integration/bench-cdk-sample/) (l2)
[`cache-streaming`](../tests/integration/cache-streaming/) (l2)
[`destroy-interrupt`](../tests/integration/destroy-interrupt/) (l2)
[`docdb-neptune`](../tests/integration/docdb-neptune/) (l2)
[`drift-revert-arrays`](../tests/integration/drift-revert-arrays/) (l2)
[`drift-revert-vpc`](../tests/integration/drift-revert-vpc/) (l2)
[`ec2-instance`](../tests/integration/ec2-instance/) (l2)
[`ec2-vpc`](../tests/integration/ec2-vpc/) (l2)
[`ecs-fargate`](../tests/integration/ecs-fargate/) (l2)
[`efs-lambda`](../tests/integration/efs-lambda/) (l2)
[`efs-standalone`](../tests/integration/efs-standalone/) (l2)
[`infra-security`](../tests/integration/infra-security/) (l2)
[`local-start-alb-from-state`](../tests/integration/local-start-alb-from-state/) (l2,literal)
[`multi-stack-deps`](../tests/integration/multi-stack-deps/) (l2)
[`rds-aurora`](../tests/integration/rds-aurora/) (l2)
[`rds-dbinstance-backfill`](../tests/integration/rds-dbinstance-backfill/) (l2)
[`remove-protection`](../tests/integration/remove-protection/) (l2)
[`rollback-failure-injection`](../tests/integration/rollback-failure-injection/) (l2)
[`vpc-lambda`](../tests/integration/vpc-lambda/) (l2)
[`vpc-lambda-cr-race`](../tests/integration/vpc-lambda-cr-race/) (l2)
[`vpc-nat-gateway`](../tests/integration/vpc-nat-gateway/) (l2) | +| `AWS::EC2::Route` | [`alb`](../tests/integration/alb/) (l2)
[`alb-advanced`](../tests/integration/alb-advanced/) (l2)
[`batch`](../tests/integration/batch/) (l2)
[`bench-cdk-sample`](../tests/integration/bench-cdk-sample/) (l2)
[`cache-streaming`](../tests/integration/cache-streaming/) (l2)
[`destroy-interrupt`](../tests/integration/destroy-interrupt/) (l2)
[`docdb-neptune`](../tests/integration/docdb-neptune/) (l2)
[`drift-revert-arrays`](../tests/integration/drift-revert-arrays/) (l2)
[`drift-revert-vpc`](../tests/integration/drift-revert-vpc/) (l2)
[`ec2-instance`](../tests/integration/ec2-instance/) (l2)
[`ec2-vpc`](../tests/integration/ec2-vpc/) (l2)
[`ecs-fargate`](../tests/integration/ecs-fargate/) (l2)
[`efs-lambda`](../tests/integration/efs-lambda/) (l2)
[`efs-standalone`](../tests/integration/efs-standalone/) (l2)
[`infra-security`](../tests/integration/infra-security/) (l2)
[`local-start-alb-from-state`](../tests/integration/local-start-alb-from-state/) (l2,literal)
[`multi-stack-deps`](../tests/integration/multi-stack-deps/) (l2)
[`rds-aurora`](../tests/integration/rds-aurora/) (l2)
[`rds-dbinstance-backfill`](../tests/integration/rds-dbinstance-backfill/) (l2)
[`remove-protection`](../tests/integration/remove-protection/) (l2)
[`rollback-failure-injection`](../tests/integration/rollback-failure-injection/) (l2)
[`sg-circular-dependency`](../tests/integration/sg-circular-dependency/) (l2)
[`vpc-lambda`](../tests/integration/vpc-lambda/) (l2)
[`vpc-lambda-cr-race`](../tests/integration/vpc-lambda-cr-race/) (l2)
[`vpc-nat-gateway`](../tests/integration/vpc-nat-gateway/) (l2) | +| `AWS::EC2::RouteTable` | [`alb`](../tests/integration/alb/) (l2)
[`alb-advanced`](../tests/integration/alb-advanced/) (l2)
[`batch`](../tests/integration/batch/) (l2)
[`bench-cdk-sample`](../tests/integration/bench-cdk-sample/) (l2)
[`cache-streaming`](../tests/integration/cache-streaming/) (l2)
[`destroy-interrupt`](../tests/integration/destroy-interrupt/) (l2)
[`docdb-neptune`](../tests/integration/docdb-neptune/) (l2)
[`drift-revert-arrays`](../tests/integration/drift-revert-arrays/) (l2)
[`drift-revert-vpc`](../tests/integration/drift-revert-vpc/) (l2)
[`ec2-instance`](../tests/integration/ec2-instance/) (l2)
[`ec2-vpc`](../tests/integration/ec2-vpc/) (l2)
[`ecs-fargate`](../tests/integration/ecs-fargate/) (l2)
[`efs-lambda`](../tests/integration/efs-lambda/) (l2)
[`efs-standalone`](../tests/integration/efs-standalone/) (l2)
[`infra-security`](../tests/integration/infra-security/) (l2)
[`local-start-alb-from-state`](../tests/integration/local-start-alb-from-state/) (l2,literal)
[`multi-stack-deps`](../tests/integration/multi-stack-deps/) (l2)
[`rds-aurora`](../tests/integration/rds-aurora/) (l2)
[`rds-dbinstance-backfill`](../tests/integration/rds-dbinstance-backfill/) (l2)
[`remove-protection`](../tests/integration/remove-protection/) (l2)
[`rollback-failure-injection`](../tests/integration/rollback-failure-injection/) (l2)
[`sg-circular-dependency`](../tests/integration/sg-circular-dependency/) (l2)
[`vpc-lambda`](../tests/integration/vpc-lambda/) (l2)
[`vpc-lambda-cr-race`](../tests/integration/vpc-lambda-cr-race/) (l2)
[`vpc-nat-gateway`](../tests/integration/vpc-nat-gateway/) (l2) | +| `AWS::EC2::SecurityGroup` | [`alb`](../tests/integration/alb/) (l2)
[`alb-advanced`](../tests/integration/alb-advanced/) (l2)
[`batch`](../tests/integration/batch/) (l2)
[`bench-cdk-sample`](../tests/integration/bench-cdk-sample/) (l2)
[`cache-streaming`](../tests/integration/cache-streaming/) (l2)
[`destroy-interrupt`](../tests/integration/destroy-interrupt/) (l2)
[`docdb-neptune`](../tests/integration/docdb-neptune/) (l2)
[`drift-revert-arrays`](../tests/integration/drift-revert-arrays/) (l2)
[`drift-revert-vpc`](../tests/integration/drift-revert-vpc/) (l2)
[`ec2-instance`](../tests/integration/ec2-instance/) (l2)
[`ec2-vpc`](../tests/integration/ec2-vpc/) (l2)
[`efs-standalone`](../tests/integration/efs-standalone/) (literal)
[`local-start-alb-from-state`](../tests/integration/local-start-alb-from-state/) (l2,literal)
[`multi-stack-deps`](../tests/integration/multi-stack-deps/) (l2)
[`rds-aurora`](../tests/integration/rds-aurora/) (l2)
[`rds-dbinstance-backfill`](../tests/integration/rds-dbinstance-backfill/) (l2)
[`remove-protection`](../tests/integration/remove-protection/) (l2)
[`rollback-failure-injection`](../tests/integration/rollback-failure-injection/) (l2)
[`sg-circular-dependency`](../tests/integration/sg-circular-dependency/) (l2,literal)
[`update-replace`](../tests/integration/update-replace/) (l2,literal)
[`vpc-lambda`](../tests/integration/vpc-lambda/) (l2)
[`vpc-lambda-cr-race`](../tests/integration/vpc-lambda-cr-race/) (l2) | +| `AWS::EC2::SecurityGroupIngress` | [`alb`](../tests/integration/alb/) (literal)
[`sg-circular-dependency`](../tests/integration/sg-circular-dependency/) (literal) | +| `AWS::EC2::Subnet` | [`alb`](../tests/integration/alb/) (l2)
[`alb-advanced`](../tests/integration/alb-advanced/) (l2)
[`batch`](../tests/integration/batch/) (l2)
[`bench-cdk-sample`](../tests/integration/bench-cdk-sample/) (l2)
[`cache-streaming`](../tests/integration/cache-streaming/) (l2)
[`destroy-interrupt`](../tests/integration/destroy-interrupt/) (l2)
[`docdb-neptune`](../tests/integration/docdb-neptune/) (l2)
[`drift-revert-arrays`](../tests/integration/drift-revert-arrays/) (l2)
[`drift-revert-vpc`](../tests/integration/drift-revert-vpc/) (l2)
[`ec2-instance`](../tests/integration/ec2-instance/) (l2)
[`ec2-vpc`](../tests/integration/ec2-vpc/) (l2)
[`ecs-fargate`](../tests/integration/ecs-fargate/) (l2)
[`efs-lambda`](../tests/integration/efs-lambda/) (l2)
[`efs-standalone`](../tests/integration/efs-standalone/) (l2)
[`infra-security`](../tests/integration/infra-security/) (l2)
[`local-start-alb-from-state`](../tests/integration/local-start-alb-from-state/) (l2,literal)
[`multi-stack-deps`](../tests/integration/multi-stack-deps/) (l2)
[`rds-aurora`](../tests/integration/rds-aurora/) (l2)
[`rds-dbinstance-backfill`](../tests/integration/rds-dbinstance-backfill/) (l2)
[`remove-protection`](../tests/integration/remove-protection/) (l2)
[`rollback-failure-injection`](../tests/integration/rollback-failure-injection/) (l2)
[`sg-circular-dependency`](../tests/integration/sg-circular-dependency/) (l2)
[`vpc-lambda`](../tests/integration/vpc-lambda/) (l2)
[`vpc-lambda-cr-race`](../tests/integration/vpc-lambda-cr-race/) (l2)
[`vpc-nat-gateway`](../tests/integration/vpc-nat-gateway/) (l2) | | `AWS::EC2::SubnetNetworkAclAssociation` | [`ec2-vpc`](../tests/integration/ec2-vpc/) (literal) | -| `AWS::EC2::SubnetRouteTableAssociation` | [`alb`](../tests/integration/alb/) (l2)
[`alb-advanced`](../tests/integration/alb-advanced/) (l2)
[`batch`](../tests/integration/batch/) (l2)
[`bench-cdk-sample`](../tests/integration/bench-cdk-sample/) (l2)
[`cache-streaming`](../tests/integration/cache-streaming/) (l2)
[`destroy-interrupt`](../tests/integration/destroy-interrupt/) (l2)
[`docdb-neptune`](../tests/integration/docdb-neptune/) (l2)
[`drift-revert-arrays`](../tests/integration/drift-revert-arrays/) (l2)
[`drift-revert-vpc`](../tests/integration/drift-revert-vpc/) (l2)
[`ec2-instance`](../tests/integration/ec2-instance/) (l2)
[`ec2-vpc`](../tests/integration/ec2-vpc/) (l2)
[`ecs-fargate`](../tests/integration/ecs-fargate/) (l2)
[`efs-lambda`](../tests/integration/efs-lambda/) (l2)
[`efs-standalone`](../tests/integration/efs-standalone/) (l2)
[`infra-security`](../tests/integration/infra-security/) (l2)
[`local-start-alb-from-state`](../tests/integration/local-start-alb-from-state/) (l2,literal)
[`multi-stack-deps`](../tests/integration/multi-stack-deps/) (l2)
[`rds-aurora`](../tests/integration/rds-aurora/) (l2)
[`rds-dbinstance-backfill`](../tests/integration/rds-dbinstance-backfill/) (l2)
[`remove-protection`](../tests/integration/remove-protection/) (l2)
[`rollback-failure-injection`](../tests/integration/rollback-failure-injection/) (l2)
[`vpc-lambda`](../tests/integration/vpc-lambda/) (l2)
[`vpc-lambda-cr-race`](../tests/integration/vpc-lambda-cr-race/) (l2)
[`vpc-nat-gateway`](../tests/integration/vpc-nat-gateway/) (l2) | -| `AWS::EC2::VPC` | [`alb`](../tests/integration/alb/) (l2)
[`alb-advanced`](../tests/integration/alb-advanced/) (l2)
[`batch`](../tests/integration/batch/) (l2)
[`bench-cdk-sample`](../tests/integration/bench-cdk-sample/) (l2)
[`cache-streaming`](../tests/integration/cache-streaming/) (l2)
[`destroy-interrupt`](../tests/integration/destroy-interrupt/) (l2)
[`docdb-neptune`](../tests/integration/docdb-neptune/) (l2)
[`drift-revert-arrays`](../tests/integration/drift-revert-arrays/) (l2)
[`drift-revert-vpc`](../tests/integration/drift-revert-vpc/) (l2)
[`ec2-instance`](../tests/integration/ec2-instance/) (l2)
[`ec2-vpc`](../tests/integration/ec2-vpc/) (l2)
[`ecs-fargate`](../tests/integration/ecs-fargate/) (l2)
[`efs-lambda`](../tests/integration/efs-lambda/) (l2)
[`efs-standalone`](../tests/integration/efs-standalone/) (l2,literal)
[`infra-security`](../tests/integration/infra-security/) (l2)
[`local-start-alb-from-state`](../tests/integration/local-start-alb-from-state/) (l2,literal)
[`multi-stack-deps`](../tests/integration/multi-stack-deps/) (l2)
[`rds-aurora`](../tests/integration/rds-aurora/) (l2)
[`rds-dbinstance-backfill`](../tests/integration/rds-dbinstance-backfill/) (l2)
[`remove-protection`](../tests/integration/remove-protection/) (l2)
[`rollback-failure-injection`](../tests/integration/rollback-failure-injection/) (l2)
[`vpc-lambda`](../tests/integration/vpc-lambda/) (l2)
[`vpc-lambda-cr-race`](../tests/integration/vpc-lambda-cr-race/) (l2)
[`vpc-nat-gateway`](../tests/integration/vpc-nat-gateway/) (l2) | -| `AWS::EC2::VPCGatewayAttachment` | [`alb`](../tests/integration/alb/) (l2)
[`alb-advanced`](../tests/integration/alb-advanced/) (l2)
[`batch`](../tests/integration/batch/) (l2)
[`bench-cdk-sample`](../tests/integration/bench-cdk-sample/) (l2)
[`cache-streaming`](../tests/integration/cache-streaming/) (l2)
[`destroy-interrupt`](../tests/integration/destroy-interrupt/) (l2)
[`docdb-neptune`](../tests/integration/docdb-neptune/) (l2)
[`drift-revert-arrays`](../tests/integration/drift-revert-arrays/) (l2)
[`drift-revert-vpc`](../tests/integration/drift-revert-vpc/) (l2)
[`ec2-instance`](../tests/integration/ec2-instance/) (l2)
[`ec2-vpc`](../tests/integration/ec2-vpc/) (l2)
[`ecs-fargate`](../tests/integration/ecs-fargate/) (l2)
[`efs-lambda`](../tests/integration/efs-lambda/) (l2)
[`efs-standalone`](../tests/integration/efs-standalone/) (l2)
[`infra-security`](../tests/integration/infra-security/) (l2)
[`local-start-alb-from-state`](../tests/integration/local-start-alb-from-state/) (l2,literal)
[`multi-stack-deps`](../tests/integration/multi-stack-deps/) (l2)
[`rds-aurora`](../tests/integration/rds-aurora/) (l2)
[`rds-dbinstance-backfill`](../tests/integration/rds-dbinstance-backfill/) (l2)
[`remove-protection`](../tests/integration/remove-protection/) (l2)
[`rollback-failure-injection`](../tests/integration/rollback-failure-injection/) (l2)
[`vpc-lambda`](../tests/integration/vpc-lambda/) (l2)
[`vpc-lambda-cr-race`](../tests/integration/vpc-lambda-cr-race/) (l2)
[`vpc-nat-gateway`](../tests/integration/vpc-nat-gateway/) (l2) | +| `AWS::EC2::SubnetRouteTableAssociation` | [`alb`](../tests/integration/alb/) (l2)
[`alb-advanced`](../tests/integration/alb-advanced/) (l2)
[`batch`](../tests/integration/batch/) (l2)
[`bench-cdk-sample`](../tests/integration/bench-cdk-sample/) (l2)
[`cache-streaming`](../tests/integration/cache-streaming/) (l2)
[`destroy-interrupt`](../tests/integration/destroy-interrupt/) (l2)
[`docdb-neptune`](../tests/integration/docdb-neptune/) (l2)
[`drift-revert-arrays`](../tests/integration/drift-revert-arrays/) (l2)
[`drift-revert-vpc`](../tests/integration/drift-revert-vpc/) (l2)
[`ec2-instance`](../tests/integration/ec2-instance/) (l2)
[`ec2-vpc`](../tests/integration/ec2-vpc/) (l2)
[`ecs-fargate`](../tests/integration/ecs-fargate/) (l2)
[`efs-lambda`](../tests/integration/efs-lambda/) (l2)
[`efs-standalone`](../tests/integration/efs-standalone/) (l2)
[`infra-security`](../tests/integration/infra-security/) (l2)
[`local-start-alb-from-state`](../tests/integration/local-start-alb-from-state/) (l2,literal)
[`multi-stack-deps`](../tests/integration/multi-stack-deps/) (l2)
[`rds-aurora`](../tests/integration/rds-aurora/) (l2)
[`rds-dbinstance-backfill`](../tests/integration/rds-dbinstance-backfill/) (l2)
[`remove-protection`](../tests/integration/remove-protection/) (l2)
[`rollback-failure-injection`](../tests/integration/rollback-failure-injection/) (l2)
[`sg-circular-dependency`](../tests/integration/sg-circular-dependency/) (l2)
[`vpc-lambda`](../tests/integration/vpc-lambda/) (l2)
[`vpc-lambda-cr-race`](../tests/integration/vpc-lambda-cr-race/) (l2)
[`vpc-nat-gateway`](../tests/integration/vpc-nat-gateway/) (l2) | +| `AWS::EC2::VPC` | [`alb`](../tests/integration/alb/) (l2)
[`alb-advanced`](../tests/integration/alb-advanced/) (l2)
[`batch`](../tests/integration/batch/) (l2)
[`bench-cdk-sample`](../tests/integration/bench-cdk-sample/) (l2)
[`cache-streaming`](../tests/integration/cache-streaming/) (l2)
[`destroy-interrupt`](../tests/integration/destroy-interrupt/) (l2)
[`docdb-neptune`](../tests/integration/docdb-neptune/) (l2)
[`drift-revert-arrays`](../tests/integration/drift-revert-arrays/) (l2)
[`drift-revert-vpc`](../tests/integration/drift-revert-vpc/) (l2)
[`ec2-instance`](../tests/integration/ec2-instance/) (l2)
[`ec2-vpc`](../tests/integration/ec2-vpc/) (l2)
[`ecs-fargate`](../tests/integration/ecs-fargate/) (l2)
[`efs-lambda`](../tests/integration/efs-lambda/) (l2)
[`efs-standalone`](../tests/integration/efs-standalone/) (l2,literal)
[`infra-security`](../tests/integration/infra-security/) (l2)
[`local-start-alb-from-state`](../tests/integration/local-start-alb-from-state/) (l2,literal)
[`multi-stack-deps`](../tests/integration/multi-stack-deps/) (l2)
[`rds-aurora`](../tests/integration/rds-aurora/) (l2)
[`rds-dbinstance-backfill`](../tests/integration/rds-dbinstance-backfill/) (l2)
[`remove-protection`](../tests/integration/remove-protection/) (l2)
[`rollback-failure-injection`](../tests/integration/rollback-failure-injection/) (l2)
[`sg-circular-dependency`](../tests/integration/sg-circular-dependency/) (l2)
[`vpc-lambda`](../tests/integration/vpc-lambda/) (l2)
[`vpc-lambda-cr-race`](../tests/integration/vpc-lambda-cr-race/) (l2)
[`vpc-nat-gateway`](../tests/integration/vpc-nat-gateway/) (l2) | +| `AWS::EC2::VPCGatewayAttachment` | [`alb`](../tests/integration/alb/) (l2)
[`alb-advanced`](../tests/integration/alb-advanced/) (l2)
[`batch`](../tests/integration/batch/) (l2)
[`bench-cdk-sample`](../tests/integration/bench-cdk-sample/) (l2)
[`cache-streaming`](../tests/integration/cache-streaming/) (l2)
[`destroy-interrupt`](../tests/integration/destroy-interrupt/) (l2)
[`docdb-neptune`](../tests/integration/docdb-neptune/) (l2)
[`drift-revert-arrays`](../tests/integration/drift-revert-arrays/) (l2)
[`drift-revert-vpc`](../tests/integration/drift-revert-vpc/) (l2)
[`ec2-instance`](../tests/integration/ec2-instance/) (l2)
[`ec2-vpc`](../tests/integration/ec2-vpc/) (l2)
[`ecs-fargate`](../tests/integration/ecs-fargate/) (l2)
[`efs-lambda`](../tests/integration/efs-lambda/) (l2)
[`efs-standalone`](../tests/integration/efs-standalone/) (l2)
[`infra-security`](../tests/integration/infra-security/) (l2)
[`local-start-alb-from-state`](../tests/integration/local-start-alb-from-state/) (l2,literal)
[`multi-stack-deps`](../tests/integration/multi-stack-deps/) (l2)
[`rds-aurora`](../tests/integration/rds-aurora/) (l2)
[`rds-dbinstance-backfill`](../tests/integration/rds-dbinstance-backfill/) (l2)
[`remove-protection`](../tests/integration/remove-protection/) (l2)
[`rollback-failure-injection`](../tests/integration/rollback-failure-injection/) (l2)
[`sg-circular-dependency`](../tests/integration/sg-circular-dependency/) (l2)
[`vpc-lambda`](../tests/integration/vpc-lambda/) (l2)
[`vpc-lambda-cr-race`](../tests/integration/vpc-lambda-cr-race/) (l2)
[`vpc-nat-gateway`](../tests/integration/vpc-nat-gateway/) (l2) | | `AWS::ECR::Repository` | [`ecr`](../tests/integration/ecr/) (l2,literal)
[`local-run-task-from-state`](../tests/integration/local-run-task-from-state/) (l2,literal)
[`migrate-from-cfn`](../tests/integration/migrate-from-cfn/) (l2,literal) | | `AWS::ECS::Cluster` | [`drift-revert`](../tests/integration/drift-revert/) (l1)
[`ecs-fargate`](../tests/integration/ecs-fargate/) (l2)
[`local-ecs-service-connect`](../tests/integration/local-ecs-service-connect/) (l1)
[`local-start-alb`](../tests/integration/local-start-alb/) (l1,literal)
[`local-start-alb-from-state`](../tests/integration/local-start-alb-from-state/) (l2,literal)
[`local-start-service`](../tests/integration/local-start-service/) (l1)
[`local-start-service-watch-fast`](../tests/integration/local-start-service-watch-fast/) (l1) | | `AWS::ECS::Service` | [`ecs-fargate`](../tests/integration/ecs-fargate/) (l2,literal)
[`local-ecs-service-connect`](../tests/integration/local-ecs-service-connect/) (l1,literal)
[`local-start-alb`](../tests/integration/local-start-alb/) (l1,literal)
[`local-start-alb-from-state`](../tests/integration/local-start-alb-from-state/) (l2,literal)
[`local-start-service`](../tests/integration/local-start-service/) (l1,literal)
[`local-start-service-watch-fast`](../tests/integration/local-start-service-watch-fast/) (l1,literal) | diff --git a/docs/scenario-coverage.md b/docs/scenario-coverage.md index 60ac5f52f..45183bd9b 100644 --- a/docs/scenario-coverage.md +++ b/docs/scenario-coverage.md @@ -4,7 +4,7 @@ Run `vp run scenario-coverage` to regenerate. -**47 / 47 canonical scenarios** have at least one integ fixture exercising them. **114 / 133 integ fixtures** carry a `.scenarios.json` sidecar (with 0+ tags); the rest are un-annotated and contributor-reviewed below. +**48 / 48 canonical scenarios** have at least one integ fixture exercising them. **115 / 134 integ fixtures** carry a `.scenarios.json` sidecar (with 0+ tags); the rest are un-annotated and contributor-reviewed below. ## How this is computed @@ -26,7 +26,7 @@ This report is a visibility tool, not a commit-time gate. Many cdkd fixtures leg _None._ Every canonical scenario has at least one integ fixture tagged with it. -## Per-scenario coverage (47 scenarios) +## Per-scenario coverage (48 scenarios) | Scenario | Description | Integ Fixture(s) | |---|---|---| @@ -72,6 +72,7 @@ _None._ Every canonical scenario has at least one integ fixture tagged with it. | `remove-protection-bypass` | `--remove-protection` flag bypassing AWS-side deletion-protection on supported types. | [`remove-protection`](../tests/integration/remove-protection/) | | `rollback-failure-injection` | deploy-engine ROLLBACK path on a RICH multi-resource stack (VPC+SG+IAM Role+Lambda-in-VPC+SSM Parameter): a self-contained env-gated (`ROLLBACK_INTEG_FAIL`) failing SQS Queue (out-of-range messageRetentionPeriod) wired to depend on the fast siblings forces a deploy failure AFTER siblings complete; verify.sh asserts the completed siblings are rolled back (no orphan VPC/SG/ENI/Role/Lambda/SSM, state empty) and the #808 events captured RESOURCE_FAILED + ROLLBACK_* + RUN_FINISHED=FAILED. | [`rollback-failure-injection`](../tests/integration/rollback-failure-injection/) | | `s3-asset-deploy` | File/ZIP asset publishing during `cdkd deploy`: a multi-file local directory is zipped + uploaded to the CDK bootstrap asset bucket by `FileAssetPublisher` (content-addressed, skip-if-exists), the Lambda `Code.S3Bucket`/`Code.S3Key` ref is wired to the uploaded object (CodeSize proves it is NOT inline), AND a generic `s3_assets.Asset` upload is read back at runtime via cdkd-resolved bucket/key env vars. Bootstrap-bucket asset objects persist by design across destroy. | [`s3-asset-deploy`](../tests/integration/s3-asset-deploy/) | +| `sg-circular-dependency` | Circular Security Group reference (SG-A ingress from SG-B AND SG-B ingress from SG-A) modeled via standalone AWS::EC2::SecurityGroupIngress resources. DAG builder must not raise a false cycle; destroy must revoke both ingress rules BEFORE deleting either SG (SecurityGroup-after-SecurityGroupIngress implicit-delete-dep) or AWS rejects DeleteSecurityGroup with DependencyViolation. | [`sg-circular-dependency`](../tests/integration/sg-circular-dependency/) | | `state-bucket-region-resolve` | State-bucket S3 clients (state backend + lock manager) auto-detect bucket region via `GetBucketLocation` regardless of caller-profile region. | [`cross-region-state-bucket`](../tests/integration/cross-region-state-bucket/) | | `state-schema-migration` | Legacy v1 / v2 state schema auto-migrates on next write; old binary fails clearly on a newer schema. | [`legacy-state-migration`](../tests/integration/legacy-state-migration/)
[`schema-v5-to-v6-migration`](../tests/integration/schema-v5-to-v6-migration/) | | `update-replace-breadth` | Second-deploy property mutation exercising BOTH cdkd update paths in one stack: in-place provider.update() (S3 versioning toggle / Lambda env+memory / IAM inline-policy edit / SecurityGroup ingress add — physical id unchanged) AND replacement (S3 BucketName change per the replacement-rules registry — new physical id, old resource cleaned up). Regression net for provider update() paths + #807 replacement propagation + #809 Cloud Control write-only-property UPDATE on non-ECS types. | [`update-replace`](../tests/integration/update-replace/) | diff --git a/scripts/build-scenario-coverage-matrix.ts b/scripts/build-scenario-coverage-matrix.ts index efe8b750c..97b8117cf 100644 --- a/scripts/build-scenario-coverage-matrix.ts +++ b/scripts/build-scenario-coverage-matrix.ts @@ -101,6 +101,8 @@ const KNOWN_SCENARIOS: Record = { 'Subnet/SecurityGroup must delete AFTER Lambda::Function to avoid ENI DependencyViolation.', 'iam-policy-propagation-retry': 'CREATE retry with exponential backoff after IAM-EC2/Lambda eventual-consistency race.', + 'sg-circular-dependency': + 'Circular Security Group reference (SG-A ingress from SG-B AND SG-B ingress from SG-A) modeled via standalone AWS::EC2::SecurityGroupIngress resources. DAG builder must not raise a false cycle; destroy must revoke both ingress rules BEFORE deleting either SG (SecurityGroup-after-SecurityGroupIngress implicit-delete-dep) or AWS rejects DeleteSecurityGroup with DependencyViolation.', 'cdk-defensive-vpc-deps-relax': 'CDK-defensive route DependsOn relaxation for VPC Lambda parallelization.', diff --git a/tests/integration/sg-circular-dependency/.scenarios.json b/tests/integration/sg-circular-dependency/.scenarios.json new file mode 100644 index 000000000..79ce480c8 --- /dev/null +++ b/tests/integration/sg-circular-dependency/.scenarios.json @@ -0,0 +1,5 @@ +{ + "scenarios": [ + "sg-circular-dependency" + ] +} diff --git a/tests/integration/sg-circular-dependency/README.md b/tests/integration/sg-circular-dependency/README.md new file mode 100644 index 000000000..c2dd15992 --- /dev/null +++ b/tests/integration/sg-circular-dependency/README.md @@ -0,0 +1,65 @@ +# Circular Security Group Reference + +Integration test that surfaces create/destroy DAG-ordering bugs with a +**circular Security Group reference**: SG-A allows ingress from SG-B AND SG-B +allows ingress from SG-A — the classic CloudFormation cycle. + +## How the cycle is modeled (the CFn-safe way) + +If both ingress rules were declared INLINE (inside each SG's +`SecurityGroupIngress` property), the two `AWS::EC2::SecurityGroup` resources +would reference each other and form a genuine dependency cycle that neither +CloudFormation nor cdkd's DAG builder can order. + +CDK breaks the cycle the same way the AWS docs recommend: when +`sgA.addIngressRule(sgB, ...)` would create a cross-reference, CDK emits the +rule as a **standalone `AWS::EC2::SecurityGroupIngress` resource** (not inline). +Each standalone ingress resource `Fn::GetAtt`s both SGs (`GroupId` = the SG it +attaches to, `SourceSecurityGroupId` = the other SG), but the two SGs +themselves no longer reference each other: + +``` +VPC ─┬─> SgA ─┐ + │ ├─> SgAfromB (ingress on SgA, source SgB) + └─> SgB ─┤ + └─> SgBfromA (ingress on SgB, source SgA) +``` + +which is acyclic. `verify.sh` confirms this via `cdkd synth` (>= 2 standalone +`AWS::EC2::SecurityGroupIngress` resources, each with a `SourceSecurityGroupId`, +and zero inline ingress on either SG). + +## Resources Created + +- **VPC** — single AZ, `natGateways: 0` (cheapest VPC; an SG must live in a VPC) +- **SG-A**, **SG-B** — two security groups, `allowAllOutbound: false` +- **2x SecurityGroupIngress** — the standalone cross-referencing ingress rules + +No EC2 instances are launched — the cross-SG reference alone exercises the +create/destroy ordering. + +## What it stresses in cdkd + +1. **DEPLOY** — the DAG builder (`src/analyzer/dag-builder.ts`) must NOT raise a + false `DependencyError`. The standalone ingress resources break what would + otherwise look like a cycle. +2. **DESTROY (the key test)** — the ingress rules must be revoked BEFORE the SGs + are deleted. An SG still referenced by a live cross-SG ingress rule cannot be + deleted; AWS rejects `DeleteSecurityGroup` with + `DependencyViolation: resource sg-xxx has a dependent object`. cdkd's + reversed-traversal delete order plus the + `AWS::EC2::SecurityGroup -> AWS::EC2::SecurityGroupIngress` + implicit-delete-dep edge (`src/analyzer/implicit-delete-deps.ts`) must put + both ingress deletes before both SG deletes. + +## Run + +```bash +/run-integ sg-circular-dependency +``` + +`verify.sh` deploys, asserts both SGs exist with the live cross-reference, +destroys, and asserts both SGs + the VPC + the state file are gone. Resources +are located by the `cdkd:integ-fixture=sg-circular-dependency` tag. The cleanup +trap revokes-then-deletes directly so a destroy-ordering bug never leaks +resources. diff --git a/tests/integration/sg-circular-dependency/bin/app.ts b/tests/integration/sg-circular-dependency/bin/app.ts new file mode 100644 index 000000000..75a8a04d2 --- /dev/null +++ b/tests/integration/sg-circular-dependency/bin/app.ts @@ -0,0 +1,13 @@ +#!/usr/bin/env node +import * as cdk from 'aws-cdk-lib'; +import { SgCircularDependencyStack } from '../lib/sg-circular-dependency-stack.ts'; + +const app = new cdk.App(); +new SgCircularDependencyStack(app, 'CdkdSgCircularExample', { + description: + 'Integration test for circular Security Group references - SG-A allows ingress from SG-B AND SG-B allows ingress from SG-A. CDK emits the cross-references as standalone AWS::EC2::SecurityGroupIngress resources so the SGs can be created before the rules. Stresses cdkd: (1) the DAG builder must NOT see a false cycle (the ingress resources break it); (2) on destroy the ingress rules must be revoked BEFORE the SGs are deleted or AWS rejects the SG delete with DependencyViolation.', + env: { + account: process.env.CDK_DEFAULT_ACCOUNT, + region: process.env.CDK_DEFAULT_REGION, + }, +}); diff --git a/tests/integration/sg-circular-dependency/cdk.json b/tests/integration/sg-circular-dependency/cdk.json new file mode 100644 index 000000000..9220088fd --- /dev/null +++ b/tests/integration/sg-circular-dependency/cdk.json @@ -0,0 +1,8 @@ +{ + "app": "node bin/app.ts", + "context": { + "cdkd": { + "stateBucket": "cdkd-state-test" + } + } +} diff --git a/tests/integration/sg-circular-dependency/lib/sg-circular-dependency-stack.ts b/tests/integration/sg-circular-dependency/lib/sg-circular-dependency-stack.ts new file mode 100644 index 000000000..2be220e4a --- /dev/null +++ b/tests/integration/sg-circular-dependency/lib/sg-circular-dependency-stack.ts @@ -0,0 +1,108 @@ +import * as cdk from 'aws-cdk-lib'; +import { Construct } from 'constructs'; +import * as ec2 from 'aws-cdk-lib/aws-ec2'; + +/** + * Integration test for a CIRCULAR Security Group reference. + * + * The classic CloudFormation cycle: SG-A allows ingress from SG-B AND + * SG-B allows ingress from SG-A. If both ingress rules were declared + * INLINE (inside each SG's `SecurityGroupIngress` property), the two SG + * resources would reference each other and form a genuine dependency + * cycle that CloudFormation (and cdkd's DAG builder) cannot order. + * + * CDK breaks the cycle the same way CloudFormation docs recommend: when + * `sgA.addIngressRule(sgB, ...)` would create a cross-reference, CDK + * emits the rule as a STANDALONE `AWS::EC2::SecurityGroupIngress` + * resource (not inline). Each standalone ingress resource Refs both SGs, + * but the two SGs themselves no longer reference each other - so the + * graph is: + * + * VPC -+-> SgA -+ + * | +-> SgAfromB (ingress on SgA, source SgB) + * +-> SgB -+ + * +-> SgBfromA (ingress on SgB, source SgA) + * + * which is acyclic. This is exactly the shape the verify.sh confirms via + * `cdkd synth` (two `AWS::EC2::SecurityGroupIngress` resources, zero + * inline `SecurityGroupIngress` entries on either SG). + * + * What this stresses in cdkd: + * 1. The DAG builder must NOT raise a false `DependencyError` - + * the standalone ingress resources break what would otherwise look + * like a cycle. + * 2. On DESTROY the ingress rules must be revoked BEFORE the SGs are + * deleted. An SG that is still referenced by a live cross-SG ingress + * rule cannot be deleted - AWS rejects `DeleteSecurityGroup` with + * `DependencyViolation: resource sg-xxx has a dependent object`. + * cdkd's reversed-traversal delete order plus the + * `AWS::EC2::SecurityGroup -> AWS::EC2::SecurityGroupIngress` + * implicit-delete-dep edge must put both ingress deletes before both + * SG deletes. + * + * Why a VPC with `natGateways: 0`: a Security Group must live in a VPC, + * and we want the cheapest possible VPC (no NAT, no instances). No EC2 + * instances are launched - the cross-SG reference alone is enough to + * exercise the create/destroy ordering. + */ +export class SgCircularDependencyStack extends cdk.Stack { + constructor(scope: Construct, id: string, props?: cdk.StackProps) { + super(scope, id, props); + + // Tag every resource so the verify.sh can find the SGs / VPC by the + // fixture tag (NOT aws:cdk:path, which AWS reserves and cdkd cannot set). + cdk.Tags.of(this).add('cdkd:integ-fixture', 'sg-circular-dependency'); + + const vpc = new ec2.Vpc(this, 'Vpc', { + ipAddresses: ec2.IpAddresses.cidr('10.60.0.0/16'), + maxAzs: 1, + natGateways: 0, + subnetConfiguration: [ + { + name: 'Public', + subnetType: ec2.SubnetType.PUBLIC, + cidrMask: 24, + }, + ], + }); + + // Two security groups. allowAllOutbound:false keeps the synthesized + // template tight (no implicit egress rule noise) and focuses the test + // on the ingress cross-references. + const sgA = new ec2.SecurityGroup(this, 'SgA', { + vpc, + description: 'SG-A - allows ingress from SG-B (circular ref test)', + allowAllOutbound: false, + }); + + const sgB = new ec2.SecurityGroup(this, 'SgB', { + vpc, + description: 'SG-B - allows ingress from SG-A (circular ref test)', + allowAllOutbound: false, + }); + + // The circular references. Because each rule's source is a DIFFERENT + // SG construct than the one it is attached to, CDK emits each as a + // standalone AWS::EC2::SecurityGroupIngress resource (breaking the + // would-be cycle) rather than an inline ingress on the SG. + // - SgA ingress FROM SgB on tcp/443 + // - SgB ingress FROM SgA on tcp/443 + sgA.addIngressRule(sgB, ec2.Port.tcp(443), 'Allow HTTPS from SG-B'); + sgB.addIngressRule(sgA, ec2.Port.tcp(443), 'Allow HTTPS from SG-A'); + + new cdk.CfnOutput(this, 'VpcId', { + value: vpc.vpcId, + description: 'VPC ID (both SGs live in this VPC)', + }); + + new cdk.CfnOutput(this, 'SgAId', { + value: sgA.securityGroupId, + description: 'SG-A id (allows ingress from SG-B)', + }); + + new cdk.CfnOutput(this, 'SgBId', { + value: sgB.securityGroupId, + description: 'SG-B id (allows ingress from SG-A)', + }); + } +} diff --git a/tests/integration/sg-circular-dependency/package.json b/tests/integration/sg-circular-dependency/package.json new file mode 100644 index 000000000..220b2fbc9 --- /dev/null +++ b/tests/integration/sg-circular-dependency/package.json @@ -0,0 +1,19 @@ +{ + "name": "cdkd-integ-sg-circular-dependency", + "version": "1.0.0", + "private": true, + "description": "Integration test for circular Security Group references: SG-A allows ingress from SG-B and SG-B allows ingress from SG-A, modeled the CFn-safe way via standalone AWS::EC2::SecurityGroupIngress resources. Stresses the DAG builder (must NOT see a false cycle) and the destroy ordering (ingress rules must be revoked BEFORE the SGs are deleted).", + "scripts": { + "build": "tsc", + "watch": "tsc -w" + }, + "devDependencies": { + "@types/node": "^20.0.0", + "typescript": "^5.0.0" + }, + "dependencies": { + "aws-cdk-lib": "^2.169.0", + "constructs": "^10.0.0" + }, + "type": "module" +} diff --git a/tests/integration/sg-circular-dependency/tsconfig.json b/tests/integration/sg-circular-dependency/tsconfig.json new file mode 100644 index 000000000..c713d4ab2 --- /dev/null +++ b/tests/integration/sg-circular-dependency/tsconfig.json @@ -0,0 +1,26 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "NodeNext", + "lib": [ + "ES2023" + ], + "declaration": true, + "strict": true, + "noImplicitAny": true, + "strictNullChecks": true, + "noImplicitThis": true, + "alwaysStrict": true, + "esModuleInterop": true, + "outDir": "dist", + "moduleResolution": "NodeNext", + "rewriteRelativeImportExtensions": true, + "erasableSyntaxOnly": true, + "verbatimModuleSyntax": true + }, + "exclude": [ + "node_modules", + "dist", + "cdk.out" + ] +} diff --git a/tests/integration/sg-circular-dependency/verify.sh b/tests/integration/sg-circular-dependency/verify.sh new file mode 100755 index 000000000..8e774f125 --- /dev/null +++ b/tests/integration/sg-circular-dependency/verify.sh @@ -0,0 +1,295 @@ +#!/usr/bin/env bash +# verify.sh — cdkd circular Security Group reference integ test. +# +# Models the classic CloudFormation cycle the CFn-safe way: SG-A allows +# ingress from SG-B AND SG-B allows ingress from SG-A, where each rule is a +# STANDALONE AWS::EC2::SecurityGroupIngress resource (NOT an inline ingress) +# so the two SGs can exist before the cross-references are added. +# +# What it stresses in cdkd: +# 1. DEPLOY: the DAG builder must NOT raise a false `DependencyError` — +# the standalone ingress resources break what would otherwise be a cycle. +# This script first confirms via `cdkd synth` that CDK emitted exactly +# two standalone `AWS::EC2::SecurityGroupIngress` resources and ZERO +# inline `SecurityGroupIngress` entries on either SG. +# 2. DESTROY (the key test): the ingress rules must be revoked BEFORE the +# SGs are deleted. An SG still referenced by a live cross-SG ingress rule +# cannot be deleted — AWS rejects `DeleteSecurityGroup` with +# `DependencyViolation: resource sg-xxx has a dependent object`. If cdkd +# orders the deletes wrong, destroy FAILS or orphans SGs/VPC here. +# +# Asserts post-deploy: both SGs exist, each carries the cross-referencing +# ingress rule (UserIdGroupPairs points at the OTHER SG). Asserts post-destroy: +# both SGs gone, VPC gone, state file gone. +# +# Required env vars: +# STATE_BUCKET — cdkd state bucket (e.g. cdkd-state-{accountId}) +# AWS_REGION — defaults to us-east-1 +# +# BSD-safe (macOS): no `grep -P`, no `date -d`. Resources are located by the +# `cdkd:integ-fixture=sg-circular-dependency` tag (NOT aws:cdk:path, which AWS +# reserves and cdkd cannot set). + +set -euo pipefail + +cd "$(dirname "$0")" + +STACK="CdkdSgCircularExample" +REGION="${AWS_REGION:-us-east-1}" +STATE_KEY="cdkd/${STACK}/${REGION}/state.json" +FIXTURE_TAG_KEY="cdkd:integ-fixture" +FIXTURE_TAG_VALUE="sg-circular-dependency" + +LOCAL_DIST="$(cd ../../../dist && pwd)/cli.js" + +# Captured ids (best-effort) so the cleanup trap can revoke-then-delete +# directly if cdkd's own destroy ordering fails. +SG_A_ID="" +SG_B_ID="" +VPC_ID="" + +# Revoke every ingress rule off both SGs, then delete the SGs, then the VPC. +# This is the SAME ordering cdkd must perform — doing it here in cleanup +# guarantees we never leak resources even if cdkd's destroy left orphans. +force_cleanup_aws() { + set +eu + # 1) Revoke ALL ingress on each SG so neither references the other anymore. + for SG in "${SG_A_ID}" "${SG_B_ID}"; do + [ -z "${SG}" ] && continue + PERMS=$(aws ec2 describe-security-groups \ + --group-ids "${SG}" \ + --region "${REGION}" \ + --query 'SecurityGroups[0].IpPermissions' --output json 2>/dev/null) + if [ -n "${PERMS}" ] && [ "${PERMS}" != "null" ] && [ "${PERMS}" != "[]" ]; then + echo " [cleanup] revoking ingress on ${SG}" + aws ec2 revoke-security-group-ingress \ + --group-id "${SG}" \ + --ip-permissions "${PERMS}" \ + --region "${REGION}" >/dev/null 2>&1 || true + fi + done + # 2) Now the cross-references are gone, the SGs can be deleted. + for SG in "${SG_A_ID}" "${SG_B_ID}"; do + [ -z "${SG}" ] && continue + echo " [cleanup] deleting ${SG}" + aws ec2 delete-security-group --group-id "${SG}" --region "${REGION}" >/dev/null 2>&1 || true + done + # 3) VPC last (subnet/IGW/route-table teardown is left to cdkd; if cdkd + # already removed them this is a no-op, otherwise we at least try). + if [ -n "${VPC_ID}" ]; then + echo " [cleanup] attempting VPC delete ${VPC_ID} (best-effort)" + aws ec2 delete-vpc --vpc-id "${VPC_ID}" --region "${REGION}" >/dev/null 2>&1 || true + fi + set -eu +} + +cleanup() { + echo "==> Cleanup: dropping any leftover state + AWS resources" + set +eu + if [ -x "${LOCAL_DIST}" ] && [ -n "${STATE_BUCKET:-}" ]; then + # state destroy first — exercises cdkd's own teardown ordering. Do NOT + # silence stderr so a partial failure is visible. + node "${LOCAL_DIST}" state destroy "${STACK}" \ + --state-bucket "${STATE_BUCKET}" \ + --region "${REGION}" \ + --yes + fi + # Belt-and-suspenders direct revoke-then-delete in case state destroy could + # not complete (e.g. ordering bug left SGs cross-referencing each other). + force_cleanup_aws + if [ -n "${STATE_BUCKET:-}" ]; then + aws s3 rm "s3://${STATE_BUCKET}/${STATE_KEY}" >/dev/null 2>&1 || true + aws s3 rm "s3://${STATE_BUCKET}/cdkd/${STACK}/${REGION}/lock.json" >/dev/null 2>&1 || true + fi + set -eu +} + +trap cleanup EXIT + +if [ -z "${STATE_BUCKET:-}" ]; then + echo "FAIL: STATE_BUCKET env var is required" >&2 + exit 1 +fi + +if [ ! -f "${LOCAL_DIST}" ]; then + echo "FAIL: local binary not built at ${LOCAL_DIST} — run 'vp run build' from repo root first" >&2 + exit 1 +fi + +echo "==> Installing fixture deps" +if [ ! -d node_modules ]; then + npm install +fi + +echo "==> Pre-run cleanup" +cleanup + +# --- Phase 0: synth — confirm the circular ref is modeled as standalone ----- +# AWS::EC2::SecurityGroupIngress resources (NOT inline ingress). +echo "==> Phase 0: synth and confirm standalone SecurityGroupIngress resources" +SYNTH_DIR="$(mktemp -d)" +# NOTE: `cdkd synth` only synthesizes the CDK app to a template — it does NOT +# read or write state, so it does NOT accept --state-bucket / --region (passing +# --state-bucket fails with `error: unknown option '--state-bucket'`). Only +# --output (+ the stack selector) are valid here. +node "${LOCAL_DIST}" synth "${STACK}" \ + --output "${SYNTH_DIR}" >/dev/null + +TEMPLATE_FILE="${SYNTH_DIR}/${STACK}.template.json" +if [ ! -f "${TEMPLATE_FILE}" ]; then + # Fall back to the first template in the synth dir. + TEMPLATE_FILE=$(find "${SYNTH_DIR}" -name '*.template.json' | head -1) +fi +if [ -z "${TEMPLATE_FILE}" ] || [ ! -f "${TEMPLATE_FILE}" ]; then + echo "FAIL: could not find synthesized template under ${SYNTH_DIR}" >&2 + rm -rf "${SYNTH_DIR}" + exit 1 +fi + +INGRESS_COUNT=$(jq '[.Resources | to_entries[] | select(.value.Type == "AWS::EC2::SecurityGroupIngress")] | length' "${TEMPLATE_FILE}") +if [ "${INGRESS_COUNT}" -lt 2 ]; then + echo "FAIL: expected >= 2 standalone AWS::EC2::SecurityGroupIngress resources, found ${INGRESS_COUNT} — the circular ref is NOT modeled the CFn-safe way" >&2 + jq '.Resources | to_entries[] | {id: .key, type: .value.Type}' "${TEMPLATE_FILE}" >&2 + rm -rf "${SYNTH_DIR}" + exit 1 +fi +echo " OK: ${INGRESS_COUNT} standalone AWS::EC2::SecurityGroupIngress resources (cycle broken)" + +# Confirm at least one standalone ingress references a SG as its source +# (UserIdGroupPairs / SourceSecurityGroupId via Fn::GetAtt or Ref), proving +# the cross-reference is the SG-to-SG kind, not a CIDR rule. +CROSS_REF_COUNT=$(jq '[.Resources | to_entries[] + | select(.value.Type == "AWS::EC2::SecurityGroupIngress") + | select(.value.Properties.SourceSecurityGroupId != null)] | length' "${TEMPLATE_FILE}") +if [ "${CROSS_REF_COUNT}" -lt 2 ]; then + echo "FAIL: expected >= 2 ingress resources with SourceSecurityGroupId (SG-to-SG cross-ref), found ${CROSS_REF_COUNT}" >&2 + jq '.Resources | to_entries[] | select(.value.Type == "AWS::EC2::SecurityGroupIngress") | .value.Properties' "${TEMPLATE_FILE}" >&2 + rm -rf "${SYNTH_DIR}" + exit 1 +fi +echo " OK: ${CROSS_REF_COUNT} ingress resources carry a SG-to-SG SourceSecurityGroupId (true circular ref)" + +# Confirm NO AWS::EC2::SecurityGroup carries a non-empty inline +# `Properties.SecurityGroupIngress` array — the circular refs MUST be emitted +# ONLY as standalone AWS::EC2::SecurityGroupIngress resources (an inline ingress +# entry pointing at the other SG is exactly what reintroduces the CFn cycle this +# fixture exists to avoid). Names the offending SG logical id on failure. +INLINE_INGRESS_SGS=$(jq -r '[.Resources | to_entries[] + | select(.value.Type == "AWS::EC2::SecurityGroup") + | select((.value.Properties.SecurityGroupIngress // []) | length > 0) + | .key] | join(", ")' "${TEMPLATE_FILE}") +if [ -n "${INLINE_INGRESS_SGS}" ]; then + echo "FAIL: SecurityGroup(s) carry a non-empty inline Properties.SecurityGroupIngress: ${INLINE_INGRESS_SGS} — the circular refs must be emitted ONLY as standalone AWS::EC2::SecurityGroupIngress resources, not inline ingress" >&2 + jq '.Resources | to_entries[] | select(.value.Type == "AWS::EC2::SecurityGroup") | {id: .key, ingress: .value.Properties.SecurityGroupIngress}' "${TEMPLATE_FILE}" >&2 + rm -rf "${SYNTH_DIR}" + exit 1 +fi +echo " OK: zero inline Properties.SecurityGroupIngress on any SecurityGroup (cross-refs are standalone-only)" +rm -rf "${SYNTH_DIR}" + +# --- Phase 1: deploy -------------------------------------------------------- +echo "==> Phase 1: deploy with the local binary (DAG builder must NOT see a false cycle)" +node "${LOCAL_DIST}" deploy "${STACK}" \ + --state-bucket "${STATE_BUCKET}" \ + --region "${REGION}" \ + --yes + +STATE=$(aws s3 cp "s3://${STATE_BUCKET}/${STATE_KEY}" - 2>/dev/null) +if [ -z "${STATE}" ]; then + echo "FAIL: no state file at s3://${STATE_BUCKET}/${STATE_KEY} after deploy (deploy likely failed)" >&2 + exit 1 +fi + +# Resolve both SG ids + the VPC id from state (physicalIds). +SG_IDS=$(echo "${STATE}" | jq -r '[.resources | to_entries[] | select(.value.resourceType == "AWS::EC2::SecurityGroup") | .value.physicalId] | .[]') +SG_A_ID=$(echo "${SG_IDS}" | sed -n '1p') +SG_B_ID=$(echo "${SG_IDS}" | sed -n '2p') +VPC_ID=$(echo "${STATE}" | jq -r '[.resources | to_entries[] | select(.value.resourceType == "AWS::EC2::VPC") | .value.physicalId] | first // ""') + +if [ -z "${SG_A_ID}" ] || [ -z "${SG_B_ID}" ] || [ "${SG_A_ID}" = "null" ] || [ "${SG_B_ID}" = "null" ]; then + echo "FAIL: could not resolve both SecurityGroup ids from state (got A='${SG_A_ID}' B='${SG_B_ID}')" >&2 + echo "${STATE}" | jq '.resources | to_entries[] | {id: .key, type: .value.resourceType, physicalId: .value.physicalId}' + exit 1 +fi +echo " resolved SG-A=${SG_A_ID} SG-B=${SG_B_ID} VPC=${VPC_ID}" + +# --- Assertions: both SGs exist and carry the cross-referencing ingress ----- +SG_DESC=$(aws ec2 describe-security-groups \ + --group-ids "${SG_A_ID}" "${SG_B_ID}" \ + --region "${REGION}" \ + --query 'SecurityGroups' --output json 2>/dev/null) +if [ -z "${SG_DESC}" ] || [ "${SG_DESC}" = "null" ]; then + echo "FAIL: DescribeSecurityGroups returned empty for ${SG_A_ID} / ${SG_B_ID}" >&2 + exit 1 +fi + +FOUND_SGS=$(echo "${SG_DESC}" | jq 'length') +if [ "${FOUND_SGS}" -ne 2 ]; then + echo "FAIL: expected 2 security groups present after deploy, found ${FOUND_SGS}" >&2 + echo "${SG_DESC}" | jq '[.[].GroupId]' + exit 1 +fi +echo " OK: both security groups exist on AWS" + +# SG-A must have an ingress rule whose source group is SG-B, and vice versa. +A_REFERENCES_B=$(echo "${SG_DESC}" | jq --arg a "${SG_A_ID}" --arg b "${SG_B_ID}" \ + '[.[] | select(.GroupId == $a) | .IpPermissions[].UserIdGroupPairs[].GroupId] | index($b) != null') +B_REFERENCES_A=$(echo "${SG_DESC}" | jq --arg a "${SG_A_ID}" --arg b "${SG_B_ID}" \ + '[.[] | select(.GroupId == $b) | .IpPermissions[].UserIdGroupPairs[].GroupId] | index($a) != null') + +if [ "${A_REFERENCES_B}" != "true" ]; then + echo "FAIL: SG-A (${SG_A_ID}) has no ingress rule referencing SG-B (${SG_B_ID}) — circular ingress not applied" >&2 + echo "${SG_DESC}" | jq --arg a "${SG_A_ID}" '.[] | select(.GroupId == $a) | .IpPermissions' + exit 1 +fi +if [ "${B_REFERENCES_A}" != "true" ]; then + echo "FAIL: SG-B (${SG_B_ID}) has no ingress rule referencing SG-A (${SG_A_ID}) — circular ingress not applied" >&2 + echo "${SG_DESC}" | jq --arg b "${SG_B_ID}" '.[] | select(.GroupId == $b) | .IpPermissions' + exit 1 +fi +echo " OK: SG-A ingress references SG-B AND SG-B ingress references SG-A (circular ref is live on AWS)" + +# --- Phase 2: destroy (THE KEY TEST) --------------------------------------- +# cdkd MUST revoke both ingress rules BEFORE deleting either SG. If it deletes +# an SG while the cross-reference is still live, AWS returns DependencyViolation +# and this step fails / leaves orphans. +echo "==> Phase 2: destroy (ingress rules MUST be revoked before the SGs are deleted)" +if ! node "${LOCAL_DIST}" destroy "${STACK}" \ + --state-bucket "${STATE_BUCKET}" \ + --region "${REGION}" \ + --force; then + echo "FAIL: cdkd destroy returned non-zero — likely DeleteSecurityGroup DependencyViolation because an SG was deleted while still cross-referenced. Check the destroy output above for the offending resource + AWS error." >&2 + exit 1 +fi + +if aws s3 ls "s3://${STATE_BUCKET}/${STATE_KEY}" >/dev/null 2>&1; then + echo "FAIL: state file s3://${STATE_BUCKET}/${STATE_KEY} still exists after destroy" >&2 + exit 1 +fi +echo " OK: state file is gone" + +# Both SGs must be gone from AWS. +for SG in "${SG_A_ID}" "${SG_B_ID}"; do + if aws ec2 describe-security-groups --group-ids "${SG}" --region "${REGION}" >/dev/null 2>&1; then + echo "FAIL: security group ${SG} still exists after destroy (orphan — destroy ordering likely deleted in the wrong order or skipped it)" >&2 + exit 1 + fi +done +echo " OK: both security groups are gone from AWS" + +# VPC must be gone. +if [ -n "${VPC_ID}" ]; then + if aws ec2 describe-vpcs --vpc-ids "${VPC_ID}" --region "${REGION}" >/dev/null 2>&1; then + echo "FAIL: VPC ${VPC_ID} still exists after destroy (orphan)" >&2 + exit 1 + fi + echo " OK: VPC ${VPC_ID} is gone from AWS" +fi + +# Everything cleaned by cdkd — clear ids so the EXIT trap is a no-op. +SG_A_ID="" +SG_B_ID="" +VPC_ID="" + +echo "" +echo "=== PASS: circular Security Group reference deploy + destroy integ ==="