Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
115 changes: 115 additions & 0 deletions packages/@aws-cdk/toolkit-lib/lib/actions/evaluate/index.ts
Original file line number Diff line number Diff line change
@@ -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[];
}
Original file line number Diff line number Diff line change
@@ -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<void>;
}

/**
* 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<EvaluatedStack> {
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<string, ListStackResources>(),
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<EvaluatedStack> {
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<any>(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<any>(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<any>(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,
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './evaluate-stack';
1 change: 1 addition & 0 deletions packages/@aws-cdk/toolkit-lib/lib/actions/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,4 @@ export * from './synth';
export * from './watch';
export * from './diagnose';
export * from './validate';
export * from './evaluate';
Original file line number Diff line number Diff line change
@@ -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
*/
Expand Down
3 changes: 2 additions & 1 deletion packages/@aws-cdk/toolkit-lib/lib/api/io/toolkit-action.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,4 +23,5 @@ export type ToolkitAction =
| 'diagnose'
| 'orphan'
| 'flags'
| 'validate';
| 'validate'
| 'evaluate';
Loading
Loading