Skip to content

feat(toolkit-lib): unstable evaluate action to resolve a stack's template against its deployed stack - #2066

Draft
iankhou wants to merge 3 commits into
feat/toolkit-lib-best-effort-evaluationfrom
feat/toolkit-lib-evaluate-action
Draft

iankhou wants to merge 3 commits into
feat/toolkit-lib-best-effort-evaluationfrom
feat/toolkit-lib-evaluate-action

Conversation

@iankhou

@iankhou iankhou commented Oct 20, 2026 •

Copy link
Copy Markdown
Contributor

Third of three stacked PRs. Review the earlier ones first; this PR's diff only contains the action itself:

  1. fix(toolkit-lib): stricter, opt-in evaluation modes for EvaluateCloudFormationTemplate #2074: evaluator changes (CloudFormation value semantics, unavailableParameters, SharedLookups, error handling)
  2. feat(toolkit-lib): best-effort evaluation of a whole template (internal) #2075: best-effort evaluation of a whole template (conditions, Fn::If, NoEcho, error classification)
  3. This PR: the unstable evaluate action

The description below covers the whole feature; the parts implemented in #2074 and #2075 are described in
more detail there.

Reason for this change

Tools that check deployed resources against their CloudFormation templates (for example, validating that an
emulator or a deployment produced the configuration the template asked for) need the template with its
references, attributes, parameters and pseudo parameters replaced by the values CloudFormation actually used.

toolkit-lib already has this capability in EvaluateCloudFormationTemplate (used by hotswap), but it is not
part of the public API, and its constructor takes the private SDK type, so it can't be exported as-is.

Description of changes

Adds an unstable evaluate action (unstableFeatures: ['evaluate']):

const toolkit = new Toolkit({ unstableFeatures: ['evaluate'] });
const result = await toolkit.evaluate(await toolkit.fromAssemblyDirectory('cdk.out'), {
  stacks: { strategy: StackSelectionStrategy.ALL_STACKS },
});
// result.stacks[i].template   - template with every evaluable expression in Resources and Outputs replaced
// result.stacks[i].unresolved - expressions that could not be evaluated: { path, intrinsic, reason }
// result.stacks[i].nestedStacks - CDK-managed nested stacks, evaluated the same way, by logical id
// result.notDeployed - selected stacks that are not deployed, or whose creation failed or never ran (skipped with a warning)

