diff --git a/packages/@aws-cdk/toolkit-lib/lib/actions/evaluate/index.ts b/packages/@aws-cdk/toolkit-lib/lib/actions/evaluate/index.ts new file mode 100644 index 000000000..7c06e9c91 --- /dev/null +++ b/packages/@aws-cdk/toolkit-lib/lib/actions/evaluate/index.ts @@ -0,0 +1,115 @@ +import type { StackSelector } from '../../api/cloud-assembly'; + +export interface EvaluateOptions { + /** + * Select the stacks to evaluate + * + * @default - All stacks + */ + readonly stacks?: StackSelector; + + /** + * Name of the toolkit stack, used to access the stacks' environments + * + * @default "CDKToolkit" + */ + readonly toolkitStackName?: string; + + /** + * Maximum number of stacks to evaluate in parallel + * + * @default 10 + */ + readonly concurrency?: number; + + /** + * Cancel the synth operation + * + * @default - No ability to abort + */ + readonly abort?: AbortSignal; +} + +/** + * An expression in a stack template that could not be evaluated against the deployed stack + */ +export interface UnresolvedExpression { + /** + * Location of the expression in the template, e.g. `Resources.MyBucket.Properties.BucketName` + */ + readonly path: string; + + /** + * The intrinsic function that could not be evaluated, e.g. `Fn::GetAtt` + */ + readonly intrinsic: string; + + /** + * Why the expression could not be evaluated + */ + readonly reason: string; +} + +/** + * A stack template evaluated against the deployed stack + */ +export interface EvaluatedStack { + /** + * The name of the stack + */ + readonly stackName: string; + + /** + * The hierarchical id of the stack in the cloud assembly + * + * For a nested stack, the hierarchical id of its parent followed by the nested stack's logical id. + */ + readonly hierarchicalId: string; + + /** + * The deployed stack's id (its ARN) + */ + readonly stackId: string; + + /** + * The physical id of each deployed resource in the stack, by logical id + */ + readonly physicalResourceIds: { [logicalId: string]: string }; + + /** + * The stack's template, with every expression in `Resources` and `Outputs` that could be + * evaluated against the deployed stack replaced by its value + * + * Expressions that could not be evaluated are left in place, and listed in `unresolved`. + */ + readonly template: { [key: string]: any }; + + /** + * Expressions that could not be evaluated + */ + readonly unresolved: UnresolvedExpression[]; + + /** + * The stack's deployed CDK-managed nested stacks, evaluated the same way, by logical id + * + * A nested stack gets the parameter values its parent passes, as evaluated in the parent. + * + * @default - the stack has no nested stacks + */ + readonly nestedStacks?: { [logicalId: string]: EvaluatedStack }; +} + +/** + * Result of the evaluate action + */ +export interface EvaluateResult { + /** + * The evaluated stacks + */ + readonly stacks: EvaluatedStack[]; + + /** + * Names of the selected stacks that are not deployed (including stacks whose creation failed or never ran), and so were not evaluated + */ + readonly notDeployed: string[]; +} diff --git a/packages/@aws-cdk/toolkit-lib/lib/actions/evaluate/private/evaluate-stack.ts b/packages/@aws-cdk/toolkit-lib/lib/actions/evaluate/private/evaluate-stack.ts new file mode 100644 index 000000000..d8b8ea131 --- /dev/null +++ b/packages/@aws-cdk/toolkit-lib/lib/actions/evaluate/private/evaluate-stack.ts @@ -0,0 +1,147 @@ +import type { CloudFormationStackArtifact } from '@aws-cdk/cloud-assembly-api'; +import type { SDK } from '../../../api/aws-auth/private'; +import type { ListStackResources, NestedStackTemplates, SharedLookups, Template } from '../../../api/cloudformation'; +import { EvaluateCloudFormationTemplate, evaluateTemplateBestEffort, containsExpression, loadCurrentTemplateWithNestedStacks } from '../../../api/cloudformation'; +import { parallelPromises } from '../../../util'; +import type { EvaluatedStack } from '../index'; + +/** Nested stacks of one stack to evaluate at the same time */ +const NESTED_STACK_CONCURRENCY = 4; + +export interface EvaluateStackProps { + readonly stackArtifact: CloudFormationStackArtifact; + /** The deployed stack's parameter values, as DescribeStacks returns them (NoEcho values are masked) */ + readonly parameters: { [name: string]: string }; + /** Lookups shared by all evaluators of the run */ + readonly lookups: SharedLookups; + readonly stackId: string; + readonly account: string; + readonly region: string; + readonly partition: string; + readonly sdk: SDK; + /** Receives warnings, e.g. when the deployed template differs from the one being evaluated */ + readonly warn: (message: string) => Promise; +} + +/** + * Evaluates a deployed stack's template and, recursively, the templates of its CDK-managed nested stacks. + * + * A nested stack is evaluated with the parameter values its parent passes, as evaluated in the parent; + * values the parent could not evaluate are unavailable in the nested stack. + */ +export async function evaluateStackBestEffort(props: EvaluateStackProps): Promise { + const { stackArtifact, sdk } = props; + const currentTemplate = await loadCurrentTemplateWithNestedStacks(stackArtifact, sdk); + const common = { + stackArtifact, + account: props.account, + region: props.region, + partition: props.partition, + sdk, + // shared by all the stacks' evaluators, so each stack is listed at most once + stackResourcesByStackName: new Map(), + lookups: props.lookups, + }; + + async function evaluateStack( + stackName: string, + hierarchicalId: string, + template: Template, + parameters: { [name: string]: string }, + unavailableParameters: { [name: string]: string }, + stackId: string, + nestedStacks: { [logicalId: string]: NestedStackTemplates }, + ): Promise { + const evaluator = new EvaluateCloudFormationTemplate({ + ...common, + stackName, + template, + // pseudo parameters the evaluator does not provide itself + parameters: { ...parameters, 'AWS::StackName': stackName, 'AWS::StackId': stackId }, + nestedStacks, + // unavailable parameters never fall back to their Default + unavailableParameters: Object.keys(unavailableParameters), + }); + const evaluation = await evaluateTemplateBestEffort(evaluator, template, { unavailableParameters }); + + const evaluatedNestedStacks: { [logicalId: string]: EvaluatedStack } = {}; + const evaluateNested = async (logicalId: string, nested: NestedStackTemplates) => { + const resource = evaluation.template.Resources?.[logicalId]; + if (!nested.physicalName || !resource) { + return; // not deployed, or not created because of its condition + } + await warnOnDrift(nested.physicalName, nested.generatedTemplate, nested.deployedTemplate); + const nestedParameters: { [name: string]: string } = {}; + const nestedUnavailable: { [name: string]: string } = {}; + for (const [name, value] of Object.entries(resource.Properties?.Parameters ?? {})) { + if (containsExpression(value)) { + nestedUnavailable[name] = `the value passed by stack '${stackName}' could not be evaluated`; + } else { + nestedParameters[name] = Array.isArray(value) ? value.join(',') : String(value); + } + } + const nestedStackId = await evaluator.evaluateCfnExpression({ Ref: logicalId }); + evaluatedNestedStacks[logicalId] = await evaluateStack( + nested.physicalName, + `${hierarchicalId}/${logicalId}`, + nested.generatedTemplate, + nestedParameters, + nestedUnavailable, + nestedStackId, + nested.nestedStackTemplates, + ); + }; + await parallelPromises(NESTED_STACK_CONCURRENCY, + Object.entries(nestedStacks).map(([logicalId, nested]) => () => evaluateNested(logicalId, nested))); + + // in template order, whichever finished first + const ordered = Object.fromEntries(Object.keys(nestedStacks) + .filter((id) => id in evaluatedNestedStacks) + .map((id) => [id, evaluatedNestedStacks[id]])); + // the evaluator has listed the stack's resources already (or does so now, once) + const resources = await common.stackResourcesByStackName.get(stackName)?.listStackResources() ?? []; + return { + stackName, + hierarchicalId, + stackId, + physicalResourceIds: Object.fromEntries(resources + .filter((r) => r.LogicalResourceId && r.PhysicalResourceId) + .map((r) => [r.LogicalResourceId!, r.PhysicalResourceId!])), + template: evaluation.template, + unresolved: evaluation.unresolved, + ...(Object.keys(ordered).length > 0 ? { nestedStacks: ordered } : {}), + }; + } + + // The template being evaluated should be the one that is deployed. Compare structure rather than text: + // GetTemplate doesn't always return the template's characters exactly (e.g. non-ASCII punctuation). + async function warnOnDrift(stackName: string, evaluated: Template, deployed: Template) { + const types = (t: Template) => Object.entries(t.Resources ?? {}).map(([id, r]) => `${id} (${r?.Type})`); + const deployedTypes = new Set(types(deployed)); + const evaluatedTypes = new Set(types(evaluated)); + const onlyEvaluated = [...evaluatedTypes].filter((x) => !deployedTypes.has(x)); + const onlyDeployed = [...deployedTypes].filter((x) => !evaluatedTypes.has(x)); + if (onlyEvaluated.length || onlyDeployed.length) { + await props.warn(`The template of stack '${stackName}' differs from its deployed template, so evaluated values may not match ` + + `the deployment. Not deployed: ${onlyEvaluated.join(', ') || 'none'}. Only in the deployed template: ${onlyDeployed.join(', ') || 'none'}`); + } + } + await warnOnDrift(stackArtifact.stackName, stackArtifact.template, currentTemplate.deployedRootTemplate); + + // NoEcho values come back masked; expressions that use them are reported as unresolved instead. A parameter + // counts as NoEcho if either the evaluated or the deployed template says so. + const noEcho = new Set([stackArtifact.template, currentTemplate.deployedRootTemplate] + .flatMap((t) => Object.entries(t.Parameters ?? {}).filter(([, p]) => String(p?.NoEcho).toLowerCase() === 'true').map(([name]) => name))); + const unavailableParameters = Object.fromEntries([...noEcho].map((name) => [name, 'NoEcho parameter, whose deployed value can\'t be read'])); + const parameters = Object.fromEntries(Object.entries(props.parameters).filter(([name]) => !noEcho.has(name))); + + return evaluateStack( + stackArtifact.stackName, + stackArtifact.hierarchicalId, + stackArtifact.template, + parameters, + unavailableParameters, + props.stackId, + currentTemplate.nestedStacks, + ); +} diff --git a/packages/@aws-cdk/toolkit-lib/lib/actions/evaluate/private/index.ts b/packages/@aws-cdk/toolkit-lib/lib/actions/evaluate/private/index.ts new file mode 100644 index 000000000..a3a3966ba --- /dev/null +++ b/packages/@aws-cdk/toolkit-lib/lib/actions/evaluate/private/index.ts @@ -0,0 +1 @@ +export * from './evaluate-stack'; diff --git a/packages/@aws-cdk/toolkit-lib/lib/actions/index.ts b/packages/@aws-cdk/toolkit-lib/lib/actions/index.ts index ee126522f..e849ce735 100644 --- a/packages/@aws-cdk/toolkit-lib/lib/actions/index.ts +++ b/packages/@aws-cdk/toolkit-lib/lib/actions/index.ts @@ -12,3 +12,4 @@ export * from './synth'; export * from './watch'; export * from './diagnose'; export * from './validate'; +export * from './evaluate'; diff --git a/packages/@aws-cdk/toolkit-lib/lib/api/cloudformation/evaluate-template-best-effort.ts b/packages/@aws-cdk/toolkit-lib/lib/api/cloudformation/evaluate-template-best-effort.ts index 6c73410ae..444741b1e 100644 --- a/packages/@aws-cdk/toolkit-lib/lib/api/cloudformation/evaluate-template-best-effort.ts +++ b/packages/@aws-cdk/toolkit-lib/lib/api/cloudformation/evaluate-template-best-effort.ts @@ -1,28 +1,9 @@ import type { EvaluateCloudFormationTemplate } from './evaluate-cloudformation-template'; import { CfnEvaluationException, CfnUnsupportedFunctionException, parseIntrinsic } from './evaluate-cloudformation-template'; import type { Template } from './stack-helpers'; +import type { UnresolvedExpression } from '../../actions/evaluate'; import { formatErrorMessage, parallelPromises } from '../../util'; -/** - * An expression in a template that could not be evaluated - */ -export interface UnresolvedExpression { - /** - * Location of the expression in the template, e.g. `Resources.MyBucket.Properties.BucketName` - */ - readonly path: string; - - /** - * The intrinsic function that could not be evaluated, e.g. `Fn::GetAtt` - */ - readonly intrinsic: string; - - /** - * Why the expression could not be evaluated - */ - readonly reason: string; -} - /** * A template with as many expressions as possible replaced by their values */ diff --git a/packages/@aws-cdk/toolkit-lib/lib/api/io/toolkit-action.ts b/packages/@aws-cdk/toolkit-lib/lib/api/io/toolkit-action.ts index aa25e06e7..e40568da3 100644 --- a/packages/@aws-cdk/toolkit-lib/lib/api/io/toolkit-action.ts +++ b/packages/@aws-cdk/toolkit-lib/lib/api/io/toolkit-action.ts @@ -23,4 +23,5 @@ export type ToolkitAction = | 'diagnose' | 'orphan' | 'flags' -| 'validate'; +| 'validate' +| 'evaluate'; diff --git a/packages/@aws-cdk/toolkit-lib/lib/toolkit/toolkit.ts b/packages/@aws-cdk/toolkit-lib/lib/toolkit/toolkit.ts index 8cb119854..d47d2e878 100644 --- a/packages/@aws-cdk/toolkit-lib/lib/toolkit/toolkit.ts +++ b/packages/@aws-cdk/toolkit-lib/lib/toolkit/toolkit.ts @@ -52,6 +52,8 @@ import type { DiagnosedStack, DiagnoseOptions, DiagnoseResult } from '../actions import type { DiffOptions } from '../actions/diff'; import { appendObject, prepareDiff } from '../actions/diff/private'; import type { DriftOptions, DriftResult } from '../actions/drift'; +import type { EvaluatedStack, EvaluateOptions, EvaluateResult } from '../actions/evaluate'; +import { evaluateStackBestEffort } from '../actions/evaluate/private'; import { type ListOptions } from '../actions/list'; import type { OrphanOptions } from '../actions/orphan'; import type { PublishAssetsOptions, PublishAssetsResult } from '../actions/publish-assets'; @@ -79,6 +81,7 @@ import { ALL_STACKS } from '../api/cloud-assembly/private'; import { AsyncDisposableBox } from '../api/cloud-assembly/private/disposable-box'; import { CloudAssemblySourceBuilder } from '../api/cloud-assembly/source-builder'; import { combineConclusions, type StackCollection } from '../api/cloud-assembly/stack-collection'; +import { CloudFormationStack as DeployedStack, SharedLookups } from '../api/cloudformation'; import { Deployments } from '../api/deployments'; import { createValidationChangeSet } from '../api/deployments/cfn-api'; import { hostMessageFromDiagnosis } from '../api/diagnosing/diagnosis-formatting'; @@ -112,7 +115,7 @@ import type { AssetBuildNode, AssetPublishNode, Concurrency, StackNode } from '. import { WorkGraph, WorkGraphBuilder, buildDestroyWorkGraph } from '../api/work-graph'; import type { AssemblyData, RefactorResult, StackDetails, SuccessfulDeployStackResult } from '../payloads'; import { PermissionChangeType } from '../payloads'; -import { formatErrorMessage, formatExpressStabilizationWarning, formatTime, obscureTemplate, serializeStructure, toCloudFormationRollbackConfiguration, validateSnsTopicArn } from '../util'; +import { formatErrorMessage, formatExpressStabilizationWarning, formatTime, obscureTemplate, parallelPromises, serializeStructure, toCloudFormationRollbackConfiguration, validateSnsTopicArn } from '../util'; import { pLimit } from '../util/concurrency'; import { createIgnoreMatcher } from '../util/glob-matcher'; import { promiseWithResolvers } from '../util/promises'; @@ -182,7 +185,7 @@ export interface ToolkitOptions { * Names of toolkit features that are still under development, and may change in * the future. */ -export type UnstableFeature = 'refactor' | 'orphan' | 'flags' | 'publish-assets' | 'diagnose' | 'validate'; +export type UnstableFeature = 'refactor' | 'orphan' | 'flags' | 'publish-assets' | 'diagnose' | 'validate' | 'evaluate'; /** * The AWS CDK Programmatic Toolkit @@ -680,6 +683,73 @@ export class Toolkit extends CloudAssemblySourceBuilder { return { stacks }; } + /** + * Evaluate Action + * + * Evaluates the templates of the selected stacks against their deployed stacks: references, attributes, + * parameters, pseudo parameters and the supported intrinsic functions are replaced + * by their deployed values. This gives the concrete values CloudFormation used for each resource, for + * example to compare them with the live state of the resources. + * + * Expressions that cannot be evaluated are left in place and reported with the reason, so one unresolvable + * expression does not prevent the rest. CDK-managed nested stacks are evaluated with their parent. + * + * Selected stacks that are not deployed (including stacks whose creation failed or never ran) are skipped with a + * warning, and listed in `notDeployed`. A warning is also given when a stack's deployed template has different + * resources than the one being evaluated. + */ + public async evaluate(cx: ICloudAssemblySource, options: EvaluateOptions = {}): Promise { + this.requireUnstableFeature('evaluate'); + + const concurrency = options.concurrency ?? 10; + if (!Number.isInteger(concurrency) || concurrency < 1) { + throw new ToolkitError('InvalidConcurrency', `concurrency must be a positive integer, got ${concurrency}`); + } + + const ioHelper = asIoHelper(this.ioHost, 'evaluate'); + const selectStacks = stacksOpt(options); + await using assembly = await synthAndMeasure(ioHelper, cx, selectStacks, options.abort); + const stackCollection = await assembly.selectStacks(selectStacks); + const envs = new EnvironmentAccess(await this.sdkProvider('evaluate'), options.toolkitStackName ?? DEFAULT_TOOLKIT_STACK_NAME, ioHelper); + const artifacts = stackCollection.stackArtifacts; + // one limit on resource lookups for the whole run, however many stacks and nested stacks run in parallel + const lookups = new SharedLookups(10); + const results: Array = new Array(artifacts.length); + const evaluateStack = async (index: number) => { + const stack = artifacts[index]; + const stackEnv = await envs.accessStackForLookupBestEffort(stack); + const sdk = stackEnv.sdk; + + const deployed = await DeployedStack.lookup(sdk.cloudFormation(), stack.stackName); + const status = deployed.stackStatus; + // a stack that exists but whose resources were never (or are no longer) created + if (status.isNotFound || status.isReviewInProgress || status.isCreationFailure || status.isDeleted + || ['CREATE_IN_PROGRESS', 'CREATE_FAILED', 'ROLLBACK_IN_PROGRESS'].includes(status.name)) { + const state = status.isNotFound ? 'is not deployed' : `is ${status.name}`; + await ioHelper.defaults.warn(`Stack '${stack.stackName}' ${state} in ${stackEnv.resolvedEnvironment.name}, skipping it`); + return; + } + results[index] = await evaluateStackBestEffort({ + stackArtifact: stack, + parameters: deployed.parameters, + lookups, + stackId: deployed.stackId, + account: stackEnv.resolvedEnvironment.account, + region: stackEnv.resolvedEnvironment.region, + partition: (await sdk.currentAccount()).partition, + sdk, + warn: (message) => ioHelper.defaults.warn(message), + }); + }; + + await parallelPromises(concurrency, artifacts.map((_, index) => () => evaluateStack(index))); + + return { + stacks: results.filter((r): r is EvaluatedStack => r !== undefined), + notDeployed: artifacts.filter((_, i) => results[i] === undefined).map((a) => a.stackName), + }; + } + /** * Validate Action * diff --git a/packages/@aws-cdk/toolkit-lib/test/_fixtures/stack-with-references/cdk.out/Stack1.assets.json b/packages/@aws-cdk/toolkit-lib/test/_fixtures/stack-with-references/cdk.out/Stack1.assets.json new file mode 100644 index 000000000..269d256d6 --- /dev/null +++ b/packages/@aws-cdk/toolkit-lib/test/_fixtures/stack-with-references/cdk.out/Stack1.assets.json @@ -0,0 +1,36 @@ +{ + "version": "54.0.0", + "files": { + "79bf57b1b3bbe8f179f2b75327d6f95f059a31631484c17e6ee3aaceea8a0264": { + "displayName": "Nested Nested Stack Template", + "source": { + "path": "Stack1Nested36ADE314.nested.template.json", + "packaging": "file" + }, + "destinations": { + "123456789012-us-east-1-1498841e": { + "bucketName": "cdk-hnb659fds-assets-123456789012-us-east-1", + "objectKey": "79bf57b1b3bbe8f179f2b75327d6f95f059a31631484c17e6ee3aaceea8a0264.json", + "region": "us-east-1", + "assumeRoleArn": "arn:${AWS::Partition}:iam::123456789012:role/cdk-hnb659fds-file-publishing-role-123456789012-us-east-1" + } + } + }, + "91b722a85f337ba12178b933113aa50e118a4bb71559f94ab1a70ff2f01aef35": { + "displayName": "Stack1 Template", + "source": { + "path": "Stack1.template.json", + "packaging": "file" + }, + "destinations": { + "123456789012-us-east-1-b947283a": { + "bucketName": "cdk-hnb659fds-assets-123456789012-us-east-1", + "objectKey": "91b722a85f337ba12178b933113aa50e118a4bb71559f94ab1a70ff2f01aef35.json", + "region": "us-east-1", + "assumeRoleArn": "arn:${AWS::Partition}:iam::123456789012:role/cdk-hnb659fds-file-publishing-role-123456789012-us-east-1" + } + } + } + }, + "dockerImages": {} +} \ No newline at end of file diff --git a/packages/@aws-cdk/toolkit-lib/test/_fixtures/stack-with-references/cdk.out/Stack1.metadata.json b/packages/@aws-cdk/toolkit-lib/test/_fixtures/stack-with-references/cdk.out/Stack1.metadata.json new file mode 100644 index 000000000..4b3a2071b --- /dev/null +++ b/packages/@aws-cdk/toolkit-lib/test/_fixtures/stack-with-references/cdk.out/Stack1.metadata.json @@ -0,0 +1,92 @@ +{ + "/Stack1/BucketArn": [ + { + "type": "aws:cdk:logicalId", + "data": "BucketArn" + } + ], + "/Stack1/BucketDomain": [ + { + "type": "aws:cdk:logicalId", + "data": "BucketDomain" + } + ], + "/Stack1/Name": [ + { + "type": "aws:cdk:logicalId", + "data": "Name" + } + ], + "/Stack1/Joined": [ + { + "type": "aws:cdk:logicalId", + "data": "Joined" + } + ], + "/Stack1/Secret": [ + { + "type": "aws:cdk:logicalId", + "data": "Secret" + } + ], + "/Stack1/SecretValue": [ + { + "type": "aws:cdk:logicalId", + "data": "SecretValue" + } + ], + "/Stack1/BootstrapVersion": [ + { + "type": "aws:cdk:logicalId", + "data": "BootstrapVersion" + } + ], + "/Stack1/CheckBootstrapVersion": [ + { + "type": "aws:cdk:logicalId", + "data": "CheckBootstrapVersion" + } + ], + "/Stack1/MyBucket/Resource": [ + { + "type": "aws:cdk:logicalId", + "data": "MyBucketF68F3FF0" + } + ], + "/Stack1/BucketName/Resource": [ + { + "type": "aws:cdk:logicalId", + "data": "BucketName742CD8B0" + } + ], + "/Stack1/Nested/reference-to-Stack1MyBucket3E4D6F6CRef": [ + { + "type": "aws:cdk:logicalId", + "data": "referencetoStack1MyBucket3E4D6F6CRef" + } + ], + "/Stack1/Nested.NestedStack/Nested.NestedStackResource": [ + { + "type": "aws:cdk:logicalId", + "data": "NestedNestedStackNestedNestedStackResourceDEFDAA4D" + } + ], + "/Stack1/CDKMetadata/Default": [ + { + "type": "aws:cdk:logicalId", + "data": "CDKMetadata" + } + ], + "/Stack1/Nested/NestedBucketName/Resource": [ + { + "type": "aws:cdk:logicalId", + "data": "NestedBucketName7CD077DC" + } + ], + "/Stack1/Nested/CDKMetadata/Default": [ + { + "type": "aws:cdk:logicalId", + "data": "CDKMetadata" + } + ] +} \ No newline at end of file diff --git a/packages/@aws-cdk/toolkit-lib/test/_fixtures/stack-with-references/cdk.out/Stack1.template.json b/packages/@aws-cdk/toolkit-lib/test/_fixtures/stack-with-references/cdk.out/Stack1.template.json new file mode 100644 index 000000000..a2d3b44fc --- /dev/null +++ b/packages/@aws-cdk/toolkit-lib/test/_fixtures/stack-with-references/cdk.out/Stack1.template.json @@ -0,0 +1,143 @@ +{ + "Resources": { + "MyBucketF68F3FF0": { + "Type": "AWS::S3::Bucket", + "UpdateReplacePolicy": "Retain", + "DeletionPolicy": "Retain", + "Metadata": { + "aws:cdk:path": "Stack1/MyBucket/Resource" + } + }, + "BucketName742CD8B0": { + "Type": "AWS::SSM::Parameter", + "Properties": { + "Type": "String", + "Value": { + "Ref": "MyBucketF68F3FF0" + } + }, + "Metadata": { + "aws:cdk:path": "Stack1/BucketName/Resource" + } + }, + "NestedNestedStackNestedNestedStackResourceDEFDAA4D": { + "Type": "AWS::CloudFormation::Stack", + "Properties": { + "Parameters": { + "referencetoStack1MyBucket3E4D6F6CRef": { + "Ref": "MyBucketF68F3FF0" + } + }, + "TemplateURL": { + "Fn::Join": [ + "", + [ + "https://s3.us-east-1.", + { + "Ref": "AWS::URLSuffix" + }, + "/cdk-hnb659fds-assets-123456789012-us-east-1/79bf57b1b3bbe8f179f2b75327d6f95f059a31631484c17e6ee3aaceea8a0264.json" + ] + ] + } + }, + "UpdateReplacePolicy": "Delete", + "DeletionPolicy": "Delete", + "Metadata": { + "aws:cdk:path": "Stack1/Nested.NestedStack/Nested.NestedStackResource", + "aws:asset:path": "Stack1Nested36ADE314.nested.template.json", + "aws:asset:property": "TemplateURL" + } + }, + "CDKMetadata": { + "Type": "AWS::CDK::Metadata", + "Properties": { + "Analytics": "v2:deflate64:H4sIAAAAAAAA/1WMQQ7CIBBFz9I9jIQ2ca8H0OgBzEjRUAQbZrALwt0bbGLi6v+X9/M16H0PqsOFpBm9fLk7lCuj8QIXuhXqoRyy8ZbF8RG3VgVRaKvk4vOMCYNlm5r/Qa0NT5nnzH+iwfe+ivgeLUy0++gBtALVTeScTDmyCxYuW64tJoN+oAAAAA==" + }, + "Metadata": { + "aws:cdk:path": "Stack1/CDKMetadata/Default" + } + } + }, + "Outputs": { + "BucketArn": { + "Value": { + "Fn::GetAtt": [ + "MyBucketF68F3FF0", + "Arn" + ] + } + }, + "BucketDomain": { + "Value": { + "Fn::GetAtt": [ + "MyBucketF68F3FF0", + "RegionalDomainName" + ] + } + }, + "Name": { + "Value": { + "Ref": "AWS::StackName" + } + }, + "Joined": { + "Value": { + "Fn::Join": [ + "-", + [ + { + "Ref": "MyBucketF68F3FF0" + }, + { + "Ref": "AWS::Region" + } + ] + ] + } + }, + "SecretValue": { + "Value": { + "Ref": "Secret" + } + } + }, + "Parameters": { + "Secret": { + "Type": "String", + "Default": "template-default" + }, + "BootstrapVersion": { + "Type": "AWS::SSM::Parameter::Value", + "Default": "/cdk-bootstrap/hnb659fds/version", + "Description": "Version of the CDK Bootstrap resources in this environment, automatically retrieved from SSM Parameter Store. [cdk:skip]" + } + }, + "Rules": { + "CheckBootstrapVersion": { + "Assertions": [ + { + "Assert": { + "Fn::Not": [ + { + "Fn::Contains": [ + [ + "1", + "2", + "3", + "4", + "5" + ], + { + "Ref": "BootstrapVersion" + } + ] + } + ] + }, + "AssertDescription": "CDK bootstrap stack version 6 required. Please run 'cdk bootstrap' with a recent version of the CDK CLI." + } + ] + } + } +} \ No newline at end of file diff --git a/packages/@aws-cdk/toolkit-lib/test/_fixtures/stack-with-references/cdk.out/Stack1Nested36ADE314.nested.template.json b/packages/@aws-cdk/toolkit-lib/test/_fixtures/stack-with-references/cdk.out/Stack1Nested36ADE314.nested.template.json new file mode 100644 index 000000000..b1f1dd5b5 --- /dev/null +++ b/packages/@aws-cdk/toolkit-lib/test/_fixtures/stack-with-references/cdk.out/Stack1Nested36ADE314.nested.template.json @@ -0,0 +1,30 @@ +{ + "Resources": { + "NestedBucketName7CD077DC": { + "Type": "AWS::SSM::Parameter", + "Properties": { + "Type": "String", + "Value": { + "Ref": "referencetoStack1MyBucket3E4D6F6CRef" + } + }, + "Metadata": { + "aws:cdk:path": "Stack1/Nested/NestedBucketName/Resource" + } + }, + "CDKMetadata": { + "Type": "AWS::CDK::Metadata", + "Properties": { + "Analytics": "v2:deflate64:H4sIAAAAAAAA/z3HQQqDMBAAwLd4T9aQFnyAdyn6ANmaVVZNhOyqB/HvPRQ8DePBVy9wBZ5ih7DYlb9wNSRKoVMcFoOn9CIRrk4zp+mDGSMpZVOP6cl9m7QFglnKw7/BO3DFLMw270k5ErR/f0wpXLduAAAA" + }, + "Metadata": { + "aws:cdk:path": "Stack1/Nested/CDKMetadata/Default" + } + } + }, + "Parameters": { + "referencetoStack1MyBucket3E4D6F6CRef": { + "Type": "String" + } + } +} \ No newline at end of file diff --git a/packages/@aws-cdk/toolkit-lib/test/_fixtures/stack-with-references/cdk.out/manifest.json b/packages/@aws-cdk/toolkit-lib/test/_fixtures/stack-with-references/cdk.out/manifest.json new file mode 100644 index 000000000..eea1e5d64 --- /dev/null +++ b/packages/@aws-cdk/toolkit-lib/test/_fixtures/stack-with-references/cdk.out/manifest.json @@ -0,0 +1,512 @@ +{ + "version": "54.0.0", + "artifacts": { + "Stack1.assets": { + "type": "cdk:asset-manifest", + "properties": { + "file": "Stack1.assets.json", + "requiresBootstrapStackVersion": 6, + "bootstrapStackVersionSsmParameter": "/cdk-bootstrap/hnb659fds/version" + } + }, + "Stack1": { + "type": "aws:cloudformation:stack", + "environment": "aws://123456789012/us-east-1", + "properties": { + "templateFile": "Stack1.template.json", + "terminationProtection": false, + "validateOnSynth": false, + "assumeRoleArn": "arn:${AWS::Partition}:iam::123456789012:role/cdk-hnb659fds-deploy-role-123456789012-us-east-1", + "cloudFormationExecutionRoleArn": "arn:${AWS::Partition}:iam::123456789012:role/cdk-hnb659fds-cfn-exec-role-123456789012-us-east-1", + "stackTemplateAssetObjectUrl": "s3://cdk-hnb659fds-assets-123456789012-us-east-1/91b722a85f337ba12178b933113aa50e118a4bb71559f94ab1a70ff2f01aef35.json", + "requiresBootstrapStackVersion": 6, + "bootstrapStackVersionSsmParameter": "/cdk-bootstrap/hnb659fds/version", + "additionalDependencies": [ + "Stack1.assets" + ], + "lookupRole": { + "arn": "arn:${AWS::Partition}:iam::123456789012:role/cdk-hnb659fds-lookup-role-123456789012-us-east-1", + "requiresBootstrapStackVersion": 8, + "bootstrapStackVersionSsmParameter": "/cdk-bootstrap/hnb659fds/version" + } + }, + "dependencies": [ + "Stack1.assets" + ], + "additionalMetadataFile": "Stack1.metadata.json", + "displayName": "Stack1" + }, + "Tree": { + "type": "cdk:tree", + "properties": { + "file": "tree.json" + } + }, + "aws-cdk-lib/feature-flag-report": { + "type": "cdk:feature-flag-report", + "properties": { + "module": "aws-cdk-lib", + "flags": { + "@aws-cdk/aws-signer:signingProfileNamePassedToCfn": { + "recommendedValue": true, + "explanation": "Pass signingProfileName to CfnSigningProfile" + }, + "@aws-cdk/core:newStyleStackSynthesis": { + "recommendedValue": true, + "explanation": "Switch to new stack synthesis method which enables CI/CD", + "unconfiguredBehavesLike": { + "v2": true + } + }, + "@aws-cdk/core:stackRelativeExports": { + "recommendedValue": true, + "explanation": "Name exports based on the construct paths relative to the stack, rather than the global construct path", + "unconfiguredBehavesLike": { + "v2": true + } + }, + "@aws-cdk/aws-ecs-patterns:secGroupsDisablesImplicitOpenListener": { + "recommendedValue": true, + "explanation": "Disable implicit openListener when custom security groups are provided" + }, + "@aws-cdk/aws-rds:lowercaseDbIdentifier": { + "recommendedValue": true, + "explanation": "Force lowercasing of RDS Cluster names in CDK", + "unconfiguredBehavesLike": { + "v2": true + } + }, + "@aws-cdk/aws-apigateway:usagePlanKeyOrderInsensitiveId": { + "recommendedValue": true, + "explanation": "Allow adding/removing multiple UsagePlanKeys independently", + "unconfiguredBehavesLike": { + "v2": true + } + }, + "@aws-cdk/aws-lambda:recognizeVersionProps": { + "recommendedValue": true, + "explanation": "Enable this feature flag to opt in to the updated logical id calculation for Lambda Version created using the `fn.currentVersion`.", + "unconfiguredBehavesLike": { + "v2": true + } + }, + "@aws-cdk/aws-lambda:recognizeLayerVersion": { + "recommendedValue": true, + "explanation": "Enable this feature flag to opt in to the updated logical id calculation for Lambda Version created using the `fn.currentVersion`." + }, + "@aws-cdk/aws-cloudfront:defaultSecurityPolicyTLSv1.2_2021": { + "recommendedValue": true, + "explanation": "Enable this feature flag to have cloudfront distributions use the security policy TLSv1.2_2021 by default.", + "unconfiguredBehavesLike": { + "v2": true + } + }, + "@aws-cdk/core:checkSecretUsage": { + "recommendedValue": true, + "explanation": "Enable this flag to make it impossible to accidentally use SecretValues in unsafe locations" + }, + "@aws-cdk/core:target-partitions": { + "recommendedValue": [ + "aws", + "aws-cn" + ], + "explanation": "What regions to include in lookup tables of environment agnostic stacks" + }, + "@aws-cdk-containers/ecs-service-extensions:enableDefaultLogDriver": { + "recommendedValue": true, + "explanation": "ECS extensions will automatically add an `awslogs` driver if no logging is specified" + }, + "@aws-cdk/aws-ec2:uniqueImdsv2TemplateName": { + "recommendedValue": true, + "explanation": "Enable this feature flag to have Launch Templates generated by the `InstanceRequireImdsv2Aspect` use unique names." + }, + "@aws-cdk/aws-ecs:arnFormatIncludesClusterName": { + "recommendedValue": true, + "explanation": "ARN format used by ECS. In the new ARN format, the cluster name is part of the resource ID." + }, + "@aws-cdk/aws-iam:minimizePolicies": { + "recommendedValue": true, + "explanation": "Minimize IAM policies by combining Statements" + }, + "@aws-cdk/core:validateSnapshotRemovalPolicy": { + "recommendedValue": true, + "explanation": "Error on snapshot removal policies on resources that do not support it." + }, + "@aws-cdk/aws-codepipeline:crossAccountKeyAliasStackSafeResourceName": { + "recommendedValue": true, + "explanation": "Generate key aliases that include the stack name" + }, + "@aws-cdk/aws-s3:createDefaultLoggingPolicy": { + "recommendedValue": true, + "explanation": "Enable this feature flag to create an S3 bucket policy by default in cases where an AWS service would automatically create the Policy if one does not exist." + }, + "@aws-cdk/aws-sns-subscriptions:restrictSqsDescryption": { + "recommendedValue": true, + "explanation": "Restrict KMS key policy for encrypted Queues a bit more" + }, + "@aws-cdk/aws-apigateway:disableCloudWatchRole": { + "recommendedValue": true, + "explanation": "Make default CloudWatch Role behavior safe for multiple API Gateways in one environment" + }, + "@aws-cdk/core:enablePartitionLiterals": { + "recommendedValue": true, + "explanation": "Make ARNs concrete if AWS partition is known" + }, + "@aws-cdk/aws-events:eventsTargetQueueSameAccount": { + "recommendedValue": true, + "explanation": "Event Rules may only push to encrypted SQS queues in the same account" + }, + "@aws-cdk/aws-ecs:disableExplicitDeploymentControllerForCircuitBreaker": { + "recommendedValue": true, + "explanation": "Avoid setting the \"ECS\" deployment controller when adding a circuit breaker" + }, + "@aws-cdk/aws-iam:importedRoleStackSafeDefaultPolicyName": { + "recommendedValue": true, + "explanation": "Enable this feature to create default policy names for imported roles that depend on the stack the role is in." + }, + "@aws-cdk/aws-s3:serverAccessLogsUseBucketPolicy": { + "recommendedValue": true, + "explanation": "Use S3 Bucket Policy instead of ACLs for Server Access Logging" + }, + "@aws-cdk/aws-route53-patters:useCertificate": { + "recommendedValue": true, + "explanation": "Use the official `Certificate` resource instead of `DnsValidatedCertificate`" + }, + "@aws-cdk/customresources:installLatestAwsSdkDefault": { + "recommendedValue": false, + "explanation": "Whether to install the latest SDK by default in AwsCustomResource" + }, + "@aws-cdk/aws-rds:databaseProxyUniqueResourceName": { + "recommendedValue": true, + "explanation": "Use unique resource name for Database Proxy" + }, + "@aws-cdk/aws-codedeploy:removeAlarmsFromDeploymentGroup": { + "recommendedValue": true, + "explanation": "Remove CloudWatch alarms from deployment group" + }, + "@aws-cdk/aws-apigateway:authorizerChangeDeploymentLogicalId": { + "recommendedValue": true, + "explanation": "Include authorizer configuration in the calculation of the API deployment logical ID." + }, + "@aws-cdk/aws-ec2:launchTemplateDefaultUserData": { + "recommendedValue": true, + "explanation": "Define user data for a launch template by default when a machine image is provided." + }, + "@aws-cdk/aws-secretsmanager:useAttachedSecretResourcePolicyForSecretTargetAttachments": { + "recommendedValue": true, + "explanation": "SecretTargetAttachments uses the ResourcePolicy of the attached Secret." + }, + "@aws-cdk/aws-redshift:columnId": { + "recommendedValue": true, + "explanation": "Whether to use an ID to track Redshift column changes" + }, + "@aws-cdk/aws-stepfunctions-tasks:enableEmrServicePolicyV2": { + "recommendedValue": true, + "explanation": "Enable AmazonEMRServicePolicy_v2 managed policies" + }, + "@aws-cdk/aws-ec2:restrictDefaultSecurityGroup": { + "recommendedValue": true, + "explanation": "Restrict access to the VPC default security group" + }, + "@aws-cdk/aws-apigateway:requestValidatorUniqueId": { + "recommendedValue": true, + "explanation": "Generate a unique id for each RequestValidator added to a method" + }, + "@aws-cdk/aws-kms:aliasNameRef": { + "recommendedValue": true, + "explanation": "KMS Alias name and keyArn will have implicit reference to KMS Key" + }, + "@aws-cdk/aws-kms:applyImportedAliasPermissionsToPrincipal": { + "recommendedValue": true, + "explanation": "Enable grant methods on Aliases imported by name to use kms:ResourceAliases condition" + }, + "@aws-cdk/aws-autoscaling:generateLaunchTemplateInsteadOfLaunchConfig": { + "recommendedValue": true, + "explanation": "Generate a launch template when creating an AutoScalingGroup" + }, + "@aws-cdk/core:includePrefixInUniqueNameGeneration": { + "recommendedValue": true, + "explanation": "Include the stack prefix in the stack name generation process" + }, + "@aws-cdk/aws-efs:denyAnonymousAccess": { + "recommendedValue": true, + "explanation": "EFS denies anonymous clients accesses" + }, + "@aws-cdk/aws-opensearchservice:enableOpensearchMultiAzWithStandby": { + "recommendedValue": true, + "explanation": "Enables support for Multi-AZ with Standby deployment for opensearch domains" + }, + "@aws-cdk/aws-lambda-nodejs:useLatestRuntimeVersion": { + "recommendedValue": true, + "explanation": "Enables aws-lambda-nodejs.Function to use the latest available NodeJs runtime as the default" + }, + "@aws-cdk/aws-efs:mountTargetOrderInsensitiveLogicalId": { + "recommendedValue": true, + "explanation": "When enabled, mount targets will have a stable logicalId that is linked to the associated subnet." + }, + "@aws-cdk/aws-rds:auroraClusterChangeScopeOfInstanceParameterGroupWithEachParameters": { + "recommendedValue": true, + "explanation": "When enabled, a scope of InstanceParameterGroup for AuroraClusterInstance with each parameters will change." + }, + "@aws-cdk/aws-appsync:useArnForSourceApiAssociationIdentifier": { + "recommendedValue": true, + "explanation": "When enabled, will always use the arn for identifiers for CfnSourceApiAssociation in the GraphqlApi construct rather than id." + }, + "@aws-cdk/aws-rds:preventRenderingDeprecatedCredentials": { + "recommendedValue": true, + "explanation": "When enabled, creating an RDS database cluster from a snapshot will only render credentials for snapshot credentials." + }, + "@aws-cdk/aws-codepipeline-actions:useNewDefaultBranchForCodeCommitSource": { + "recommendedValue": true, + "explanation": "When enabled, the CodeCommit source action is using the default branch name 'main'." + }, + "@aws-cdk/aws-cloudwatch-actions:changeLambdaPermissionLogicalIdForLambdaAction": { + "recommendedValue": true, + "explanation": "When enabled, the logical ID of a Lambda permission for a Lambda action includes an alarm ID." + }, + "@aws-cdk/aws-codepipeline:crossAccountKeysDefaultValueToFalse": { + "recommendedValue": true, + "explanation": "Enables Pipeline to set the default value for crossAccountKeys to false." + }, + "@aws-cdk/aws-codepipeline:defaultPipelineTypeToV2": { + "recommendedValue": true, + "explanation": "Enables Pipeline to set the default pipeline type to V2." + }, + "@aws-cdk/aws-kms:reduceCrossAccountRegionPolicyScope": { + "recommendedValue": true, + "explanation": "When enabled, IAM Policy created from KMS key grant will reduce the resource scope to this key only." + }, + "@aws-cdk/pipelines:reduceAssetRoleTrustScope": { + "recommendedValue": true, + "explanation": "Remove the root account principal from PipelineAssetsFileRole trust policy", + "unconfiguredBehavesLike": { + "v2": true + } + }, + "@aws-cdk/aws-eks:nodegroupNameAttribute": { + "recommendedValue": true, + "explanation": "When enabled, nodegroupName attribute of the provisioned EKS NodeGroup will not have the cluster name prefix." + }, + "@aws-cdk/aws-eks:useNativeOidcProvider": { + "recommendedValue": true, + "explanation": "When enabled, EKS V2 clusters will use the native OIDC provider resource AWS::IAM::OIDCProvider instead of creating the OIDCProvider with a custom resource (iam.OpenIDConnectProvider)." + }, + "@aws-cdk/aws-ec2:ebsDefaultGp3Volume": { + "recommendedValue": true, + "explanation": "When enabled, the default volume type of the EBS volume will be GP3" + }, + "@aws-cdk/aws-ecs:removeDefaultDeploymentAlarm": { + "recommendedValue": true, + "explanation": "When enabled, remove default deployment alarm settings" + }, + "@aws-cdk/custom-resources:logApiResponseDataPropertyTrueDefault": { + "recommendedValue": false, + "explanation": "When enabled, the custom resource used for `AwsCustomResource` will configure the `logApiResponseData` property as true by default" + }, + "@aws-cdk/aws-s3:keepNotificationInImportedBucket": { + "recommendedValue": false, + "explanation": "When enabled, Adding notifications to a bucket in the current stack will not remove notification from imported stack." + }, + "@aws-cdk/aws-stepfunctions-tasks:useNewS3UriParametersForBedrockInvokeModelTask": { + "recommendedValue": true, + "explanation": "When enabled, use new props for S3 URI field in task definition of state machine for bedrock invoke model.", + "unconfiguredBehavesLike": { + "v2": true + } + }, + "@aws-cdk/core:explicitStackTags": { + "recommendedValue": true, + "explanation": "When enabled, stack tags need to be assigned explicitly on a Stack." + }, + "@aws-cdk/aws-ecs:reduceEc2FargateCloudWatchPermissions": { + "recommendedValue": true, + "explanation": "When enabled, we will only grant the necessary permissions when users specify cloudwatch log group through logConfiguration" + }, + "@aws-cdk/aws-dynamodb:resourcePolicyPerReplica": { + "recommendedValue": true, + "explanation": "When enabled will allow you to specify a resource policy per replica, and not copy the source table policy to all replicas" + }, + "@aws-cdk/aws-ec2:ec2SumTImeoutEnabled": { + "recommendedValue": true, + "explanation": "When enabled, initOptions.timeout and resourceSignalTimeout values will be summed together." + }, + "@aws-cdk/aws-appsync:appSyncGraphQLAPIScopeLambdaPermission": { + "recommendedValue": true, + "explanation": "When enabled, a Lambda authorizer Permission created when using GraphqlApi will be properly scoped with a SourceArn." + }, + "@aws-cdk/aws-rds:setCorrectValueForDatabaseInstanceReadReplicaInstanceResourceId": { + "recommendedValue": true, + "explanation": "When enabled, the value of property `instanceResourceId` in construct `DatabaseInstanceReadReplica` will be set to the correct value which is `DbiResourceId` instead of currently `DbInstanceArn`" + }, + "@aws-cdk/core:cfnIncludeRejectComplexResourceUpdateCreatePolicyIntrinsics": { + "recommendedValue": true, + "explanation": "When enabled, CFN templates added with `cfn-include` will error if the template contains Resource Update or Create policies with CFN Intrinsics that include non-primitive values." + }, + "@aws-cdk/aws-lambda-nodejs:sdkV3ExcludeSmithyPackages": { + "recommendedValue": true, + "explanation": "When enabled, both `@aws-sdk` and `@smithy` packages will be excluded from the Lambda Node.js 18.x runtime to prevent version mismatches in bundled applications." + }, + "@aws-cdk/aws-stepfunctions-tasks:fixRunEcsTaskPolicy": { + "recommendedValue": true, + "explanation": "When enabled, the resource of IAM Run Ecs policy generated by SFN EcsRunTask will reference the definition, instead of constructing ARN." + }, + "@aws-cdk/aws-ec2:bastionHostUseAmazonLinux2023ByDefault": { + "recommendedValue": true, + "explanation": "When enabled, the BastionHost construct will use the latest Amazon Linux 2023 AMI, instead of Amazon Linux 2." + }, + "@aws-cdk/core:aspectStabilization": { + "recommendedValue": true, + "explanation": "When enabled, a stabilization loop will be run when invoking Aspects during synthesis.", + "unconfiguredBehavesLike": { + "v2": true + } + }, + "@aws-cdk/aws-route53-targets:userPoolDomainNameMethodWithoutCustomResource": { + "recommendedValue": true, + "explanation": "When enabled, use a new method for DNS Name of user pool domain target without creating a custom resource." + }, + "@aws-cdk/aws-elasticloadbalancingV2:albDualstackWithoutPublicIpv4SecurityGroupRulesDefault": { + "recommendedValue": true, + "explanation": "When enabled, the default security group ingress rules will allow IPv6 ingress from anywhere" + }, + "@aws-cdk/aws-iam:oidcRejectUnauthorizedConnections": { + "recommendedValue": true, + "explanation": "When enabled, the default behaviour of OIDC provider will reject unauthorized connections" + }, + "@aws-cdk/core:enableAdditionalMetadataCollection": { + "recommendedValue": true, + "explanation": "When enabled, CDK will expand the scope of usage data collected to better inform CDK development and improve communication for security concerns and emerging issues." + }, + "@aws-cdk/aws-lambda:createNewPoliciesWithAddToRolePolicy": { + "recommendedValue": false, + "explanation": "[Deprecated] When enabled, Lambda will create new inline policies with AddToRolePolicy instead of adding to the Default Policy Statement" + }, + "@aws-cdk/aws-s3:setUniqueReplicationRoleName": { + "recommendedValue": true, + "explanation": "When enabled, CDK will automatically generate a unique role name that is used for s3 object replication." + }, + "@aws-cdk/pipelines:reduceStageRoleTrustScope": { + "recommendedValue": true, + "explanation": "Remove the root account principal from Stage addActions trust policy", + "unconfiguredBehavesLike": { + "v2": true + } + }, + "@aws-cdk/aws-events:requireEventBusPolicySid": { + "recommendedValue": true, + "explanation": "When enabled, grantPutEventsTo() will use resource policies with Statement IDs for service principals." + }, + "@aws-cdk/core:aspectPrioritiesMutating": { + "recommendedValue": true, + "explanation": "When set to true, Aspects added by the construct library on your behalf will be given a priority of MUTATING." + }, + "@aws-cdk/aws-dynamodb:retainTableReplica": { + "recommendedValue": true, + "explanation": "When enabled, table replica will be default to the removal policy of source table unless specified otherwise." + }, + "@aws-cdk/cognito:logUserPoolClientSecretValue": { + "recommendedValue": false, + "explanation": "When disabled, the value of the user pool client secret will not be logged in the custom resource lambda function logs." + }, + "@aws-cdk/pipelines:reduceCrossAccountActionRoleTrustScope": { + "recommendedValue": true, + "explanation": "When enabled, scopes down the trust policy for the cross-account action role", + "unconfiguredBehavesLike": { + "v2": true + } + }, + "@aws-cdk/aws-stepfunctions:useDistributedMapResultWriterV2": { + "recommendedValue": true, + "explanation": "When enabled, the resultWriterV2 property of DistributedMap will be used insted of resultWriter" + }, + "@aws-cdk/s3-notifications:addS3TrustKeyPolicyForSnsSubscriptions": { + "recommendedValue": true, + "explanation": "Add an S3 trust policy to a KMS key resource policy for SNS subscriptions." + }, + "@aws-cdk/aws-ec2:requirePrivateSubnetsForEgressOnlyInternetGateway": { + "recommendedValue": true, + "explanation": "When enabled, the EgressOnlyGateway resource is only created if private subnets are defined in the dual-stack VPC." + }, + "@aws-cdk/aws-ec2-alpha:useResourceIdForVpcV2Migration": { + "recommendedValue": false, + "explanation": "When enabled, use resource IDs for VPC V2 migration" + }, + "@aws-cdk/aws-s3:publicAccessBlockedByDefault": { + "recommendedValue": true, + "explanation": "When enabled, setting any combination of options for BlockPublicAccess will automatically set true for any options not defined." + }, + "@aws-cdk/aws-lambda:useCdkManagedLogGroup": { + "recommendedValue": true, + "explanation": "When enabled, CDK creates and manages loggroup for the lambda function" + }, + "@aws-cdk/aws-elasticloadbalancingv2:networkLoadBalancerWithSecurityGroupByDefault": { + "recommendedValue": true, + "explanation": "When enabled, Network Load Balancer will be created with a security group by default." + }, + "@aws-cdk/aws-stepfunctions-tasks:httpInvokeDynamicJsonPathEndpoint": { + "recommendedValue": true, + "explanation": "When enabled, allows using a dynamic apiEndpoint with JSONPath format in HttpInvoke tasks.", + "unconfiguredBehavesLike": { + "v2": true + } + }, + "@aws-cdk/aws-ecs-patterns:uniqueTargetGroupId": { + "recommendedValue": true, + "explanation": "When enabled, ECS patterns will generate unique target group IDs to prevent conflicts during load balancer replacement" + }, + "@aws-cdk/aws-route53-patterns:useDistribution": { + "recommendedValue": true, + "explanation": "Use the `Distribution` resource instead of `CloudFrontWebDistribution`" + }, + "@aws-cdk/aws-cloudfront:defaultFunctionRuntimeV2_0": { + "recommendedValue": true, + "explanation": "Use cloudfront-js-2.0 as the default runtime for CloudFront Functions" + }, + "@aws-cdk/aws-elasticloadbalancingv2:usePostQuantumTlsPolicy": { + "recommendedValue": true, + "explanation": "When enabled, HTTPS/TLS listeners use post-quantum TLS policy by default" + }, + "@aws-cdk/core:automaticL1Traits": { + "recommendedValue": true, + "explanation": "Automatically use the default L1 traits for L1 constructs`", + "unconfiguredBehavesLike": { + "v2": true + } + }, + "@aws-cdk/aws-batch:defaultToAL2023": { + "recommendedValue": true, + "explanation": "Use AL2023 as the default imageType for EC2 Batch compute environments instead of the deprecated AL2" + }, + "@aws-cdk/aws-eks:defaultToAL2023": { + "recommendedValue": true, + "explanation": "Use AL2023 as the default AMI type for EKS managed node groups using non-GPU instance types instead of the deprecated AL2" + }, + "@aws-cdk/core:annotationsInValidationReport": { + "recommendedValue": true, + "explanation": "Include construct annotations (warnings and errors) in the policy validation report" + }, + "@aws-cdk/core:defaultCrossStackReferences": { + "recommendedValue": "weak", + "explanation": "Controls whether cross-stack references are strong, weak, or both", + "unconfiguredBehavesLike": { + "v2": "strong" + } + }, + "@aws-cdk/core:validateAgainstDefaultRules": { + "recommendedValue": true, + "explanation": "Treat CloudFormation Validate findings as errors" + }, + "@aws-cdk/aws-ecs:removeEmptyLoadBalancers": { + "recommendedValue": true, + "explanation": "Render an empty `LoadBalancers` array on an ECS service that has no target groups" + }, + "@aws-cdk/aws-cloudfront:defaultViewerProtocolPolicyRedirectToHttps": { + "recommendedValue": true, + "explanation": "Default Distribution cache behaviors to redirect HTTP viewer requests to HTTPS" + } + } + } + } + }, + "minimumCliVersion": "2.1143.0" +} \ No newline at end of file diff --git a/packages/@aws-cdk/toolkit-lib/test/_fixtures/stack-with-references/cdk.out/tree.json b/packages/@aws-cdk/toolkit-lib/test/_fixtures/stack-with-references/cdk.out/tree.json new file mode 100644 index 000000000..168885b55 --- /dev/null +++ b/packages/@aws-cdk/toolkit-lib/test/_fixtures/stack-with-references/cdk.out/tree.json @@ -0,0 +1 @@ +{"version":"tree-0.1","tree":{"id":"App","path":"","constructInfo":{"fqn":"aws-cdk-lib.App","version":"2.273.0"},"children":{"Stack1":{"id":"Stack1","path":"Stack1","constructInfo":{"fqn":"aws-cdk-lib.Stack","version":"2.273.0"},"children":{"MyBucket":{"id":"MyBucket","path":"Stack1/MyBucket","constructInfo":{"fqn":"aws-cdk-lib.aws_s3.Bucket","version":"2.273.0"},"children":{"Resource":{"id":"Resource","path":"Stack1/MyBucket/Resource","constructInfo":{"fqn":"aws-cdk-lib.aws_s3.CfnBucket","version":"2.273.0"},"attributes":{"aws:cdk:cloudformation:type":"AWS::S3::Bucket","aws:cdk:cloudformation:logicalId":"MyBucketF68F3FF0","aws:cdk:cloudformation:props":{}}}}},"BucketName":{"id":"BucketName","path":"Stack1/BucketName","constructInfo":{"fqn":"aws-cdk-lib.aws_ssm.StringParameter","version":"2.273.0"},"children":{"Resource":{"id":"Resource","path":"Stack1/BucketName/Resource","constructInfo":{"fqn":"aws-cdk-lib.aws_ssm.CfnParameter","version":"2.273.0"},"attributes":{"aws:cdk:cloudformation:type":"AWS::SSM::Parameter","aws:cdk:cloudformation:logicalId":"BucketName742CD8B0","aws:cdk:cloudformation:props":{"type":"String","value":{"Ref":"MyBucketF68F3FF0"}}}}}},"BucketArn":{"id":"BucketArn","path":"Stack1/BucketArn","constructInfo":{"fqn":"aws-cdk-lib.CfnOutput","version":"2.273.0"}},"BucketDomain":{"id":"BucketDomain","path":"Stack1/BucketDomain","constructInfo":{"fqn":"aws-cdk-lib.CfnOutput","version":"2.273.0"}},"Name":{"id":"Name","path":"Stack1/Name","constructInfo":{"fqn":"aws-cdk-lib.CfnOutput","version":"2.273.0"}},"Joined":{"id":"Joined","path":"Stack1/Joined","constructInfo":{"fqn":"aws-cdk-lib.CfnOutput","version":"2.273.0"}},"Secret":{"id":"Secret","path":"Stack1/Secret","constructInfo":{"fqn":"aws-cdk-lib.CfnParameter","version":"2.273.0"}},"SecretValue":{"id":"SecretValue","path":"Stack1/SecretValue","constructInfo":{"fqn":"aws-cdk-lib.CfnOutput","version":"2.273.0"}},"Nested":{"id":"Nested","path":"Stack1/Nested","constructInfo":{"fqn":"aws-cdk-lib.NestedStack","version":"2.273.0"},"children":{"NestedBucketName":{"id":"NestedBucketName","path":"Stack1/Nested/NestedBucketName","constructInfo":{"fqn":"aws-cdk-lib.aws_ssm.StringParameter","version":"2.273.0"},"children":{"Resource":{"id":"Resource","path":"Stack1/Nested/NestedBucketName/Resource","constructInfo":{"fqn":"aws-cdk-lib.aws_ssm.CfnParameter","version":"2.273.0"},"attributes":{"aws:cdk:cloudformation:type":"AWS::SSM::Parameter","aws:cdk:cloudformation:logicalId":"NestedBucketName7CD077DC","aws:cdk:cloudformation:props":{"type":"String","value":{"Ref":"referencetoStack1MyBucket3E4D6F6CRef"}}}}}},"CDKMetadata":{"id":"CDKMetadata","path":"Stack1/Nested/CDKMetadata","constructInfo":{"fqn":"constructs.Construct","version":"10.6.0"},"children":{"Default":{"id":"Default","path":"Stack1/Nested/CDKMetadata/Default","constructInfo":{"fqn":"aws-cdk-lib.CfnResource","version":"2.273.0"}}}},"reference-to-Stack1MyBucket3E4D6F6CRef":{"id":"reference-to-Stack1MyBucket3E4D6F6CRef","path":"Stack1/Nested/reference-to-Stack1MyBucket3E4D6F6CRef","constructInfo":{"fqn":"aws-cdk-lib.CfnParameter","version":"2.273.0"}}}},"Nested.NestedStack":{"id":"Nested.NestedStack","path":"Stack1/Nested.NestedStack","constructInfo":{"fqn":"constructs.Construct","version":"10.6.0"},"children":{"Nested.NestedStackResource":{"id":"Nested.NestedStackResource","path":"Stack1/Nested.NestedStack/Nested.NestedStackResource","constructInfo":{"fqn":"aws-cdk-lib.CfnStack","version":"2.273.0"},"attributes":{"aws:cdk:cloudformation:type":"AWS::CloudFormation::Stack","aws:cdk:cloudformation:logicalId":"NestedNestedStackNestedNestedStackResourceDEFDAA4D","aws:cdk:cloudformation:props":{"parameters":{"referencetoStack1MyBucket3E4D6F6CRef":{"Ref":"MyBucketF68F3FF0"}},"templateUrl":{"Fn::Join":["",["https://s3.us-east-1.",{"Ref":"AWS::URLSuffix"},"/cdk-hnb659fds-assets-123456789012-us-east-1/79bf57b1b3bbe8f179f2b75327d6f95f059a31631484c17e6ee3aaceea8a0264.json"]]}}}}}},"CDKMetadata":{"id":"CDKMetadata","path":"Stack1/CDKMetadata","constructInfo":{"fqn":"constructs.Construct","version":"10.6.0"},"children":{"Default":{"id":"Default","path":"Stack1/CDKMetadata/Default","constructInfo":{"fqn":"aws-cdk-lib.CfnResource","version":"2.273.0"}}}},"BootstrapVersion":{"id":"BootstrapVersion","path":"Stack1/BootstrapVersion","constructInfo":{"fqn":"aws-cdk-lib.CfnParameter","version":"2.273.0"}},"CheckBootstrapVersion":{"id":"CheckBootstrapVersion","path":"Stack1/CheckBootstrapVersion","constructInfo":{"fqn":"aws-cdk-lib.CfnRule","version":"2.273.0"}}}},"Tree":{"id":"Tree","path":"Tree","constructInfo":{"fqn":"constructs.Construct","version":"10.6.0"}}}}} \ No newline at end of file diff --git a/packages/@aws-cdk/toolkit-lib/test/_fixtures/stack-with-references/index.ts b/packages/@aws-cdk/toolkit-lib/test/_fixtures/stack-with-references/index.ts new file mode 100644 index 000000000..247ae9756 --- /dev/null +++ b/packages/@aws-cdk/toolkit-lib/test/_fixtures/stack-with-references/index.ts @@ -0,0 +1,32 @@ +import * as s3 from 'aws-cdk-lib/aws-s3'; +import * as ssm from 'aws-cdk-lib/aws-ssm'; +import * as core from 'aws-cdk-lib/core'; + +export default async () => { + const app = new core.App({ + autoSynth: false, + // keep local paths out of the checked-in metadata + context: { 'aws:cdk:disable-creation-stack-traces': true }, + }); + const stack = new core.Stack(app, 'Stack1', { + env: { + account: '123456789012', + region: 'us-east-1', + }, + }); + const bucket = new s3.Bucket(stack, 'MyBucket'); + // Ref to another resource + new ssm.StringParameter(stack, 'BucketName', { stringValue: bucket.bucketName }); + // Fn::GetAtt the evaluator formats itself, one it has to look up, a pseudo parameter and a Fn::Join + new core.CfnOutput(stack, 'BucketArn', { value: bucket.bucketArn }); + new core.CfnOutput(stack, 'BucketDomain', { value: bucket.bucketRegionalDomainName }); + new core.CfnOutput(stack, 'Name', { value: core.Aws.STACK_NAME }); + new core.CfnOutput(stack, 'Joined', { value: core.Fn.join('-', [bucket.bucketName, core.Aws.REGION]) }); + // A parameter, to test parameters that are NoEcho in the deployed stack only + const secret = new core.CfnParameter(stack, 'Secret', { default: 'template-default' }); + new core.CfnOutput(stack, 'SecretValue', { value: secret.valueAsString }); + // A nested stack that gets the bucket name as a parameter + const nested = new core.NestedStack(stack, 'Nested'); + new ssm.StringParameter(nested, 'NestedBucketName', { stringValue: bucket.bucketName }); + return app.synth(); +}; diff --git a/packages/@aws-cdk/toolkit-lib/test/actions/evaluate.test.ts b/packages/@aws-cdk/toolkit-lib/test/actions/evaluate.test.ts new file mode 100644 index 000000000..fed13a071 --- /dev/null +++ b/packages/@aws-cdk/toolkit-lib/test/actions/evaluate.test.ts @@ -0,0 +1,284 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import { GetResourceCommand } from '@aws-sdk/client-cloudcontrol'; +import { DescribeStacksCommand, GetTemplateCommand, ListStackResourcesCommand, StackStatus } from '@aws-sdk/client-cloudformation'; +import { StackSelectionStrategy } from '../../lib/api/cloud-assembly'; +import { Toolkit } from '../../lib/toolkit'; +import { cdkOutFixture, TestIoHost } from '../_helpers'; +import { + mockCloudControlClient, + mockCloudFormationClient, + mockSdkProvider, + restoreSdkMocksToDefault, + setDefaultSTSMocks, +} from '../_helpers/mock-sdk'; + +const FIXTURE = 'stack-with-references'; +const template = JSON.parse(fs.readFileSync(path.join(__dirname, '..', '_fixtures', FIXTURE, 'cdk.out', 'Stack1.template.json'), 'utf-8')); +const nestedTemplate = JSON.parse(fs.readFileSync(path.join(__dirname, '..', '_fixtures', FIXTURE, 'cdk.out', 'Stack1Nested36ADE314.nested.template.json'), 'utf-8')); +const STACK_ID = 'arn:aws:cloudformation:us-east-1:123456789012:stack/Stack1/f1b0a1b0-0000-0000-0000-000000000000'; +const NESTED_LOGICAL_ID = 'NestedNestedStackNestedNestedStackResourceDEFDAA4D'; +const NESTED_STACK_NAME = 'Stack1-NestedNestedStack-ABC123'; +const NESTED_STACK_ID = `arn:aws:cloudformation:us-east-1:123456789012:stack/${NESTED_STACK_NAME}/f1b0a1b0-0000-0000-0000-000000000001`; + +let ioHost: TestIoHost; +let toolkit: Toolkit; + +beforeEach(() => { + jest.restoreAllMocks(); + restoreSdkMocksToDefault(); + setDefaultSTSMocks(); + mockSdkProvider(); + ioHost = new TestIoHost('info', true); + toolkit = new Toolkit({ ioHost, unstableFeatures: ['evaluate'] }); +}); + +function givenDeployedStack() { + mockCloudFormationClient.on(DescribeStacksCommand).resolves({ + Stacks: [{ + StackName: 'Stack1', + StackId: STACK_ID, + StackStatus: StackStatus.CREATE_COMPLETE, + CreationTime: new Date(), + Parameters: [{ ParameterKey: 'BootstrapVersion', ParameterValue: '/cdk-bootstrap/hnb659fds/version', ResolvedValue: '30' }], + }], + }); + mockCloudFormationClient.on(DescribeStacksCommand, { StackName: NESTED_STACK_NAME }).resolves({ + Stacks: [{ StackName: NESTED_STACK_NAME, StackId: NESTED_STACK_ID, StackStatus: StackStatus.CREATE_COMPLETE, CreationTime: new Date() }], + }); + mockCloudFormationClient.on(GetTemplateCommand).resolves({ TemplateBody: JSON.stringify(template) }); + mockCloudFormationClient.on(GetTemplateCommand, { StackName: NESTED_STACK_NAME }).resolves({ TemplateBody: JSON.stringify(nestedTemplate) }); + mockCloudFormationClient.on(ListStackResourcesCommand, { StackName: NESTED_STACK_NAME }).resolves({ + StackResourceSummaries: [{ + LogicalResourceId: 'NestedBucketName7CD077DC', + PhysicalResourceId: 'CFN-NestedBucketName7CD077DC-def', + ResourceType: 'AWS::SSM::Parameter', + ResourceStatus: 'CREATE_COMPLETE', + LastUpdatedTimestamp: new Date(), + }], + }); + mockCloudFormationClient.on(ListStackResourcesCommand, { StackName: 'Stack1' }).resolves({ + StackResourceSummaries: [ + { + LogicalResourceId: NESTED_LOGICAL_ID, + PhysicalResourceId: NESTED_STACK_ID, + ResourceType: 'AWS::CloudFormation::Stack', + ResourceStatus: 'CREATE_COMPLETE', + LastUpdatedTimestamp: new Date(), + }, + { + LogicalResourceId: 'MyBucketF68F3FF0', + PhysicalResourceId: 'stack1-mybucket-1a2b3c', + ResourceType: 'AWS::S3::Bucket', + ResourceStatus: 'CREATE_COMPLETE', + LastUpdatedTimestamp: new Date(), + }, + { + LogicalResourceId: 'BucketName742CD8B0', + PhysicalResourceId: 'CFN-BucketName742CD8B0-abc', + ResourceType: 'AWS::SSM::Parameter', + ResourceStatus: 'CREATE_COMPLETE', + LastUpdatedTimestamp: new Date(), + }, + ], + }); +} + +describe('evaluate', () => { + test('requires the evaluate unstable feature', async () => { + const stableToolkit = new Toolkit({ ioHost }); + const cx = await cdkOutFixture(stableToolkit, FIXTURE); + + await expect(stableToolkit.evaluate(cx)).rejects.toThrow(/Unstable feature 'evaluate' is not enabled/); + }); + + test('replaces references, attributes and pseudo parameters with their deployed values', async () => { + // GIVEN + givenDeployedStack(); + mockCloudControlClient.on(GetResourceCommand).rejects(Object.assign(new Error('Resource not found'), { name: 'ResourceNotFoundException' })); + + // WHEN + const cx = await cdkOutFixture(toolkit, FIXTURE); + const result = await toolkit.evaluate(cx, { stacks: { strategy: StackSelectionStrategy.ALL_STACKS } }); + + // THEN + expect(result.stacks).toHaveLength(1); + const stack = result.stacks[0]; + expect(stack.stackName).toEqual('Stack1'); + expect(stack.template.Resources.BucketName742CD8B0.Properties.Value).toEqual('stack1-mybucket-1a2b3c'); + expect(stack.template.Outputs.BucketArn.Value).toEqual('arn:aws:s3:::stack1-mybucket-1a2b3c'); + expect(stack.template.Outputs.Name.Value).toEqual('Stack1'); + expect(stack.template.Outputs.Joined.Value).toEqual('stack1-mybucket-1a2b3c-us-east-1'); + + expect(result.notDeployed).toEqual([]); + expect(stack.stackId).toEqual(STACK_ID); + expect(stack.physicalResourceIds).toEqual(expect.objectContaining({ + MyBucketF68F3FF0: 'stack1-mybucket-1a2b3c', + BucketName742CD8B0: 'CFN-BucketName742CD8B0-abc', + })); + + // the attribute that needs a Cloud Control lookup is kept and reported + expect(stack.template.Outputs.BucketDomain.Value).toEqual({ 'Fn::GetAtt': ['MyBucketF68F3FF0', 'RegionalDomainName'] }); + expect(stack.unresolved).toEqual([expect.objectContaining({ + path: 'Outputs.BucketDomain.Value', + intrinsic: 'Fn::GetAtt', + })]); + }); + + test('looks up attributes through Cloud Control, and leaves the other template sections as they are', async () => { + // GIVEN + givenDeployedStack(); + mockCloudControlClient.on(GetResourceCommand).resolves({ + ResourceDescription: { Properties: JSON.stringify({ RegionalDomainName: 'stack1-mybucket-1a2b3c.s3.us-east-1.amazonaws.com' }) }, + }); + + // WHEN + const cx = await cdkOutFixture(toolkit, FIXTURE); + const result = await toolkit.evaluate(cx, { stacks: { strategy: StackSelectionStrategy.ALL_STACKS } }); + + // THEN + const stack = result.stacks[0]; + expect(stack.unresolved).toEqual([]); + expect(stack.template.Outputs.BucketDomain.Value).toEqual('stack1-mybucket-1a2b3c.s3.us-east-1.amazonaws.com'); + expect(stack.template.Rules.CheckBootstrapVersion).toEqual(template.Rules.CheckBootstrapVersion); + }); + + test('evaluates nested stacks with the parameter values their parent passes', async () => { + // GIVEN + givenDeployedStack(); + mockCloudControlClient.on(GetResourceCommand).rejects(Object.assign(new Error('Resource not found'), { name: 'ResourceNotFoundException' })); + + // WHEN + const cx = await cdkOutFixture(toolkit, FIXTURE); + const result = await toolkit.evaluate(cx, { stacks: { strategy: StackSelectionStrategy.ALL_STACKS } }); + + // THEN + const nested = result.stacks[0].nestedStacks?.[NESTED_LOGICAL_ID]; + expect(nested).toEqual(expect.objectContaining({ + stackName: NESTED_STACK_NAME, + hierarchicalId: `Stack1/${NESTED_LOGICAL_ID}`, + stackId: NESTED_STACK_ID, + physicalResourceIds: { NestedBucketName7CD077DC: 'CFN-NestedBucketName7CD077DC-def' }, + unresolved: [], + })); + expect(nested?.template.Resources.NestedBucketName7CD077DC.Properties.Value).toEqual('stack1-mybucket-1a2b3c'); + }); + + test('reports expressions in nested stacks that use a parameter value the parent could not evaluate', async () => { + // GIVEN: the bucket is missing from the parent's resources, so its Ref can't be evaluated + givenDeployedStack(); + mockCloudFormationClient.on(ListStackResourcesCommand, { StackName: 'Stack1' }).resolves({ + StackResourceSummaries: [{ + LogicalResourceId: NESTED_LOGICAL_ID, + PhysicalResourceId: NESTED_STACK_ID, + ResourceType: 'AWS::CloudFormation::Stack', + ResourceStatus: 'CREATE_COMPLETE', + LastUpdatedTimestamp: new Date(), + }], + }); + mockCloudControlClient.on(GetResourceCommand).rejects(Object.assign(new Error('Resource not found'), { name: 'ResourceNotFoundException' })); + + // WHEN + const cx = await cdkOutFixture(toolkit, FIXTURE); + const result = await toolkit.evaluate(cx, { stacks: { strategy: StackSelectionStrategy.ALL_STACKS } }); + + // THEN + const nested = result.stacks[0].nestedStacks?.[NESTED_LOGICAL_ID]; + expect(nested?.template.Resources.NestedBucketName7CD077DC.Properties.Value).toEqual({ Ref: 'referencetoStack1MyBucket3E4D6F6CRef' }); + expect(nested?.unresolved).toEqual([{ + path: 'Resources.NestedBucketName7CD077DC.Properties.Value', + intrinsic: 'Ref', + reason: "references parameter 'referencetoStack1MyBucket3E4D6F6CRef': the value passed by stack 'Stack1' could not be evaluated", + }]); + }); + + test('fails, rather than reporting expressions as unresolved, when AWS calls fail', async () => { + // GIVEN + givenDeployedStack(); + mockCloudControlClient.on(GetResourceCommand).rejects(Object.assign(new Error('Rate exceeded'), { name: 'ThrottlingException' })); + + // WHEN + const cx = await cdkOutFixture(toolkit, FIXTURE); + + // THEN + await expect(toolkit.evaluate(cx, { stacks: { strategy: StackSelectionStrategy.ALL_STACKS } })) + .rejects.toThrow(/Could not find 'RegionalDomainName' attribute/); + }); + + test('skips stacks that are not deployed, and lists them', async () => { + // GIVEN + mockCloudFormationClient.on(DescribeStacksCommand).rejects(Object.assign(new Error('Stack with id Stack1 does not exist'), { name: 'ValidationError' })); + + // WHEN + const cx = await cdkOutFixture(toolkit, FIXTURE); + const result = await toolkit.evaluate(cx, { stacks: { strategy: StackSelectionStrategy.ALL_STACKS } }); + + // THEN + expect(result).toEqual({ stacks: [], notDeployed: ['Stack1'] }); + ioHost.expectMessage({ level: 'warn', containing: "Stack 'Stack1' is not deployed in aws://123456789012/us-east-1, skipping it" }); + }); + + test.each(['REVIEW_IN_PROGRESS', 'ROLLBACK_IN_PROGRESS', 'ROLLBACK_COMPLETE', 'DELETE_IN_PROGRESS', 'CREATE_FAILED'])( + 'skips a stack that is %s, and lists it as not deployed', async (status) => { + // GIVEN + givenDeployedStack(); + mockCloudFormationClient.on(DescribeStacksCommand, { StackName: 'Stack1' }).resolves({ + Stacks: [{ StackName: 'Stack1', StackId: STACK_ID, StackStatus: status as StackStatus, CreationTime: new Date() }], + }); + + // WHEN + const cx = await cdkOutFixture(toolkit, FIXTURE); + const result = await toolkit.evaluate(cx, { stacks: { strategy: StackSelectionStrategy.ALL_STACKS } }); + + // THEN + expect(result).toEqual({ stacks: [], notDeployed: ['Stack1'] }); + ioHost.expectMessage({ level: 'warn', containing: `Stack 'Stack1' is ${status} in aws://123456789012/us-east-1, skipping it` }); + }); + + test.each([true, 'True'])('treats a parameter that is NoEcho (%p) only in the deployed template as unavailable', async (noEcho) => { + // GIVEN: DescribeStacks masks the value, and the deployed template says NoEcho + givenDeployedStack(); + mockCloudFormationClient.on(DescribeStacksCommand, { StackName: 'Stack1' }).resolves({ + Stacks: [{ + StackName: 'Stack1', + StackId: STACK_ID, + StackStatus: StackStatus.CREATE_COMPLETE, + CreationTime: new Date(), + Parameters: [{ ParameterKey: 'Secret', ParameterValue: '****' }], + }], + }); + const deployed = { ...template, Parameters: { ...template.Parameters, Secret: { ...template.Parameters.Secret, NoEcho: noEcho } } }; + mockCloudFormationClient.on(GetTemplateCommand, { StackName: 'Stack1' }).resolves({ TemplateBody: JSON.stringify(deployed) }); + mockCloudControlClient.on(GetResourceCommand).rejects(Object.assign(new Error('Resource not found'), { name: 'ResourceNotFoundException' })); + + // WHEN + const cx = await cdkOutFixture(toolkit, FIXTURE); + const result = await toolkit.evaluate(cx, { stacks: { strategy: StackSelectionStrategy.ALL_STACKS } }); + + // THEN: neither the mask nor the template default is used + expect(result.stacks[0].template.Outputs.SecretValue.Value).toEqual({ Ref: 'Secret' }); + expect(result.stacks[0].unresolved).toContainEqual(expect.objectContaining({ path: 'Outputs.SecretValue.Value', intrinsic: 'Ref' })); + }); + + test('warns when the deployed template has different resources than the evaluated one', async () => { + // GIVEN + givenDeployedStack(); + const { BucketName742CD8B0: _removed, ...resources } = template.Resources; + mockCloudFormationClient.on(GetTemplateCommand, { StackName: 'Stack1' }).resolves({ TemplateBody: JSON.stringify({ ...template, Resources: resources }) }); + mockCloudControlClient.on(GetResourceCommand).rejects(Object.assign(new Error('Resource not found'), { name: 'ResourceNotFoundException' })); + + // WHEN + const cx = await cdkOutFixture(toolkit, FIXTURE); + await toolkit.evaluate(cx, { stacks: { strategy: StackSelectionStrategy.ALL_STACKS } }); + + // THEN + ioHost.expectMessage({ level: 'warn', containing: "The template of stack 'Stack1' differs from its deployed template" }); + ioHost.expectMessage({ level: 'warn', containing: 'Not deployed: BucketName742CD8B0 (AWS::SSM::Parameter)' }); + }); + + test('rejects a concurrency that is not a positive integer', async () => { + const cx = await cdkOutFixture(toolkit, FIXTURE); + + await expect(toolkit.evaluate(cx, { concurrency: 0 })).rejects.toThrow('concurrency must be a positive integer, got 0'); + }); +}); diff --git a/packages/aws-cdk/lib/cli/cdk-toolkit.ts b/packages/aws-cdk/lib/cli/cdk-toolkit.ts index c9a353074..45bdf58a5 100644 --- a/packages/aws-cdk/lib/cli/cdk-toolkit.ts +++ b/packages/aws-cdk/lib/cli/cdk-toolkit.ts @@ -212,6 +212,7 @@ export class CdkToolkit { const iPromiseUnstablenessIsCheckedByTheCli: Record = { 'publish-assets': true, 'diagnose': true, + 'evaluate': true, 'flags': true, 'orphan': true, 'refactor': true,