Skip to content

Commit 7aa312e

Browse files
authored
test(integ): sg-circular-dependency failure-seeking integration test (#842)
1 parent 7ba3f74 commit 7aa312e

14 files changed

Lines changed: 615 additions & 13 deletions

docs/_generated/integ-coverage.json

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -743,6 +743,7 @@
743743
"rds-dbinstance-backfill",
744744
"remove-protection",
745745
"rollback-failure-injection",
746+
"sg-circular-dependency",
746747
"vpc-lambda",
747748
"vpc-lambda-cr-race",
748749
"vpc-nat-gateway"
@@ -812,6 +813,9 @@
812813
"rollback-failure-injection": [
813814
"l2"
814815
],
816+
"sg-circular-dependency": [
817+
"l2"
818+
],
815819
"vpc-lambda": [
816820
"l2"
817821
],
@@ -847,6 +851,7 @@
847851
"rds-dbinstance-backfill",
848852
"remove-protection",
849853
"rollback-failure-injection",
854+
"sg-circular-dependency",
850855
"vpc-lambda",
851856
"vpc-lambda-cr-race",
852857
"vpc-nat-gateway"
@@ -915,6 +920,9 @@
915920
"rollback-failure-injection": [
916921
"l2"
917922
],
923+
"sg-circular-dependency": [
924+
"l2"
925+
],
918926
"vpc-lambda": [
919927
"l2"
920928
],
@@ -973,6 +981,7 @@
973981
"rds-dbinstance-backfill",
974982
"remove-protection",
975983
"rollback-failure-injection",
984+
"sg-circular-dependency",
976985
"vpc-lambda",
977986
"vpc-lambda-cr-race",
978987
"vpc-nat-gateway"
@@ -1042,6 +1051,9 @@
10421051
"rollback-failure-injection": [
10431052
"l2"
10441053
],
1054+
"sg-circular-dependency": [
1055+
"l2"
1056+
],
10451057
"vpc-lambda": [
10461058
"l2"
10471059
],
@@ -1077,6 +1089,7 @@
10771089
"rds-dbinstance-backfill",
10781090
"remove-protection",
10791091
"rollback-failure-injection",
1092+
"sg-circular-dependency",
10801093
"vpc-lambda",
10811094
"vpc-lambda-cr-race",
10821095
"vpc-nat-gateway"
@@ -1146,6 +1159,9 @@
11461159
"rollback-failure-injection": [
11471160
"l2"
11481161
],
1162+
"sg-circular-dependency": [
1163+
"l2"
1164+
],
11491165
"vpc-lambda": [
11501166
"l2"
11511167
],
@@ -1178,6 +1194,7 @@
11781194
"rds-dbinstance-backfill",
11791195
"remove-protection",
11801196
"rollback-failure-injection",
1197+
"sg-circular-dependency",
11811198
"update-replace",
11821199
"vpc-lambda",
11831200
"vpc-lambda-cr-race"
@@ -1238,6 +1255,10 @@
12381255
"rollback-failure-injection": [
12391256
"l2"
12401257
],
1258+
"sg-circular-dependency": [
1259+
"l2",
1260+
"literal"
1261+
],
12411262
"update-replace": [
12421263
"l2",
12431264
"literal"
@@ -1253,11 +1274,15 @@
12531274
{
12541275
"resourceType": "AWS::EC2::SecurityGroupIngress",
12551276
"integs": [
1256-
"alb"
1277+
"alb",
1278+
"sg-circular-dependency"
12571279
],
12581280
"signals": {
12591281
"alb": [
12601282
"literal"
1283+
],
1284+
"sg-circular-dependency": [
1285+
"literal"
12611286
]
12621287
}
12631288
},
@@ -1285,6 +1310,7 @@
12851310
"rds-dbinstance-backfill",
12861311
"remove-protection",
12871312
"rollback-failure-injection",
1313+
"sg-circular-dependency",
12881314
"vpc-lambda",
12891315
"vpc-lambda-cr-race",
12901316
"vpc-nat-gateway"
@@ -1354,6 +1380,9 @@
13541380
"rollback-failure-injection": [
13551381
"l2"
13561382
],
1383+
"sg-circular-dependency": [
1384+
"l2"
1385+
],
13571386
"vpc-lambda": [
13581387
"l2"
13591388
],
@@ -1400,6 +1429,7 @@
14001429
"rds-dbinstance-backfill",
14011430
"remove-protection",
14021431
"rollback-failure-injection",
1432+
"sg-circular-dependency",
14031433
"vpc-lambda",
14041434
"vpc-lambda-cr-race",
14051435
"vpc-nat-gateway"
@@ -1469,6 +1499,9 @@
14691499
"rollback-failure-injection": [
14701500
"l2"
14711501
],
1502+
"sg-circular-dependency": [
1503+
"l2"
1504+
],
14721505
"vpc-lambda": [
14731506
"l2"
14741507
],
@@ -1504,6 +1537,7 @@
15041537
"rds-dbinstance-backfill",
15051538
"remove-protection",
15061539
"rollback-failure-injection",
1540+
"sg-circular-dependency",
15071541
"vpc-lambda",
15081542
"vpc-lambda-cr-race",
15091543
"vpc-nat-gateway"
@@ -1574,6 +1608,9 @@
15741608
"rollback-failure-injection": [
15751609
"l2"
15761610
],
1611+
"sg-circular-dependency": [
1612+
"l2"
1613+
],
15771614
"vpc-lambda": [
15781615
"l2"
15791616
],
@@ -1609,6 +1646,7 @@
16091646
"rds-dbinstance-backfill",
16101647
"remove-protection",
16111648
"rollback-failure-injection",
1649+
"sg-circular-dependency",
16121650
"vpc-lambda",
16131651
"vpc-lambda-cr-race",
16141652
"vpc-nat-gateway"
@@ -1678,6 +1716,9 @@
16781716
"rollback-failure-injection": [
16791717
"l2"
16801718
],
1719+
"sg-circular-dependency": [
1720+
"l2"
1721+
],
16811722
"vpc-lambda": [
16821723
"l2"
16831724
],

docs/_generated/scenario-coverage.json

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,10 @@
168168
"tag": "s3-asset-deploy",
169169
"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."
170170
},
171+
{
172+
"tag": "sg-circular-dependency",
173+
"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."
174+
},
171175
{
172176
"tag": "state-bucket-region-resolve",
173177
"description": "State-bucket S3 clients (state backend + lock manager) auto-detect bucket region via `GetBucketLocation` regardless of caller-profile region."
@@ -943,6 +947,13 @@
943947
"annotated": true,
944948
"scenarios": []
945949
},
950+
{
951+
"name": "sg-circular-dependency",
952+
"annotated": true,
953+
"scenarios": [
954+
"sg-circular-dependency"
955+
]
956+
},
946957
{
947958
"name": "sns-sqs-event",
948959
"annotated": true,
@@ -1340,6 +1351,13 @@
13401351
"s3-asset-deploy"
13411352
]
13421353
},
1354+
{
1355+
"scenario": "sg-circular-dependency",
1356+
"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.",
1357+
"fixtures": [
1358+
"sg-circular-dependency"
1359+
]
1360+
},
13431361
{
13441362
"scenario": "state-bucket-region-resolve",
13451363
"description": "State-bucket S3 clients (state backend + lock manager) auto-detect bucket region via `GetBucketLocation` regardless of caller-profile region.",

docs/changelog-cdkd.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ The CLAUDE.md `## Known Limitations` section retains the load-bearing summary
1919
**Recently Implemented** (2026-06-13):
2020

2121
- ✅ **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('<distinct multi-file dir>')` 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.)
22+
- ✅ **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).
2223
- ✅ **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.)
2324
- ✅ **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.)
2425
- ✅ **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`.

0 commit comments

Comments
 (0)