Skip to content

fix(deployment): resolve composite conditions + prune Condition-gated resources on update (#840) - #846

Merged
go-to-k merged 4 commits into
mainfrom
test/conditions-and-if
Jun 13, 2026
Merged

fix(deployment): resolve composite conditions + prune Condition-gated resources on update (#840)#846
go-to-k merged 4 commits into
mainfrom
test/conditions-and-if

Conversation

@go-to-k

@go-to-k go-to-k commented Jun 13, 2026

Copy link
Copy Markdown
Owner

Summary

Fixes two related cdkd defects around CloudFormation Conditions (issue #840), plus a failure-seeking integ and unit tests.

1. Composite conditions referencing other named conditions resolve correctly and order-independently

src/deployment/intrinsic-function-resolver.ts + src/analyzer/template-parser.ts. A Conditions section entry can compose other named conditions via {Condition: X} inside Fn::And / Fn::Or / Fn::Not (for example IsPremiumPrimary: { Fn::And: [ { Condition: IsPremium }, { Condition: IsPrimaryRegion } ] }). cdkd previously did not resolve the {Condition: X} reference form when evaluating composite conditions, and the result also depended on the order conditions appeared in the template. Conditions are now evaluated so that a {Condition: X} reference resolves to the already-evaluated boolean of condition X regardless of declaration order, matching CloudFormation semantics.

2. A resource whose Condition: flips true -> false on a redeploy is now pruned / deleted

src/analyzer/template-parser.ts (new filterResourcesByCondition) + src/deployment/deploy-engine.ts. CloudFormation does not strip condition-gated resources at synth time -- CDK emits a resource carrying a Condition: key into Resources regardless of the condition value. cdkd evaluated the Conditions section for Fn::If resolution but never consulted the resource-level Condition: key, so a condition-false resource was created on first deploy and never deleted when its condition flipped (it stayed in the desired set and diffed as NO_CHANGE). The deploy engine now prunes every resource whose Condition: resolved to false (via TemplateParser.filterResourcesByCondition) immediately after evaluating the Conditions section, so the whole downstream pipeline (type/property validation, DAG build, diff, provisioning) operates on the CFn-effective resource set: a condition-false resource is never created, and one present in prior state but condition-excluded from the effective template falls through the diff's existing "present in state, absent from desired -> DELETE" path exactly as CloudFormation removes it. A resource whose Condition: names an unknown/unevaluated condition is kept (treated as present, not silently dropped).

Tests

  • New conditions-and-if integ (tests/integration/conditions-and-if/**): a cheap stack (3x AWS::SSM::Parameter + 1x AWS::SNS::Topic, no VPC / NAT) whose Conditions section combines Fn::Equals on a CfnParameter with Fn::And / Fn::Or / Fn::Not, carries two resources with a Condition: key, an always-created parameter whose Value is an Fn::If branch, and an SNS topic whose DisplayName is Fn::If(IsPremium, ..., AWS::NoValue). verify.sh runs three phases against real AWS: Phase 1 (-c tier=premium) asserts the Fn::If branch, both condition-gated parameters present, and the SNS DisplayName set; Phase 2 (-c tier=basic, in-place redeploy) asserts the Fn::If branch flipped, both condition-gated parameters now absent (resource removed because its Condition: went false), and the DisplayName genuinely omitted (AWS::NoValue); Phase 3 destroys and asserts every named resource is gone and the state file removed.
  • New filterResourcesByCondition unit tests (tests/unit/analyzer/template-parser.test.ts): false -> removed, true -> kept, no-Condition -> kept, unknown-condition -> kept, preserves Conditions+Outputs and does not mutate input.
  • New diff-calculator test (tests/unit/analyzer/diff-calculator.test.ts): a resource in state but absent from the pruned template diffs as DELETE.
  • New composite-condition {Condition: X} resolution unit tests covering the order-independent evaluation.

Validation

  • Validated GREEN end-to-end against real AWS: /run-integ conditions-and-if passed (deploy + redeploy flip + destroy clean, 0 orphans).
  • The cross-cutting bench-cdk-sample broad integ ran clean in this worktree (deploy + destroy, 0 orphans), so the integ-broad and integ-destroy gates are satisfied.
  • Full local check passes: typecheck, lint (0 errors), build, and the unit suite (5776 tests). Coverage matrices regenerated.

Closes #840

@go-to-k

go-to-k commented Jun 13, 2026

Copy link
Copy Markdown
Owner Author

Independent 3-axis review complete (code + test; spec N/A for a bug fix). Test review: clean, with the prune-on-UPDATE path integ-asserted (deploy condition=true -> redeploy condition=false -> resource DELETED on AWS). Code review found one latent hazard: the {Condition: X} reference branch fired for every property, so a resource property literally {Condition: "string"} could be coerced to false. Fixed in d97cf5a by gating that branch on context.conditionResolver presence (set only during evaluateConditions, the sole valid context) + a regression unit test. Setting pr-review bound to d97cf5a.

go-to-k added 4 commits June 14, 2026 00:34
…handling

cdkd evaluates the Conditions section, the resource-level Condition: key,
and Fn::If / Fn::Equals / Fn::And / Fn::Or / Fn::Not itself (no
CloudFormation engine underneath). The existing `conditions` fixture has no
verify.sh and only exercised a single Fn::And + one conditionally-created
S3 bucket + an Fn::If bucket name, leaving condition-gated resource
creation, Fn::If -> AWS::NoValue property omission, and Fn::Or / Fn::Not
without an end-to-end real-AWS backstop.

The new CdkdConditionsIfExample stack (cheap: 3x SSM Parameter + 1x SNS
Topic, no VPC / NAT) closes the gap:

- Conditions section combining Fn::Equals on a CfnParameter with Fn::And
  (IsPremiumPrimary), Fn::Or (IsPremiumOrSecondary), and Fn::Not
  (IsSecondaryRegion).
- TWO resources with a Condition: key (PremiumOnlyParam on the bare
  Fn::Equals condition, PremiumPrimaryParam on the Fn::And condition) so a
  resource is created in one parameter setting and absent in another.
- An always-created parameter whose Value is an Fn::If branch
  (TierLabelParam).
- An SNS topic whose DisplayName is Fn::If(IsPremium, 'Premium
  Notifications', AWS::NoValue) plus two tag values driven by Fn::If.

The Tier CfnParameter default is read from CDK context (-c tier=premium
|basic) at synth, since cdkd has no deploy-time --parameter flag, so
flipping the context is the param-flip mechanism.

verify.sh (BSD/macOS-portable: no grep -P, no date -d, real exit codes,
explicit pass line) runs three phases against real AWS:

- Phase 1 (-c tier=premium): Fn::If property branch reached AWS
  (TierLabelParam Value == tier-is-premium), both condition-gated
  parameters PRESENT, SNS DisplayName SET to Premium Notifications, Fn::If
  tag values / Fn::Or tag value reached AWS.
- Phase 2 (-c tier=basic, redeploy in place): Fn::If branch FLIPPED on AWS
  (tier-is-basic), both condition-gated parameters now ABSENT, SNS
  DisplayName genuinely OMITTED on AWS (Fn::If -> AWS::NoValue), tag values
  flipped.
- Phase 3: destroy + assert every named resource is NOT-FOUND on AWS and
  the state file is gone.

New scenario tag conditions-and-if in KNOWN_SCENARIOS
(scripts/build-scenario-coverage-matrix.ts); coverage matrices regenerated;
changelog entry added.

Test-only, no src/ change. Not yet run against real AWS.
…e on redeploy (#840)

The new conditions-and-if integ surfaced a real cdkd gap, not a test
artifact. Synthing the fixture both ways confirms CloudFormation does NOT
strip condition-gated resources at synth time: CDK emits PremiumOnlyParam
into Resources carrying `Condition: IsPremium` in BOTH `-c tier=premium`
and `-c tier=basic`, differing only in the Tier parameter default that
flips the condition. cdkd evaluated the Conditions section for Fn::If
resolution but never consulted the resource-level `Condition:` key, so a
condition-false resource was created on first deploy AND never deleted when
its condition flipped (it stayed in the desired set and diffed as
NO_CHANGE). That is exactly the integ's Phase 2 FAIL: PremiumOnlyParam
STILL EXISTS after the basic redeploy.

Fix: the deploy engine now prunes every resource whose `Condition:`
resolved to false (new TemplateParser.filterResourcesByCondition) right
after evaluating the Conditions section, so the whole downstream pipeline
(type/property validation, DAG build, diff, provisioning) operates on the
CFn-effective resource set. A condition-false resource is never created,
and one present in prior state but condition-excluded from the effective
template flows through the diff's existing "present in state, absent from
desired -> DELETE" path exactly as CloudFormation removes it. A resource
whose `Condition:` names an unevaluated/unknown condition is kept
(absent-from-map is not === false), never silently dropped.

Tests:
- 5 filterResourcesByCondition unit tests (tests/unit/analyzer/template-parser.test.ts):
  false->removed, true->kept, no-Condition->kept, unknown-condition->kept,
  preserves Conditions/Outputs and does not mutate input.
- 1 diff-calculator unit test (tests/unit/analyzer/diff-calculator.test.ts):
  resource in state but absent from the pruned template -> DELETE.

Docs: architecture.md diff comparison section, .claude/rules/analyzer.md,
and a changelog-cdkd.md entry.

Closes #840
…tions (Fn::And/Or/Not), order-independent (#840)

The #840 fix (filterResourcesByCondition + effectiveTemplate) pruned
condition-gated resources whose simple Fn::Equals condition evaluated
false, but a resource gated on a COMPOSITE condition such as
IsPremiumPrimary = Fn::And[{Condition: IsPremium}, {Condition: IsPrimaryRegion}]
was not pruned in the basic tier: the conditions-and-if integ re-validation
reported "PremiumPrimaryParam STILL EXISTS but should be removed".

Root cause (two compounding bugs in evaluateConditions / resolveValue):

1. Reference resolution: resolveValue had NO case for a {Condition: X}
   named-condition reference. Inside Fn::And/Or/Not the inner
   {Condition: IsPremium} fell through to the generic "not an intrinsic,
   recurse object properties" branch and produced the object
   {Condition: "IsPremium"} -- a truthy value -- so And(false, true)
   wrongly evaluated to And(truthy, truthy) = true, and the gated
   resource was never pruned.

2. Evaluation order: evaluateConditions iterated the Conditions block in
   declaration order with no dependency ordering, so a composite condition
   declared before the conditions it references would see them unevaluated.

Fix:

- Add a {Condition: X} case to resolveValue (single-key, string-valued
  guard so a resource property literally named "Condition" alongside
  siblings is not misdetected) that delegates to resolveConditionReference.
- Rewrite evaluateConditions to evaluate lazily/recursively with
  memoization (the result map doubles as the memo cache) and an
  in-progress set as a cycle guard, threading a conditionResolver hook
  onto the context so nested {Condition: Y} references recurse through the
  same evaluator. Evaluation is now dependency-ordered, not
  declaration-ordered. A detected cycle or an undeclared reference
  downgrades to false (warn) rather than aborting the deploy, matching the
  prior per-condition error tolerance and Fn::If's not-found behavior.

Verified: in the basic tier IsPremium=false -> IsPremiumPrimary=And(false,true)=false
-> PremiumPrimaryParam pruned. Fn::Or / Fn::Not composites referencing
other conditions also resolve correctly.

Unit tests (tests/unit/deployment/intrinsic-functions.test.ts): Fn::And of
two {Condition: X} refs across all four truth combinations;
declaration-order independence (composite declared before its referenced
conditions); Fn::Or / Fn::Not composites; nested composite-referencing-composite;
circular-reference guard; undeclared-reference warn-and-false; and the
"Condition" property false-positive guard.

Closes #840
…ditionResolver presence

The `{Condition: <name>}` named-condition reference branch in
`resolveValue` fired for every property, not just inside a condition
definition. A resource/output property whose value was exactly a
single-key `{ "Condition": "<string>" }` object was silently coerced to a
boolean via `resolveConditionReference` — and in normal property context
(no `conditionResolver`, name absent from `context.conditions`) that
returns `false`, corrupting the property.

The reference form is only reachable during `evaluateConditions`, the
sole code path that threads a `conditionResolver` hook onto the context.
Gate the branch on `context.conditionResolver` being present so a
resource property literally named `Condition` is never coerced to false;
it now falls through and resolves as an ordinary object, exactly as
before the #840 change. The single-key + `typeof string` guards remain
as defense in depth. Composite-condition behavior is unaffected (it
always runs with `conditionResolver` set).

Adds a unit test pinning that a single-key `{ Condition: "Foo" }`
property resolved in normal context stays a plain object and is not
`false` (review fix).
@go-to-k
go-to-k force-pushed the test/conditions-and-if branch from d97cf5a to 5f897e7 Compare June 13, 2026 15:40
@go-to-k
go-to-k merged commit 5aa7ca4 into main Jun 13, 2026
5 checks passed
@go-to-k
go-to-k deleted the test/conditions-and-if branch June 13, 2026 15:49
github-actions Bot pushed a commit that referenced this pull request Jun 13, 2026
## [0.221.4](v0.221.3...v0.221.4) (2026-06-13)

### Bug Fixes

* **deployment:** resolve composite conditions + prune Condition-gated resources on update ([#840](#840)) ([#846](#846)) ([5aa7ca4](5aa7ca4))
@github-actions

Copy link
Copy Markdown

🎉 This PR is included in version 0.221.4 🎉

The release is available on:

Your semantic-release bot 📦🚀

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fix(deployment): resource whose Condition flips to false on update may not be deleted (needs confirm vs test artifact)

1 participant