For each selected stack (up to concurrency stacks at a time, default 10) it:

  • accesses the stack's environment the same way diagnose does (EnvironmentAccess.accessStackForLookupBestEffort),
  • reads the deployed stack's parameters (CloudFormationStack.lookup, so SSM-resolved values are used) and its
    nested stack templates (loadCurrentTemplateWithNestedStacks, as hotswap does),
  • evaluates Resources and Outputs with EvaluateCloudFormationTemplate, one expression at a time, and on top
    of it the template's Conditions (Fn::Equals, Fn::And, Fn::Or, Fn::Not, Condition), Fn::If and
    AWS::NoValue; resources and outputs whose condition is false are dropped, as CloudFormation does,
  • looks up the distinct Fn::GetAtt expressions (and ${Resource.Attribute} placeholders in Fn::Sub) up
    front, only those in resources, outputs and Fn::If branches CloudFormation creates, and evaluates each distinct expression once per stack. Resource lookups go
    through one shared limit for the whole run (10 in flight, across stacks and nested stacks), and each Cloud
    Control resource is read once,
  • evaluates its CDK-managed nested stacks recursively, with the parameter values the parent passes as evaluated
    in the parent; a value the parent could not evaluate is reported in the nested stack wherever it is used.
    When the parent uses a nested stack's output, it gets the value CloudFormation reports for the deployed nested
    stack (found by its physical id, so this also works for nested stacks CDK doesn't manage),
  • warns when the deployed template has different resources (logical ids and types) than the one being evaluated.
    The comparison is structural, since GetTemplate doesn't always return the template's characters exactly.

Expressions that can't be evaluated are left in place and reported with the reason; when an expression fails
because one of its arguments failed, only the innermost failure is reported, and a function the evaluator doesn't
support is reported itself (the evaluator now throws CfnUnsupportedFunctionException for these). Expressions that reference NoEcho parameters are reported rather than evaluated,
since DescribeStacks only returns **** for their values; they never fall back to the parameter's Default,
including where the evaluator uses them internally (Cloud Control identifiers, nested stack parameters). A
parameter is treated as NoEcho (case-insensitively) if either the cloud assembly's template or the deployed
template says so. Dynamic references ({{resolve:ssm:...}}, {{resolve:secretsmanager:...}}) are reported as
unresolved, since CloudFormation resolves them at deployment (and secrets must not be read).

Only evaluation failures are reported this way: any other error (expired credentials, throttling, permissions,
unexpected exceptions) fails the action. Fn::Equals compares values the way CloudFormation does (scalars as
strings, so a deployed '10' equals 10). One unresolvable expression
therefore doesn't prevent the rest of the template from being evaluated. Selected stacks that aren't deployed
(including REVIEW_IN_PROGRESS, ROLLBACK_IN_PROGRESS, ROLLBACK_COMPLETE, CREATE_FAILED and deleted stacks) are skipped with a warning and
listed in notDeployed.

AWS::StackName and AWS::StackId are passed to the evaluator alongside the stack parameters, since it doesn't
provide them itself.

Decisions worth reviewing

  • Public API. The new public surface is the evaluate method, 'evaluate' in UnstableFeature and
    ToolkitAction, and the EvaluateOptions, EvaluateResult, EvaluatedStack and UnresolvedExpression
    types. A nested stack's hierarchicalId is its parent's followed by its logical id, since nested stacks aren't
    cloud assembly artifacts. EvaluateCloudFormationTemplate itself stays internal; the action exposes its result instead of its
    constructor (which would require exporting SDK). Gated as unstable so the shape can still change.

  • Which errors are reported, and which are thrown. Evaluation failures are reported per expression, so the
    rest of the template is still evaluated. Everything else is thrown. For Fn::GetAtt values looked up through
    Cloud Control, the evaluator now keeps the original error as cause; only ResourceNotFoundException,
    TypeNotFoundException, UnsupportedActionException, InvalidRequestException and ValidationException
    causes count as "can't be evaluated" (these are the not-found / unsupported cases seen against real stacks),
    as do Cloud Control handler failures (HandlerFailureException, HandlerInternalFailureException,
    GeneralServiceException) and AccessDeniedException: one resource that can't be read (for example with
    least-privilege credentials) doesn't stop the rest. Credentials that can't read the stack fail on
    DescribeStacks. Expired credentials, throttling and network errors still fail the action.

  • Shared evaluator changes (in fix(toolkit-lib): stricter, opt-in evaluation modes for EvaluateCloudFormationTemplate #2074). EvaluateCloudFormationTemplate now evaluates values the way
    CloudFormation does (an empty string is a value, and only a missing or null value is missing; Fn::Select out
    of range is an evaluation failure; Fn::Sub treats ${!Literal} as literal text), which also fixes those cases
    for hotswap. It gets two opt-in props that only this action sets:

    • unavailableParameters: a Ref to one fails instead of falling back to its Default.
    • lookups (SharedLookups): Cloud Control reads are cached per resource and limited across evaluators, and
      nested stack outputs come from DescribeStacks of the nested stack.

    The walker also reports an expression that evaluates to no value, and a function whose whole argument is
    AWS::NoValue, instead of dropping the property or leaking the placeholder. Other changes, visible to hotswap only in error
    messages: unsupported functions throw CfnUnsupportedFunctionException (a CfnEvaluationException, same
    message), failed Cloud Control lookups keep their cause, and a Cloud Control identifier that can't be evaluated
    throws its own evaluation error rather than being reported as a failed lookup (any other error while building
    the identifier is still wrapped in a CfnEvaluationException, so hotswap still treats it as non-hotswappable).
    Fn::GetAtt of an output of a nested stack whose template has no Outputs is now an evaluation failure
    instead of a TypeError, which also fixes that case for hotswap. Hotswap's handling of empty-string
    parameters (it uses the default) is unchanged here and could be fixed separately.

  • Scope. Conditions, Fn::If and AWS::NoValue are evaluated in the action's own walker, not in
    EvaluateCloudFormationTemplate.

  • Template evaluated. The action evaluates the cloud assembly's template (the one that was deployed from it),
    and warns on structural differences from the deployed template, rather than evaluating GetTemplate's output.

  • Not changed. loadCurrentTemplateWithNestedStacks (shared with hotswap and diff) is used as is, so the root
    stack is described twice and listed twice per evaluation, outside the shared lookup limit.

  • Known limitation: Fn::If in a Cloud Control identifier. Conditions are evaluated by the action's walker,
    not by EvaluateCloudFormationTemplate, so a resource whose Cloud Control identifier comes from a conditional
    property is reported as unresolved. Supporting it would mean moving condition evaluation into the shared
    evaluator.

  • Throttling still fails the action (after the SDK's retries); the shared lookup limit is what keeps it rare.

  • Known limitations (existing evaluator behaviour, left for follow-ups): Fn::GetAZs, Fn::FindInMap,
    Fn::Base64 and Fn::Cidr are not supported; Fn::GetAtt on AWS::EC2::EIP builds the wrong Cloud Control
    identifier (the public IP is used as the allocation ID); attributes of custom resources can't be looked up;
    with a custom endpoint (AWS_ENDPOINT_URL), AWS::URLSuffix is derived from the endpoint host.

  • CLI. No CLI command is added. cdk-toolkit.ts lists 'evaluate' in its exhaustive
    Record<UnstableFeature, true>, as for the other unstable features.

Description of how you validated changes

  • Unit tests for the best-effort evaluation (resolved, unresolvable, innermost-failure reporting, untouched
    sections) and for the action (feature gate, Ref / Fn::GetAtt formatted and looked up through Cloud
    Control / AWS::StackName / Fn::Join, Cloud Control failure reported as unresolved, nested stacks and
    unevaluable nested stack parameters, undeployed / failed / never-executed stacks skipped, template drift
    warning, concurrency validation, bounded and reachable-only lookups, handler failures and wrapped causes), and for
    the shared evaluator (CloudFormation value semantics, unavailableParameters including the
    Cloud Control identifier path, null attributes),
    using a new stack-with-references fixture generated with gen-fixture (with creation stack traces
    disabled, so no local paths are checked in).
  • Ran the action (built from this branch, through the public API) against three apps deployed to a test
    account: construct-hub-dev (275 resources), a CDK CLI telemetry service (145) and atmosphere-dev (143).
    • It left 2, 1 and 2 expressions unresolved (an AWS::EC2::EIP Fn::GetAtt, a custom-resource attribute,
      Fn::GetAZs). An independent resolver left unresolved values in 126, 66 and 75 resources on the same stacks.
    • It agreed with that resolver on all values both resolved (4,887 values), except two strings where the
      other resolver read the template through GetTemplate, which replaced an em dash with ?.
    • The live configuration of every resource was then read back through the service APIs and compared with the
      action's values: 1,761 properties matched (each also failed when the expected value was altered), including
      every property only the action resolved; the remaining mismatches are known service-side representations
      (and one shared account-level setting that two of the stacks both set).
    • Evaluating each distinct expression once took the largest stack from 80s to 45s with identical results.
    • After the nested stack / concurrency / prefetch changes, the action gave identical templates and unresolved
      expressions on the same three apps deployed to an emulator (these apps have no nested stacks, so nested
      stack evaluation is covered by the unit tests only). After the stricter Fn::Select, construct-hub on the
      emulator reports four more unresolved expressions: the emulator returns a name where CloudFormation returns
      an ECS task definition ARN, and the template splits that ARN. Previously those parts were silently empty.

Checklist

  • This change contains a major version upgrade for a dependency and I confirm all breaking changes are addressed
    • Release notes for the new version:

By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license

@github-actions

Copy link
Copy Markdown
Contributor

Dependency Review

✅ No vulnerabilities or license issues or OpenSSF Scorecard issues found.

Scanned Files

None

@iankhou
iankhou deployed to run-tests October 20, 2026 16:53 — with GitHub Actions Active
@iankhou
iankhou deployed to run-tests October 20, 2026 16:53 — with GitHub Actions Active
@iankhou
iankhou deployed to run-tests October 20, 2026 16:53 — with GitHub Actions Active
@iankhou
iankhou deployed to run-tests October 20, 2026 16:53 — with GitHub Actions Active
@iankhou
iankhou deployed to run-tests October 20, 2026 16:53 — with GitHub Actions Active
@iankhou
iankhou deployed to run-tests October 20, 2026 16:53 — with GitHub Actions Active
@iankhou
iankhou deployed to run-tests October 20, 2026 16:53 — with GitHub Actions Active
@iankhou
iankhou deployed to run-tests October 20, 2026 16:53 — with GitHub Actions Active
@iankhou
iankhou deployed to run-tests October 20, 2026 16:53 — with GitHub Actions Active
@iankhou
iankhou deployed to run-tests October 20, 2026 16:53 — with GitHub Actions Active
@iankhou
iankhou deployed to run-tests October 20, 2026 16:53 — with GitHub Actions Active
@iankhou
iankhou deployed to run-tests October 20, 2026 16:53 — with GitHub Actions Active
@iankhou
iankhou deployed to run-tests October 20, 2026 16:53 — with GitHub Actions Active
@iankhou
iankhou deployed to run-tests October 20, 2026 16:53 — with GitHub Actions Active
@iankhou
iankhou deployed to run-tests October 20, 2026 16:53 — with GitHub Actions Active
@iankhou
iankhou deployed to run-tests October 20, 2026 16:53 — with GitHub Actions Active
@iankhou
iankhou deployed to run-tests October 20, 2026 16:53 — with GitHub Actions Active
@iankhou
iankhou deployed to run-tests October 20, 2026 16:53 — with GitHub Actions Active
@iankhou
iankhou deployed to run-tests October 20, 2026 16:53 — with GitHub Actions Active
@iankhou
iankhou deployed to run-tests October 20, 2026 16:53 — with GitHub Actions Active
@iankhou
iankhou deployed to run-tests October 20, 2026 16:53 — with GitHub Actions Active
@iankhou
iankhou deployed to run-tests October 20, 2026 16:53 — with GitHub Actions Active
@iankhou
iankhou deployed to run-tests October 20, 2026 16:53 — with GitHub Actions Active
@iankhou
iankhou deployed to run-tests October 20, 2026 16:53 — with GitHub Actions Active
@iankhou
iankhou deployed to run-tests October 20, 2026 16:53 — with GitHub Actions Active
@iankhou
iankhou deployed to run-tests October 20, 2026 17:39 — with GitHub Actions Active
@iankhou
iankhou deployed to run-tests October 20, 2026 17:39 — with GitHub Actions Active
@iankhou
iankhou deployed to run-tests October 20, 2026 17:39 — with GitHub Actions Active
@iankhou
iankhou deployed to run-tests October 20, 2026 17:39 — with GitHub Actions Active
@iankhou
iankhou deployed to run-tests October 20, 2026 17:39 — with GitHub Actions Active
@iankhou
iankhou deployed to run-tests October 20, 2026 17:39 — with GitHub Actions Active
@iankhou
iankhou deployed to run-tests October 20, 2026 17:39 — with GitHub Actions Active
@iankhou
iankhou deployed to run-tests October 20, 2026 17:39 — with GitHub Actions Active
@iankhou
iankhou deployed to run-tests October 20, 2026 17:39 — with GitHub Actions Active
@iankhou
iankhou deployed to run-tests October 20, 2026 17:39 — with GitHub Actions Active
@iankhou
iankhou deployed to run-tests October 20, 2026 17:39 — with GitHub Actions Active
@iankhou
iankhou deployed to run-tests October 20, 2026 17:39 — with GitHub Actions Active
@iankhou
iankhou deployed to run-tests October 20, 2026 17:39 — with GitHub Actions Active
@iankhou
iankhou deployed to run-tests October 20, 2026 17:39 — with GitHub Actions Active
@iankhou
iankhou deployed to run-tests October 20, 2026 17:39 — with GitHub Actions Active
@iankhou
iankhou deployed to run-tests October 20, 2026 17:39 — with GitHub Actions Active
@iankhou
iankhou deployed to run-tests October 20, 2026 17:39 — with GitHub Actions Active
@iankhou
iankhou deployed to no-approval October 20, 2026 19:12 — with GitHub Actions Active
@iankhou
iankhou deployed to run-tests October 20, 2026 19:16 — with GitHub Actions Active
@iankhou
iankhou deployed to run-tests October 20, 2026 19:16 — with GitHub Actions Active
@iankhou
iankhou deployed to run-tests October 20, 2026 19:16 — with GitHub Actions Active
@iankhou
iankhou deployed to run-tests October 20, 2026 19:16 — with GitHub Actions Active
@iankhou
iankhou deployed to run-tests October 20, 2026 19:16 — with GitHub Actions Active
@iankhou
iankhou deployed to run-tests October 20, 2026 19:16 — with GitHub Actions Active
…late against its deployed stack

toolkit.evaluate(cx, { stacks }) (unstable feature 'evaluate') returns each selected stack's template
with every expression in Resources and Outputs that can be evaluated against the deployed stack
replaced by its value, and the expressions that could not be evaluated with the reason.

- reads the deployed stack's parameters, physical ids and nested stack templates
- evaluates CDK-managed nested stacks recursively, with the parameter values their parent passes
- NoEcho parameters (in the evaluated or deployed template) are reported, never replaced by a default
- skips stacks that are not deployed, or whose creation failed or never ran, and lists them in notDeployed
- warns when the deployed template has different resources than the evaluated one
- one shared limit on resource lookups for the whole run
@github-actions

Copy link
Copy Markdown
Contributor

Total lines changed 1720 is greater than 1000. Please consider breaking this PR down.

This branch had an error being deployed

1 failed (outdated) and 2 active deployments
run-tests — cf143e53 Deployed Oct 21, 2026 by iankhou via integ_init-templates (init-fsharp, 24.19) #7381
no-approval — cf143e53 Deployed Oct 21, 2026 by iankhou via prepare #7381
automation — 7e00d371 Deployed Oct 20, 2026 by iankhou via Set AutoQueue on PR #2066 #3785
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.

2 participants