From 674ff01849b96d6a70b293f3a79cc7375cdd93fc Mon Sep 17 00:00:00 2001 From: Florian Stadler Date: Tue, 17 Dec 2024 14:53:55 +0100 Subject: [PATCH 1/4] Enhance CDK support with nested stack handling - Introduced `StackAddress` type to uniquely identify resources across nested stacks. - Updated `CloudFormationParameterWithId` to use `stackAddress` instead of `id`. - Added `NestedStackTemplate` interface and `isNestedStackTemplate` function for better nested stack management. - Modified various functions and interfaces to accommodate stack paths and addresses, improving resource mapping and retrieval. - Created `StackMap` class for efficient management of resources across stacks. - Updated tests to reflect changes in resource identification and nested stack handling. This update improves the overall structure and usability of arbitrary stacks in the cloud assembly. --- src/assembly/manifest.ts | 100 +- src/assembly/stack.ts | 143 +- src/assembly/types.ts | 11 +- src/cfn.ts | 23 +- src/converters/app-converter.ts | 342 +++-- src/converters/intrinsics.ts | 77 +- src/graph.ts | 319 ++++- src/interop.ts | 25 + src/stack-map.ts | 166 +++ src/stack.ts | 4 +- tests/assembly/manifest.test.ts | 399 ++++-- tests/assembly/stack.test.ts | 59 +- tests/converters/app-converter.test.ts | 77 +- tests/converters/intrinsics.test.ts | 233 +-- .../secretsmanager-converter.test.ts | 13 +- tests/converters/ssm-converter.test.ts | 13 +- tests/graph.test.ts | 48 +- tests/mocks.ts | 11 + tests/options.test.ts | 18 +- tests/stack-map.test.ts | 101 ++ .../custom-resource-stack/stack-manifest.json | 58 +- tests/test-data/nested-stack/manifest.json | 120 ++ .../nested-stack/stack-manifest.json | 1243 +++++++++++++++++ .../nested-stack/teststack.assets.json | 84 ++ .../nested-stack/teststack.template.json | 297 ++++ ...eststacknesty613E34DC.nested.template.json | 234 ++++ tests/test-data/nested-stack/tree.json | 711 ++++++++++ tests/utils.ts | 5 +- 28 files changed, 4335 insertions(+), 599 deletions(-) create mode 100644 src/stack-map.ts create mode 100644 tests/stack-map.test.ts create mode 100644 tests/test-data/nested-stack/manifest.json create mode 100644 tests/test-data/nested-stack/stack-manifest.json create mode 100644 tests/test-data/nested-stack/teststack.assets.json create mode 100644 tests/test-data/nested-stack/teststack.template.json create mode 100644 tests/test-data/nested-stack/teststacknesty613E34DC.nested.template.json create mode 100644 tests/test-data/nested-stack/tree.json diff --git a/src/assembly/manifest.ts b/src/assembly/manifest.ts index ebfaa2eb..ba003da7 100644 --- a/src/assembly/manifest.ts +++ b/src/assembly/manifest.ts @@ -1,7 +1,7 @@ import * as path from 'path'; import { AssemblyManifest, Manifest, ArtifactType, ArtifactMetadataEntryType } from '@aws-cdk/cloud-assembly-schema'; import * as fs from 'fs-extra'; -import { CloudFormationTemplate } from '../cfn'; +import { CloudFormationResource, CloudFormationTemplate, NestedStackTemplate } from '../cfn'; import { ArtifactManifest, LogicalIdMetadataEntry } from 'aws-cdk-lib/cloud-assembly-schema'; import { StackManifest } from './stack'; import { ConstructTree, StackMetadata } from './types'; @@ -72,12 +72,15 @@ export class AssemblyManifestReader { throw new Error(`Failed to read CloudFormation template at path: ${templateFile}: ${e}`); } - const metadata = this.getMetadata(artifact); - if (!this.tree.children) { throw new Error('Invalid tree.json found'); } + const stackTree = this.tree.children[artifactId]; + const nestedStacks = this.loadNestedStacks(template.Resources); + const stackPaths = Object.keys(nestedStacks).concat([stackTree.path]); + const metadata = this.getMetadata(artifact, stackPaths); + const stackManifest = new StackManifest({ id: artifactId, templatePath: templateFile, @@ -85,26 +88,113 @@ export class AssemblyManifestReader { tree: stackTree, template, dependencies: artifact.dependencies ?? [], + nestedStacks, }); this._stackManifests.set(artifactId, stackManifest); } } } + /** + * Recursively loads the nested CloudFormation stacks referenced by the provided resources. + * + * This method filters the given resources to find those of type 'AWS::CloudFormation::Stack' + * and with a defined 'aws:asset:path' in their metadata. This identifies a CloudFormation + * Stack as a nested stack that needs to be loaded from a separate template file instead of + * a regular stack that's deployed be referencing an existing template in S3. + * See: https://github.com/aws/aws-cdk/blob/cbe2bec488ff9b9823eacf6de14dff1dcb3033a1/packages/aws-cdk/lib/api/nested-stack-helpers.ts#L139-L145 + * + * It then reads the corresponding CloudFormation templates from the specified asset paths before recursively + * loading any nested stacks they define. It returns the nested stacks in a dictionary keyed by their tree paths. + * + * @param resources - An object containing CloudFormation resources, indexed by their logical IDs. + * @returns An object containing the loaded CloudFormation templates, indexed by their tree paths. + * @throws Will throw an error if the 'assetPath' metadata of a 'AWS::CloudFormation::Stack' is not a string + * or if reading the template file fails. + */ + private loadNestedStacks(resources: { [logicalIds: string]: CloudFormationResource } | undefined): { + [path: string]: NestedStackTemplate; + } { + return Object.entries(resources ?? {}) + .filter(([_, resource]) => { + return resource.Type === 'AWS::CloudFormation::Stack' && resource.Metadata?.['aws:asset:path']; + }) + .reduce((acc, [logicalId, resource]) => { + const assetPath = resource.Metadata?.['aws:asset:path']; + if (typeof assetPath !== 'string') { + throw new Error( + `Expected the Metadata 'aws:asset:path' of ${logicalId} to be a string, got '${assetPath}' of type ${typeof assetPath}`, + ); + } + + const cdkPath = resource.Metadata?.['aws:cdk:path']; + if (!cdkPath) { + throw new Error(`Expected the nested stack ${logicalId} to have a 'aws:cdk:path' metadata entry`); + } + if (typeof cdkPath !== 'string') { + throw new Error( + `Expected the Metadata 'aws:cdk:path' of ${logicalId} to be a string, got '${cdkPath}' of type ${typeof cdkPath}`, + ); + } + + let template: CloudFormationTemplate; + const templateFile = path.join(this.directory, assetPath); + try { + template = fs.readJSONSync(path.resolve(this.directory, templateFile)); + } catch (e) { + throw new Error(`Failed to read CloudFormation template at path: ${templateFile}: ${e}`); + } + + const nestedStackPath = StackManifest.getNestedStackPath(cdkPath, logicalId); + + return { + ...acc, + [nestedStackPath]: { + ...template, + logicalId, + }, + ...this.loadNestedStacks(template.Resources), + }; + }, {} as { [path: string]: NestedStackTemplate }); + } + /** * Creates a metadata map of constructPath to logicalId for all resources in the stack * * @param artifact - The manifest containing the stack metadata * @returns The StackMetadata lookup table */ - private getMetadata(artifact: ArtifactManifest): StackMetadata { + private getMetadata(artifact: ArtifactManifest, stackPaths: string[]): StackMetadata { + // Add a '/' to the end of each stack path to make it easier to find the stack path for a resource + // This is because the stack path suffixed with '/' is the prefix of the resource path but guarantees + // that there's no collisions between stack paths (e.g. 'MyStack/MyNestedStack' and 'MyStack/MyNestedStackPrime') + const stackPrefixes = stackPaths.map((stackPath) => `${stackPath}/`); + const metadata: StackMetadata = {}; for (const [metadataId, metadataEntry] of Object.entries(artifact.metadata ?? {})) { metadataEntry.forEach((meta) => { if (meta.type === ArtifactMetadataEntryType.LOGICAL_ID) { // For some reason the metadata entry prefixes the path with a `/` const path = metadataId.startsWith('/') ? metadataId.substring(1) : metadataId; - metadata[path] = meta.data as LogicalIdMetadataEntry; + + // Find the longest stack path that is a prefix of the resource path. This is the parent stack + // of the resource. + let stackPath: string | undefined; + for (const stackPrefix of stackPrefixes) { + if (stackPrefix.length > (stackPath?.length ?? 0) && path.startsWith(stackPrefix)) { + stackPath = stackPrefix; + } + } + + if (!stackPath && stackPath !== '') { + throw new Error(`Failed to determine the stack path for resource at path ${path}`); + } + + metadata[path] = { + id: meta.data as LogicalIdMetadataEntry, + // Remove the trailing '/' from the stack path again + stackPath: stackPath.slice(0, -1), + }; } }); } diff --git a/src/assembly/stack.ts b/src/assembly/stack.ts index 7c119e01..ac407729 100644 --- a/src/assembly/stack.ts +++ b/src/assembly/stack.ts @@ -1,13 +1,7 @@ import * as path from 'path'; import { DestinationIdentifier, FileManifestEntry } from 'cdk-assets'; -import { - CloudFormationMapping, - CloudFormationParameter, - CloudFormationResource, - CloudFormationTemplate, - CloudFormationCondition, -} from '../cfn'; -import { ConstructTree, StackMetadata } from './types'; +import { CloudFormationResource, CloudFormationTemplate, NestedStackTemplate } from '../cfn'; +import { ConstructTree, StackAddress, StackMetadata } from './types'; import { FileAssetPackaging, FileDestination } from 'aws-cdk-lib/cloud-assembly-schema'; /** @@ -77,6 +71,11 @@ export interface StackManifestProps { * A list of artifact ids that this stack depends on */ readonly dependencies: string[]; + + /** + * The nested stack CloudFormation templates, indexed by their tree path. + */ + readonly nestedStacks: { [path: string]: NestedStackTemplate }; } /** @@ -100,42 +99,13 @@ export class StackManifest { */ public readonly templatePath: string; - /** - * The Outputs from the CFN Stack - */ - public readonly outputs?: { [id: string]: any }; - - /** - * The Parameters from the CFN Stack - */ - public readonly parameters?: { [id: string]: CloudFormationParameter }; - - /** - * Map of resource logicalId to CloudFormation template resource fragment - */ - public readonly resources: { [logicalId: string]: CloudFormationResource }; - - /** - * The Mappings from the CFN Stack - * @see https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/mappings-section-structure.html - */ - public readonly mappings?: CloudFormationMapping; - - /** - * CloudFormation conditions from the template. - * - * @internal - */ - public readonly conditions?: { [id: string]: CloudFormationCondition }; + public readonly stacks: { [path: string]: CloudFormationTemplate }; private readonly metadata: StackMetadata; public readonly dependencies: string[]; constructor(props: StackManifestProps) { this.dependencies = props.dependencies; - this.outputs = props.template.Outputs; - this.parameters = props.template.Parameters; - this.mappings = props.template.Mappings; this.metadata = props.metadata; this.templatePath = props.templatePath; this.id = props.id; @@ -143,50 +113,105 @@ export class StackManifest { if (!props.template.Resources) { throw new Error('CloudFormation template has no resources!'); } - this.resources = props.template.Resources; - this.conditions = props.template.Conditions; + + this.stacks = { + [props.tree.path]: props.template, + ...props.nestedStacks, + }; } /** - * Get the CloudFormation logicalId for the CFN resource at the given Construct path + * Checks if the stack is the root stack + * @param stackPath - The path to the stack + * @returns whether the stack is the root stack + */ + public isRootStack(stackPath: string): boolean { + return stackPath === this.constructTree.path; + } + + /** + * Get the root stack template + * @returns the root stack template + */ + public getRootStack(): CloudFormationTemplate { + return this.stacks[this.constructTree.path]; + } + + /** + * Get the CloudFormation stack address for the CFN resource at the given Construct path * * @param path - The construct path - * @returns the logicalId of the resource - * @throws error if the construct path does not relate to a CFN resource with a logicalId + * @returns the metadata of the resource + * @throws error if the construct path does not relate to a CFN resource */ - public logicalIdForPath(path: string): string { + public resourceAddressForPath(path: string): StackAddress { if (path in this.metadata) { return this.metadata[path]; } - throw new Error(`Could not find logicalId for path ${path}`); + throw new Error(`Could not find stack address for path ${path}`); } /** * Get the CloudFormation template fragment of the resource with the given - * logicalId + * logicalId in the given stack * + * @param stackPath - The path to the stack * @param logicalId - The CFN LogicalId of the resource * @returns The resource portion of the CFN template */ - public resourceWithLogicalId(logicalId: string): CloudFormationResource { - if (logicalId in this.resources) { - return this.resources[logicalId]; + public resourceWithLogicalId(stackPath: string, logicalId: string): CloudFormationResource { + const stackTemplate = this.stacks[stackPath]; + if (!stackTemplate) { + throw new Error(`Could not find stack template for path ${stackPath}`); + } + const resourcesToSearch = stackTemplate.Resources ?? {}; + if (logicalId in resourcesToSearch) { + return resourcesToSearch[logicalId]; } throw new Error(`Could not find resource with logicalId '${logicalId}'`); } /** - * Get the CloudFormation template fragment of the resource with the given - * CDK construct path + * Get the nested stack path from the path of the nested stack resource (i.e. 'AWS::CloudFormation::Stack'). + * For Nested Stacks, there's two nodes in the tree that are of interest. The first node is the `AWS::CloudFormation::Stack` resource, + * it's located at the path `parent/${NESTED_STACK_NAME}.NestedStack/${NESTED_STACK_NAME}.NestedStackResource`. + * This is the input to the function. The second node houses all of the children resources of the nested stack. + * It's located at the path `parent/${NESTED_STACK_NAME}`. This is the the return value of the function. * - * @param path - The construct path to find the CFN Resource for - * @returns The resource portion of the CFN template - */ - public resourceWithPath(path: string): CloudFormationResource { - const logicalId = this.logicalIdForPath(path); - if (logicalId && logicalId in this.resources) { - return this.resources[logicalId]; + * The tree structure looks like this: + * ``` + * Root + * ├── MyNestedStack.NestedStack + * │ └── AWS::CloudFormation::Stack (Nested Stack Resource) + * │ └── Properties + * │ └── Parameters + * │ └── MyParameter + * │ + * ├── MyNestedStack (Nested Stack Node - Path returned by this function) + * │ ├── ChildResource1 + * │ │ └── Properties + * │ └── ChildResource2 + * │ └── Properties + * ``` + * + * @param nestedStackResourcePath - The path to the nested stack resource in the construct tree + * @param logicalId - The logicalId of the nested stack + * @returns The path to the nested stack wrapper node in the construct tree + */ + public static getNestedStackPath(nestedStackResourcePath: string, logicalId: string): string { + const cdkPathParts = nestedStackResourcePath.split('/'); + if (cdkPathParts.length < 3) { + throw new Error( + `Failed to detect the nested stack path for ${logicalId}. The path is too short ${nestedStackResourcePath}, expected at least 3 parts`, + ); + } + const nestedStackPath = cdkPathParts.slice(0, -1).join('/'); + if (nestedStackPath.endsWith('.NestedStack')) { + return nestedStackPath.slice(0, -'.NestedStack'.length); + } else { + throw new Error( + `Failed to detect the nested stack path for ${logicalId}. The path does not end with '.NestedStack': ${nestedStackPath}`, + ); } - throw new Error(`Could not find resource with logicalId '${logicalId}'`); } } diff --git a/src/assembly/types.ts b/src/assembly/types.ts index 30459f13..2b42ce09 100644 --- a/src/assembly/types.ts +++ b/src/assembly/types.ts @@ -6,7 +6,16 @@ export type StackAsset = FileManifestEntry | DockerImageManifestEntry; /** * Map of CDK construct path to logicalId */ -export type StackMetadata = { [path: string]: string }; +export type StackMetadata = { [path: string]: StackAddress }; + +/** + * StackAddress uniquely identifies a resource in a cloud assembly + * across several (nested) stacks. + */ +export type StackAddress = { + id: string; + stackPath: string; +}; /** * ConstructTree is a tree of the current CDK construct diff --git a/src/cfn.ts b/src/cfn.ts index 37b92377..fe300075 100644 --- a/src/cfn.ts +++ b/src/cfn.ts @@ -13,6 +13,7 @@ // limitations under the License. import { CfnDeletionPolicy } from 'aws-cdk-lib/core'; +import { StackAddress } from './assembly'; /** * Represents a CF parameter declaration from the Parameters template section. @@ -28,10 +29,8 @@ export interface CloudFormationParameter { readonly Default?: any; } -export type CloudFormationParameterLogicalId = string; - export interface CloudFormationParameterWithId extends CloudFormationParameter { - id: CloudFormationParameterLogicalId; + stackAddress: StackAddress; } export interface CloudFormationResource { @@ -40,6 +39,11 @@ export interface CloudFormationResource { readonly Condition?: string; readonly DeletionPolicy?: CfnDeletionPolicy; readonly DependsOn?: string | string[]; + readonly Metadata?: { [key: string]: any }; +} + +export interface CloudFormationOutput { + Value: any; } export type CloudFormationMapping = { [mappingLogicalName: string]: TopLevelMapping }; @@ -63,7 +67,18 @@ export interface CloudFormationTemplate { Resources?: { [id: string]: CloudFormationResource }; Conditions?: { [id: string]: CloudFormationCondition }; Mappings?: CloudFormationMapping; - Outputs?: { [id: string]: any }; + Outputs?: { [id: string]: CloudFormationOutput }; +} + +export interface NestedStackTemplate extends CloudFormationTemplate { + /** + * The logical ID identifying the nested stack in the parent stack. + */ + logicalId: string; +} + +export function isNestedStackTemplate(template: CloudFormationTemplate): template is NestedStackTemplate { + return 'logicalId' in template; } export function getDependsOn(resource: CloudFormationResource): string[] | undefined { diff --git a/src/converters/app-converter.ts b/src/converters/app-converter.ts index bdbceb1f..87ca7b39 100644 --- a/src/converters/app-converter.ts +++ b/src/converters/app-converter.ts @@ -1,11 +1,17 @@ import * as cdk from 'aws-cdk-lib/core'; import * as aws from '@pulumi/aws-native'; import * as pulumi from '@pulumi/pulumi'; -import { AssemblyManifestReader, StackManifest } from '../assembly'; +import { AssemblyManifestReader, StackAddress, StackManifest } from '../assembly'; import { ConstructInfo, Graph, GraphBuilder, GraphNode } from '../graph'; import { ArtifactConverter } from './artifact-converter'; import { lift, Mapping, AppComponent, CdkAdapterError } from '../types'; -import { CdkConstruct, ResourceAttributeMapping, ResourceMapping, resourcesFromResourceMapping } from '../interop'; +import { + CdkConstruct, + NestedStackConstruct, + ResourceAttributeMapping, + ResourceMapping, + resourcesFromResourceMapping, +} from '../interop'; import { debug, warn } from '@pulumi/pulumi/log'; import { cidr, @@ -18,16 +24,18 @@ import { } from '@pulumi/aws-native'; import { mapToAwsResource } from '../aws-resource-mappings'; import { attributePropertyName, mapToCfnResource } from '../cfn-resource-mappings'; -import { CloudFormationResource, getDependsOn } from '../cfn'; +import { CloudFormationResource, CloudFormationTemplate, getDependsOn } from '../cfn'; import { OutputMap, OutputRepr } from '../output-map'; import { parseSub } from '../sub'; import { getPartition } from '@pulumi/aws-native/getPartition'; import { mapToCustomResource } from '../custom-resource-mapping'; import * as intrinsics from './intrinsics'; -import { CloudFormationParameter, CloudFormationParameterLogicalId, CloudFormationParameterWithId } from '../cfn'; +import { CloudFormationParameter, CloudFormationParameterWithId } from '../cfn'; import { Metadata, PulumiResource } from '../pulumi-metadata'; import { PulumiProvider } from '../types'; import { parseDynamicValue } from './dynamic-references'; +import { StackMap } from '../stack-map'; +import { NestedStackParameter } from './intrinsics'; /** * AppConverter will convert all CDK resources into Pulumi resources. @@ -95,9 +103,11 @@ export class AppConverter { * StackConverter converts all of the resources in a CDK stack to Pulumi resources */ export class StackConverter extends ArtifactConverter implements intrinsics.IntrinsicContext { - readonly parameters = new Map(); - readonly resources = new Map>(); + readonly parameters = new StackMap(); + readonly resources = new StackMap>(); readonly constructs = new Map(); + readonly nestedStackParameters = new StackMap(); + readonly nestedStackNodes = new StackMap(); private readonly cdkStack: cdk.Stack; private readonly stackOptions?: pulumi.ComponentResourceOptions; @@ -116,13 +126,18 @@ export class StackConverter extends ArtifactConverter implements intrinsics.Intr this.cdkStack = host.stacks[stack.id]; this.stackOptions = host.stackOptions[stack.id]; this.graph = GraphBuilder.build(this.stack); + this.graph.nestedStackNodes.forEach( + (node) => node.resourceAddress && this.nestedStackNodes.set(node.resourceAddress, node), + ); } public convert(dependencies: Set) { // process parameters first because resources will reference them - for (const [logicalId, value] of Object.entries(this.stack.parameters ?? {})) { - this.mapParameter(logicalId, value.Type, value.Default); - } + Object.entries(this.stack.stacks).forEach(([stackPath, stack]) => { + for (const [logicalId, value] of Object.entries(stack.Parameters ?? {})) { + this.mapParameter({ stackPath, id: logicalId }, value.Type, value.Default); + } + }); for (const n of this.graph.nodes) { if (n.construct.id === this.stack.id) { @@ -143,17 +158,29 @@ export class StackConverter extends ArtifactConverter implements intrinsics.Intr throw new Error(`Construct at path ${n.construct.path} should be created in the scope of a Stack`); } const parent = this.constructs.get(n.construct.parent)!; - if (n.resource && n.logicalId) { + if (this.graph.nestedStackNodes.has(n.construct.path)) { + const nestedStack = this.stack.stacks[n.construct.path]; + if (!nestedStack) { + throw new Error(`Could not find nested stack template for path ${n.construct.path}`); + } + + // this is a nested stack, we create a special construct for it to handle outputs + const r = new NestedStackConstruct(`${this.app.name}/${n.construct.path}`, { + parent, + }); + + this.registerResource(r, n); + } else if (n.resource && n.resourceAddress) { const cfn = n.resource; debug(`Processing node with template: ${JSON.stringify(cfn)}`); - debug(`Creating resource for ${n.logicalId}`); - const props = this.processIntrinsics(cfn.Properties); - const options = this.processOptions(n.logicalId, cfn, parent); + debug(`Creating resource ${n.resourceAddress.id} in stack ${n.resourceAddress.stackPath}`); + const props = this.processIntrinsics(cfn.Properties, n.resourceAddress.stackPath); + const options = this.processOptions(n.resourceAddress, cfn, parent); - const mapped = this.mapResource(n.logicalId, cfn.Type, props, options); + const mapped = this.mapResource(n.resourceAddress, cfn.Type, props, options); this.registerResource(mapped, n); - debug(`Done creating resource for ${n.logicalId}`); + debug(`Done creating resource ${n.resourceAddress.id} in stack ${n.resourceAddress.stackPath}`); } else { const r = new CdkConstruct(`${this.app.name}/${n.construct.path}`, n.construct.type, { parent, @@ -198,27 +225,29 @@ export class StackConverter extends ArtifactConverter implements intrinsics.Intr */ private registerResource(mapped: ResourceMapping, node: GraphNode): void { const cfn = node.resource; + const resourceAddress = node.resourceAddress; // This should always be set because we only call this function when it is, but // TypeScript doesn't know that. - if (!cfn) { + if (!cfn || !resourceAddress) { throw new Error('Cannot map a resource without a CloudFormation resource'); } + const mainResource: ResourceAttributeMapping | undefined = Array.isArray(mapped) - ? mapped.find((res) => res.logicalId === node.logicalId) + ? mapped.find((res) => res.logicalId === resourceAddress.id) : pulumi.Resource.isInstance(mapped) ? { resource: mapped } : mapped; if (!mainResource) { throw new CdkAdapterError( - `Resource mapping for ${node.logicalId} of type ${cfn.Type} did not return a primary resource. \n` + + `Resource mapping for ${resourceAddress.id} of type ${cfn.Type} did not return a primary resource. \n` + 'Examine your code in "remapCloudControlResource"', ); } const otherResources: pulumi.Resource[] | undefined = Array.isArray(mapped) ? mapped - .filter((map) => map.logicalId !== node.logicalId) + .filter((map) => map.logicalId !== resourceAddress.id) .flatMap((m) => { - this.resources.set(m.logicalId, { + this.resources.set(resourceAddress, { resource: m.resource, attributes: m.attributes, resourceType: cfn.Type, @@ -233,7 +262,7 @@ export class StackConverter extends ArtifactConverter implements intrinsics.Intr otherResources, }; this.constructs.set(node.construct, mainResource.resource); - this.resources.set(node.logicalId!, resourceMapping); + this.resources.set(resourceAddress, resourceMapping); } private stackDependsOn(dependencies: Set): pulumi.Resource[] { @@ -246,14 +275,45 @@ export class StackConverter extends ArtifactConverter implements intrinsics.Intr return dependsOn; } - private mapParameter(logicalId: string, typeName: string, defaultValue: any | undefined) { - // TODO: support arbitrary parameters? + private mapParameter(stackAddress: StackAddress, typeName: string, defaultValue: any | undefined) { + if (!this.stack.isRootStack(stackAddress.stackPath)) { + // This is a nested stack. We need to look up the "AWS::CloudFormation::Stack" from the parent stack and then find the + // parameter in `Properties.Parameters` of the "AWS::CloudFormation::Stack" resource. + // This parameter cannot be resolved immediately because the nested stack resource is not created yet. + // Instead we'll store the parameter in `nestedStackParameters` and resolve it later on demand. + + const nestedStackNode = this.graph.nestedStackNodes.get(stackAddress.stackPath); + if (!nestedStackNode) { + throw new CdkAdapterError(`Could not find nested stack node for ${stackAddress.stackPath}`); + } + + const nestedStackResource = nestedStackNode.resource; + const nestedStackAddress = nestedStackNode.resourceAddress; + if (!nestedStackResource || !nestedStackAddress) { + throw new CdkAdapterError(`Could not find nested stack resource for ${stackAddress.stackPath}`); + } + + // if the parameter is set by the parent stack, we can use it directly. Otherwise, we fall through + // and handle it like any other parameter + const nestedStackParameter = nestedStackResource.Properties?.Parameters?.[stackAddress.id]; + if (nestedStackParameter) { + this.nestedStackParameters.set(stackAddress, { + expression: nestedStackParameter, + stackPath: nestedStackAddress.stackPath, + }); + return; + } + } if (!typeName.startsWith('AWS::SSM::Parameter::')) { - throw new CdkAdapterError(`unsupported parameter ${logicalId} of type ${typeName}`); + throw new CdkAdapterError( + `unsupported parameter ${stackAddress.id} of type ${typeName} in stack ${stackAddress.stackPath}`, + ); } if (defaultValue === undefined) { - throw new CdkAdapterError(`unsupported parameter ${logicalId} with no default value`); + throw new CdkAdapterError( + `unsupported parameter ${stackAddress.id} with no default value in stack ${stackAddress.stackPath}`, + ); } function parameterValue(parent: pulumi.Resource): any { @@ -269,48 +329,62 @@ export class StackConverter extends ArtifactConverter implements intrinsics.Intr return key; } - this.parameters.set(logicalId, parameterValue(this.app.component)); + this.parameters.set(stackAddress, parameterValue(this.app.component)); } private mapResource( - logicalId: string, + resourceAddress: StackAddress, typeName: string, props: any, options: pulumi.ResourceOptions, ): ResourceMapping { if (this.app.appOptions?.remapCloudControlResource !== undefined) { - const res = this.app.appOptions.remapCloudControlResource(logicalId, typeName, props, options); + const res = this.app.appOptions.remapCloudControlResource(resourceAddress.id, typeName, props, options); if (res !== undefined) { resourcesFromResourceMapping(res).forEach((r) => - debug(`[CDK Adapter] remapped type ${typeName} with logicalId ${logicalId}`, r), + debug(`[CDK Adapter] remapped type ${typeName} with logicalId ${resourceAddress.id}`, r), ); return res; } } - const awsMapping = mapToAwsResource(logicalId, typeName, props, options); + const awsMapping = mapToAwsResource(resourceAddress.id, typeName, props, options); if (awsMapping !== undefined) { resourcesFromResourceMapping(awsMapping).forEach((r) => - debug(`[CDK Adapter] mapped type ${typeName} with logicalId ${logicalId} to AWS Provider resource`, r), + debug( + `[CDK Adapter] mapped type ${typeName} with logicalId ${resourceAddress.id} to AWS Provider resource`, + r, + ), ); return awsMapping; } - const customResourceMapping = mapToCustomResource(logicalId, typeName, props, options, this.cdkStack); + const customResourceMapping = mapToCustomResource(resourceAddress.id, typeName, props, options, this.cdkStack); if (customResourceMapping !== undefined) { resourcesFromResourceMapping(customResourceMapping).forEach((r) => - debug(`[CDK Adapter] mapped type ${typeName} with logicalId ${logicalId} to Custom resource`, r), + debug( + `[CDK Adapter] mapped type ${typeName} with logicalId ${resourceAddress.id} to Custom resource`, + r, + ), ); return customResourceMapping; } - const cfnMapping = mapToCfnResource(logicalId, typeName, props, options); + const cfnMapping = mapToCfnResource(resourceAddress.id, typeName, props, options); resourcesFromResourceMapping(cfnMapping).forEach((r) => - debug(`[CDK Adapter] mapped type ${typeName} with logicalId ${logicalId} to CCAPI resource`, r), + debug(`[CDK Adapter] mapped type ${typeName} with logicalId ${resourceAddress.id} to CCAPI resource`, r), ); return cfnMapping; } + private getStackTemplate(stackPath: string): CloudFormationTemplate { + const stack = this.stack.stacks[stackPath]; + if (!stack) { + throw new Error(`Could not find stack template for ${stackPath}`); + } + return stack; + } + /** * Converts a CloudFormation deletion policy to a Pulumi retainOnDelete value. * @@ -323,7 +397,7 @@ export class StackConverter extends ArtifactConverter implements intrinsics.Intr * @param resource - The CloudFormation resource * @returns - The retainOnDelete value */ - private getRetainOnDelete(logicalId: string, resource: CloudFormationResource): boolean | undefined { + private getRetainOnDelete(stackAddress: StackAddress, resource: CloudFormationResource): boolean | undefined { if (resource.DeletionPolicy === undefined) { return undefined; } @@ -336,25 +410,32 @@ export class StackConverter extends ArtifactConverter implements intrinsics.Intr // if it fails to deploy. Pulumi does not have the same behavior, so we will treat it as RETAIN return true; case cdk.CfnDeletionPolicy.SNAPSHOT: - warn(`DeletionPolicy Snapshot is not supported. Resource '${logicalId}' will be retained.`); + warn( + `DeletionPolicy Snapshot is not supported. Resource '${stackAddress.id}' in stack '${stackAddress.stackPath}' will be retained.`, + ); return true; } } private processOptions( - logicalId: string, + stackAddress: StackAddress, resource: CloudFormationResource, parent: pulumi.Resource, ): pulumi.ResourceOptions { const dependsOn = getDependsOn(resource); - const retainOnDelete = this.getRetainOnDelete(logicalId, resource); + const retainOnDelete = this.getRetainOnDelete(stackAddress, resource); return { parent: parent, retainOnDelete, dependsOn: dependsOn?.flatMap((id) => { - const resource = this.resources.get(id); + const resource = this.resources.get({ + id, + stackPath: stackAddress.stackPath, + }); if (resource === undefined) { - throw new Error(`Something went wrong, resource with logicalId '${id}' not found`); + throw new Error( + `Something went wrong, resource with logicalId '${id}' not found in stack '${stackAddress.stackPath}'`, + ); } if (resource.otherResources && resource.otherResources.length > 0) { return resource.otherResources; @@ -367,10 +448,39 @@ export class StackConverter extends ArtifactConverter implements intrinsics.Intr /** @internal */ asOutputValue(v: T): T { const value = this.cdkStack.resolve(v); - return this.processIntrinsics(value) as T; + try { + return this.processIntrinsics(value, this.stack.constructTree.path) as T; + } catch (e) { + // If value is not found in the current stack, try the other stacks + let foundValue = null; + let foundStack = null; + + for (const [stackPath, _] of this.graph.nestedStackNodes) { + if (stackPath === this.stack.constructTree.path) continue; + + try { + const result = this.processIntrinsics(value, stackPath); + if (foundValue !== null) { + throw new CdkAdapterError( + `Value found in multiple stacks: ${foundStack} and ${stackPath}. Pulumi cannot resolve this value.`, + ); + } + foundValue = result; + foundStack = stackPath; + } catch { + // Continue searching other stacks + } + } + + if (foundValue !== null) { + return foundValue as T; + } + + throw e; // Re-throw original error if not found in any stack + } } - public processIntrinsics(obj: any): any { + public processIntrinsics(obj: any, stackPath: string): any { try { debug(`Processing intrinsics for ${JSON.stringify(obj)}`); } catch { @@ -385,17 +495,17 @@ export class StackConverter extends ArtifactConverter implements intrinsics.Intr } if (Array.isArray(obj)) { - return obj.filter((x) => !this.isNoValue(x)).map((x) => this.processIntrinsics(x)); + return obj.filter((x) => !this.isNoValue(x)).map((x) => this.processIntrinsics(x, stackPath)); } const ref = obj.Ref; if (ref) { - return intrinsics.ref.evaluate(this, [ref]); + return intrinsics.ref.evaluate(this, [ref], stackPath); } const keys = Object.keys(obj); if (keys.length == 1 && keys[0]?.startsWith('Fn::')) { - return this.resolveIntrinsic(keys[0], obj[keys[0]]); + return this.resolveIntrinsic(keys[0], obj[keys[0]], stackPath); } // This is where we can do any final processing on the resolved value @@ -417,7 +527,7 @@ export class StackConverter extends ArtifactConverter implements intrinsics.Intr return Object.entries(obj) .filter(([_, v]) => !this.isNoValue(v)) .reduce((result, [k, v]) => { - let value = this.processIntrinsics(v); + let value = this.processIntrinsics(v, stackPath); value = parseDynamicValue(this.stackResource, value); return { ...result, @@ -441,7 +551,7 @@ export class StackConverter extends ArtifactConverter implements intrinsics.Intr return result; } - private resolveIntrinsic(fn: string, params: any) { + private resolveIntrinsic(fn: string, params: any, stackPath: string) { switch (fn) { case 'Fn::GetAtt': { const logicalId = params[0]; @@ -451,30 +561,26 @@ export class StackConverter extends ArtifactConverter implements intrinsics.Intr // Ipv6 cidr blocks are added to the VPC through a separate VpcCidrBlock resource // Due to [pulumi/pulumi-aws-native#1798] the `Ipv6CidrBlocks` attribute will always be empty // and we need to instead pull the `Ipv6CidrBlock` attribute from the VpcCidrBlock resource. - if ( - logicalId in this.graph.vpcNodes && - attributeName === 'Ipv6CidrBlocks' && - this.graph.vpcNodes[logicalId].vpcCidrBlockNode?.logicalId - ) { - return [ - this.resolveAtt(this.graph.vpcNodes[logicalId].vpcCidrBlockNode.logicalId, 'Ipv6CidrBlock'), - ]; + const vpcNodeAddress = this.graph.vpcNodes.get({ stackPath, id: logicalId })?.vpcCidrBlockNode + ?.resourceAddress; + if (attributeName === 'Ipv6CidrBlocks' && vpcNodeAddress) { + return [this.resolveAtt(vpcNodeAddress, 'Ipv6CidrBlock')]; } - return this.resolveAtt(params[0], params[1]); + return this.resolveAtt({ id: params[0], stackPath }, params[1]); } case 'Fn::Join': - return lift(([delim, strings]) => strings.join(delim), this.processIntrinsics(params)); + return lift(([delim, strings]) => strings.join(delim), this.processIntrinsics(params, stackPath)); case 'Fn::Select': - return lift(([index, list]) => list[index], this.processIntrinsics(params)); + return lift(([index, list]) => list[index], this.processIntrinsics(params, stackPath)); case 'Fn::Split': - return lift(([delim, str]) => str.split(delim), this.processIntrinsics(params)); + return lift(([delim, str]) => str.split(delim), this.processIntrinsics(params, stackPath)); case 'Fn::Base64': - return lift((str) => Buffer.from(str).toString('base64'), this.processIntrinsics(params)); + return lift((str) => Buffer.from(str).toString('base64'), this.processIntrinsics(params, stackPath)); case 'Fn::Cidr': { return lift( @@ -484,11 +590,14 @@ export class StackConverter extends ArtifactConverter implements intrinsics.Intr count: parseInt(count, 10), cidrBits: parseInt(cidrBits, 10), }).then((r) => r.subnets), - this.processIntrinsics(params), + this.processIntrinsics(params, stackPath), ); } case 'Fn::GetAZs': - return lift(([region]) => getAzs({ region }).then((r) => r.azs), this.processIntrinsics(params)); + return lift( + ([region]) => getAzs({ region }).then((r) => r.azs), + this.processIntrinsics(params, stackPath), + ); case 'Fn::Sub': return lift((params) => { @@ -502,15 +611,15 @@ export class StackConverter extends ArtifactConverter implements intrinsics.Intr if (part.ref !== undefined) { if (part.ref.attr !== undefined) { - parts.push(this.resolveAtt(part.ref.id, part.ref.attr!)); + parts.push(this.resolveAtt({ id: part.ref.id, stackPath }, part.ref.attr!)); } else { - parts.push(intrinsics.ref.evaluate(this, [part.ref.id])); + parts.push(intrinsics.ref.evaluate(this, [part.ref.id], stackPath)); } } } return lift((parts) => parts.map((v: any) => v.toString()).join(''), parts); - }, this.processIntrinsics(params)); + }, this.processIntrinsics(params, stackPath)); case 'Fn::Transform': { // https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/template-macros.html @@ -529,17 +638,21 @@ export class StackConverter extends ArtifactConverter implements intrinsics.Intr if (params.length !== 3) { throw new CdkAdapterError(`Fn::FindInMap requires exactly 3 parameters, got ${params.length}`); } - if (!this.stack.mappings) { + const stack = this.stack.stacks[stackPath]; + if (!stack) { + throw new Error(`No stack found for ${stackPath}`); + } + if (!stack.Mappings) { throw new Error(`No mappings found in stack`); } - if (!(mappingLogicalName in this.stack.mappings)) { + if (!(mappingLogicalName in stack.Mappings)) { throw new Error( - `Mapping ${mappingLogicalName} not found in mappings. Available mappings are ${Object.keys( - this.stack.mappings, + `Mapping ${mappingLogicalName} not found in mappings of stack ${stackPath}. Available mappings are ${Object.keys( + stack.Mappings, )}`, ); } - const topLevelMapping = this.stack.mappings[mappingLogicalName]; + const topLevelMapping = stack.Mappings[mappingLogicalName]; if (!(topLevelKey in topLevelMapping)) { throw new Error( `Key ${topLevelKey} not found in mapping ${mappingLogicalName}. Available keys are ${Object.keys( @@ -558,19 +671,19 @@ export class StackConverter extends ArtifactConverter implements intrinsics.Intr const value = secondLevelMapping[secondLevelKey]; return value; - }, this.processIntrinsics(params)); + }, this.processIntrinsics(params, stackPath)); } case 'Fn::Equals': { - return intrinsics.fnEquals.evaluate(this, params); + return intrinsics.fnEquals.evaluate(this, params, stackPath); } case 'Fn::If': { - return intrinsics.fnIf.evaluate(this, params); + return intrinsics.fnIf.evaluate(this, params, stackPath); } case 'Fn::Or': { - return intrinsics.fnOr.evaluate(this, params); + return intrinsics.fnOr.evaluate(this, params, stackPath); } default: @@ -578,30 +691,49 @@ export class StackConverter extends ArtifactConverter implements intrinsics.Intr } } - private lookup(logicalId: string): Mapping | { value: any } { - const targetParameter = this.parameters.get(logicalId); + private lookup(stackAddress: StackAddress): Mapping | { value: any } { + const targetParameter = this.parameters.get(stackAddress); if (targetParameter !== undefined) { return { value: targetParameter }; } - const targetMapping = this.resources.get(logicalId); + const targetMapping = this.resources.get(stackAddress); if (targetMapping !== undefined) { return targetMapping; } - throw new Error(`missing reference for ${logicalId}`); + throw new Error(`missing reference for ${stackAddress.id} in stack ${stackAddress.stackPath}`); } - private resolveAtt(logicalId: string, attribute: string) { - const mapping = >this.lookup(logicalId); + private resolveAtt(resourceAddress: StackAddress, attribute: string) { + const mapping = >this.lookup(resourceAddress); debug( - `Resource: ${logicalId} - resourceType: ${mapping.resourceType} - ${Object.getOwnPropertyNames( - mapping.resource, - )}`, + `Resource: ${resourceAddress.id} - stackPath: ${resourceAddress.stackPath} - resourceType: ${ + mapping.resourceType + } - ${Object.getOwnPropertyNames(mapping.resource)}`, ); // If this resource has explicit attribute mappings, those mappings will use PascalCase, not camelCase. const propertyName = mapping.attributes !== undefined ? attribute : attributePropertyName(attribute); + if (NestedStackConstruct.isNestedStackConstruct(mapping.resource)) { + const nestedStackNode = this.nestedStackNodes.get(resourceAddress); + if (!nestedStackNode) { + throw new Error(`Could not find nested stack node for path ${resourceAddress.stackPath}`); + } + + const nestedStack = this.stack.stacks[nestedStackNode.construct.path]; + if (!nestedStack) { + throw new Error(`Could not find nested stack template for path ${nestedStackNode.construct.path}`); + } + + const outputName = attribute.replace(/^Outputs\./, ''); + if (nestedStack.Outputs?.[outputName]?.Value) { + return this.processIntrinsics(nestedStack.Outputs[outputName].Value, nestedStackNode.construct.path); + } + + throw new Error(`No output ${outputName} found in nested stack ${nestedStackNode.construct.path}`); + } + // CFN CustomResources have a `data` property that contains the attributes. It is part of the response // of the Lambda Function backing the Custom Resource. if (aws.cloudformation.CustomResourceEmulator.isInstance(mapping.resource)) { @@ -609,7 +741,9 @@ export class StackConverter extends ArtifactConverter implements intrinsics.Intr const descs = Object.getOwnPropertyDescriptors(attrs); const d = descs[attribute]; if (!d) { - throw new Error(`No attribute ${attribute} on custom resource ${logicalId}`); + throw new Error( + `No attribute ${attribute} on custom resource ${resourceAddress.id} in stack ${resourceAddress.stackPath}`, + ); } return d.value; }); @@ -619,22 +753,23 @@ export class StackConverter extends ArtifactConverter implements intrinsics.Intr const d = descs[propertyName]; if (!d) { throw new CdkAdapterError( - `No property ${propertyName} for attribute ${attribute} on resource ${logicalId}`, + `No property ${propertyName} for attribute ${attribute} on resource ${resourceAddress.id} in stack ${resourceAddress.stackPath}`, ); } return d.value; } - findCondition(conditionName: string): intrinsics.Expression | undefined { - if (conditionName in (this.stack.conditions || {})) { - return this.stack.conditions![conditionName]; + findCondition(stackAddress: StackAddress): intrinsics.Expression | undefined { + const template = this.getStackTemplate(stackAddress.stackPath); + if (stackAddress.id in (template.Conditions || {})) { + return template.Conditions![stackAddress.id]; } else { return undefined; } } - evaluate(expression: intrinsics.Expression): intrinsics.Result { - return this.processIntrinsics(expression); + evaluate(expression: intrinsics.Expression, stackPath: string): intrinsics.Result { + return this.processIntrinsics(expression, stackPath); } fail(msg: string): intrinsics.Result { @@ -649,21 +784,32 @@ export class StackConverter extends ArtifactConverter implements intrinsics.Intr return lift(fn, result); } - findParameter(parameterLogicalId: CloudFormationParameterLogicalId): CloudFormationParameterWithId | undefined { - const p: CloudFormationParameter | undefined = (this.stack.parameters || {})[parameterLogicalId]; - return p ? { ...p, id: parameterLogicalId } : undefined; + findParameter(stackAddress: StackAddress): CloudFormationParameterWithId | undefined { + const template = this.getStackTemplate(stackAddress.stackPath); + const p: CloudFormationParameter | undefined = (template.Parameters || {})[stackAddress.id]; + return p ? { ...p, stackAddress } : undefined; } evaluateParameter(param: CloudFormationParameterWithId): intrinsics.Result { - const value = this.parameters.get(param.id); + const value = this.parameters.get(param.stackAddress); + if (value === undefined) { - throw new Error(`No value for the CloudFormation "${param.id}" parameter`); + // If the parameter is a nested stack parameter we need to resolve the expression from the nested stack resource. + const nestedStackParam = this.nestedStackParameters.get(param.stackAddress); + if (nestedStackParam !== undefined) { + return this.evaluate(nestedStackParam.expression, nestedStackParam.stackPath); + } + + throw new Error( + `No value for the CloudFormation parameter "${param.stackAddress.id}" in stack "${param.stackAddress.stackPath}"`, + ); } + return value; } - findResourceMapping(resourceLogicalID: string): Mapping | undefined { - return this.resources.get(resourceLogicalID); + findResourceMapping(stackAddress: StackAddress): Mapping | undefined { + return this.resources.get(stackAddress); } tryFindResource(cfnType: string): PulumiResource | undefined { diff --git a/src/converters/intrinsics.ts b/src/converters/intrinsics.ts index f2ef073d..8b05983a 100644 --- a/src/converters/intrinsics.ts +++ b/src/converters/intrinsics.ts @@ -21,6 +21,7 @@ import { Mapping } from '../types'; import { PulumiResource } from '../pulumi-metadata'; import { toSdkName } from '../naming'; import { OutputRepr, isOutputReprInstance } from '../output-map'; +import { StackAddress } from '../assembly'; /** * Models a CF Intrinsic Function. @@ -45,7 +46,7 @@ export interface Intrinsic { * processing them. Conditional intrinsics such as 'Fn::If' or 'Fn::Or' are an exception to this and need to * evaluate their parameters only when necessary. */ - evaluate(ctx: IntrinsicContext, params: Expression[]): Result; + evaluate(ctx: IntrinsicContext, params: Expression[], stackPath: string): Result; } /** @@ -69,6 +70,16 @@ export interface Expression {} // eslint-disable-next-line export interface Result {} +/** + * A nested stack parameter is a parameter that is defined in a nested stack and configured in the parent stack. + * + * @internal + */ +export interface NestedStackParameter { + expression: Expression; + stackPath: string; +} + /** * Context available when evaluating CF expressions. * @@ -85,22 +96,22 @@ export interface IntrinsicContext { * * See https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/conditions-section-structure.html */ - findCondition(conditionName: string): Expression | undefined; + findCondition(stackAddress: StackAddress): Expression | undefined; /** * Finds the value of a CF expression evaluating any intrinsic functions or references within. */ - evaluate(expression: Expression): Result; + evaluate(expression: Expression, stackPath: string): Result; /** * Resolves a logical parameter ID to a parameter, or indicates that no such parameter is defined on the template. */ - findParameter(parameterLogicalID: string): CloudFormationParameterWithId | undefined; + findParameter(stackAddress: StackAddress): CloudFormationParameterWithId | undefined; /** * Resolves a logical resource ID to a Mapping. */ - findResourceMapping(resourceLogicalID: string): Mapping | undefined; + findResourceMapping(stackAddress: StackAddress): Mapping | undefined; /** * Find the current value of a given Cf parameter. @@ -173,7 +184,7 @@ export interface IntrinsicContext { */ export const fnIf: Intrinsic = { name: 'Fn::If', - evaluate: (ctx: IntrinsicContext, params: Expression[]): Result => { + evaluate: (ctx: IntrinsicContext, params: Expression[], stackPath: string): Result => { if (params.length !== 3) { return ctx.fail(`Expected 3 parameters, got ${params.length}`); } @@ -186,11 +197,11 @@ export const fnIf: Intrinsic = { const exprIfTrue = params[1]; const exprIfFalse = params[2]; - return ctx.apply(evaluateCondition(ctx, conditionName), (ok) => { + return ctx.apply(evaluateCondition(ctx, conditionName, stackPath), (ok) => { if (ok) { - return ctx.evaluate(exprIfTrue); + return ctx.evaluate(exprIfTrue, stackPath); } else { - return ctx.evaluate(exprIfFalse); + return ctx.evaluate(exprIfFalse, stackPath); } }); }, @@ -213,7 +224,7 @@ export const fnIf: Intrinsic = { */ export const fnOr: Intrinsic = { name: 'Fn::Or', - evaluate: (ctx: IntrinsicContext, params: Expression[]): Result => { + evaluate: (ctx: IntrinsicContext, params: Expression[], stackPath: string): Result => { if (params.length < 2) { return ctx.fail(`Fn::Or expects at least 2 params, got ${params.length}`); } @@ -222,7 +233,7 @@ export const fnOr: Intrinsic = { if (ok) { return ctx.succeed(true); } else { - return evaluateConditionSubExpression(ctx, expr); + return evaluateConditionSubExpression(ctx, expr, stackPath); } }); return params.reduce(reducer, ctx.succeed(false)); @@ -246,7 +257,7 @@ export const fnOr: Intrinsic = { */ export const fnAnd: Intrinsic = { name: 'Fn::And', - evaluate: (ctx: IntrinsicContext, params: Expression[]): Result => { + evaluate: (ctx: IntrinsicContext, params: Expression[], stackPath: string): Result => { if (params.length < 2) { return ctx.fail(`Fn::And expects at least 2 params, got ${params.length}`); } @@ -255,7 +266,7 @@ export const fnAnd: Intrinsic = { if (!ok) { return ctx.succeed(false); } else { - return evaluateConditionSubExpression(ctx, expr); + return evaluateConditionSubExpression(ctx, expr, stackPath); } }); return params.reduce(reducer, ctx.succeed(true)); @@ -278,11 +289,11 @@ export const fnAnd: Intrinsic = { */ export const fnNot: Intrinsic = { name: 'Fn::Not', - evaluate: (ctx: IntrinsicContext, params: Expression[]): Result => { + evaluate: (ctx: IntrinsicContext, params: Expression[], stackPath: string): Result => { if (params.length != 1) { return ctx.fail(`Fn::Not expects exactly 1 param, got ${params.length}`); } - const x = evaluateConditionSubExpression(ctx, params[0]); + const x = evaluateConditionSubExpression(ctx, params[0], stackPath); return ctx.apply(x, (v) => ctx.succeed(!v)); }, }; @@ -297,12 +308,12 @@ export const fnNot: Intrinsic = { */ export const fnEquals: Intrinsic = { name: 'Fn::Equals', - evaluate: (ctx: IntrinsicContext, params: Expression[]): Result => { + evaluate: (ctx: IntrinsicContext, params: Expression[], stackPath: string): Result => { if (params.length != 2) { return ctx.fail(`Fn::Equals expects exactly 2 params, got ${params.length}`); } - return ctx.apply(ctx.evaluate(params[0]), (x) => - ctx.apply(ctx.evaluate(params[1]), (y) => { + return ctx.apply(ctx.evaluate(params[0], stackPath), (x) => + ctx.apply(ctx.evaluate(params[1], stackPath), (y) => { if (equal(x, y)) { return ctx.succeed(true); } else { @@ -325,7 +336,7 @@ export const fnEquals: Intrinsic = { */ export const ref: Intrinsic = { name: 'Ref', - evaluate: (ctx: IntrinsicContext, params: Expression[]): Result => { + evaluate: (ctx: IntrinsicContext, params: Expression[], stackPath: string): Result => { if (params.length != 1) { return ctx.fail(`Ref intrinsic expects exactly 1 param, got ${params.length}`); } @@ -341,17 +352,17 @@ export const ref: Intrinsic = { // // CF docs: "When the AWS::LanguageExtensions transform is used, you can use intrinsic functions..". if (typeof param !== 'string') { - const s = ctx.apply(ctx.evaluate(param), (p) => mustBeString(ctx, p)); - return ctx.apply(s, (name) => evaluateRef(ctx, name)); + const s = ctx.apply(ctx.evaluate(param, stackPath), (p) => mustBeString(ctx, p)); + return ctx.apply(s, (name) => evaluateRef(ctx, name, stackPath)); } - return evaluateRef(ctx, param); + return evaluateRef(ctx, param, stackPath); }, }; /** * See `ref`. */ -function evaluateRef(ctx: IntrinsicContext, param: string): Result { +function evaluateRef(ctx: IntrinsicContext, param: string, stackPath: string): Result { // Handle pseudo-parameters. // See https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/pseudo-parameter-reference.html switch (param) { @@ -378,13 +389,13 @@ function evaluateRef(ctx: IntrinsicContext, param: string): Result { } // Handle Cf template parameters. - const cfParam = ctx.findParameter(param); + const cfParam = ctx.findParameter({ stackPath, id: param }); if (cfParam !== undefined) { return ctx.evaluateParameter(cfParam); } // Handle references to resources. - const map = ctx.findResourceMapping(param); + const map = ctx.findResourceMapping({ stackPath, id: param }); if (map !== undefined) { if (map.attributes && 'id' in map.attributes) { // Users may override the `id` in a custom-supplied mapping, respect this. @@ -443,7 +454,9 @@ function evaluateRef(ctx: IntrinsicContext, param: string): Result { }); } - return ctx.fail(`Ref intrinsic unable to resolve ${param}: not a known logical resource or parameter reference`); + return ctx.fail( + `Ref intrinsic unable to resolve ${param} in stack ${stackPath}: not a known logical resource or parameter reference`, + ); } /** @@ -463,12 +476,12 @@ function parseConditionExpr(raw: Expression): string | undefined { /** * Like `ctx.evaluate` but also recognizes Condition sub-expressions as required by `Fn::Or`. */ -function evaluateConditionSubExpression(ctx: IntrinsicContext, expr: Expression): Result { +function evaluateConditionSubExpression(ctx: IntrinsicContext, expr: Expression, stackPath: string): Result { const firstExprConditonName = parseConditionExpr(expr); if (firstExprConditonName !== undefined) { - return evaluateCondition(ctx, firstExprConditonName); + return evaluateCondition(ctx, firstExprConditonName, stackPath); } else { - return ctx.apply(ctx.evaluate(expr), (r) => mustBeBoolean(ctx, r)); + return ctx.apply(ctx.evaluate(expr, stackPath), (r) => mustBeBoolean(ctx, r)); } } @@ -488,10 +501,10 @@ function mustBeString(ctx: IntrinsicContext, r: any): Result { } } -function evaluateCondition(ctx: IntrinsicContext, conditionName: string): Result { - const conditionExpr = ctx.findCondition(conditionName); +function evaluateCondition(ctx: IntrinsicContext, conditionName: string, stackPath: string): Result { + const conditionExpr = ctx.findCondition({ stackPath, id: conditionName }); if (conditionExpr === undefined) { return ctx.fail(`No condition '${conditionName}' found`); } - return ctx.apply(ctx.evaluate(conditionExpr), (r) => mustBeBoolean(ctx, r)); + return ctx.apply(ctx.evaluate(conditionExpr, stackPath), (r) => mustBeBoolean(ctx, r)); } diff --git a/src/graph.ts b/src/graph.ts index 312826e0..7bed7a83 100644 --- a/src/graph.ts +++ b/src/graph.ts @@ -14,13 +14,24 @@ import { debug, warn } from '@pulumi/pulumi/log'; import { CloudFormationResource } from './cfn'; import { parseSub } from './sub'; -import { ConstructTree, StackManifest } from './assembly'; +import { ConstructTree, StackAddress, StackManifest } from './assembly'; import { CdkAdapterError } from './types'; +import { StackMap } from './stack-map'; +import { Node } from 'aws-cdk-lib/core/lib/private/tree-metadata'; // Represents a value that will be used as the (or part of the) pulumi resource // type token export type PulumiResourceType = string; +interface NestedStackData { + // The stack address of the nested stack resource. Uniquely identifies the nested stack resource across all stacks. + resourceAddress: StackAddress; + // The CloudFormation resource (AWS::CloudFormation::Stack) that represents the nested stack + resource: CloudFormationResource; + // The graph node representing the nested stack. It contains all the children of the nested stack. + node: Node; +} + /** * Represents a CDK Construct */ @@ -62,13 +73,14 @@ export interface ConstructMetadata { export interface GraphNode { incomingEdges: Set; outgoingEdges: Set; + /** - * The CFN LogicalID. + * The StackAddress uniquely identifying the CFN resource in the (nested) stacks. * * This will only be set if this node represents a CloudFormation resource. * It will not be set for wrapper constructs */ - logicalId?: string; + resourceAddress?: StackAddress; /** * The info on the Construct this node represents @@ -129,7 +141,12 @@ export interface Graph { /** * The VPC nodes in the graph */ - vpcNodes: { [logicalId: string]: VpcGraphNode }; + vpcNodes: StackMap; + + /** + * The nested stack nodes in the graph indexed by the stack path + */ + nestedStackNodes: Map; } /** @@ -150,17 +167,22 @@ export interface VpcGraphNode { export class GraphBuilder { // Allows for easy access to the GraphNode of a specific Construct constructNodes: Map; - // Map of resource logicalId to GraphNode. Allows for easy lookup by logicalId - cfnElementNodes: Map; + // Map of stack address to GraphNode. Allows for easy lookup by stack address + cfnElementNodes: StackMap; // If the app has a VpcCidrBlock resource, this will be set to the GraphNode representing it - private readonly _vpcCidrBlockNodes: { [logicalId: string]: GraphNode } = {}; + private readonly _vpcCidrBlockNodes: StackMap; // If the app has a Vpc resource, this will be set to the GraphNode representing it - private readonly vpcNodes: { [logicalId: string]: VpcGraphNode } = {}; + private readonly vpcNodes: StackMap; + // Map of stack path to GraphNode of nested stacks. Allows for looking up the nested stack node by stack path + private readonly nestedStackNodes: Map; constructor(private readonly stack: StackManifest) { this.constructNodes = new Map(); - this.cfnElementNodes = new Map(); + this.cfnElementNodes = new StackMap(); + this._vpcCidrBlockNodes = new StackMap(); + this.vpcNodes = new StackMap(); + this.nestedStackNodes = new Map(); } // build constructs a dependency graph from the adapter and returns its nodes sorted in topological order. @@ -185,6 +207,7 @@ export class GraphBuilder { attributes: tree.attributes, constructInfo: tree.constructInfo, }; + const node: GraphNode = { incomingEdges: new Set(), outgoingEdges: new Set(), @@ -192,33 +215,33 @@ export class GraphBuilder { }; if (tree.attributes && 'aws:cdk:cloudformation:type' in tree.attributes) { const cfnType = tree.attributes['aws:cdk:cloudformation:type'] as string; - const logicalId = this.stack.logicalIdForPath(tree.path); - const resource = this.stack.resourceWithLogicalId(logicalId); + const resourceAddress = this.stack.resourceAddressForPath(tree.path); + const resource = this.stack.resourceWithLogicalId(resourceAddress.stackPath, resourceAddress.id); const typ = typeFromCfn(cfnType); node.construct.type = typ; construct.type = typ; if (resource.Type === cfnType) { node.resource = resource; - node.logicalId = logicalId; - this.cfnElementNodes.set(logicalId, node); + node.resourceAddress = resourceAddress; + this.cfnElementNodes.set(resourceAddress, node); } else { throw new CdkAdapterError( `Something went wrong: resourceType ${resource.Type} does not equal CfnType ${cfnType}`, ); } if (resource.Type === 'AWS::EC2::VPCCidrBlock') { - this._vpcCidrBlockNodes[node.logicalId] = node; + this._vpcCidrBlockNodes.set(resourceAddress, node); } if (resource.Type === 'AWS::EC2::VPC') { - this.vpcNodes[node.logicalId] = { vpcNode: node, vpcCidrBlockNode: undefined }; + this.vpcNodes.set(resourceAddress, { vpcNode: node, vpcCidrBlockNode: undefined }); } } else if (node.construct.constructInfo?.fqn === 'aws-cdk-lib.CfnResource') { // If the construct is a CfnResource, then we need to treat it as a resource - const logicalId = this.stack.logicalIdForPath(tree.path); - const resource = this.stack.resourceWithLogicalId(logicalId); + const resourceAddress = this.stack.resourceAddressForPath(tree.path); + const resource = this.stack.resourceWithLogicalId(resourceAddress.stackPath, resourceAddress.id); node.resource = resource; - node.logicalId = logicalId; - this.cfnElementNodes.set(logicalId, node); + node.resourceAddress = resourceAddress; + this.cfnElementNodes.set(resourceAddress, node); // Custom Resources do not map to types. E.g. Custom::Bucket should not map to Bucket if (!GraphBuilder.isCustomResource(tree, parent)) { @@ -227,11 +250,132 @@ export class GraphBuilder { } this.constructNodes.set(construct, node); + + const nestedStacks = this.findNestedStacks(tree); + nestedStacks.forEach((nestedStack) => { + const nestedStackConstruct: ConstructInfo = { + parent: construct, + id: nestedStack.node.id, + path: nestedStack.node.path, + type: typeFromCfn('AWS::CloudFormation::Stack'), + constructInfo: nestedStack.node.constructInfo, + }; + + const node: GraphNode = { + incomingEdges: new Set(), + outgoingEdges: new Set(), + construct: nestedStackConstruct, + resource: nestedStack.resource, + resourceAddress: nestedStack.resourceAddress, + }; + + this.cfnElementNodes.set(nestedStack.resourceAddress, node); + this.constructNodes.set(nestedStackConstruct, node); + this.nestedStackNodes.set(nestedStack.node.path, node); + + // load all the children of the nested stack + Object.values(nestedStack.node.children ?? {}).forEach((child) => + this.parseTree(child, nestedStackConstruct), + ); + }); + + // handle all the children of the current construct that are not nested stacks if (tree.children) { - Object.values(tree.children).forEach((child) => this.parseTree(child, construct)); + Object.values(tree.children) + .filter( + (child) => + !nestedStacks.find( + (nestedStack) => + nestedStack.node.id === child.id || child.id === `${nestedStack.node.id}.NestedStack`, + ), + ) + .forEach((child) => this.parseTree(child, construct)); } } + /** + * Finds all the nested stacks in the children of the current construct tree node. + * It identifies nested stacks by matching a nested stack resource node (named `NAME.NestedStack`) + * with a child that is a 'AWS::CloudFormation::Stack' to a tree node (named `NAME`) that includes + * all the children of the nested stack. + * + * The tree structure looks like this: + * ``` + * Root + * ├── MyNestedStack.NestedStack (Nested Stack Resource Node) + * │ └── AWS::CloudFormation::Stack + * │ └── Properties + * │ └── Parameters + * │ └── MyParameter + * │ + * ├── MyNestedStack (Nested Stack Node) + * │ ├── ChildResource1 + * │ │ └── Properties + * │ └── ChildResource2 + * │ └── Properties + * ``` + * + * @param tree - The construct tree node to search + * @returns The nested stacks found in the construct tree + */ + private findNestedStacks(tree: ConstructTree): NestedStackData[] { + // find the nested stack resources. Those are nodes that have a name ending in `.NestedStack` + // and have a child that is a 'AWS::CloudFormation::Stack' resource + const nestedStackResources = Object.values(tree.children ?? {}) + .map((child) => { + if (!child.id.endsWith('.NestedStack')) { + return undefined; + } + + return Object.values(child.children ?? {}).find((child) => { + return ( + child.attributes && + 'aws:cdk:cloudformation:type' in child.attributes && + child.attributes['aws:cdk:cloudformation:type'] === 'AWS::CloudFormation::Stack' + ); + }); + }) + .filter((x) => x !== undefined); + + const nestedStacks = nestedStackResources + .map((nestedStackResourceNode) => { + debug( + `Found potential nested stack resource ${nestedStackResourceNode.id} in ${nestedStackResourceNode.path}`, + ); + const nestedStackPath = StackManifest.getNestedStackPath( + nestedStackResourceNode.path, + nestedStackResourceNode.id, + ); + const resourceAddress = this.stack.resourceAddressForPath(nestedStackResourceNode.path); + const resource = this.stack.resourceWithLogicalId(resourceAddress.stackPath, resourceAddress.id); + + // the nested stack node is the node that contains all the child nodes of the nested stack + // and is on the same level as the nested stack resource node. Its path should match the + // nestedStackPath that was computed above + const nestedStackNode = Object.values(tree.children ?? {}).find( + (child) => child.path === nestedStackPath, + ); + + if (!nestedStackNode) { + // This is not a nested CDK stack, but just a regular CFN stack that accidentally follows the CDK naming + debug( + `CloudFormation stack ${nestedStackResourceNode.id} in ${nestedStackResourceNode.path} does not correspond to a nested CDK stack. Handling it as a regular resource.`, + ); + return; + } + + return nestedStackResourceNode + ? { + resource, + node: nestedStackNode, + resourceAddress, + } + : undefined; + }) + .filter((x) => x !== undefined); + return nestedStacks; + } + private static isCustomResource(node: ConstructTree, parent?: ConstructInfo): boolean { // CDK CustomResource are exposed as a CfnResource with the ID "Default" // If the parent construct has the fqn of CustomResource and the current tree node is the "Default" node @@ -252,42 +396,58 @@ export class GraphBuilder { this.parseTree(this.stack.constructTree); const unmappedResources: string[] = []; - Object.entries(this.stack.resources).forEach(([logicalId, resource]) => { - if (!this.cfnElementNodes.has(logicalId)) { - warn(`CDK resource ${logicalId} (${resource.Type}) was not mapped to a Pulumi resource.`); - unmappedResources.push(logicalId); - } + Object.entries(this.stack.stacks).map(([stackPath, template]) => { + Object.entries(template.Resources ?? {}).map(([logicalId, resource]) => { + if (!this.cfnElementNodes.has({ id: logicalId, stackPath })) { + warn(`CDK resource ${logicalId} (${resource.Type}) was not mapped to a Pulumi resource.`); + unmappedResources.push(logicalId); + } + }); }); + if (unmappedResources.length > 0) { - const total = Object.keys(this.stack.resources).length; + const total = Object.entries(this.stack.stacks) + .map(([_, template]) => Object.keys(template.Resources ?? {}).length) + .reduce((a, b) => a + b, 0); throw new CdkAdapterError( - `Adapter] ${unmappedResources.length} out of ${total} CDK resources failed to map to Pulumi resources.`, + `${unmappedResources.length} out of ${total} CDK resources failed to map to Pulumi resources.`, ); } // parseTree does not guarantee that the VPC resource will be parsed before the VPCCidrBlock resource // so we need to process this separately after - if (Object.keys(this._vpcCidrBlockNodes).length) { - Object.entries(this._vpcCidrBlockNodes).forEach(([logicalId, node]) => { + if (this._vpcCidrBlockNodes.size > 0) { + this._vpcCidrBlockNodes.forEach((node, resourceAddress) => { const resource = node.resource; - if (!resource) { + if (!resource || !node.resourceAddress) { throw new CdkAdapterError( - `Something went wrong. CFN Resource not found for VPCCidrBlock ${logicalId}`, + `Something went wrong. CFN Resource not found for VPCCidrBlock ${resourceAddress.id} in stack ${resourceAddress.stackPath}`, ); } const vpcRef = resource.Properties.VpcId; if (typeof vpcRef === 'object' && 'Ref' in vpcRef) { - const vpcLogicalId = this.cfnElementNodes.get(vpcRef.Ref)?.logicalId; - if (!vpcLogicalId) { + const vpcResourceAddress = this.cfnElementNodes.get({ + stackPath: node.resourceAddress.stackPath, + id: vpcRef.Ref, + })?.resourceAddress; + if (!vpcResourceAddress) { + throw new CdkAdapterError( + `VPC resource ${vpcRef.Ref} not found for VPCCidrBlock ${resourceAddress.id} in stack ${resourceAddress.stackPath}`, + ); + } + const vpcNode = this.vpcNodes.get(vpcResourceAddress); + if (!vpcNode) { throw new CdkAdapterError( - `VPC resource ${vpcRef.Ref} not found for VPCCidrBlock ${node.logicalId}`, + `VPC resource ${vpcRef.Ref} not found for VPCCidrBlock ${resourceAddress.id} in stack ${resourceAddress.stackPath}`, ); } - const vpcNode = this.vpcNodes[vpcLogicalId]; + // currently the CDK VPC only supports a single VPCCidrBlock per VPC so for now we won't allow multiple // if we get requests for this we can update this to support multiple if (vpcNode.vpcCidrBlockNode) { - throw new CdkAdapterError(`VPC ${vpcLogicalId} already has a VPCCidrBlock`); + throw new CdkAdapterError( + `VPC ${vpcResourceAddress.id} in stack ${resourceAddress.stackPath} already has a VPCCidrBlock`, + ); } vpcNode.vpcCidrBlockNode = node; } @@ -295,7 +455,7 @@ export class GraphBuilder { } for (const [construct, node] of this.constructNodes) { - // No parent means this is the construct that represents the `Stack` + // No parent means this is the construct that represents the root `Stack` if (construct.parent !== undefined) { const parentNode = this.constructNodes.get(construct.parent)!; node.outgoingEdges.add(parentNode); @@ -303,8 +463,14 @@ export class GraphBuilder { } // Then this is the construct representing the CFN resource (i.e. not a wrapper construct) - if (node.resource && node.logicalId) { - const source = this.cfnElementNodes.get(node.logicalId!)!; + if (node.resource && node.resourceAddress) { + const source = this.cfnElementNodes.get(node.resourceAddress); + if (!source) { + throw new CdkAdapterError( + `CFN Resource not found for ${node.resourceAddress.id} in stack ${node.resourceAddress.stackPath}`, + ); + } + this.addEdgesForCfnResource(node.resource, source); const dependsOn = @@ -344,10 +510,11 @@ export class GraphBuilder { return { nodes: sorted, vpcNodes: this.vpcNodes, + nestedStackNodes: this.nestedStackNodes, }; } - private addEdgesForCfnResource(obj: any, source: GraphNode): void { + private addEdgesForCfnResource(obj: any, source: GraphNode, stackPath?: string): void { // Since we are processing the final CloudFormation template, strings will always // be the fully resolved value if (typeof obj === 'string') { @@ -359,38 +526,60 @@ export class GraphBuilder { } if (Array.isArray(obj)) { - obj.map((x) => this.addEdgesForCfnResource(x, source)); + obj.map((x) => this.addEdgesForCfnResource(x, source, stackPath)); return; } const ref = obj.Ref; if (ref) { - this.addEdgeForRef(ref, source); + this.addEdgeForRef(ref, source, stackPath); return; } const keys = Object.keys(obj); if (keys.length == 1 && keys[0]?.startsWith('Fn::')) { - this.addEdgesForIntrinsic(keys[0], obj[keys[0]], source); + this.addEdgesForIntrinsic(keys[0], obj[keys[0]], source, stackPath); return; } for (const v of Object.values(obj)) { - this.addEdgesForCfnResource(v, source); + this.addEdgesForCfnResource(v, source, stackPath); } } - private addEdgeForRef(args: any, source: GraphNode) { + private addEdgeForRef(args: any, source: GraphNode, stackPath?: string) { if (typeof args !== 'string') { // Ignore these--they are either malformed references or Pulumi outputs. return; } const targetLogicalId = args; + stackPath = stackPath ?? source.resourceAddress?.stackPath; + if (!stackPath) { + throw new CdkAdapterError( + `Resource address not set for source node ${source.construct.path}. Cannot locate the source resource.`, + ); + } debug(`ref to ${args}`); if (!targetLogicalId.startsWith('AWS::')) { - const targetNode = this.cfnElementNodes.get(targetLogicalId); + const targetNode = this.cfnElementNodes.get({ + id: targetLogicalId, + stackPath, + }); if (targetNode === undefined) { + // If this is a nested stack, we need to check if the parameter is set by the parent stack and add an edge for the stack resource in the parent stack + if (!this.stack.isRootStack(stackPath)) { + const nestedStackNode = this.nestedStackNodes.get(stackPath); + if (nestedStackNode?.resource?.Properties?.Parameters?.[targetLogicalId]) { + debug( + `Parameter ${targetLogicalId} is set by the parent stack, adding edge from ${source.construct.path} to ${nestedStackNode.construct.path}`, + ); + source.outgoingEdges.add(nestedStackNode); + nestedStackNode.incomingEdges.add(source); + return; + } + } + debug(`missing node for target element ${targetLogicalId}`); } else { source.outgoingEdges.add(targetNode); @@ -399,11 +588,33 @@ export class GraphBuilder { } } - private addEdgesForIntrinsic(fn: string, params: any, source: GraphNode) { + private addEdgesForIntrinsic(fn: string, params: any, source: GraphNode, stackPath?: string) { switch (fn) { case 'Fn::GetAtt': { let logicalId = params[0]; const attributeName = params[1]; + + stackPath = stackPath ?? source.resourceAddress?.stackPath; + if (!stackPath) { + throw new CdkAdapterError( + `Resource address not set for source node ${source.construct.path}. Cannot locate the source resource.`, + ); + } + + const targetResourceAddress = { id: logicalId, stackPath }; + const targetNodePath = this.cfnElementNodes.get(targetResourceAddress)?.construct?.path; + + // If the target node is a nested stack, we need to add an edge from the source to the nested stack's output + if (targetNodePath && targetNodePath in this.stack.stacks) { + const nestedStack = this.stack.stacks[targetNodePath]; + + // For nested stacks, the attribute name is the output name without the `Outputs.` prefix + const outputName = attributeName.replace(/^Outputs\./, ''); + if (nestedStack.Outputs?.[outputName]?.Value) { + this.addEdgesForCfnResource(nestedStack.Outputs[outputName].Value, source, targetNodePath); + } + } + // Special case for VPC Ipv6CidrBlocks // Ipv6 cidr blocks are added to the VPC through a separate VpcCidrBlock resource // Due to [pulumi/pulumi-aws-native#1798] the `Ipv6CidrBlocks` attribute will always be empty @@ -411,13 +622,13 @@ export class GraphBuilder { // Here we switching the dependency to be on the `VpcCidrBlock` resource (since that will also have a dependency // on the VPC resource) if ( - logicalId in this.vpcNodes && + this.vpcNodes.has(targetResourceAddress) && attributeName === 'Ipv6CidrBlocks' && - this.vpcNodes[logicalId].vpcCidrBlockNode?.logicalId + this.vpcNodes.get(targetResourceAddress)?.vpcCidrBlockNode?.resourceAddress ) { - logicalId = this.vpcNodes[logicalId].vpcCidrBlockNode!.logicalId; + logicalId = this.vpcNodes.get(targetResourceAddress)!.vpcCidrBlockNode!.resourceAddress!.id; } - this.addEdgeForRef(logicalId, source); + this.addEdgeForRef(logicalId, source, stackPath); break; } case 'Fn::Sub': @@ -425,15 +636,15 @@ export class GraphBuilder { const [template, vars] = typeof params === 'string' ? [params, undefined] : [params[0] as string, params[1]]; - this.addEdgesForCfnResource(vars, source); + this.addEdgesForCfnResource(vars, source, stackPath); for (const part of parseSub(template).filter((p) => p.ref !== undefined)) { - this.addEdgeForRef(part.ref!.id, source); + this.addEdgeForRef(part.ref!.id, source, stackPath); } } break; default: - this.addEdgesForCfnResource(params, source); + this.addEdgesForCfnResource(params, source, stackPath); break; } } diff --git a/src/interop.ts b/src/interop.ts index b9d75e65..a81b636e 100644 --- a/src/interop.ts +++ b/src/interop.ts @@ -124,3 +124,28 @@ export class CdkConstruct extends pulumi.ComponentResource { this.registerOutputs({}); } } + +const NESTED_STACK_CONSTRUCT_SYMBOL = Symbol.for('@pulumi/cdk.NestedStackConstruct'); + +/** + * The NestedStackConstruct is a special construct that is used to represent a nested stack + * and namespace the resources within it. It achieves this by including the stack path in the + * resource type. + * @internal + */ +export class NestedStackConstruct extends pulumi.ComponentResource { + /** + * Return whether the given object is a NestedStackConstruct. + * + * We do attribute detection in order to reliably detect nested stack constructs. + * @internal + */ + public static isNestedStackConstruct(x: any): x is NestedStackConstruct { + return x !== null && typeof x === 'object' && NESTED_STACK_CONSTRUCT_SYMBOL in x; + } + + constructor(stackPath: string, options?: pulumi.ComponentResourceOptions) { + super(`cdk:construct:nested-stack/${stackPath}`, stackPath, {}, options); + Object.defineProperty(this, NESTED_STACK_CONSTRUCT_SYMBOL, { value: true }); + } +} diff --git a/src/stack-map.ts b/src/stack-map.ts new file mode 100644 index 00000000..d6d22f3b --- /dev/null +++ b/src/stack-map.ts @@ -0,0 +1,166 @@ +// Copyright 2016-2024, Pulumi Corporation. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { StackAddress } from './assembly'; + +/** + * A specialized Map implementation that uses StackAddress objects as keys. + * It internally uses nested maps to store values by stackPath and id. + * + * @typeparam T The type of values stored in the map + * + * @internal + */ +export class StackMap implements Map { + // Map of stackPath -> Map of id -> value + private _map: Map> = new Map(); + + /** + * Removes all elements from the map + */ + clear(): void { + this._map.clear(); + } + + /** + * Removes the specified element from the map + * @param key The StackAddress key to remove + * @returns true if an element was removed, false otherwise + */ + delete(key: StackAddress): boolean { + const { stackPath, id: id } = key; + const innerMap = this._map.get(stackPath); + if (!innerMap) { + return false; + } + return innerMap.delete(id); + } + + /** + * Returns the value associated with the specified key + * @param key The StackAddress key to look up + * @returns The value associated with the key, or undefined if not found + */ + get(key: StackAddress): T | undefined { + const { stackPath, id: id } = key; + return this._map.get(stackPath)?.get(id); + } + + /** + * Returns whether an element with the specified key exists + * @param key The StackAddress key to check + * @returns true if the key exists, false otherwise + */ + has(key: StackAddress): boolean { + const { stackPath, id: id } = key; + return !!this._map.get(stackPath)?.has(id); + } + + /** + * Adds or updates an element with the specified key and value + * @param key The StackAddress key to set + * @param value The value to associate with the key + * @returns The StackMap object + */ + set(key: StackAddress, value: T): this { + const { stackPath, id: id } = key; + let innerMap = this._map.get(stackPath); + if (!innerMap) { + innerMap = new Map(); + this._map.set(stackPath, innerMap); + } + innerMap.set(id, value); + return this; + } + + /** + * Returns the number of elements in the map + */ + public get size(): number { + return Array.from(this._map.values()).reduce((acc: number, innerMap) => acc + innerMap.size, 0); + } + + /** + * Executes a provided function once for each key-value pair in the map + * @param callbackfn Function to execute for each element + * @param thisArg Value to use as 'this' when executing callback + */ + forEach(callbackfn: (value: T, key: StackAddress, map: Map) => void, thisArg?: any): void { + for (const [stackPath, innerMap] of this._map) { + for (const [id, value] of innerMap) { + const key = { stackPath, id }; + callbackfn.call(thisArg, value, key, this as any); + } + } + } + + forEachStackElement( + stackPath: string, + callbackfn: (value: T, key: StackAddress, map: Map) => void, + thisArg?: any, + ): void { + const innerMap = this._map.get(stackPath); + if (!innerMap) { + return; + } + for (const [id, value] of innerMap) { + const key = { stackPath, id }; + callbackfn.call(thisArg, value, key, this as any); + } + } + + /** + * Returns an iterator of key-value pairs for every entry in the map + * @returns An iterator yielding [StackAddress, T] pairs + */ + *entries(): IterableIterator<[StackAddress, T]> { + for (const [stackPath, innerMap] of this._map) { + for (const [id, value] of innerMap) { + yield [{ stackPath, id: id }, value]; + } + } + } + + /** + * Returns an iterator of keys in the map + * @returns An iterator yielding StackAddress keys + */ + *keys(): IterableIterator { + for (const [stackPath, innerMap] of this._map) { + for (const id of innerMap.keys()) { + yield { stackPath, id: id }; + } + } + } + + /** + * Returns an iterator of values in the map + * @returns An iterator yielding the values + */ + *values(): IterableIterator { + for (const innerMap of this._map.values()) { + yield* innerMap.values(); + } + } + + /** + * Returns the default iterator for the map + * @returns An iterator for entries in the map + */ + [Symbol.iterator](): IterableIterator<[StackAddress, T]> { + return this.entries(); + } + + [Symbol.toStringTag] = 'StackMap'; +} diff --git a/src/stack.ts b/src/stack.ts index 2a9249ef..24bae4e6 100644 --- a/src/stack.ts +++ b/src/stack.ts @@ -124,8 +124,8 @@ export class App return stacks.reduce( (prev, curr) => { const o: { [outputId: string]: pulumi.Output } = {}; - for (const [outputId, args] of Object.entries(curr.stack.outputs ?? {})) { - o[outputId] = curr.processIntrinsics(args.Value); + for (const [outputId, args] of Object.entries(curr.stack.getRootStack().Outputs ?? {})) { + o[outputId] = curr.processIntrinsics(args.Value, curr.stack.id); } return { ...prev, diff --git a/tests/assembly/manifest.test.ts b/tests/assembly/manifest.test.ts index ccce3fd0..d776daf5 100644 --- a/tests/assembly/manifest.test.ts +++ b/tests/assembly/manifest.test.ts @@ -3,157 +3,296 @@ import * as mockfs from 'mock-fs'; import { AssemblyManifestReader } from '../../src/assembly'; describe('cloud assembly manifest reader', () => { - const manifestFile = '/tmp/foo/bar/does/not/exist/manifest.json'; - const manifestStack = '/tmp/foo/bar/does/not/exist/test-stack.template.json'; - const manifestTree = '/tmp/foo/bar/does/not/exist/tree.json'; - const manifestAssets = '/tmp/foo/bar/does/not/exist/test-stack.assets.json'; - beforeEach(() => { - mockfs({ - // Recursively loads all node_modules - node_modules: { - 'aws-cdk-lib': mockfs.load(path.resolve(__dirname, '../../node_modules/aws-cdk-lib')), - '@pulumi': { - aws: mockfs.load(path.resolve(__dirname, '../../node_modules/@pulumi/aws')), - 'aws-native': mockfs.load(path.resolve(__dirname, '../../node_modules/@pulumi/aws-native')), + describe('single stack', () => { + const manifestFile = '/tmp/foo/bar/does/not/exist/manifest.json'; + const manifestStack = '/tmp/foo/bar/does/not/exist/test-stack.template.json'; + const manifestTree = '/tmp/foo/bar/does/not/exist/tree.json'; + const manifestAssets = '/tmp/foo/bar/does/not/exist/test-stack.assets.json'; + beforeEach(() => { + mockfs({ + // Recursively loads all node_modules + node_modules: { + 'aws-cdk-lib': mockfs.load(path.resolve(__dirname, '../../node_modules/aws-cdk-lib')), + '@pulumi': { + aws: mockfs.load(path.resolve(__dirname, '../../node_modules/@pulumi/aws')), + 'aws-native': mockfs.load(path.resolve(__dirname, '../../node_modules/@pulumi/aws-native')), + }, }, - }, - [manifestAssets]: JSON.stringify({ - version: '36.0.0', - files: { - abe4e2f4fcc1aaaf53db4829c23a5cf08795d36cce0f68a3321c1c8d728fec44: { - source: { - path: 'asset.abe4e2f4fcc1aaaf53db4829c23a5cf08795d36cce0f68a3321c1c8d728fec44', - packaging: 'zip', + [manifestAssets]: JSON.stringify({ + version: '36.0.0', + files: { + abe4e2f4fcc1aaaf53db4829c23a5cf08795d36cce0f68a3321c1c8d728fec44: { + source: { + path: 'asset.abe4e2f4fcc1aaaf53db4829c23a5cf08795d36cce0f68a3321c1c8d728fec44', + packaging: 'zip', + }, + destinations: { + 'current_account-current_region': { + bucketName: 'cdk-hnb659fds-assets-${AWS::AccountId}-${AWS::Region}', + objectKey: 'abe4e2f4fcc1aaaf53db4829c23a5cf08795d36cce0f68a3321c1c8d728fec44.zip', + }, + }, }, - destinations: { - 'current_account-current_region': { - bucketName: 'cdk-hnb659fds-assets-${AWS::AccountId}-${AWS::Region}', - objectKey: 'abe4e2f4fcc1aaaf53db4829c23a5cf08795d36cce0f68a3321c1c8d728fec44.zip', + cd12352cc95113284dfa6575f1d74d8dea52dddcaa2f46fa695b33b59c1b4579: { + source: { + path: 'stack.template.json', + packaging: 'file', + }, + destinations: { + 'current_account-current_region': { + bucketName: 'cdk-hnb659fds-assets-${AWS::AccountId}-${AWS::Region}', + objectKey: 'cd12352cc95113284dfa6575f1d74d8dea52dddcaa2f46fa695b33b59c1b4579.json', + }, }, }, }, - cd12352cc95113284dfa6575f1d74d8dea52dddcaa2f46fa695b33b59c1b4579: { - source: { - path: 'stack.template.json', - packaging: 'file', - }, - destinations: { - 'current_account-current_region': { - bucketName: 'cdk-hnb659fds-assets-${AWS::AccountId}-${AWS::Region}', - objectKey: 'cd12352cc95113284dfa6575f1d74d8dea52dddcaa2f46fa695b33b59c1b4579.json', + dockerImages: {}, + }), + [manifestTree]: JSON.stringify({ + version: 'tree-0.1', + tree: { + id: 'App', + path: '', + children: { + 'test-stack': { + id: 'test-stack', + path: 'test-stack', }, }, }, - }, - dockerImages: {}, - }), - [manifestTree]: JSON.stringify({ - version: 'tree-0.1', - tree: { - id: 'App', - path: '', - children: { - 'test-stack': { - id: 'test-stack', - path: 'test-stack', + }), + [manifestStack]: JSON.stringify({ + Resources: { + MyFunction1ServiceRole9852B06B: { + Type: 'AWS::IAM::Role', + Properties: {}, }, - }, - }, - }), - [manifestStack]: JSON.stringify({ - Resources: { - MyFunction1ServiceRole9852B06B: { - Type: 'AWS::IAM::Role', - Properties: {}, - }, - MyFunction12A744C2E: { - Type: 'AWS::Lambda::Function', - Properties: {}, - }, - }, - }), - [manifestFile]: JSON.stringify({ - version: '17.0.0', - artifacts: { - 'test-stack.assets': { - type: 'cdk:asset-manifest', - properties: { - file: 'test-stack.assets.json', + MyFunction12A744C2E: { + Type: 'AWS::Lambda::Function', + Properties: {}, }, }, - Tree: { - type: 'cdk:tree', - properties: { - file: 'tree.json', + }), + [manifestFile]: JSON.stringify({ + version: '17.0.0', + artifacts: { + 'test-stack.assets': { + type: 'cdk:asset-manifest', + properties: { + file: 'test-stack.assets.json', + }, }, - }, - 'test-stack': { - type: 'aws:cloudformation:stack', - environment: 'aws://unknown-account/unknown-region', - properties: { - templateFile: 'test-stack.template.json', - validateOnSynth: false, - }, - metadata: { - '/test-stack/MyFunction1/ServiceRole/Resource': [ - { - type: 'aws:cdk:logicalId', - data: 'MyFunction1ServiceRole9852B06B', - }, - ], - '/test-stack/MyFunction1/Resource': [ - { - type: 'aws:cdk:logicalId', - data: 'MyFunction12A744C2E', - }, - ], + Tree: { + type: 'cdk:tree', + properties: { + file: 'tree.json', + }, + }, + 'test-stack': { + type: 'aws:cloudformation:stack', + environment: 'aws://unknown-account/unknown-region', + properties: { + templateFile: 'test-stack.template.json', + validateOnSynth: false, + }, + metadata: { + '/test-stack/MyFunction1/ServiceRole/Resource': [ + { + type: 'aws:cdk:logicalId', + data: 'MyFunction1ServiceRole9852B06B', + }, + ], + '/test-stack/MyFunction1/Resource': [ + { + type: 'aws:cdk:logicalId', + data: 'MyFunction12A744C2E', + }, + ], + }, + displayName: 'test-stack', }, - displayName: 'test-stack', }, + }), + }); + }); + + afterEach(() => { + mockfs.restore(); + }); + + test('throws if manifest file not found', () => { + expect(() => { + AssemblyManifestReader.fromDirectory('some-other-file'); + }).toThrow(/Cannot read manifest at 'some-other-file\/manifest.json'/); + }); + + test('can read manifest from path', () => { + expect(() => { + AssemblyManifestReader.fromDirectory(path.dirname(manifestFile)); + }).not.toThrow(); + }); + + test('fromPath sets directory correctly', () => { + const manifest = AssemblyManifestReader.fromDirectory(path.dirname(manifestFile)); + expect(manifest.directory).toEqual('/tmp/foo/bar/does/not/exist'); + }); + + test('can get stacks from manifest', () => { + const manifest = AssemblyManifestReader.fromDirectory(path.dirname(manifestFile)); + + expect(manifest.stackManifests[0]).toEqual({ + constructTree: { id: 'test-stack', path: 'test-stack' }, + dependencies: [], + id: 'test-stack', + metadata: { + 'test-stack/MyFunction1/Resource': { stackPath: 'test-stack', id: 'MyFunction12A744C2E' }, + 'test-stack/MyFunction1/ServiceRole/Resource': { stackPath: 'test-stack', id: 'MyFunction1ServiceRole9852B06B' }, }, - }), + stacks: { + "test-stack": { + Resources: { + MyFunction12A744C2E: { Properties: {}, Type: 'AWS::Lambda::Function' }, + MyFunction1ServiceRole9852B06B: { Properties: {}, Type: 'AWS::IAM::Role' }, + }, + } + }, + templatePath: 'test-stack.template.json', + }); }); }); - afterEach(() => { - mockfs.restore(); - }); - - test('throws if manifest file not found', () => { - expect(() => { - AssemblyManifestReader.fromDirectory('some-other-file'); - }).toThrow(/Cannot read manifest at 'some-other-file\/manifest.json'/); - }); - - test('can read manifest from path', () => { - expect(() => { - AssemblyManifestReader.fromDirectory(path.dirname(manifestFile)); - }).not.toThrow(); - }); + describe('nested stacks', () => { + afterEach(() => { + mockfs.restore(); + }); - test('fromPath sets directory correctly', () => { - const manifest = AssemblyManifestReader.fromDirectory(path.dirname(manifestFile)); - expect(manifest.directory).toEqual('/tmp/foo/bar/does/not/exist'); - }); + test('can read manifest with nested stacks', () => { + const manifestDir = path.join(__dirname, '../test-data/nested-stack'); + const manifests = AssemblyManifestReader.fromDirectory(manifestDir).stackManifests; + expect(manifests.length).toEqual(1); + const manifest = manifests[0]; + expect(Object.keys(manifest.stacks)).toEqual(['teststack', 'teststack/nesty']); + expect(Object.keys(manifest.stacks['teststack'].Resources ?? {}).length).toEqual(7); + expect(Object.keys(manifest.stacks['teststack/nesty'].Resources ?? {}).length).toEqual(5); + }); - test('can get stacks from manifest', () => { - const manifest = AssemblyManifestReader.fromDirectory(path.dirname(manifestFile)); + test('throws if nested stack template is not found', () => { + const manifestFile = '/tmp/foo/bar/does/not/exist/manifest.json'; + const manifestStack = '/tmp/foo/bar/does/not/exist/test-stack.template.json'; + const manifestTree = '/tmp/foo/bar/does/not/exist/tree.json'; + const manifestAssets = '/tmp/foo/bar/does/not/exist/test-stack.assets.json'; + mockfs({ + [manifestAssets]: JSON.stringify({ + version: '36.0.0', + files: { + abe4e2f4fcc1aaaf53db4829c23a5cf08795d36cce0f68a3321c1c8d728fec44: { + source: { + path: 'asset.abe4e2f4fcc1aaaf53db4829c23a5cf08795d36cce0f68a3321c1c8d728fec44', + packaging: 'zip', + }, + destinations: { + 'current_account-current_region': { + bucketName: 'cdk-hnb659fds-assets-${AWS::AccountId}-${AWS::Region}', + objectKey: 'abe4e2f4fcc1aaaf53db4829c23a5cf08795d36cce0f68a3321c1c8d728fec44.zip', + }, + }, + }, + cd12352cc95113284dfa6575f1d74d8dea52dddcaa2f46fa695b33b59c1b4579: { + source: { + path: 'stack.template.json', + packaging: 'file', + }, + destinations: { + 'current_account-current_region': { + bucketName: 'cdk-hnb659fds-assets-${AWS::AccountId}-${AWS::Region}', + objectKey: 'cd12352cc95113284dfa6575f1d74d8dea52dddcaa2f46fa695b33b59c1b4579.json', + }, + }, + }, + }, + dockerImages: {}, + }), + [manifestTree]: JSON.stringify({ + version: 'tree-0.1', + tree: { + id: 'App', + path: '', + children: { + 'test-stack': { + id: 'test-stack', + path: 'test-stack', + children: { + 'MyNestedStack': { + id: 'MyNestedStack', + path: 'test-stack/MyNestedStack', + }, + 'MyNestedStack.NestedStack': { + id: 'MyNestedStack.NestedStack', + path: 'test-stack/MyNestedStack.NestedStack', + children: { + 'MyNestedStack.NestedStackResource': { + id: 'MyNestedStack.NestedStackResource', + path: 'test-stack/MyNestedStack.NestedStack/MyNestedStack.NestedStackResource', + }, + }, + }, + }, + }, + }, + }, + }), + [manifestStack]: JSON.stringify({ + Resources: { + MyNestedStack: { + Type: 'AWS::CloudFormation::Stack', + Properties: {}, + Metadata: { + // this asset path does not exist + 'aws:asset:path': 'MyNestedStack.template.json', + 'aws:cdk:path': 'test-stack/MyNestedStack.NestedStack/MyNestedStack.NestedStackResource', + }, + }, + }, + }), + [manifestFile]: JSON.stringify({ + version: '17.0.0', + artifacts: { + 'test-stack.assets': { + type: 'cdk:asset-manifest', + properties: { + file: 'test-stack.assets.json', + }, + }, + Tree: { + type: 'cdk:tree', + properties: { + file: 'tree.json', + }, + }, + 'test-stack': { + type: 'aws:cloudformation:stack', + environment: 'aws://unknown-account/unknown-region', + properties: { + templateFile: 'test-stack.template.json', + validateOnSynth: false, + }, + metadata: { + '/test-stack/MyNestedStack.NestedStack/MyNestedStack.NestedStackResource': [ + { + type: 'aws:cdk:logicalId', + data: 'MyNestedStack', + }, + ], + }, + displayName: 'test-stack', + }, + }, + }), + }); - expect(manifest.stackManifests[0]).toEqual({ - constructTree: { id: 'test-stack', path: 'test-stack' }, - dependencies: [], - id: 'test-stack', - metadata: { - 'test-stack/MyFunction1/Resource': 'MyFunction12A744C2E', - 'test-stack/MyFunction1/ServiceRole/Resource': 'MyFunction1ServiceRole9852B06B', - }, - outputs: undefined, - parameters: undefined, - resources: { - MyFunction12A744C2E: { Properties: {}, Type: 'AWS::Lambda::Function' }, - MyFunction1ServiceRole9852B06B: { Properties: {}, Type: 'AWS::IAM::Role' }, - }, - templatePath: 'test-stack.template.json', + expect(() => { + AssemblyManifestReader.fromDirectory(path.dirname(manifestFile)); + }).toThrow(/no such file or directory, open '\/tmp\/foo\/bar\/does\/not\/exist\/MyNestedStack.template.json'/); }); }); }); diff --git a/tests/assembly/stack.test.ts b/tests/assembly/stack.test.ts index dda80d77..29b9bd07 100644 --- a/tests/assembly/stack.test.ts +++ b/tests/assembly/stack.test.ts @@ -9,6 +9,7 @@ describe('StackManifest', () => { metadata: {}, tree: { id: 'id', path: 'path' }, template: {}, + nestedStacks: {}, dependencies: [], }); }).toThrow(/CloudFormation template has no resources/); @@ -19,8 +20,9 @@ describe('StackManifest', () => { id: 'id', templatePath: 'path', metadata: { - 'stack/bucket': 'SomeBucket', + 'stack/bucket': { stackPath: 'stack', id: 'SomeBucket' }, }, + nestedStacks: {}, tree: { id: 'id', path: 'path', @@ -35,19 +37,20 @@ describe('StackManifest', () => { }, dependencies: [], }); - expect(stack.logicalIdForPath('stack/bucket')).toEqual('SomeBucket'); + expect(stack.resourceAddressForPath('stack/bucket')).toEqual({ stackPath: 'stack', id: 'SomeBucket' }); }); - test('can get resource for path', () => { + test('can get resource for logicalId', () => { const stack = new StackManifest({ - id: 'id', + id: 'stack', templatePath: 'path', metadata: { - 'stack/bucket': 'SomeBucket', + 'stack/bucket': { stackPath: 'stack', id: 'SomeBucket' }, }, + nestedStacks: {}, tree: { - id: 'id', - path: 'path', + id: 'stack', + path: 'stack', }, template: { Resources: { @@ -59,36 +62,26 @@ describe('StackManifest', () => { }, dependencies: [], }); - expect(stack.resourceWithPath('stack/bucket')).toEqual({ + expect(stack.resourceWithLogicalId('stack', 'SomeBucket')).toEqual({ Type: 'AWS::S3::Bucket', Properties: { Key: 'Value' }, }); }); - test('can get resource for logicalId', () => { - const stack = new StackManifest({ - id: 'id', - templatePath: 'path', - metadata: { - 'stack/bucket': 'SomeBucket', - }, - tree: { - id: 'id', - path: 'path', - }, - template: { - Resources: { - SomeBucket: { - Type: 'AWS::S3::Bucket', - Properties: { Key: 'Value' }, - }, - }, - }, - dependencies: [], - }); - expect(stack.resourceWithLogicalId('SomeBucket')).toEqual({ - Type: 'AWS::S3::Bucket', - Properties: { Key: 'Value' }, - }); + test('getNestedStackPath throws if path is too short', () => { + expect(() => { + StackManifest.getNestedStackPath('short/path', 'logicalId'); + }).toThrow(/The path is too short/); + }); + + test('getNestedStackPath throws if path does not end with .NestedStack', () => { + expect(() => { + StackManifest.getNestedStackPath('parent/child/invalidPath', 'logicalId'); + }).toThrow(/The path does not end with '.NestedStack'/); + }); + + test('getNestedStackPath returns correct nested stack path', () => { + const nestedStackPath = StackManifest.getNestedStackPath('parent/child.NestedStack/child.NestedStackResource', 'logicalId'); + expect(nestedStackPath).toBe('parent/child'); }); }); diff --git a/tests/converters/app-converter.test.ts b/tests/converters/app-converter.test.ts index 1a940746..dfe9413d 100644 --- a/tests/converters/app-converter.test.ts +++ b/tests/converters/app-converter.test.ts @@ -5,11 +5,13 @@ import * as path from 'path'; import * as mockfs from 'mock-fs'; import * as pulumi from '@pulumi/pulumi'; import { BucketPolicy } from '@pulumi/aws-native/s3'; +import { Policy } from '@pulumi/aws/iam' import { createStackManifest } from '../utils'; import { promiseOf, setMocks, MockAppComponent, MockSynth } from '../mocks'; import { StackManifest, StackManifestProps } from '../../src/assembly'; import { MockResourceArgs } from '@pulumi/pulumi/runtime'; import { Stack as CdkStack } from 'aws-cdk-lib/core'; +import { NestedStackConstruct } from '../../src/interop'; let resources: MockResourceArgs[] = []; beforeAll(() => { @@ -332,7 +334,7 @@ describe('App Converter', () => { }, }), 'result', - 'Mapping Map not found in mappings. Available mappings are OtherMap', + 'Mapping Map not found in mappings of stack stack. Available mappings are OtherMap', ], [ 'FindInMap-mappings-error', @@ -401,9 +403,9 @@ describe('App Converter', () => { converter.convert(new Set()); const promises = Array.from(converter.resources.values()).flatMap((res) => promiseOf(res.resource.urn)); await Promise.all(promises); - const bucket = converter.resources.get('resource1'); + const bucket = converter.resources.get({ stackPath: stackManifest.id, id: 'resource1' }); expect(bucket).toBeDefined(); - const policy = converter.resources.get('resource2'); + const policy = converter.resources.get({ stackPath: stackManifest.id, id: 'resource2' }); expect(policy).toBeDefined(); const policyResource = policy!.resource as BucketPolicy; const policyBucket = await promiseOf(policyResource.bucket); @@ -420,9 +422,9 @@ describe('Stack Converter', () => { id: 'stack', templatePath: 'test/stack', metadata: { - 'stack/vpc': 'vpc', - 'stack/cidr': 'cidr', - 'stack/other': 'other', + 'stack/vpc': { stackPath: 'stack', id: 'vpc' }, + 'stack/cidr': { stackPath: 'stack', id: 'cidr' }, + 'stack/other': { stackPath: 'stack', id: 'other' }, }, tree: { path: 'stack', @@ -455,6 +457,7 @@ describe('Stack Converter', () => { version: '2.149.0', }, }, + nestedStacks: {}, template: { Resources: { vpc: { @@ -481,7 +484,7 @@ describe('Stack Converter', () => { }); const converter = new StackConverter(new MockAppComponent('/tmp/foo/bar/does/not/exist'), manifest); converter.convert(new Set()); - const subnet = converter.resources.get('other')?.resource as native.ec2.Subnet; + const subnet = converter.resources.get({ stackPath: manifest.id, id: 'other' })?.resource as native.ec2.Subnet; const cidrBlock = await promiseOf(subnet.ipv6CidrBlock); expect(cidrBlock).toEqual('cidr_ipv6AddressAttribute'); }); @@ -491,12 +494,12 @@ describe('Stack Converter', () => { id: 'stack', templatePath: 'test/stack', metadata: { - 'stack/vpc': 'vpc', - 'stack/cidr': 'cidr', - 'stack/other': 'other', - 'stack/vpc2': 'vpc2', - 'stack/cidr2': 'cidr2', - 'stack/other2': 'other2', + 'stack/vpc': { stackPath: 'stack', id: 'vpc' }, + 'stack/cidr': { stackPath: 'stack', id: 'cidr' }, + 'stack/other': { stackPath: 'stack', id: 'other' }, + 'stack/vpc2': { stackPath: 'stack', id: 'vpc2' }, + 'stack/cidr2': { stackPath: 'stack', id: 'cidr2' }, + 'stack/other2': { stackPath: 'stack', id: 'other2' }, }, tree: { path: 'stack', @@ -550,6 +553,7 @@ describe('Stack Converter', () => { version: '2.149.0', }, }, + nestedStacks: {}, template: { Resources: { vpc: { @@ -594,10 +598,10 @@ describe('Stack Converter', () => { }); const converter = new StackConverter(new MockAppComponent('/tmp/foo/bar/does/not/exist'), manifest); converter.convert(new Set()); - const subnet = converter.resources.get('other')?.resource as native.ec2.Subnet; + const subnet = converter.resources.get({ stackPath: manifest.id, id: 'other' })?.resource as native.ec2.Subnet; const cidrBlock = await promiseOf(subnet.ipv6CidrBlock); expect(cidrBlock).toEqual('cidr_ipv6AddressAttribute'); - const subnet2 = converter.resources.get('other2')?.resource as native.ec2.Subnet; + const subnet2 = converter.resources.get({ stackPath: manifest.id, id: 'other2' })?.resource as native.ec2.Subnet; const cidrBlock2 = await promiseOf(subnet2.ipv6CidrBlock); expect(cidrBlock2).toEqual('cidr_ipv6AddressAttribute_2'); }); @@ -619,7 +623,7 @@ describe('Stack Converter', () => { const converter = new StackConverter(app, manifest); converter.convert(new Set()); - const customResource = converter.resources.get('DeployWebsiteCustomResourceD116527B'); + const customResource = converter.resources.get({ stackPath: manifest.id, id: 'DeployWebsiteCustomResourceD116527B' }); expect(customResource).toBeDefined(); const customResourceEmulator = customResource!.resource! as native.cloudformation.CustomResourceEmulator; @@ -628,9 +632,48 @@ describe('Stack Converter', () => { expect(customResourceEmulator.serviceToken).toBeDefined(); // This uses GetAtt to get the destination bucket from the custom resource - const customResourceRole = converter.resources.get('CustomResourceRoleAB1EF463'); + const customResourceRole = converter.resources.get({ stackPath: manifest.id, id: 'CustomResourceRoleAB1EF463' }); expect(customResourceRole).toBeDefined(); }); + + test('can convert nested stacks', async () => { + const stackManifestPath = path.join(__dirname, '../test-data/nested-stack/stack-manifest.json'); + const props: StackManifestProps = JSON.parse(fs.readFileSync(stackManifestPath, 'utf-8')); + const manifest = new StackManifest(props); + const app = new MockAppComponent('/tmp/foo/bar/does/not/exist'); + const stagingBucket = 'my-bucket'; + const customResourcePrefix = 'my-prefix'; + app.stacks[manifest.id] = { + synthesizer: new MockSynth(stagingBucket, customResourcePrefix), + node: { + id: 'my-stack', + }, + } as unknown as CdkStack; + const converter = new StackConverter(app, manifest); + converter.convert(new Set()); + + const rootBucket = converter.resources.get({ stackPath: manifest.id, id: 'bucket'})?.resource as native.s3.Bucket; + expect(rootBucket).toBeDefined(); + const rootBucketName = await promiseOf(rootBucket.bucketName); + + // nested stack resource should be mapped + const nestedStackResource = converter.resources.get({ stackPath: manifest.id, id: 'nestyNestedStacknestyNestedStackResource'}); + expect(nestedStackResource).toBeDefined(); + expect(nestedStackResource?.resourceType).toEqual('AWS::CloudFormation::Stack'); + expect(NestedStackConstruct.isNestedStackConstruct(nestedStackResource?.resource)).toBeTruthy(); + + // resources of the nested stack should be mapped + // this tests that properties are correctly passed to the nested stack + const nestedBucket = converter.resources.get({ stackPath: `${manifest.id}/nesty`, id: 'bucket43879C71'})?.resource as native.s3.Bucket; + expect(nestedBucket).toBeDefined(); + const nestedBucketName = await promiseOf(nestedBucket.bucketName); + expect(nestedBucketName).toEqual(`${rootBucketName}-nested`); + + + const policy = converter.resources.get({ stackPath: manifest.id, id: 'CustomCDKBucketDeployment8693BB64968944B69AAFB0CC9EB8756CServiceRoleDefaultPolicy'})?.resource as Policy; + const policyDocument = await promiseOf(policy.policy) as any; + expect(policyDocument.Statement[1].Resource).toEqual(expect.arrayContaining(['bucket43879c71_arn', 'bucket43879c71_arn/*'])); + }); }); function createUrn(resource: string, logicalId: string): string { diff --git a/tests/converters/intrinsics.test.ts b/tests/converters/intrinsics.test.ts index e0b0be05..57776d17 100644 --- a/tests/converters/intrinsics.test.ts +++ b/tests/converters/intrinsics.test.ts @@ -4,35 +4,35 @@ import * as pulumi from '@pulumi/pulumi'; import * as intrinsics from '../../src/converters/intrinsics'; import { CloudFormationParameter, - CloudFormationParameterLogicalId, CloudFormationParameterWithId, } from '../../src/cfn'; import { Mapping } from '../../src/types'; import { PulumiResource } from '../../src/pulumi-metadata'; import { OutputRepr } from '../../src/output-map'; +import { StackAddress } from '../../src/assembly'; describe('Fn::If', () => { test('picks true', async () => { - const tc = new TestContext({ conditions: { MyCondition: true } }); - const result = runIntrinsic(intrinsics.fnIf, tc, ['MyCondition', 'yes', 'no']); + const tc = new TestContext({ conditions: { 'test-stack': { MyCondition: true }} }); + const result = runIntrinsic(intrinsics.fnIf, tc, ['MyCondition', 'yes', 'no'], 'test-stack'); expect(result).toEqual(ok('yes')); }); test('picks false', async () => { - const tc = new TestContext({ conditions: { MyCondition: false } }); - const result = runIntrinsic(intrinsics.fnIf, tc, ['MyCondition', 'yes', 'no']); + const tc = new TestContext({ conditions: { 'test-stack': { MyCondition: false } } }); + const result = runIntrinsic(intrinsics.fnIf, tc, ['MyCondition', 'yes', 'no'], 'test-stack'); expect(result).toEqual(ok('no')); }); test('errors if condition is not found', async () => { const tc = new TestContext({}); - const result = runIntrinsic(intrinsics.fnIf, tc, ['MyCondition', 'yes', 'no']); + const result = runIntrinsic(intrinsics.fnIf, tc, ['MyCondition', 'yes', 'no'], 'test-stack'); expect(result).toEqual(failed(`No condition 'MyCondition' found`)); }); test('errors if condition evaluates to a non-boolean', async () => { - const tc = new TestContext({ conditions: { MyCondition: 'OOPS' } }); - const result = runIntrinsic(intrinsics.fnIf, tc, ['MyCondition', 'yes', 'no']); + const tc = new TestContext({ conditions: { 'test-stack': { MyCondition: 'OOPS' } } }); + const result = runIntrinsic(intrinsics.fnIf, tc, ['MyCondition', 'yes', 'no'], 'test-stack'); expect(result).toEqual(failed(`Expected a boolean, got string`)); }); }); @@ -40,37 +40,37 @@ describe('Fn::If', () => { describe('Fn::Or', () => { test('picks true', async () => { const tc = new TestContext({}); - const result = runIntrinsic(intrinsics.fnOr, tc, [true, false, true]); + const result = runIntrinsic(intrinsics.fnOr, tc, [true, false, true], 'test-stack'); expect(result).toEqual(ok(true)); }); test('picks false', async () => { const tc = new TestContext({}); - const result = runIntrinsic(intrinsics.fnOr, tc, [false, false, false]); + const result = runIntrinsic(intrinsics.fnOr, tc, [false, false, false], 'test-stack'); expect(result).toEqual(ok(false)); }); test('picks true from inner Condition', async () => { - const tc = new TestContext({ conditions: { MyCondition: true } }); - const result = runIntrinsic(intrinsics.fnOr, tc, [false, { Condition: 'MyCondition' }]); + const tc = new TestContext({ conditions: { 'test-stack': { MyCondition: true } } }); + const result = runIntrinsic(intrinsics.fnOr, tc, [false, { Condition: 'MyCondition' }], 'test-stack'); expect(result).toEqual(ok(true)); }); test('picks false with inner Condition', async () => { - const tc = new TestContext({ conditions: { MyCondition: false } }); - const result = runIntrinsic(intrinsics.fnOr, tc, [false, { Condition: 'MyCondition' }]); + const tc = new TestContext({ conditions: { 'test-stack': { MyCondition: false } } }); + const result = runIntrinsic(intrinsics.fnOr, tc, [false, { Condition: 'MyCondition' }], 'test-stack'); expect(result).toEqual(ok(false)); }); test('has to have at least two arguments', async () => { const tc = new TestContext({}); - const result = runIntrinsic(intrinsics.fnOr, tc, [false]); + const result = runIntrinsic(intrinsics.fnOr, tc, [false], 'test-stack'); expect(result).toEqual(failed(`Fn::Or expects at least 2 params, got 1`)); }); test('short-cirtcuits evaluation if true is found', async () => { const tc = new TestContext({}); - const result = runIntrinsic(intrinsics.fnOr, tc, [true, { Condition: 'DoesNotExist' }]); + const result = runIntrinsic(intrinsics.fnOr, tc, [true, { Condition: 'DoesNotExist' }], 'test-stack'); expect(result).toEqual(ok(true)); }); }); @@ -78,37 +78,37 @@ describe('Fn::Or', () => { describe('Fn::And', () => { test('picks true', async () => { const tc = new TestContext({}); - const result = runIntrinsic(intrinsics.fnAnd, tc, [true, true, true]); + const result = runIntrinsic(intrinsics.fnAnd, tc, [true, true, true], 'test-stack'); expect(result).toEqual(ok(true)); }); test('picks false', async () => { const tc = new TestContext({}); - const result = runIntrinsic(intrinsics.fnAnd, tc, [true, false, true]); + const result = runIntrinsic(intrinsics.fnAnd, tc, [true, false, true], 'test-stack'); expect(result).toEqual(ok(false)); }); test('picks true from inner Condition', async () => { - const tc = new TestContext({ conditions: { MyCondition: true } }); - const result = runIntrinsic(intrinsics.fnAnd, tc, [true, { Condition: 'MyCondition' }]); + const tc = new TestContext({ conditions: { 'test-stack': { MyCondition: true } } }); + const result = runIntrinsic(intrinsics.fnAnd, tc, [true, { Condition: 'MyCondition' }], 'test-stack'); expect(result).toEqual(ok(true)); }); test('picks false with inner Condition', async () => { - const tc = new TestContext({ conditions: { MyCondition: false } }); - const result = runIntrinsic(intrinsics.fnAnd, tc, [true, { Condition: 'MyCondition' }]); + const tc = new TestContext({ conditions: { 'test-stack': { MyCondition: false } } }); + const result = runIntrinsic(intrinsics.fnAnd, tc, [true, { Condition: 'MyCondition' }], 'test-stack'); expect(result).toEqual(ok(false)); }); test('has to have at least two arguments', async () => { const tc = new TestContext({}); - const result = runIntrinsic(intrinsics.fnAnd, tc, [false]); + const result = runIntrinsic(intrinsics.fnAnd, tc, [false], 'test-stack'); expect(result).toEqual(failed(`Fn::And expects at least 2 params, got 1`)); }); test('short-cirtcuits evaluation if false is found', async () => { const tc = new TestContext({}); - const result = runIntrinsic(intrinsics.fnAnd, tc, [false, { Condition: 'DoesNotExist' }]); + const result = runIntrinsic(intrinsics.fnAnd, tc, [false, { Condition: 'DoesNotExist' }], 'test-stack'); expect(result).toEqual(ok(false)); }); }); @@ -116,31 +116,31 @@ describe('Fn::And', () => { describe('Fn::Not', () => { test('inverts false', async () => { const tc = new TestContext({}); - const result = runIntrinsic(intrinsics.fnNot, tc, [true]); + const result = runIntrinsic(intrinsics.fnNot, tc, [true], 'test-stack'); expect(result).toEqual(ok(false)); }); test('inverts true', async () => { const tc = new TestContext({}); - const result = runIntrinsic(intrinsics.fnNot, tc, [false]); + const result = runIntrinsic(intrinsics.fnNot, tc, [false], 'test-stack'); expect(result).toEqual(ok(true)); }); test('inverts a false Condition', async () => { - const tc = new TestContext({ conditions: { MyCondition: false } }); - const result = runIntrinsic(intrinsics.fnNot, tc, [{ Condition: 'MyCondition' }]); + const tc = new TestContext({ conditions: { 'test-stack': { MyCondition: false } } }); + const result = runIntrinsic(intrinsics.fnNot, tc, [{ Condition: 'MyCondition' }], 'test-stack'); expect(result).toEqual(ok(true)); }); test('inverts a true Condition', async () => { - const tc = new TestContext({ conditions: { MyCondition: true } }); - const result = runIntrinsic(intrinsics.fnNot, tc, [{ Condition: 'MyCondition' }]); + const tc = new TestContext({ conditions: { 'test-stack': { MyCondition: true } } }); + const result = runIntrinsic(intrinsics.fnNot, tc, [{ Condition: 'MyCondition' }], 'test-stack'); expect(result).toEqual(ok(false)); }); test('requires a boolean', async () => { const tc = new TestContext({}); - const result = runIntrinsic(intrinsics.fnNot, tc, ['ok']); + const result = runIntrinsic(intrinsics.fnNot, tc, ['ok'], 'test-stack'); expect(result).toEqual(failed(`Expected a boolean, got string`)); }); }); @@ -148,31 +148,31 @@ describe('Fn::Not', () => { describe('Fn::Equals', () => { test('detects equal strings', async () => { const tc = new TestContext({}); - const result = runIntrinsic(intrinsics.fnEquals, tc, ['a', 'a']); + const result = runIntrinsic(intrinsics.fnEquals, tc, ['a', 'a'], 'test-stack'); expect(result).toEqual(ok(true)); }); test('detects unequal strings', async () => { const tc = new TestContext({}); - const result = runIntrinsic(intrinsics.fnEquals, tc, ['a', 'b']); + const result = runIntrinsic(intrinsics.fnEquals, tc, ['a', 'b'], 'test-stack'); expect(result).toEqual(ok(false)); }); test('detects equal objects', async () => { const tc = new TestContext({}); - const result = runIntrinsic(intrinsics.fnEquals, tc, [{ x: 'a' }, { x: 'a' }]); + const result = runIntrinsic(intrinsics.fnEquals, tc, [{ x: 'a' }, { x: 'a' }], 'test-stack'); expect(result).toEqual(ok(true)); }); test('detects unequal objects', async () => { const tc = new TestContext({}); - const result = runIntrinsic(intrinsics.fnEquals, tc, [{ x: 'a' }, { x: 'b' }]); + const result = runIntrinsic(intrinsics.fnEquals, tc, [{ x: 'a' }, { x: 'b' }], 'test-stack'); expect(result).toEqual(ok(false)); }); test('insists on two arguments', async () => { const tc = new TestContext({}); - const result = runIntrinsic(intrinsics.fnEquals, tc, [1]); + const result = runIntrinsic(intrinsics.fnEquals, tc, [1], 'test-stack'); expect(result).toEqual(failed(`Fn::Equals expects exactly 2 params, got 1`)); }); }); @@ -181,53 +181,59 @@ describe('Ref', () => { test('resolves a parameter by its logical ID', async () => { const tc = new TestContext({ parameters: { - MyParam: { id: 'MyParam', Type: 'String', Default: 'MyParamValue' }, + 'test-stack': { MyParam: { id: 'MyParam', Type: 'String', Default: 'MyParamValue' } }, }, }); - const result = runIntrinsic(intrinsics.ref, tc, ['MyParam']); + const result = runIntrinsic(intrinsics.ref, tc, ['MyParam'], 'test-stack'); expect(result).toEqual(ok('MyParamValue')); }); test('respects "id" resource mapping provided by the user', async () => { const tc = new TestContext({ resources: { - MyRes: { - resource: {}, - resourceType: 'AWS::S3::Bucket', - attributes: { - id: 'myID', + 'test-stack': { + MyRes: { + resource: {}, + resourceType: 'AWS::S3::Bucket', + attributes: { + id: 'myID', + }, }, }, }, }); - const result = runIntrinsic(intrinsics.ref, tc, ['MyRes']); + const result = runIntrinsic(intrinsics.ref, tc, ['MyRes'], 'test-stack'); expect(result).toEqual(ok('myID')); }); test('resolves a CustomResource to its physical ID', async () => { const tc = new TestContext({ resources: { - MyRes: { - resource: { - __pulumiType: (ccapi.cloudformation.CustomResourceEmulator).__pulumiType, - physicalResourceId: 'physicalID', + 'test-stack': { + MyRes: { + resource: { + __pulumiType: (ccapi.cloudformation.CustomResourceEmulator).__pulumiType, + physicalResourceId: 'physicalID', + }, + resourceType: 'AWS::CloudFormation::CustomResource', }, - resourceType: 'AWS::CloudFormation::CustomResource', }, }, }); - const result = runIntrinsic(intrinsics.ref, tc, ['MyRes']); + const result = runIntrinsic(intrinsics.ref, tc, ['MyRes'], 'test-stack'); expect(result).toEqual(ok('physicalID')); }); test('fails if Pulumi metadata indicates Ref is not supported', async () => { const tc = new TestContext({ resources: { - MyRes: { - resource: { - __pulumiType: (ccapi.s3.Bucket).__pulumiType, + 'test-stack': { + MyRes: { + resource: { + __pulumiType: (ccapi.s3.Bucket).__pulumiType, + }, + resourceType: 'AWS::S3::Bucket', }, - resourceType: 'AWS::S3::Bucket', }, }, pulumiMetadata: { @@ -240,19 +246,21 @@ describe('Ref', () => { }, }, }); - const result = runIntrinsic(intrinsics.ref, tc, ['MyRes']); + const result = runIntrinsic(intrinsics.ref, tc, ['MyRes'], 'test-stack'); expect(result).toEqual(failed('Ref intrinsic is not supported for the AWS::S3::Bucket resource type')); }); test('resolves to a property value indicated by Pulumi metadata', async () => { const tc = new TestContext({ resources: { - MyRes: { - resource: { - stageName: 'my-stage', - __pulumiType: (ccapi.apigateway.Stage).__pulumiType, + 'test-stack': { + MyRes: { + resource: { + stageName: 'my-stage', + __pulumiType: (ccapi.apigateway.Stage).__pulumiType, + }, + resourceType: 'AWS::ApiGateway::Stage', }, - resourceType: 'AWS::ApiGateway::Stage', }, }, pulumiMetadata: { @@ -265,19 +273,21 @@ describe('Ref', () => { }, }, }); - const result = runIntrinsic(intrinsics.ref, tc, ['MyRes']); + const result = runIntrinsic(intrinsics.ref, tc, ['MyRes'], 'test-stack'); expect(result).toEqual(ok('my-stage')); }); test('does not use Pulumi metadata for AWS provider resource', async () => { const tc = new TestContext({ resources: { - MyRes: { - resource: { - id: 'my-stage', - __pulumiType: (aws.apigateway.Stage).__pulumiType, + 'test-stack': { + MyRes: { + resource: { + id: 'my-stage', + __pulumiType: (aws.apigateway.Stage).__pulumiType, + }, + resourceType: 'AWS::ApiGateway::Stage', }, - resourceType: 'AWS::ApiGateway::Stage', }, }, pulumiMetadata: { @@ -290,20 +300,22 @@ describe('Ref', () => { }, }, }); - const result = runIntrinsic(intrinsics.ref, tc, ['MyRes']); + const result = runIntrinsic(intrinsics.ref, tc, ['MyRes'], 'test-stack'); expect(result).toEqual(ok('my-stage')); }); test('resolves to a join of several property values indicated by Pulumi metadata', async () => { const tc = new TestContext({ resources: { - MyRes: { - resource: { - roleName: 'my-role', - policyName: 'my-policy', - __pulumiType: (ccapi.iam.RolePolicy).__pulumiType, + 'test-stack': { + MyRes: { + resource: { + roleName: 'my-role', + policyName: 'my-policy', + __pulumiType: (ccapi.iam.RolePolicy).__pulumiType, + }, + resourceType: 'AWS::IAM::RolePolicy', }, - resourceType: 'AWS::IAM::RolePolicy', }, }, pulumiMetadata: { @@ -317,28 +329,28 @@ describe('Ref', () => { }, }, }); - const result = runIntrinsic(intrinsics.ref, tc, ['MyRes']); + const result = runIntrinsic(intrinsics.ref, tc, ['MyRes'], 'test-stack'); expect(result).toEqual(ok('my-policy|my-role')); }); test('fails if called with an ID that does not resolve', async () => { const tc = new TestContext({}); - const result = runIntrinsic(intrinsics.ref, tc, ['MyParam']); + const result = runIntrinsic(intrinsics.ref, tc, ['MyParam'], 'test-stack'); expect(result).toEqual( - failed('Ref intrinsic unable to resolve MyParam: not a known logical resource or parameter reference'), + failed('Ref intrinsic unable to resolve MyParam in stack test-stack: not a known logical resource or parameter reference'), ); }); test('evaluates inner expressions before resolving', async () => { const tc = new TestContext({ parameters: { - MyParam: { id: 'MyParam', Type: 'String', Default: 'MyParamValue' }, + 'test-stack': { MyParam: { id: 'MyParam', Type: 'String', Default: 'MyParamValue' } }, }, conditions: { - MyCondition: true, + 'test-stack': { MyCondition: true }, }, }); - const result = runIntrinsic(intrinsics.ref, tc, [{ 'Fn::If': ['MyCondition', 'MyParam', 'MyParam2'] }]); + const result = runIntrinsic(intrinsics.ref, tc, [{ 'Fn::If': ['MyCondition', 'MyParam', 'MyParam2'] }], 'test-stack'); expect(result).toEqual(ok('MyParamValue')); }); @@ -346,10 +358,10 @@ describe('Ref', () => { const stackNodeId = 'stackNodeId'; const tc = new TestContext({ parameters: { - MyParam: { id: 'MyParam', Type: 'String', Default: 'MyParamValue' }, + 'test-stack': { MyParam: { id: 'MyParam', Type: 'String', Default: 'MyParamValue' } }, }, conditions: { - MyCondition: true, + 'test-stack': { MyCondition: true }, }, accountId: '012345678901', region: 'us-west-2', @@ -358,24 +370,24 @@ describe('Ref', () => { stackNodeId: stackNodeId, }); - expect(runIntrinsic(intrinsics.ref, tc, ['AWS::AccountId'])).toEqual(ok('012345678901')); - expect(runIntrinsic(intrinsics.ref, tc, ['AWS::Region'])).toEqual(ok('us-west-2')); - expect(runIntrinsic(intrinsics.ref, tc, ['AWS::Partition'])).toEqual(ok('aws-us-gov')); - expect(runIntrinsic(intrinsics.ref, tc, ['AWS::URLSuffix'])).toEqual(ok('amazonaws.com.cn')); - expect(runIntrinsic(intrinsics.ref, tc, ['AWS::NoValue'])).toEqual(ok(undefined)); + expect(runIntrinsic(intrinsics.ref, tc, ['AWS::AccountId'], 'test-stack')).toEqual(ok('012345678901')); + expect(runIntrinsic(intrinsics.ref, tc, ['AWS::Region'], 'test-stack')).toEqual(ok('us-west-2')); + expect(runIntrinsic(intrinsics.ref, tc, ['AWS::Partition'], 'test-stack')).toEqual(ok('aws-us-gov')); + expect(runIntrinsic(intrinsics.ref, tc, ['AWS::URLSuffix'], 'test-stack')).toEqual(ok('amazonaws.com.cn')); + expect(runIntrinsic(intrinsics.ref, tc, ['AWS::NoValue'], 'test-stack')).toEqual(ok(undefined)); - expect(runIntrinsic(intrinsics.ref, tc, ['AWS::NotificationARNs'])).toEqual( + expect(runIntrinsic(intrinsics.ref, tc, ['AWS::NotificationARNs'], 'test-stack')).toEqual( failed('AWS::NotificationARNs pseudo-parameter is not yet supported in pulumi-cdk'), ); // These are approximations; testing the current behavior for completeness sake. - expect(runIntrinsic(intrinsics.ref, tc, ['AWS::StackId'])).toEqual(ok(stackNodeId)); - expect(runIntrinsic(intrinsics.ref, tc, ['AWS::StackName'])).toEqual(ok(stackNodeId)); + expect(runIntrinsic(intrinsics.ref, tc, ['AWS::StackId'], 'test-stack')).toEqual(ok(stackNodeId)); + expect(runIntrinsic(intrinsics.ref, tc, ['AWS::StackName'], 'test-stack')).toEqual(ok(stackNodeId)); }); }); -function runIntrinsic(fn: intrinsics.Intrinsic, tc: TestContext, args: intrinsics.Expression[]): TestResult { - const result: TestResult = fn.evaluate(tc, args); +function runIntrinsic(fn: intrinsics.Intrinsic, tc: TestContext, args: intrinsics.Expression[], stackPath: string): TestResult { + const result: TestResult = fn.evaluate(tc, args, stackPath); return result; } @@ -389,15 +401,19 @@ function failed(errorMessage: string): TestResult { return { ok: false, errorMessage: errorMessage }; } +interface StackNode { + [stackPath: string]: { [id: string]: T }; +} + class TestContext implements intrinsics.IntrinsicContext { accountId: string; region: string; partition: string; urlSuffix: string; stackNodeId: string; - conditions: { [id: string]: intrinsics.Expression }; - parameters: { [id: CloudFormationParameterLogicalId]: CloudFormationParameterWithId }; - resources: { [id: string]: Mapping }; + conditions: StackNode; + parameters: StackNode; + resources: StackNode>; pulumiMetadata: { [cfnType: string]: PulumiResource }; constructor(args: { @@ -406,9 +422,9 @@ class TestContext implements intrinsics.IntrinsicContext { partition?: string; urlSuffix?: string; stackNodeId?: string; - conditions?: { [id: string]: intrinsics.Expression }; - parameters?: { [id: CloudFormationParameterLogicalId]: CloudFormationParameterWithId }; - resources?: { [id: string]: Mapping }; + conditions?: StackNode; + parameters?: StackNode; + resources?: StackNode>; pulumiMetadata?: { [cfnType: string]: PulumiResource }; }) { this.stackNodeId = args.stackNodeId || ''; @@ -432,9 +448,10 @@ class TestContext implements intrinsics.IntrinsicContext { } } - findParameter(parameterLogicalID: string): CloudFormationParameterWithId | undefined { - if (parameterLogicalID in this.parameters) { - return this.parameters[parameterLogicalID]; + findParameter(stackAddress: StackAddress): CloudFormationParameterWithId | undefined { + const param = this.parameters?.[stackAddress.stackPath]?.[stackAddress.id]; + if (param) { + return { ...param, stackAddress }; } } @@ -443,19 +460,15 @@ class TestContext implements intrinsics.IntrinsicContext { return this.succeed(param.Default!); } - findCondition(conditionName: string): intrinsics.Expression | undefined { - if (conditionName in this.conditions) { - return this.conditions[conditionName]; - } + findCondition(stackAddress: StackAddress): intrinsics.Expression | undefined { + return this.conditions?.[stackAddress.stackPath]?.[stackAddress.id]; } - findResourceMapping(resourceLogicalID: string): Mapping | undefined { - if (resourceLogicalID in this.resources) { - return this.resources[resourceLogicalID]; - } + findResourceMapping(stackAddress: StackAddress): Mapping | undefined { + return this.resources?.[stackAddress.stackPath]?.[stackAddress.id]; } - evaluate(expression: intrinsics.Expression): intrinsics.Result { + evaluate(expression: intrinsics.Expression, stackPath: string): intrinsics.Result { // Evaluate known heuristics. const known = [ intrinsics.fnAnd, @@ -469,7 +482,7 @@ class TestContext implements intrinsics.IntrinsicContext { for (const k of known) { if (k.name === Object.keys(expression)[0]) { const args = expression[k.name]; - return k.evaluate(this, args); + return k.evaluate(this, args, stackPath); } } } diff --git a/tests/converters/secretsmanager-converter.test.ts b/tests/converters/secretsmanager-converter.test.ts index a9b019cf..3327c1b2 100644 --- a/tests/converters/secretsmanager-converter.test.ts +++ b/tests/converters/secretsmanager-converter.test.ts @@ -110,9 +110,10 @@ describe('SecretsManager tests', () => { id: 'stack', templatePath: 'test/stack', metadata: { - 'stack/db': 'db', - 'stack/secret': 'secret', + 'stack/db': { stackPath: 'stack', id: 'db' }, + 'stack/secret': { stackPath: 'stack', id: 'secret' }, }, + nestedStacks: {}, tree: { path: 'stack', id: 'stack', @@ -180,7 +181,7 @@ describe('SecretsManager tests', () => { converter.convert(new Set()); // THEN - const subnet = converter.resources.get('db')?.resource as native.rds.DbInstance; + const subnet = converter.resources.get({ stackPath: 'stack', id: 'db' })?.resource as native.rds.DbInstance; const cidrBlock = await promiseOf(subnet.masterUserSecret); expect(cidrBlock).toEqual('abcd'); }); @@ -208,7 +209,7 @@ describe('SecretsManager tests', () => { converter.convert(new Set()); // THEN - const subnet = converter.resources.get('db')?.resource as native.rds.DbInstance; + const subnet = converter.resources.get({ stackPath: 'stack', id: 'db' })?.resource as native.rds.DbInstance; const cidrBlock = await promiseOf(subnet.masterUserSecret); expect(cidrBlock).toEqual('abcd'); }); @@ -222,7 +223,7 @@ describe('SecretsManager tests', () => { converter.convert(new Set()); // THEN - const subnet = converter.resources.get('db')?.resource as native.rds.DbInstance; + const subnet = converter.resources.get({ stackPath: 'stack', id: 'db' })?.resource as native.rds.DbInstance; const cidrBlock = await promiseOf(subnet.masterUserSecret); expect(cidrBlock).toEqual('abcd'); }); @@ -236,7 +237,7 @@ describe('SecretsManager tests', () => { converter.convert(new Set()); // THEN - const subnet = converter.resources.get('db')?.resource as native.rds.DbInstance; + const subnet = converter.resources.get({ stackPath: 'stack', id: 'db' })?.resource as native.rds.DbInstance; const cidrBlock = await promiseOf(subnet.masterUserSecret); expect(cidrBlock).toEqual('abcd'); }); diff --git a/tests/converters/ssm-converter.test.ts b/tests/converters/ssm-converter.test.ts index e99f7f60..ca7552f6 100644 --- a/tests/converters/ssm-converter.test.ts +++ b/tests/converters/ssm-converter.test.ts @@ -97,9 +97,10 @@ describe('SSM tests', () => { id: 'stack', templatePath: 'test/stack', metadata: { - 'stack/db': 'db', - 'stack/param': 'param', + 'stack/db': { stackPath: 'stack', id: 'db' }, + 'stack/param': { stackPath: 'stack', id: 'param' }, }, + nestedStacks: {}, tree: { path: 'stack', id: 'stack', @@ -166,7 +167,7 @@ describe('SSM tests', () => { converter.convert(new Set()); // THEN - const subnet = converter.resources.get('db')?.resource as native.rds.DbInstance; + const subnet = converter.resources.get({ stackPath: 'stack', id: 'db' })?.resource as native.rds.DbInstance; const cidrBlock = await promiseOf(subnet.masterUserSecret); expect(cidrBlock).toEqual('abcd'); }); @@ -180,7 +181,7 @@ describe('SSM tests', () => { converter.convert(new Set()); // THEN - const subnet = converter.resources.get('db')?.resource as native.rds.DbInstance; + const subnet = converter.resources.get({ stackPath: 'stack', id: 'db' })?.resource as native.rds.DbInstance; const cidrBlock = await promiseOf(subnet.masterUserSecret); expect(cidrBlock).toEqual('abcd'); }); @@ -201,7 +202,7 @@ describe('SSM tests', () => { converter.convert(new Set()); // THEN - const subnet = converter.resources.get('db')?.resource as native.rds.DbInstance; + const subnet = converter.resources.get({ stackPath: 'stack', id: 'db' })?.resource as native.rds.DbInstance; const value = await promiseOf(subnet.masterUserSecret); expect(value).toEqual('abcd'); }); @@ -222,7 +223,7 @@ describe('SSM tests', () => { converter.convert(new Set()); // THEN - const subnet = converter.resources.get('db')?.resource as native.rds.DbInstance; + const subnet = converter.resources.get({ stackPath: 'stack', id: 'db' })?.resource as native.rds.DbInstance; const value = await promiseOf(subnet.masterUserSecret); expect(value).toEqual('abcd,efgh'); }); diff --git a/tests/graph.test.ts b/tests/graph.test.ts index bf20a71b..8280713f 100644 --- a/tests/graph.test.ts +++ b/tests/graph.test.ts @@ -24,9 +24,10 @@ describe('GraphBuilder', () => { id: 'stack', templatePath: 'test/stack', metadata: { - 'stack/example-bucket/Resource': 'examplebucketC9DFA43E', - 'stack/example-bucket/Policy/Resource': 'examplebucketPolicyE09B485E', + 'stack/example-bucket/Resource': { stackPath: 'stack', id: 'examplebucketC9DFA43E' }, + 'stack/example-bucket/Policy/Resource': { stackPath: 'stack', id: 'examplebucketPolicyE09B485E' }, }, + nestedStacks: {}, tree: { path: 'stack', id: 'stack', @@ -109,7 +110,7 @@ describe('GraphBuilder', () => { type: 'aws-cdk-lib:Stack', parent: undefined, }, - logicalId: undefined, + resourceAddress: undefined, resource: undefined, incomingEdges: ['stack/example-bucket'], outgoingEdges: [], @@ -125,7 +126,7 @@ describe('GraphBuilder', () => { id: 'example-bucket', type: 'aws-cdk-lib/aws_s3:Bucket', }, - logicalId: undefined, + resourceAddress: undefined, resource: undefined, incomingEdges: ['stack/example-bucket/Resource', 'stack/example-bucket/Policy'], outgoingEdges: ['stack'], @@ -145,7 +146,7 @@ describe('GraphBuilder', () => { Type: 'AWS::S3::Bucket', Properties: {}, }, - logicalId: 'examplebucketC9DFA43E', + resourceAddress: { stackPath: 'stack', id: 'examplebucketC9DFA43E' }, incomingEdges: ['stack/example-bucket/Policy/Resource'], outgoingEdges: ['stack/example-bucket'], }, @@ -160,7 +161,7 @@ describe('GraphBuilder', () => { id: 'Policy', type: 'aws-cdk-lib/aws_s3:BucketPolicy', }, - logicalId: undefined, + resourceAddress: undefined, resource: undefined, incomingEdges: ['stack/example-bucket/Policy/Resource'], outgoingEdges: ['stack/example-bucket'], @@ -184,7 +185,7 @@ describe('GraphBuilder', () => { }, }, }, - logicalId: 'examplebucketPolicyE09B485E', + resourceAddress: { stackPath: 'stack', id: 'examplebucketPolicyE09B485E' }, incomingEdges: [], outgoingEdges: ['stack/example-bucket/Policy', 'stack/example-bucket/Resource'], }, @@ -192,7 +193,7 @@ describe('GraphBuilder', () => { ])('Parses the graph correctly', (graph, path, expected) => { const actual = graph.nodes.find((node) => node.construct.path === path); expect(actual).toBeDefined(); - expect(actual!.logicalId).toEqual(expected.logicalId); + expect(actual!.resourceAddress).toEqual(expected.resourceAddress); expect(actual!.resource).toEqual(expected.resource); expect(actual!.construct.parent?.id).toEqual(expected.construct.parent); expect(actual!.construct.type).toEqual(expected.construct.type); @@ -251,10 +252,11 @@ test('vpc with ipv6 cidr block', () => { id: 'stack', templatePath: 'test/stack', metadata: { - 'stack/vpc': 'vpc', - 'stack/cidr': 'cidr', - 'stack/other': 'other', + 'stack/vpc': { stackPath: 'stack', id: 'vpc' }, + 'stack/cidr': { stackPath: 'stack', id: 'cidr' }, + 'stack/other': { stackPath: 'stack', id: 'other' }, }, + nestedStacks: {}, tree: { path: 'stack', id: 'stack', @@ -316,8 +318,8 @@ test('vpc with ipv6 cidr block', () => { expect(nodes[3].construct.type).toEqual('Resource'); // The other resource should have it's edge swapped to the cidr resource - expect(Array.from(nodes[2].incomingEdges.values())[0].logicalId).toEqual('other'); - expect(Array.from(nodes[3].outgoingEdges.values())[1].logicalId).toEqual('cidr'); + expect(Array.from(nodes[2].incomingEdges.values())[0].resourceAddress).toEqual({ stackPath: 'stack', id: 'other' }); + expect(Array.from(nodes[3].outgoingEdges.values())[1].resourceAddress).toEqual({ stackPath: 'stack', id: 'cidr' }); }); test('vpc with multiple ipv6 cidr blocks fails', () => { @@ -327,11 +329,12 @@ test('vpc with multiple ipv6 cidr blocks fails', () => { id: 'stack', templatePath: 'test/stack', metadata: { - 'stack/vpc': 'vpc', - 'stack/cidr': 'cidr', - 'stack/cidr2': 'cidr2', - 'stack/other': 'other', + 'stack/vpc': { stackPath: 'stack', id: 'vpc' }, + 'stack/cidr': { stackPath: 'stack', id: 'cidr' }, + 'stack/cidr2': { stackPath: 'stack', id: 'cidr2' }, + 'stack/other': { stackPath: 'stack', id: 'other' }, }, + nestedStacks: {}, tree: { path: 'stack', id: 'stack', @@ -399,7 +402,7 @@ test('vpc with multiple ipv6 cidr blocks fails', () => { dependencies: [], }), ).nodes; - }).toThrow(/VPC vpc already has a VPCCidrBlock/); + }).toThrow(/VPC vpc in stack stack already has a VPCCidrBlock/); }); test('pulumi resource type name fallsback when fqn not available', () => { @@ -410,9 +413,10 @@ test('pulumi resource type name fallsback when fqn not available', () => { id: 'stack', templatePath: 'test/stack', metadata: { - 'stack/example-bucket/Resource': 'examplebucketC9DFA43E', - 'stack/example-bucket/Policy/Resource': 'examplebucketPolicyE09B485E', + 'stack/example-bucket/Resource': { stackPath: 'stack', id: 'examplebucketC9DFA43E' }, + 'stack/example-bucket/Policy/Resource': { stackPath: 'stack', id: 'examplebucketPolicyE09B485E' }, }, + nestedStacks: {}, tree: { path: 'stack', id: 'stack', @@ -490,7 +494,7 @@ test('parses custom resources', () => { const stackManifest = new StackManifest(props); const graph = GraphBuilder.build(stackManifest); - const deployWebsiteCR = graph.nodes.find((node) => node.logicalId === 'DeployWebsiteCustomResourceD116527B'); + const deployWebsiteCR = graph.nodes.find((node) => node.resourceAddress?.id === 'DeployWebsiteCustomResourceD116527B'); expect(deployWebsiteCR).toBeDefined(); expect(deployWebsiteCR?.construct.type).toEqual('aws-cdk-lib:CfnResource'); expect(deployWebsiteCR?.resource).toBeDefined(); @@ -506,7 +510,7 @@ test('parses custom resources', () => { 'a386ba9b8c0d9b386083b2f6952db278a5a0ce88f497484eb5e62172219468fd.zip', ]); - const testRole = graph.nodes.find((node) => node.logicalId === 'CustomResourceRoleAB1EF463'); + const testRole = graph.nodes.find((node) => node.resourceAddress?.id === 'CustomResourceRoleAB1EF463'); expect(testRole).toBeDefined(); const policies = testRole?.resource?.Properties?.Policies; expect(policies).toBeDefined(); diff --git a/tests/mocks.ts b/tests/mocks.ts index 029ca14d..fd0e2adb 100644 --- a/tests/mocks.ts +++ b/tests/mocks.ts @@ -189,6 +189,17 @@ export function setMocks(resources?: MockResourceArgs[], overrides?: { [pulumiTy }, }, }; + case 'aws-native:s3:Bucket': + resources?.push(args); + return { + id: args.name + '_id', + state: { + ...args.inputs, + id: args.name + '_id', + arn: args.name + '_arn', + bucketName: args.inputs?.bucketName ?? (args.name + '_name'), + }, + }; default: { resources?.push(args); const attrName = args.type.split(':')[2]; diff --git a/tests/options.test.ts b/tests/options.test.ts index ee1a11fe..d4168a11 100644 --- a/tests/options.test.ts +++ b/tests/options.test.ts @@ -27,8 +27,9 @@ describe('options', () => { id: 'stack', templatePath: 'test/stack', metadata: { - 'stack/bucket': 'bucket', + 'stack/bucket': { stackPath: 'stack', id: 'bucket' }, }, + nestedStacks: {}, tree: { path: 'stack', id: 'stack', @@ -74,8 +75,9 @@ describe('options', () => { id: 'stack', templatePath: 'test/stack', metadata: { - 'stack/bucket': 'bucket', + 'stack/bucket': { stackPath: 'stack', id: 'bucket' }, }, + nestedStacks: {}, tree: { path: 'stack', id: 'stack', @@ -121,8 +123,9 @@ describe('options', () => { id: 'stack', templatePath: 'test/stack', metadata: { - 'stack/bucket': 'bucket', + 'stack/bucket': { stackPath: 'stack', id: 'bucket' }, }, + nestedStacks: {}, tree: { path: 'stack', id: 'stack', @@ -168,8 +171,9 @@ describe('options', () => { id: 'stack', templatePath: 'test/stack', metadata: { - 'stack/bucket': 'bucket', + 'stack/bucket': { stackPath: 'stack', id: 'bucket' }, }, + nestedStacks: {}, tree: { path: 'stack', id: 'stack', @@ -215,8 +219,9 @@ describe('options', () => { id: 'stack', templatePath: 'test/stack', metadata: { - 'stack/bucket': 'bucket', + 'stack/bucket': { stackPath: 'stack', id: 'bucket' }, }, + nestedStacks: {}, tree: { path: 'stack', id: 'stack', @@ -261,8 +266,9 @@ describe('options', () => { id: 'stack', templatePath: 'test/stack', metadata: { - 'stack/bucket': 'bucket', + 'stack/bucket': { stackPath: 'stack', id: 'bucket' }, }, + nestedStacks: {}, tree: { path: 'stack', id: 'stack', diff --git a/tests/stack-map.test.ts b/tests/stack-map.test.ts new file mode 100644 index 00000000..f01c1ebf --- /dev/null +++ b/tests/stack-map.test.ts @@ -0,0 +1,101 @@ +import { StackMap } from '../src/stack-map'; +import { StackAddress } from '../src/assembly'; + +describe('StackMap', () => { + let stackMap: StackMap; + const stackAddress1: StackAddress = { stackPath: 'path1', id: 'id1' }; + const stackAddress2: StackAddress = { stackPath: 'path2', id: 'id2' }; + + beforeEach(() => { + stackMap = new StackMap(); + }); + + test('should set and get values correctly', () => { + stackMap.set(stackAddress1, 1); + expect(stackMap.get(stackAddress1)).toBe(1); + }); + + test('should return undefined for non-existent keys', () => { + expect(stackMap.get(stackAddress1)).toBeUndefined(); + }); + + test('should delete values correctly', () => { + stackMap.set(stackAddress1, 1); + expect(stackMap.delete(stackAddress1)).toBe(true); + expect(stackMap.get(stackAddress1)).toBeUndefined(); + }); + + test('should return false when deleting non-existent keys', () => { + expect(stackMap.delete(stackAddress1)).toBe(false); + }); + + test('should check existence of keys correctly', () => { + stackMap.set(stackAddress1, 1); + expect(stackMap.has(stackAddress1)).toBe(true); + expect(stackMap.has(stackAddress2)).toBe(false); + }); + + test('should clear all values', () => { + stackMap.set(stackAddress1, 1); + stackMap.set(stackAddress2, 2); + stackMap.clear(); + expect(stackMap.size).toBe(0); + }); + + test('should return the correct size', () => { + expect(stackMap.size).toBe(0); + stackMap.set(stackAddress1, 1); + expect(stackMap.size).toBe(1); + }); + + test('should iterate over entries correctly', () => { + stackMap.set(stackAddress1, 1); + stackMap.set(stackAddress2, 2); + const entries = Array.from(stackMap.entries()); + expect(entries).toEqual([ + [stackAddress1, 1], + [stackAddress2, 2] + ]); + }); + + test('should iterate over keys correctly', () => { + stackMap.set(stackAddress1, 1); + stackMap.set(stackAddress2, 2); + const keys = Array.from(stackMap.keys()); + expect(keys).toEqual([stackAddress1, stackAddress2]); + }); + + test('should iterate over values correctly', () => { + stackMap.set(stackAddress1, 1); + stackMap.set(stackAddress2, 2); + const values = Array.from(stackMap.values()); + expect(values).toEqual([1, 2]); + }); + + test('should execute forEach correctly', () => { + stackMap.set(stackAddress1, 1); + stackMap.set(stackAddress2, 2); + const mockCallback = jest.fn(); + stackMap.forEach(mockCallback); + expect(mockCallback).toHaveBeenCalledTimes(2); + expect(mockCallback).toHaveBeenCalledWith(1, stackAddress1, stackMap); + expect(mockCallback).toHaveBeenCalledWith(2, stackAddress2, stackMap); + }); + + test('should execute forEachStackElement correctly for existing stackPath', () => { + stackMap.set(stackAddress1, 1); + stackMap.set({ stackPath: 'path1', id: 'id2' }, 2); + const mockCallback = jest.fn(); + stackMap.forEachStackElement('path1', mockCallback); + expect(mockCallback).toHaveBeenCalledTimes(2); + expect(mockCallback).toHaveBeenCalledWith(1, { stackPath: 'path1', id: 'id1' }, stackMap); + expect(mockCallback).toHaveBeenCalledWith(2, { stackPath: 'path1', id: 'id2' }, stackMap); + }); + + test('should not execute forEachStackElement for non-existent stackPath', () => { + stackMap.set(stackAddress1, 1); + const mockCallback = jest.fn(); + stackMap.forEachStackElement('nonExistentPath', mockCallback); + expect(mockCallback).not.toHaveBeenCalled(); + }); +}); \ No newline at end of file diff --git a/tests/test-data/custom-resource-stack/stack-manifest.json b/tests/test-data/custom-resource-stack/stack-manifest.json index 69bbf0bf..ffc53da9 100644 --- a/tests/test-data/custom-resource-stack/stack-manifest.json +++ b/tests/test-data/custom-resource-stack/stack-manifest.json @@ -2,21 +2,55 @@ "id": "stack", "templatePath": "test/stack", "metadata": { - "s3deployment/WebsiteBucket/Resource": "WebsiteBucket75C24D94", - "s3deployment/WebsiteBucket/Policy/Resource": "WebsiteBucketPolicyE10E3262", - "s3deployment/WebsiteBucket/AutoDeleteObjectsCustomResource/Default": "WebsiteBucketAutoDeleteObjectsCustomResource8750E461", - "s3deployment/Custom::S3AutoDeleteObjectsCustomResourceProvider/Role": "CustomS3AutoDeleteObjectsCustomResourceProviderRole3B1BD092", - "s3deployment/Custom::S3AutoDeleteObjectsCustomResourceProvider/Handler": "CustomS3AutoDeleteObjectsCustomResourceProviderHandler9D90184F", - "s3deployment/DeployWebsite/AwsCliLayer/Resource": "DeployWebsiteAwsCliLayer17DBC421", - "s3deployment/DeployWebsite/CustomResource/Default": "DeployWebsiteCustomResourceD116527B", - "s3deployment/Custom::CDKBucketDeployment8693BB64968944B69AAFB0CC9EB8756C/ServiceRole/Resource": "CustomCDKBucketDeployment8693BB64968944B69AAFB0CC9EB8756CServiceRole89A01265", - "s3deployment/Custom::CDKBucketDeployment8693BB64968944B69AAFB0CC9EB8756C/ServiceRole/DefaultPolicy/Resource": "CustomCDKBucketDeployment8693BB64968944B69AAFB0CC9EB8756CServiceRoleDefaultPolicy88902FDF", - "s3deployment/Custom::CDKBucketDeployment8693BB64968944B69AAFB0CC9EB8756C/Resource": "CustomCDKBucketDeployment8693BB64968944B69AAFB0CC9EB8756C81C01536", - "s3deployment/CustomResourceRole/Resource": "CustomResourceRoleAB1EF463" + "s3deployment/WebsiteBucket/Resource": { + "stackPath": "stack", + "id": "WebsiteBucket75C24D94" + }, + "s3deployment/WebsiteBucket/Policy/Resource": { + "stackPath": "stack", + "id": "WebsiteBucketPolicyE10E3262" + }, + "s3deployment/WebsiteBucket/AutoDeleteObjectsCustomResource/Default": { + "stackPath": "stack", + "id": "WebsiteBucketAutoDeleteObjectsCustomResource8750E461" + }, + "s3deployment/Custom::S3AutoDeleteObjectsCustomResourceProvider/Role": { + "stackPath": "stack", + "id": "CustomS3AutoDeleteObjectsCustomResourceProviderRole3B1BD092" + }, + "s3deployment/Custom::S3AutoDeleteObjectsCustomResourceProvider/Handler": { + "stackPath": "stack", + "id": "CustomS3AutoDeleteObjectsCustomResourceProviderHandler9D90184F" + }, + "s3deployment/DeployWebsite/AwsCliLayer/Resource": { + "stackPath": "stack", + "id": "DeployWebsiteAwsCliLayer17DBC421" + }, + "s3deployment/DeployWebsite/CustomResource/Default": { + "stackPath": "stack", + "id": "DeployWebsiteCustomResourceD116527B" + }, + "s3deployment/Custom::CDKBucketDeployment8693BB64968944B69AAFB0CC9EB8756C/ServiceRole/Resource": { + "stackPath": "stack", + "id": "CustomCDKBucketDeployment8693BB64968944B69AAFB0CC9EB8756CServiceRole89A01265" + }, + "s3deployment/Custom::CDKBucketDeployment8693BB64968944B69AAFB0CC9EB8756C/ServiceRole/DefaultPolicy/Resource": { + "stackPath": "stack", + "id": "CustomCDKBucketDeployment8693BB64968944B69AAFB0CC9EB8756CServiceRoleDefaultPolicy88902FDF" + }, + "s3deployment/Custom::CDKBucketDeployment8693BB64968944B69AAFB0CC9EB8756C/Resource": { + "stackPath": "stack", + "id": "CustomCDKBucketDeployment8693BB64968944B69AAFB0CC9EB8756C81C01536" + }, + "s3deployment/CustomResourceRole/Resource": { + "stackPath": "stack", + "id": "CustomResourceRoleAB1EF463" + } }, + "nestedStacks": {}, "tree": { "id": "stack", - "path": "", + "path": "stack", "children": { "s3deployment": { "id": "s3deployment", diff --git a/tests/test-data/nested-stack/manifest.json b/tests/test-data/nested-stack/manifest.json new file mode 100644 index 00000000..a651ba53 --- /dev/null +++ b/tests/test-data/nested-stack/manifest.json @@ -0,0 +1,120 @@ +{ + "version": "36.3.0", + "artifacts": { + "teststack.assets": { + "type": "cdk:asset-manifest", + "properties": { + "file": "teststack.assets.json" + } + }, + "teststack": { + "type": "aws:cloudformation:stack", + "environment": "aws://616138583583/us-west-2", + "properties": { + "templateFile": "teststack.template.json", + "terminationProtection": false, + "validateOnSynth": false, + "stackTemplateAssetObjectUrl": "s3://pulumi-cdk-sted-sta-3ca1883d-staging/deploy-time/4cc5fad210e6468e30325de9b3f814b86a6bb8c61b9b5d7e8437c5290ab31f99.json" + }, + "metadata": { + "/teststack/bucket/Resource": [ + { + "type": "aws:cdk:logicalId", + "data": "bucket" + } + ], + "/teststack/nesty/bucket/Resource": [ + { + "type": "aws:cdk:logicalId", + "data": "bucket43879C71" + } + ], + "/teststack/nesty/bucket/Policy/Resource": [ + { + "type": "aws:cdk:logicalId", + "data": "bucketPolicy638F945D" + } + ], + "/teststack/nesty/bucket/AutoDeleteObjectsCustomResource/Default": [ + { + "type": "aws:cdk:logicalId", + "data": "bucketAutoDeleteObjectsCustomResource3F4990B2" + } + ], + "/teststack/nesty/Custom::S3AutoDeleteObjectsCustomResourceProvider/Role": [ + { + "type": "aws:cdk:logicalId", + "data": "CustomS3AutoDeleteObjectsCustomResourceProviderRole3B1BD092" + } + ], + "/teststack/nesty/Custom::S3AutoDeleteObjectsCustomResourceProvider/Handler": [ + { + "type": "aws:cdk:logicalId", + "data": "CustomS3AutoDeleteObjectsCustomResourceProviderHandler9D90184F" + } + ], + "/teststack/nesty/reference-to-teststackbucket4A009163Ref": [ + { + "type": "aws:cdk:logicalId", + "data": "referencetoteststackbucket4A009163Ref" + } + ], + "/teststack/nesty/teststacknestybucket3B9EFE19Ref": [ + { + "type": "aws:cdk:logicalId", + "data": "teststacknestybucket3B9EFE19Ref" + } + ], + "/teststack/nesty/teststacknestybucket3B9EFE19Arn": [ + { + "type": "aws:cdk:logicalId", + "data": "teststacknestybucket3B9EFE19Arn" + } + ], + "/teststack/nesty.NestedStack/nesty.NestedStackResource": [ + { + "type": "aws:cdk:logicalId", + "data": "nestyNestedStacknestyNestedStackResource" + } + ], + "/teststack/DeployWebsite/AwsCliLayer/Resource": [ + { + "type": "aws:cdk:logicalId", + "data": "DeployWebsiteAwsCliLayer" + } + ], + "/teststack/DeployWebsite/CustomResource/Default": [ + { + "type": "aws:cdk:logicalId", + "data": "DeployWebsiteCustomResource" + } + ], + "/teststack/Custom::CDKBucketDeployment8693BB64968944B69AAFB0CC9EB8756C/ServiceRole/Resource": [ + { + "type": "aws:cdk:logicalId", + "data": "CustomCDKBucketDeployment8693BB64968944B69AAFB0CC9EB8756CServiceRole" + } + ], + "/teststack/Custom::CDKBucketDeployment8693BB64968944B69AAFB0CC9EB8756C/ServiceRole/DefaultPolicy/Resource": [ + { + "type": "aws:cdk:logicalId", + "data": "CustomCDKBucketDeployment8693BB64968944B69AAFB0CC9EB8756CServiceRoleDefaultPolicy" + } + ], + "/teststack/Custom::CDKBucketDeployment8693BB64968944B69AAFB0CC9EB8756C/Resource": [ + { + "type": "aws:cdk:logicalId", + "data": "CustomCDKBucketDeployment8693BB64968944B69AAFB0CC9EB8756C" + } + ] + }, + "displayName": "teststack" + }, + "Tree": { + "type": "cdk:tree", + "properties": { + "file": "tree.json" + } + } + } +} \ No newline at end of file diff --git a/tests/test-data/nested-stack/stack-manifest.json b/tests/test-data/nested-stack/stack-manifest.json new file mode 100644 index 00000000..e3eb79e4 --- /dev/null +++ b/tests/test-data/nested-stack/stack-manifest.json @@ -0,0 +1,1243 @@ +{ + "dependencies": [], + "metadata": { + "teststack/bucket/Resource": { + "id": "bucket", + "stackPath": "teststack" + }, + "teststack/nesty/bucket/Resource": { + "id": "bucket43879C71", + "stackPath": "teststack/nesty" + }, + "teststack/nesty/bucket/Policy/Resource": { + "id": "bucketPolicy638F945D", + "stackPath": "teststack/nesty" + }, + "teststack/nesty/bucket/AutoDeleteObjectsCustomResource/Default": { + "id": "bucketAutoDeleteObjectsCustomResource3F4990B2", + "stackPath": "teststack/nesty" + }, + "teststack/nesty/Custom::S3AutoDeleteObjectsCustomResourceProvider/Role": { + "id": "CustomS3AutoDeleteObjectsCustomResourceProviderRole3B1BD092", + "stackPath": "teststack/nesty" + }, + "teststack/nesty/Custom::S3AutoDeleteObjectsCustomResourceProvider/Handler": { + "id": "CustomS3AutoDeleteObjectsCustomResourceProviderHandler9D90184F", + "stackPath": "teststack/nesty" + }, + "teststack/nesty/reference-to-teststackbucket4A009163Ref": { + "id": "referencetoteststackbucket4A009163Ref", + "stackPath": "teststack/nesty" + }, + "teststack/nesty/teststacknestybucket3B9EFE19Ref": { + "id": "teststacknestybucket3B9EFE19Ref", + "stackPath": "teststack/nesty" + }, + "teststack/nesty/teststacknestybucket3B9EFE19Arn": { + "id": "teststacknestybucket3B9EFE19Arn", + "stackPath": "teststack/nesty" + }, + "teststack/nesty.NestedStack/nesty.NestedStackResource": { + "id": "nestyNestedStacknestyNestedStackResource", + "stackPath": "teststack" + }, + "teststack/DeployWebsite/AwsCliLayer/Resource": { + "id": "DeployWebsiteAwsCliLayer", + "stackPath": "teststack" + }, + "teststack/DeployWebsite/CustomResource/Default": { + "id": "DeployWebsiteCustomResource", + "stackPath": "teststack" + }, + "teststack/Custom::CDKBucketDeployment8693BB64968944B69AAFB0CC9EB8756C/ServiceRole/Resource": { + "id": "CustomCDKBucketDeployment8693BB64968944B69AAFB0CC9EB8756CServiceRole", + "stackPath": "teststack" + }, + "teststack/Custom::CDKBucketDeployment8693BB64968944B69AAFB0CC9EB8756C/ServiceRole/DefaultPolicy/Resource": { + "id": "CustomCDKBucketDeployment8693BB64968944B69AAFB0CC9EB8756CServiceRoleDefaultPolicy", + "stackPath": "teststack" + }, + "teststack/Custom::CDKBucketDeployment8693BB64968944B69AAFB0CC9EB8756C/Resource": { + "id": "CustomCDKBucketDeployment8693BB64968944B69AAFB0CC9EB8756C", + "stackPath": "teststack" + } + }, + "templatePath": "teststack.template.json", + "id": "teststack", + "tree": { + "id": "teststack", + "path": "teststack", + "children": { + "bucket": { + "id": "bucket", + "path": "teststack/bucket", + "children": { + "Resource": { + "id": "Resource", + "path": "teststack/bucket/Resource", + "attributes": { + "aws:cdk:cloudformation:type": "AWS::S3::Bucket", + "aws:cdk:cloudformation:props": {} + }, + "constructInfo": { + "fqn": "aws-cdk-lib.aws_s3.CfnBucket", + "version": "2.149.0" + } + } + }, + "constructInfo": { + "fqn": "aws-cdk-lib.aws_s3.Bucket", + "version": "2.149.0" + } + }, + "nesty": { + "id": "nesty", + "path": "teststack/nesty", + "children": { + "bucket": { + "id": "bucket", + "path": "teststack/nesty/bucket", + "children": { + "Resource": { + "id": "Resource", + "path": "teststack/nesty/bucket/Resource", + "attributes": { + "aws:cdk:cloudformation:type": "AWS::S3::Bucket", + "aws:cdk:cloudformation:props": { + "bucketName": { + "Fn::Join": [ + "", + [ + { + "Ref": "referencetoteststackbucket4A009163Ref" + }, + "-nested" + ] + ] + }, + "publicAccessBlockConfiguration": { + "blockPublicAcls": false, + "blockPublicPolicy": false, + "ignorePublicAcls": false, + "restrictPublicBuckets": false + }, + "tags": [ + { + "key": "aws-cdk:auto-delete-objects", + "value": "true" + }, + { + "key": "aws-cdk:cr-owned:043cc088", + "value": "true" + } + ], + "websiteConfiguration": { + "indexDocument": "index.html" + } + } + }, + "constructInfo": { + "fqn": "aws-cdk-lib.aws_s3.CfnBucket", + "version": "2.149.0" + } + }, + "Policy": { + "id": "Policy", + "path": "teststack/nesty/bucket/Policy", + "children": { + "Resource": { + "id": "Resource", + "path": "teststack/nesty/bucket/Policy/Resource", + "attributes": { + "aws:cdk:cloudformation:type": "AWS::S3::BucketPolicy", + "aws:cdk:cloudformation:props": { + "bucket": { + "Ref": "bucket43879C71" + }, + "policyDocument": { + "Statement": [ + { + "Action": "s3:GetObject", + "Effect": "Allow", + "Principal": { + "AWS": "*" + }, + "Resource": { + "Fn::Join": [ + "", + [ + { + "Fn::GetAtt": [ + "bucket43879C71", + "Arn" + ] + }, + "/*" + ] + ] + } + }, + { + "Action": [ + "s3:PutBucketPolicy", + "s3:GetBucket*", + "s3:List*", + "s3:DeleteObject*" + ], + "Effect": "Allow", + "Principal": { + "AWS": { + "Fn::GetAtt": [ + "CustomS3AutoDeleteObjectsCustomResourceProviderRole3B1BD092", + "Arn" + ] + } + }, + "Resource": [ + { + "Fn::GetAtt": [ + "bucket43879C71", + "Arn" + ] + }, + { + "Fn::Join": [ + "", + [ + { + "Fn::GetAtt": [ + "bucket43879C71", + "Arn" + ] + }, + "/*" + ] + ] + } + ] + } + ], + "Version": "2012-10-17" + } + } + }, + "constructInfo": { + "fqn": "aws-cdk-lib.aws_s3.CfnBucketPolicy", + "version": "2.149.0" + } + } + }, + "constructInfo": { + "fqn": "aws-cdk-lib.aws_s3.BucketPolicy", + "version": "2.149.0" + } + }, + "AutoDeleteObjectsCustomResource": { + "id": "AutoDeleteObjectsCustomResource", + "path": "teststack/nesty/bucket/AutoDeleteObjectsCustomResource", + "children": { + "Default": { + "id": "Default", + "path": "teststack/nesty/bucket/AutoDeleteObjectsCustomResource/Default", + "constructInfo": { + "fqn": "aws-cdk-lib.CfnResource", + "version": "2.149.0" + } + } + }, + "constructInfo": { + "fqn": "aws-cdk-lib.CustomResource", + "version": "2.149.0" + } + } + }, + "constructInfo": { + "fqn": "aws-cdk-lib.aws_s3.Bucket", + "version": "2.149.0" + } + }, + "Custom::S3AutoDeleteObjectsCustomResourceProvider": { + "id": "Custom::S3AutoDeleteObjectsCustomResourceProvider", + "path": "teststack/nesty/Custom::S3AutoDeleteObjectsCustomResourceProvider", + "children": { + "Staging": { + "id": "Staging", + "path": "teststack/nesty/Custom::S3AutoDeleteObjectsCustomResourceProvider/Staging", + "constructInfo": { + "fqn": "aws-cdk-lib.AssetStaging", + "version": "2.149.0" + } + }, + "Role": { + "id": "Role", + "path": "teststack/nesty/Custom::S3AutoDeleteObjectsCustomResourceProvider/Role", + "constructInfo": { + "fqn": "aws-cdk-lib.CfnResource", + "version": "2.149.0" + } + }, + "Handler": { + "id": "Handler", + "path": "teststack/nesty/Custom::S3AutoDeleteObjectsCustomResourceProvider/Handler", + "constructInfo": { + "fqn": "aws-cdk-lib.CfnResource", + "version": "2.149.0" + } + } + }, + "constructInfo": { + "fqn": "aws-cdk-lib.CustomResourceProviderBase", + "version": "2.149.0" + } + }, + "reference-to-teststackbucket4A009163Ref": { + "id": "reference-to-teststackbucket4A009163Ref", + "path": "teststack/nesty/reference-to-teststackbucket4A009163Ref", + "constructInfo": { + "fqn": "aws-cdk-lib.CfnParameter", + "version": "2.156.0" + } + }, + "teststacknestybucket3B9EFE19Ref": { + "id": "teststacknestybucket3B9EFE19Ref", + "path": "teststack/nesty/teststacknestybucket3B9EFE19Ref", + "constructInfo": { + "fqn": "aws-cdk-lib.CfnOutput", + "version": "2.156.0" + } + }, + "teststacknestybucket3B9EFE19Arn": { + "id": "teststacknestybucket3B9EFE19Arn", + "path": "teststack/nesty/teststacknestybucket3B9EFE19Arn", + "constructInfo": { + "fqn": "aws-cdk-lib.CfnOutput", + "version": "2.156.0" + } + } + }, + "constructInfo": { + "fqn": "aws-cdk-lib.NestedStack", + "version": "2.149.0" + } + }, + "nesty.NestedStack": { + "id": "nesty.NestedStack", + "path": "teststack/nesty.NestedStack", + "children": { + "nesty.NestedStackResource": { + "id": "nesty.NestedStackResource", + "path": "teststack/nesty.NestedStack/nesty.NestedStackResource", + "attributes": { + "aws:cdk:cloudformation:type": "AWS::CloudFormation::Stack", + "aws:cdk:cloudformation:props": { + "parameters": { + "referencetoteststackbucket4A009163Ref": { + "Ref": "bucket" + } + }, + "templateUrl": { + "Fn::Join": [ + "", + [ + "https://s3.us-west-2.", + { + "Ref": "AWS::URLSuffix" + }, + "/", + { + "Ref": { + "PulumiOutput": 18 + } + }, + "/", + { + "Ref": { + "PulumiOutput": 19 + } + } + ] + ] + } + } + }, + "constructInfo": { + "fqn": "aws-cdk-lib.CfnStack", + "version": "2.149.0" + } + } + }, + "constructInfo": { + "fqn": "constructs.Construct", + "version": "10.3.0" + } + }, + "DeployWebsite": { + "id": "DeployWebsite", + "path": "teststack/DeployWebsite", + "children": { + "AwsCliLayer": { + "id": "AwsCliLayer", + "path": "teststack/DeployWebsite/AwsCliLayer", + "children": { + "Code": { + "id": "Code", + "path": "teststack/DeployWebsite/AwsCliLayer/Code", + "children": { + "Stage": { + "id": "Stage", + "path": "teststack/DeployWebsite/AwsCliLayer/Code/Stage", + "constructInfo": { + "fqn": "aws-cdk-lib.AssetStaging", + "version": "2.149.0" + } + }, + "AssetBucket": { + "id": "AssetBucket", + "path": "teststack/DeployWebsite/AwsCliLayer/Code/AssetBucket", + "constructInfo": { + "fqn": "aws-cdk-lib.aws_s3.BucketBase", + "version": "2.149.0" + } + } + }, + "constructInfo": { + "fqn": "aws-cdk-lib.aws_s3_assets.Asset", + "version": "2.149.0" + } + }, + "Resource": { + "id": "Resource", + "path": "teststack/DeployWebsite/AwsCliLayer/Resource", + "attributes": { + "aws:cdk:cloudformation:type": "AWS::Lambda::LayerVersion", + "aws:cdk:cloudformation:props": { + "content": { + "s3Bucket": { + "Ref": { + "PulumiOutput": 6 + } + }, + "s3Key": { + "Ref": { + "PulumiOutput": 7 + } + } + }, + "description": "/opt/awscli/aws" + } + }, + "constructInfo": { + "fqn": "aws-cdk-lib.aws_lambda.CfnLayerVersion", + "version": "2.149.0" + } + } + }, + "constructInfo": { + "fqn": "aws-cdk-lib.lambda_layer_awscli.AwsCliLayer", + "version": "2.149.0" + } + }, + "CustomResourceHandler": { + "id": "CustomResourceHandler", + "path": "teststack/DeployWebsite/CustomResourceHandler", + "constructInfo": { + "fqn": "aws-cdk-lib.aws_lambda.SingletonFunction", + "version": "2.149.0" + } + }, + "Asset1": { + "id": "Asset1", + "path": "teststack/DeployWebsite/Asset1", + "children": { + "Stage": { + "id": "Stage", + "path": "teststack/DeployWebsite/Asset1/Stage", + "constructInfo": { + "fqn": "aws-cdk-lib.AssetStaging", + "version": "2.149.0" + } + }, + "AssetBucket": { + "id": "AssetBucket", + "path": "teststack/DeployWebsite/Asset1/AssetBucket", + "constructInfo": { + "fqn": "aws-cdk-lib.aws_s3.BucketBase", + "version": "2.149.0" + } + } + }, + "constructInfo": { + "fqn": "aws-cdk-lib.aws_s3_assets.Asset", + "version": "2.149.0" + } + }, + "CustomResource": { + "id": "CustomResource", + "path": "teststack/DeployWebsite/CustomResource", + "children": { + "Default": { + "id": "Default", + "path": "teststack/DeployWebsite/CustomResource/Default", + "constructInfo": { + "fqn": "aws-cdk-lib.CfnResource", + "version": "2.149.0" + } + } + }, + "constructInfo": { + "fqn": "aws-cdk-lib.CustomResource", + "version": "2.149.0" + } + } + }, + "constructInfo": { + "fqn": "aws-cdk-lib.aws_s3_deployment.BucketDeployment", + "version": "2.149.0" + } + }, + "Custom::CDKBucketDeployment8693BB64968944B69AAFB0CC9EB8756C": { + "id": "Custom::CDKBucketDeployment8693BB64968944B69AAFB0CC9EB8756C", + "path": "teststack/Custom::CDKBucketDeployment8693BB64968944B69AAFB0CC9EB8756C", + "children": { + "ServiceRole": { + "id": "ServiceRole", + "path": "teststack/Custom::CDKBucketDeployment8693BB64968944B69AAFB0CC9EB8756C/ServiceRole", + "children": { + "ImportServiceRole": { + "id": "ImportServiceRole", + "path": "teststack/Custom::CDKBucketDeployment8693BB64968944B69AAFB0CC9EB8756C/ServiceRole/ImportServiceRole", + "constructInfo": { + "fqn": "aws-cdk-lib.Resource", + "version": "2.149.0" + } + }, + "Resource": { + "id": "Resource", + "path": "teststack/Custom::CDKBucketDeployment8693BB64968944B69AAFB0CC9EB8756C/ServiceRole/Resource", + "attributes": { + "aws:cdk:cloudformation:type": "AWS::IAM::Role", + "aws:cdk:cloudformation:props": { + "assumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "lambda.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + }, + "managedPolicyArns": [ + { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":iam::aws:policy/service-role/AWSLambdaBasicExecutionRole" + ] + ] + } + ] + } + }, + "constructInfo": { + "fqn": "aws-cdk-lib.aws_iam.CfnRole", + "version": "2.149.0" + } + }, + "DefaultPolicy": { + "id": "DefaultPolicy", + "path": "teststack/Custom::CDKBucketDeployment8693BB64968944B69AAFB0CC9EB8756C/ServiceRole/DefaultPolicy", + "children": { + "Resource": { + "id": "Resource", + "path": "teststack/Custom::CDKBucketDeployment8693BB64968944B69AAFB0CC9EB8756C/ServiceRole/DefaultPolicy/Resource", + "attributes": { + "aws:cdk:cloudformation:type": "AWS::IAM::Policy", + "aws:cdk:cloudformation:props": { + "policyDocument": { + "Statement": [ + { + "Action": [ + "s3:GetObject*", + "s3:GetBucket*", + "s3:List*" + ], + "Effect": "Allow", + "Resource": [ + { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":s3:::", + { + "Ref": { + "PulumiOutput": 14 + } + } + ] + ] + }, + { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":s3:::", + { + "Ref": { + "PulumiOutput": 14 + } + }, + "/*" + ] + ] + } + ] + }, + { + "Action": [ + "s3:GetObject*", + "s3:GetBucket*", + "s3:List*", + "s3:DeleteObject*", + "s3:PutObject", + "s3:PutObjectLegalHold", + "s3:PutObjectRetention", + "s3:PutObjectTagging", + "s3:PutObjectVersionTagging", + "s3:Abort*" + ], + "Effect": "Allow", + "Resource": [ + { + "Fn::GetAtt": [ + "nestyNestedStacknestyNestedStackResource", + "Outputs.teststacknestybucket3B9EFE19Arn" + ] + }, + { + "Fn::Join": [ + "", + [ + { + "Fn::GetAtt": [ + "nestyNestedStacknestyNestedStackResource", + "Outputs.teststacknestybucket3B9EFE19Arn" + ] + }, + "/*" + ] + ] + } + ] + } + ], + "Version": "2012-10-17" + }, + "policyName": "CustomCDKBucketDeployment8693BB64968944B69AAFB0CC9EB8756CServiceRoleDefaultPolicy", + "roles": [ + { + "Ref": "CustomCDKBucketDeployment8693BB64968944B69AAFB0CC9EB8756CServiceRole" + } + ] + } + }, + "constructInfo": { + "fqn": "aws-cdk-lib.aws_iam.CfnPolicy", + "version": "2.149.0" + } + } + }, + "constructInfo": { + "fqn": "aws-cdk-lib.aws_iam.Policy", + "version": "2.149.0" + } + } + }, + "constructInfo": { + "fqn": "aws-cdk-lib.aws_iam.Role", + "version": "2.149.0" + } + }, + "Code": { + "id": "Code", + "path": "teststack/Custom::CDKBucketDeployment8693BB64968944B69AAFB0CC9EB8756C/Code", + "children": { + "Stage": { + "id": "Stage", + "path": "teststack/Custom::CDKBucketDeployment8693BB64968944B69AAFB0CC9EB8756C/Code/Stage", + "constructInfo": { + "fqn": "aws-cdk-lib.AssetStaging", + "version": "2.149.0" + } + }, + "AssetBucket": { + "id": "AssetBucket", + "path": "teststack/Custom::CDKBucketDeployment8693BB64968944B69AAFB0CC9EB8756C/Code/AssetBucket", + "constructInfo": { + "fqn": "aws-cdk-lib.aws_s3.BucketBase", + "version": "2.149.0" + } + } + }, + "constructInfo": { + "fqn": "aws-cdk-lib.aws_s3_assets.Asset", + "version": "2.149.0" + } + }, + "Resource": { + "id": "Resource", + "path": "teststack/Custom::CDKBucketDeployment8693BB64968944B69AAFB0CC9EB8756C/Resource", + "attributes": { + "aws:cdk:cloudformation:type": "AWS::Lambda::Function", + "aws:cdk:cloudformation:props": { + "code": { + "s3Bucket": { + "Ref": { + "PulumiOutput": 10 + } + }, + "s3Key": { + "Ref": { + "PulumiOutput": 11 + } + } + }, + "environment": { + "variables": { + "AWS_CA_BUNDLE": "/etc/pki/ca-trust/extracted/pem/tls-ca-bundle.pem" + } + }, + "handler": "index.handler", + "layers": [ + { + "Ref": "DeployWebsiteAwsCliLayer" + } + ], + "role": { + "Fn::GetAtt": [ + "CustomCDKBucketDeployment8693BB64968944B69AAFB0CC9EB8756CServiceRole", + "Arn" + ] + }, + "runtime": "python3.9", + "timeout": 900 + } + }, + "constructInfo": { + "fqn": "aws-cdk-lib.aws_lambda.CfnFunction", + "version": "2.149.0" + } + } + }, + "constructInfo": { + "fqn": "aws-cdk-lib.aws_lambda.Function", + "version": "2.149.0" + } + } + }, + "constructInfo": { + "fqn": "aws-cdk-lib.Stack", + "version": "2.156.0" + } + }, + "template": { + "Resources": { + "bucket": { + "Type": "AWS::S3::Bucket", + "UpdateReplacePolicy": "Retain", + "DeletionPolicy": "Retain", + "Metadata": { + "aws:cdk:path": "teststack/bucket/Resource" + } + }, + "nestyNestedStacknestyNestedStackResource": { + "Type": "AWS::CloudFormation::Stack", + "Properties": { + "Parameters": { + "referencetoteststackbucket4A009163Ref": { + "Ref": "bucket" + } + }, + "TemplateURL": { + "Fn::Join": [ + "", + [ + "https://s3.us-west-2.", + { + "Ref": "AWS::URLSuffix" + }, + "/", + "DUMMY", + "/", + "DUMMY" + ] + ] + } + }, + "UpdateReplacePolicy": "Delete", + "DeletionPolicy": "Delete", + "Metadata": { + "aws:cdk:path": "teststack/nesty.NestedStack/nesty.NestedStackResource", + "aws:asset:path": "teststacknesty613E34DC.nested.template.json", + "aws:asset:property": "TemplateURL" + } + }, + "DeployWebsiteAwsCliLayer": { + "Type": "AWS::Lambda::LayerVersion", + "Properties": { + "Content": { + "S3Bucket": "DUMMY", + "S3Key": "DUMMY" + }, + "Description": "/opt/awscli/aws" + }, + "Metadata": { + "aws:cdk:path": "teststack/DeployWebsite/AwsCliLayer/Resource", + "aws:asset:path": "asset.6620cb784ea0d3cf3a6e3e128827087f88e7e9999cd41b0be7c50431fbf12026.zip", + "aws:asset:is-bundled": false, + "aws:asset:property": "Content" + } + }, + "DeployWebsiteCustomResource": { + "Type": "Custom::CDKBucketDeployment", + "Properties": { + "ServiceToken": { + "Fn::GetAtt": [ + "CustomCDKBucketDeployment8693BB64968944B69AAFB0CC9EB8756C", + "Arn" + ] + }, + "SourceBucketNames": [ + "DUMMY" + ], + "SourceObjectKeys": [ + "DUMMY" + ], + "SourceMarkers": [ + {} + ], + "DestinationBucketName": { + "Fn::GetAtt": [ + "nestyNestedStacknestyNestedStackResource", + "Outputs.teststacknestybucket3B9EFE19Ref" + ] + }, + "Prune": true + }, + "UpdateReplacePolicy": "Delete", + "DeletionPolicy": "Delete", + "Metadata": { + "aws:cdk:path": "teststack/DeployWebsite/CustomResource/Default" + } + }, + "CustomCDKBucketDeployment8693BB64968944B69AAFB0CC9EB8756CServiceRole": { + "Type": "AWS::IAM::Role", + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "lambda.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + }, + "ManagedPolicyArns": [ + { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":iam::aws:policy/service-role/AWSLambdaBasicExecutionRole" + ] + ] + } + ] + }, + "Metadata": { + "aws:cdk:path": "teststack/Custom::CDKBucketDeployment8693BB64968944B69AAFB0CC9EB8756C/ServiceRole/Resource" + } + }, + "CustomCDKBucketDeployment8693BB64968944B69AAFB0CC9EB8756CServiceRoleDefaultPolicy": { + "Type": "AWS::IAM::Policy", + "Properties": { + "PolicyDocument": { + "Statement": [ + { + "Action": [ + "s3:GetObject*", + "s3:GetBucket*", + "s3:List*" + ], + "Effect": "Allow", + "Resource": [ + { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":s3:::", + "DUMMY" + ] + ] + }, + { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":s3:::", + "DUMMY", + "/*" + ] + ] + } + ] + }, + { + "Action": [ + "s3:GetObject*", + "s3:GetBucket*", + "s3:List*", + "s3:DeleteObject*", + "s3:PutObject", + "s3:PutObjectLegalHold", + "s3:PutObjectRetention", + "s3:PutObjectTagging", + "s3:PutObjectVersionTagging", + "s3:Abort*" + ], + "Effect": "Allow", + "Resource": [ + { + "Fn::GetAtt": [ + "nestyNestedStacknestyNestedStackResource", + "Outputs.teststacknestybucket3B9EFE19Arn" + ] + }, + { + "Fn::Join": [ + "", + [ + { + "Fn::GetAtt": [ + "nestyNestedStacknestyNestedStackResource", + "Outputs.teststacknestybucket3B9EFE19Arn" + ] + }, + "/*" + ] + ] + } + ] + } + ], + "Version": "2012-10-17" + }, + "PolicyName": "CustomCDKBucketDeployment8693BB64968944B69AAFB0CC9EB8756CServiceRoleDefaultPolicy", + "Roles": [ + { + "Ref": "CustomCDKBucketDeployment8693BB64968944B69AAFB0CC9EB8756CServiceRole" + } + ] + }, + "Metadata": { + "aws:cdk:path": "teststack/Custom::CDKBucketDeployment8693BB64968944B69AAFB0CC9EB8756C/ServiceRole/DefaultPolicy/Resource" + } + }, + "CustomCDKBucketDeployment8693BB64968944B69AAFB0CC9EB8756C": { + "Type": "AWS::Lambda::Function", + "Properties": { + "Code": { + "S3Bucket": "DUMMY", + "S3Key": "DUMMY" + }, + "Environment": { + "Variables": { + "AWS_CA_BUNDLE": "/etc/pki/ca-trust/extracted/pem/tls-ca-bundle.pem" + } + }, + "Handler": "index.handler", + "Layers": [ + { + "Ref": "DeployWebsiteAwsCliLayer" + } + ], + "Role": { + "Fn::GetAtt": [ + "CustomCDKBucketDeployment8693BB64968944B69AAFB0CC9EB8756CServiceRole", + "Arn" + ] + }, + "Runtime": "python3.9", + "Timeout": 900 + }, + "DependsOn": [ + "CustomCDKBucketDeployment8693BB64968944B69AAFB0CC9EB8756CServiceRoleDefaultPolicy", + "CustomCDKBucketDeployment8693BB64968944B69AAFB0CC9EB8756CServiceRole" + ], + "Metadata": { + "aws:cdk:path": "teststack/Custom::CDKBucketDeployment8693BB64968944B69AAFB0CC9EB8756C/Resource", + "aws:asset:path": "asset.2d56e153cac88d3e0c2f842e8e6f6783b8725bf91f95e0673b4725448a56e96d", + "aws:asset:is-bundled": false, + "aws:asset:property": "Code" + } + } + } + }, + "nestedStacks": { + "teststack/nesty": { + "Resources": { + "bucket43879C71": { + "Type": "AWS::S3::Bucket", + "Properties": { + "BucketName": { + "Fn::Join": [ + "", + [ + { + "Ref": "referencetoteststackbucket4A009163Ref" + }, + "-nested" + ] + ] + }, + "PublicAccessBlockConfiguration": { + "BlockPublicAcls": false, + "BlockPublicPolicy": false, + "IgnorePublicAcls": false, + "RestrictPublicBuckets": false + }, + "Tags": [ + { + "Key": "aws-cdk:auto-delete-objects", + "Value": "true" + }, + { + "Key": "aws-cdk:cr-owned:043cc088", + "Value": "true" + } + ], + "WebsiteConfiguration": { + "IndexDocument": "index.html" + } + }, + "UpdateReplacePolicy": "Delete", + "DeletionPolicy": "Delete", + "Metadata": { + "aws:cdk:path": "teststack/nesty/bucket/Resource" + } + }, + "bucketPolicy638F945D": { + "Type": "AWS::S3::BucketPolicy", + "Properties": { + "Bucket": { + "Ref": "bucket43879C71" + }, + "PolicyDocument": { + "Statement": [ + { + "Action": "s3:GetObject", + "Effect": "Allow", + "Principal": { + "AWS": "*" + }, + "Resource": { + "Fn::Join": [ + "", + [ + { + "Fn::GetAtt": [ + "bucket43879C71", + "Arn" + ] + }, + "/*" + ] + ] + } + }, + { + "Action": [ + "s3:PutBucketPolicy", + "s3:GetBucket*", + "s3:List*", + "s3:DeleteObject*" + ], + "Effect": "Allow", + "Principal": { + "AWS": { + "Fn::GetAtt": [ + "CustomS3AutoDeleteObjectsCustomResourceProviderRole3B1BD092", + "Arn" + ] + } + }, + "Resource": [ + { + "Fn::GetAtt": [ + "bucket43879C71", + "Arn" + ] + }, + { + "Fn::Join": [ + "", + [ + { + "Fn::GetAtt": [ + "bucket43879C71", + "Arn" + ] + }, + "/*" + ] + ] + } + ] + } + ], + "Version": "2012-10-17" + } + }, + "Metadata": { + "aws:cdk:path": "teststack/nesty/bucket/Policy/Resource" + } + }, + "bucketAutoDeleteObjectsCustomResource3F4990B2": { + "Type": "Custom::S3AutoDeleteObjects", + "Properties": { + "ServiceToken": { + "Fn::GetAtt": [ + "CustomS3AutoDeleteObjectsCustomResourceProviderHandler9D90184F", + "Arn" + ] + }, + "BucketName": { + "Ref": "bucket43879C71" + } + }, + "DependsOn": [ + "bucketPolicy638F945D" + ], + "UpdateReplacePolicy": "Delete", + "DeletionPolicy": "Delete", + "Metadata": { + "aws:cdk:path": "teststack/nesty/bucket/AutoDeleteObjectsCustomResource/Default" + } + }, + "CustomS3AutoDeleteObjectsCustomResourceProviderRole3B1BD092": { + "Type": "AWS::IAM::Role", + "Properties": { + "AssumeRolePolicyDocument": { + "Version": "2012-10-17", + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "lambda.amazonaws.com" + } + } + ] + }, + "ManagedPolicyArns": [ + { + "Fn::Sub": "arn:${AWS::Partition}:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole" + } + ] + }, + "Metadata": { + "aws:cdk:path": "teststack/nesty/Custom::S3AutoDeleteObjectsCustomResourceProvider/Role" + } + }, + "CustomS3AutoDeleteObjectsCustomResourceProviderHandler9D90184F": { + "Type": "AWS::Lambda::Function", + "Properties": { + "Code": { + "S3Bucket": "DUMMY", + "S3Key": "DUMMY" + }, + "Timeout": 900, + "MemorySize": 128, + "Handler": "index.handler", + "Role": { + "Fn::GetAtt": [ + "CustomS3AutoDeleteObjectsCustomResourceProviderRole3B1BD092", + "Arn" + ] + }, + "Runtime": "nodejs20.x", + "Description": { + "Fn::Join": [ + "", + [ + "Lambda function for auto-deleting objects in ", + { + "Ref": "bucket43879C71" + }, + " S3 bucket." + ] + ] + } + }, + "DependsOn": [ + "CustomS3AutoDeleteObjectsCustomResourceProviderRole3B1BD092" + ], + "Metadata": { + "aws:cdk:path": "teststack/nesty/Custom::S3AutoDeleteObjectsCustomResourceProvider/Handler", + "aws:asset:path": "asset.faa95a81ae7d7373f3e1f242268f904eb748d8d0fdd306e8a6fe515a1905a7d6", + "aws:asset:property": "Code" + } + } + }, + "Parameters": { + "referencetoteststackbucket4A009163Ref": { + "Type": "String" + } + }, + "Outputs": { + "teststacknestybucket3B9EFE19Ref": { + "Value": { + "Ref": "bucket43879C71" + } + }, + "teststacknestybucket3B9EFE19Arn": { + "Value": { + "Fn::GetAtt": [ + "bucket43879C71", + "Arn" + ] + } + } + }, + "logicalId": "nestyNestedStacknestyNestedStackResource" + } + } +} \ No newline at end of file diff --git a/tests/test-data/nested-stack/teststack.assets.json b/tests/test-data/nested-stack/teststack.assets.json new file mode 100644 index 00000000..f806f2c3 --- /dev/null +++ b/tests/test-data/nested-stack/teststack.assets.json @@ -0,0 +1,84 @@ +{ + "version": "36.3.0", + "files": { + "faa95a81ae7d7373f3e1f242268f904eb748d8d0fdd306e8a6fe515a1905a7d6": { + "source": { + "path": "asset.faa95a81ae7d7373f3e1f242268f904eb748d8d0fdd306e8a6fe515a1905a7d6", + "packaging": "zip" + }, + "destinations": { + "616138583583-us-west-2": { + "bucketName": "pulumi-cdk-sted-sta-3ca1883d-staging", + "objectKey": "faa95a81ae7d7373f3e1f242268f904eb748d8d0fdd306e8a6fe515a1905a7d6.zip", + "region": "us-west-2" + } + } + }, + "6620cb784ea0d3cf3a6e3e128827087f88e7e9999cd41b0be7c50431fbf12026": { + "source": { + "path": "asset.6620cb784ea0d3cf3a6e3e128827087f88e7e9999cd41b0be7c50431fbf12026.zip", + "packaging": "file" + }, + "destinations": { + "616138583583-us-west-2": { + "bucketName": "pulumi-cdk-sted-sta-3ca1883d-staging", + "objectKey": "deploy-time/6620cb784ea0d3cf3a6e3e128827087f88e7e9999cd41b0be7c50431fbf12026.zip", + "region": "us-west-2" + } + } + }, + "2d56e153cac88d3e0c2f842e8e6f6783b8725bf91f95e0673b4725448a56e96d": { + "source": { + "path": "asset.2d56e153cac88d3e0c2f842e8e6f6783b8725bf91f95e0673b4725448a56e96d", + "packaging": "zip" + }, + "destinations": { + "616138583583-us-west-2": { + "bucketName": "pulumi-cdk-sted-sta-3ca1883d-staging", + "objectKey": "deploy-time/2d56e153cac88d3e0c2f842e8e6f6783b8725bf91f95e0673b4725448a56e96d.zip", + "region": "us-west-2" + } + } + }, + "a386ba9b8c0d9b386083b2f6952db278a5a0ce88f497484eb5e62172219468fd": { + "source": { + "path": "asset.a386ba9b8c0d9b386083b2f6952db278a5a0ce88f497484eb5e62172219468fd", + "packaging": "zip" + }, + "destinations": { + "616138583583-us-west-2": { + "bucketName": "pulumi-cdk-sted-sta-3ca1883d-staging", + "objectKey": "a386ba9b8c0d9b386083b2f6952db278a5a0ce88f497484eb5e62172219468fd.zip", + "region": "us-west-2" + } + } + }, + "95f78991f9fb0b754d5d23113980217d0636f22d034b8f8bb451154a21d811a6": { + "source": { + "path": "teststacknesty613E34DC.nested.template.json", + "packaging": "file" + }, + "destinations": { + "616138583583-us-west-2": { + "bucketName": "pulumi-cdk-sted-sta-3ca1883d-staging", + "objectKey": "95f78991f9fb0b754d5d23113980217d0636f22d034b8f8bb451154a21d811a6.json", + "region": "us-west-2" + } + } + }, + "4cc5fad210e6468e30325de9b3f814b86a6bb8c61b9b5d7e8437c5290ab31f99": { + "source": { + "path": "teststack.template.json", + "packaging": "file" + }, + "destinations": { + "616138583583-us-west-2": { + "bucketName": "pulumi-cdk-sted-sta-3ca1883d-staging", + "objectKey": "deploy-time/4cc5fad210e6468e30325de9b3f814b86a6bb8c61b9b5d7e8437c5290ab31f99.json", + "region": "us-west-2" + } + } + } + }, + "dockerImages": {} +} \ No newline at end of file diff --git a/tests/test-data/nested-stack/teststack.template.json b/tests/test-data/nested-stack/teststack.template.json new file mode 100644 index 00000000..c4e84558 --- /dev/null +++ b/tests/test-data/nested-stack/teststack.template.json @@ -0,0 +1,297 @@ +{ + "Resources": { + "bucket": { + "Type": "AWS::S3::Bucket", + "UpdateReplacePolicy": "Retain", + "DeletionPolicy": "Retain", + "Metadata": { + "aws:cdk:path": "teststack/bucket/Resource" + } + }, + "nestyNestedStacknestyNestedStackResource": { + "Type": "AWS::CloudFormation::Stack", + "Properties": { + "Parameters": { + "referencetoteststackbucket4A009163Ref": { + "Ref": "bucket" + } + }, + "TemplateURL": { + "Fn::Join": [ + "", + [ + "https://s3.us-west-2.", + { + "Ref": "AWS::URLSuffix" + }, + "/", + { + "Ref": { + "PulumiOutput": 18 + } + }, + "/", + { + "Ref": { + "PulumiOutput": 19 + } + } + ] + ] + } + }, + "UpdateReplacePolicy": "Delete", + "DeletionPolicy": "Delete", + "Metadata": { + "aws:cdk:path": "teststack/nesty.NestedStack/nesty.NestedStackResource", + "aws:asset:path": "teststacknesty613E34DC.nested.template.json", + "aws:asset:property": "TemplateURL" + } + }, + "DeployWebsiteAwsCliLayer": { + "Type": "AWS::Lambda::LayerVersion", + "Properties": { + "Content": { + "S3Bucket": { + "Ref": { + "PulumiOutput": 6 + } + }, + "S3Key": { + "Ref": { + "PulumiOutput": 7 + } + } + }, + "Description": "/opt/awscli/aws" + }, + "Metadata": { + "aws:cdk:path": "teststack/DeployWebsite/AwsCliLayer/Resource", + "aws:asset:path": "asset.6620cb784ea0d3cf3a6e3e128827087f88e7e9999cd41b0be7c50431fbf12026.zip", + "aws:asset:is-bundled": false, + "aws:asset:property": "Content" + } + }, + "DeployWebsiteCustomResource": { + "Type": "Custom::CDKBucketDeployment", + "Properties": { + "ServiceToken": { + "Fn::GetAtt": [ + "CustomCDKBucketDeployment8693BB64968944B69AAFB0CC9EB8756C", + "Arn" + ] + }, + "SourceBucketNames": [ + { + "Ref": { + "PulumiOutput": 14 + } + } + ], + "SourceObjectKeys": [ + { + "Ref": { + "PulumiOutput": 15 + } + } + ], + "SourceMarkers": [ + {} + ], + "DestinationBucketName": { + "Fn::GetAtt": [ + "nestyNestedStacknestyNestedStackResource", + "Outputs.teststacknestybucket3B9EFE19Ref" + ] + }, + "Prune": true + }, + "UpdateReplacePolicy": "Delete", + "DeletionPolicy": "Delete", + "Metadata": { + "aws:cdk:path": "teststack/DeployWebsite/CustomResource/Default" + } + }, + "CustomCDKBucketDeployment8693BB64968944B69AAFB0CC9EB8756CServiceRole": { + "Type": "AWS::IAM::Role", + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "lambda.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + }, + "ManagedPolicyArns": [ + { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":iam::aws:policy/service-role/AWSLambdaBasicExecutionRole" + ] + ] + } + ] + }, + "Metadata": { + "aws:cdk:path": "teststack/Custom::CDKBucketDeployment8693BB64968944B69AAFB0CC9EB8756C/ServiceRole/Resource" + } + }, + "CustomCDKBucketDeployment8693BB64968944B69AAFB0CC9EB8756CServiceRoleDefaultPolicy": { + "Type": "AWS::IAM::Policy", + "Properties": { + "PolicyDocument": { + "Statement": [ + { + "Action": [ + "s3:GetObject*", + "s3:GetBucket*", + "s3:List*" + ], + "Effect": "Allow", + "Resource": [ + { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":s3:::", + { + "Ref": { + "PulumiOutput": 14 + } + } + ] + ] + }, + { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":s3:::", + { + "Ref": { + "PulumiOutput": 14 + } + }, + "/*" + ] + ] + } + ] + }, + { + "Action": [ + "s3:GetObject*", + "s3:GetBucket*", + "s3:List*", + "s3:DeleteObject*", + "s3:PutObject", + "s3:PutObjectLegalHold", + "s3:PutObjectRetention", + "s3:PutObjectTagging", + "s3:PutObjectVersionTagging", + "s3:Abort*" + ], + "Effect": "Allow", + "Resource": [ + { + "Fn::GetAtt": [ + "nestyNestedStacknestyNestedStackResource", + "Outputs.teststacknestybucket3B9EFE19Arn" + ] + }, + { + "Fn::Join": [ + "", + [ + { + "Fn::GetAtt": [ + "nestyNestedStacknestyNestedStackResource", + "Outputs.teststacknestybucket3B9EFE19Arn" + ] + }, + "/*" + ] + ] + } + ] + } + ], + "Version": "2012-10-17" + }, + "PolicyName": "CustomCDKBucketDeployment8693BB64968944B69AAFB0CC9EB8756CServiceRoleDefaultPolicy", + "Roles": [ + { + "Ref": "CustomCDKBucketDeployment8693BB64968944B69AAFB0CC9EB8756CServiceRole" + } + ] + }, + "Metadata": { + "aws:cdk:path": "teststack/Custom::CDKBucketDeployment8693BB64968944B69AAFB0CC9EB8756C/ServiceRole/DefaultPolicy/Resource" + } + }, + "CustomCDKBucketDeployment8693BB64968944B69AAFB0CC9EB8756C": { + "Type": "AWS::Lambda::Function", + "Properties": { + "Code": { + "S3Bucket": { + "Ref": { + "PulumiOutput": 10 + } + }, + "S3Key": { + "Ref": { + "PulumiOutput": 11 + } + } + }, + "Environment": { + "Variables": { + "AWS_CA_BUNDLE": "/etc/pki/ca-trust/extracted/pem/tls-ca-bundle.pem" + } + }, + "Handler": "index.handler", + "Layers": [ + { + "Ref": "DeployWebsiteAwsCliLayer" + } + ], + "Role": { + "Fn::GetAtt": [ + "CustomCDKBucketDeployment8693BB64968944B69AAFB0CC9EB8756CServiceRole", + "Arn" + ] + }, + "Runtime": "python3.9", + "Timeout": 900 + }, + "DependsOn": [ + "CustomCDKBucketDeployment8693BB64968944B69AAFB0CC9EB8756CServiceRoleDefaultPolicy", + "CustomCDKBucketDeployment8693BB64968944B69AAFB0CC9EB8756CServiceRole" + ], + "Metadata": { + "aws:cdk:path": "teststack/Custom::CDKBucketDeployment8693BB64968944B69AAFB0CC9EB8756C/Resource", + "aws:asset:path": "asset.2d56e153cac88d3e0c2f842e8e6f6783b8725bf91f95e0673b4725448a56e96d", + "aws:asset:is-bundled": false, + "aws:asset:property": "Code" + } + } + } +} \ No newline at end of file diff --git a/tests/test-data/nested-stack/teststacknesty613E34DC.nested.template.json b/tests/test-data/nested-stack/teststacknesty613E34DC.nested.template.json new file mode 100644 index 00000000..35e56942 --- /dev/null +++ b/tests/test-data/nested-stack/teststacknesty613E34DC.nested.template.json @@ -0,0 +1,234 @@ +{ + "Resources": { + "bucket43879C71": { + "Type": "AWS::S3::Bucket", + "Properties": { + "BucketName": { + "Fn::Join": [ + "", + [ + { + "Ref": "referencetoteststackbucket4A009163Ref" + }, + "-nested" + ] + ] + }, + "PublicAccessBlockConfiguration": { + "BlockPublicAcls": false, + "BlockPublicPolicy": false, + "IgnorePublicAcls": false, + "RestrictPublicBuckets": false + }, + "Tags": [ + { + "Key": "aws-cdk:auto-delete-objects", + "Value": "true" + }, + { + "Key": "aws-cdk:cr-owned:043cc088", + "Value": "true" + } + ], + "WebsiteConfiguration": { + "IndexDocument": "index.html" + } + }, + "UpdateReplacePolicy": "Delete", + "DeletionPolicy": "Delete", + "Metadata": { + "aws:cdk:path": "teststack/nesty/bucket/Resource" + } + }, + "bucketPolicy638F945D": { + "Type": "AWS::S3::BucketPolicy", + "Properties": { + "Bucket": { + "Ref": "bucket43879C71" + }, + "PolicyDocument": { + "Statement": [ + { + "Action": "s3:GetObject", + "Effect": "Allow", + "Principal": { + "AWS": "*" + }, + "Resource": { + "Fn::Join": [ + "", + [ + { + "Fn::GetAtt": [ + "bucket43879C71", + "Arn" + ] + }, + "/*" + ] + ] + } + }, + { + "Action": [ + "s3:PutBucketPolicy", + "s3:GetBucket*", + "s3:List*", + "s3:DeleteObject*" + ], + "Effect": "Allow", + "Principal": { + "AWS": { + "Fn::GetAtt": [ + "CustomS3AutoDeleteObjectsCustomResourceProviderRole3B1BD092", + "Arn" + ] + } + }, + "Resource": [ + { + "Fn::GetAtt": [ + "bucket43879C71", + "Arn" + ] + }, + { + "Fn::Join": [ + "", + [ + { + "Fn::GetAtt": [ + "bucket43879C71", + "Arn" + ] + }, + "/*" + ] + ] + } + ] + } + ], + "Version": "2012-10-17" + } + }, + "Metadata": { + "aws:cdk:path": "teststack/nesty/bucket/Policy/Resource" + } + }, + "bucketAutoDeleteObjectsCustomResource3F4990B2": { + "Type": "Custom::S3AutoDeleteObjects", + "Properties": { + "ServiceToken": { + "Fn::GetAtt": [ + "CustomS3AutoDeleteObjectsCustomResourceProviderHandler9D90184F", + "Arn" + ] + }, + "BucketName": { + "Ref": "bucket43879C71" + } + }, + "DependsOn": [ + "bucketPolicy638F945D" + ], + "UpdateReplacePolicy": "Delete", + "DeletionPolicy": "Delete", + "Metadata": { + "aws:cdk:path": "teststack/nesty/bucket/AutoDeleteObjectsCustomResource/Default" + } + }, + "CustomS3AutoDeleteObjectsCustomResourceProviderRole3B1BD092": { + "Type": "AWS::IAM::Role", + "Properties": { + "AssumeRolePolicyDocument": { + "Version": "2012-10-17", + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "lambda.amazonaws.com" + } + } + ] + }, + "ManagedPolicyArns": [ + { + "Fn::Sub": "arn:${AWS::Partition}:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole" + } + ] + }, + "Metadata": { + "aws:cdk:path": "teststack/nesty/Custom::S3AutoDeleteObjectsCustomResourceProvider/Role" + } + }, + "CustomS3AutoDeleteObjectsCustomResourceProviderHandler9D90184F": { + "Type": "AWS::Lambda::Function", + "Properties": { + "Code": { + "S3Bucket": { + "Ref": { + "PulumiOutput": 2 + } + }, + "S3Key": { + "Ref": { + "PulumiOutput": 3 + } + } + }, + "Timeout": 900, + "MemorySize": 128, + "Handler": "index.handler", + "Role": { + "Fn::GetAtt": [ + "CustomS3AutoDeleteObjectsCustomResourceProviderRole3B1BD092", + "Arn" + ] + }, + "Runtime": "nodejs20.x", + "Description": { + "Fn::Join": [ + "", + [ + "Lambda function for auto-deleting objects in ", + { + "Ref": "bucket43879C71" + }, + " S3 bucket." + ] + ] + } + }, + "DependsOn": [ + "CustomS3AutoDeleteObjectsCustomResourceProviderRole3B1BD092" + ], + "Metadata": { + "aws:cdk:path": "teststack/nesty/Custom::S3AutoDeleteObjectsCustomResourceProvider/Handler", + "aws:asset:path": "asset.faa95a81ae7d7373f3e1f242268f904eb748d8d0fdd306e8a6fe515a1905a7d6", + "aws:asset:property": "Code" + } + } + }, + "Parameters": { + "referencetoteststackbucket4A009163Ref": { + "Type": "String" + } + }, + "Outputs": { + "teststacknestybucket3B9EFE19Ref": { + "Value": { + "Ref": "bucket43879C71" + } + }, + "teststacknestybucket3B9EFE19Arn": { + "Value": { + "Fn::GetAtt": [ + "bucket43879C71", + "Arn" + ] + } + } + } +} \ No newline at end of file diff --git a/tests/test-data/nested-stack/tree.json b/tests/test-data/nested-stack/tree.json new file mode 100644 index 00000000..b74ad816 --- /dev/null +++ b/tests/test-data/nested-stack/tree.json @@ -0,0 +1,711 @@ +{ + "version": "tree-0.1", + "tree": { + "id": "App", + "path": "", + "children": { + "teststack": { + "id": "teststack", + "path": "teststack", + "children": { + "bucket": { + "id": "bucket", + "path": "teststack/bucket", + "children": { + "Resource": { + "id": "Resource", + "path": "teststack/bucket/Resource", + "attributes": { + "aws:cdk:cloudformation:type": "AWS::S3::Bucket", + "aws:cdk:cloudformation:props": {} + }, + "constructInfo": { + "fqn": "aws-cdk-lib.aws_s3.CfnBucket", + "version": "2.149.0" + } + } + }, + "constructInfo": { + "fqn": "aws-cdk-lib.aws_s3.Bucket", + "version": "2.149.0" + } + }, + "nesty": { + "id": "nesty", + "path": "teststack/nesty", + "children": { + "bucket": { + "id": "bucket", + "path": "teststack/nesty/bucket", + "children": { + "Resource": { + "id": "Resource", + "path": "teststack/nesty/bucket/Resource", + "attributes": { + "aws:cdk:cloudformation:type": "AWS::S3::Bucket", + "aws:cdk:cloudformation:props": { + "bucketName": { + "Fn::Join": [ + "", + [ + { + "Ref": "referencetoteststackbucket4A009163Ref" + }, + "-nested" + ] + ] + }, + "publicAccessBlockConfiguration": { + "blockPublicAcls": false, + "blockPublicPolicy": false, + "ignorePublicAcls": false, + "restrictPublicBuckets": false + }, + "tags": [ + { + "key": "aws-cdk:auto-delete-objects", + "value": "true" + }, + { + "key": "aws-cdk:cr-owned:043cc088", + "value": "true" + } + ], + "websiteConfiguration": { + "indexDocument": "index.html" + } + } + }, + "constructInfo": { + "fqn": "aws-cdk-lib.aws_s3.CfnBucket", + "version": "2.149.0" + } + }, + "Policy": { + "id": "Policy", + "path": "teststack/nesty/bucket/Policy", + "children": { + "Resource": { + "id": "Resource", + "path": "teststack/nesty/bucket/Policy/Resource", + "attributes": { + "aws:cdk:cloudformation:type": "AWS::S3::BucketPolicy", + "aws:cdk:cloudformation:props": { + "bucket": { + "Ref": "bucket43879C71" + }, + "policyDocument": { + "Statement": [ + { + "Action": "s3:GetObject", + "Effect": "Allow", + "Principal": { + "AWS": "*" + }, + "Resource": { + "Fn::Join": [ + "", + [ + { + "Fn::GetAtt": [ + "bucket43879C71", + "Arn" + ] + }, + "/*" + ] + ] + } + }, + { + "Action": [ + "s3:PutBucketPolicy", + "s3:GetBucket*", + "s3:List*", + "s3:DeleteObject*" + ], + "Effect": "Allow", + "Principal": { + "AWS": { + "Fn::GetAtt": [ + "CustomS3AutoDeleteObjectsCustomResourceProviderRole3B1BD092", + "Arn" + ] + } + }, + "Resource": [ + { + "Fn::GetAtt": [ + "bucket43879C71", + "Arn" + ] + }, + { + "Fn::Join": [ + "", + [ + { + "Fn::GetAtt": [ + "bucket43879C71", + "Arn" + ] + }, + "/*" + ] + ] + } + ] + } + ], + "Version": "2012-10-17" + } + } + }, + "constructInfo": { + "fqn": "aws-cdk-lib.aws_s3.CfnBucketPolicy", + "version": "2.149.0" + } + } + }, + "constructInfo": { + "fqn": "aws-cdk-lib.aws_s3.BucketPolicy", + "version": "2.149.0" + } + }, + "AutoDeleteObjectsCustomResource": { + "id": "AutoDeleteObjectsCustomResource", + "path": "teststack/nesty/bucket/AutoDeleteObjectsCustomResource", + "children": { + "Default": { + "id": "Default", + "path": "teststack/nesty/bucket/AutoDeleteObjectsCustomResource/Default", + "constructInfo": { + "fqn": "aws-cdk-lib.CfnResource", + "version": "2.149.0" + } + } + }, + "constructInfo": { + "fqn": "aws-cdk-lib.CustomResource", + "version": "2.149.0" + } + } + }, + "constructInfo": { + "fqn": "aws-cdk-lib.aws_s3.Bucket", + "version": "2.149.0" + } + }, + "Custom::S3AutoDeleteObjectsCustomResourceProvider": { + "id": "Custom::S3AutoDeleteObjectsCustomResourceProvider", + "path": "teststack/nesty/Custom::S3AutoDeleteObjectsCustomResourceProvider", + "children": { + "Staging": { + "id": "Staging", + "path": "teststack/nesty/Custom::S3AutoDeleteObjectsCustomResourceProvider/Staging", + "constructInfo": { + "fqn": "aws-cdk-lib.AssetStaging", + "version": "2.149.0" + } + }, + "Role": { + "id": "Role", + "path": "teststack/nesty/Custom::S3AutoDeleteObjectsCustomResourceProvider/Role", + "constructInfo": { + "fqn": "aws-cdk-lib.CfnResource", + "version": "2.149.0" + } + }, + "Handler": { + "id": "Handler", + "path": "teststack/nesty/Custom::S3AutoDeleteObjectsCustomResourceProvider/Handler", + "constructInfo": { + "fqn": "aws-cdk-lib.CfnResource", + "version": "2.149.0" + } + } + }, + "constructInfo": { + "fqn": "aws-cdk-lib.CustomResourceProviderBase", + "version": "2.149.0" + } + }, + "reference-to-teststackbucket4A009163Ref": { + "id": "reference-to-teststackbucket4A009163Ref", + "path": "teststack/nesty/reference-to-teststackbucket4A009163Ref", + "constructInfo": { + "fqn": "aws-cdk-lib.CfnParameter", + "version": "2.156.0" + } + }, + "teststacknestybucket3B9EFE19Ref": { + "id": "teststacknestybucket3B9EFE19Ref", + "path": "teststack/nesty/teststacknestybucket3B9EFE19Ref", + "constructInfo": { + "fqn": "aws-cdk-lib.CfnOutput", + "version": "2.156.0" + } + }, + "teststacknestybucket3B9EFE19Arn": { + "id": "teststacknestybucket3B9EFE19Arn", + "path": "teststack/nesty/teststacknestybucket3B9EFE19Arn", + "constructInfo": { + "fqn": "aws-cdk-lib.CfnOutput", + "version": "2.156.0" + } + } + }, + "constructInfo": { + "fqn": "aws-cdk-lib.NestedStack", + "version": "2.149.0" + } + }, + "nesty.NestedStack": { + "id": "nesty.NestedStack", + "path": "teststack/nesty.NestedStack", + "children": { + "nesty.NestedStackResource": { + "id": "nesty.NestedStackResource", + "path": "teststack/nesty.NestedStack/nesty.NestedStackResource", + "attributes": { + "aws:cdk:cloudformation:type": "AWS::CloudFormation::Stack", + "aws:cdk:cloudformation:props": { + "parameters": { + "referencetoteststackbucket4A009163Ref": { + "Ref": "bucket" + } + }, + "templateUrl": { + "Fn::Join": [ + "", + [ + "https://s3.us-west-2.", + { + "Ref": "AWS::URLSuffix" + }, + "/", + { + "Ref": { + "PulumiOutput": 18 + } + }, + "/", + { + "Ref": { + "PulumiOutput": 19 + } + } + ] + ] + } + } + }, + "constructInfo": { + "fqn": "aws-cdk-lib.CfnStack", + "version": "2.149.0" + } + } + }, + "constructInfo": { + "fqn": "constructs.Construct", + "version": "10.3.0" + } + }, + "DeployWebsite": { + "id": "DeployWebsite", + "path": "teststack/DeployWebsite", + "children": { + "AwsCliLayer": { + "id": "AwsCliLayer", + "path": "teststack/DeployWebsite/AwsCliLayer", + "children": { + "Code": { + "id": "Code", + "path": "teststack/DeployWebsite/AwsCliLayer/Code", + "children": { + "Stage": { + "id": "Stage", + "path": "teststack/DeployWebsite/AwsCliLayer/Code/Stage", + "constructInfo": { + "fqn": "aws-cdk-lib.AssetStaging", + "version": "2.149.0" + } + }, + "AssetBucket": { + "id": "AssetBucket", + "path": "teststack/DeployWebsite/AwsCliLayer/Code/AssetBucket", + "constructInfo": { + "fqn": "aws-cdk-lib.aws_s3.BucketBase", + "version": "2.149.0" + } + } + }, + "constructInfo": { + "fqn": "aws-cdk-lib.aws_s3_assets.Asset", + "version": "2.149.0" + } + }, + "Resource": { + "id": "Resource", + "path": "teststack/DeployWebsite/AwsCliLayer/Resource", + "attributes": { + "aws:cdk:cloudformation:type": "AWS::Lambda::LayerVersion", + "aws:cdk:cloudformation:props": { + "content": { + "s3Bucket": { + "Ref": { + "PulumiOutput": 6 + } + }, + "s3Key": { + "Ref": { + "PulumiOutput": 7 + } + } + }, + "description": "/opt/awscli/aws" + } + }, + "constructInfo": { + "fqn": "aws-cdk-lib.aws_lambda.CfnLayerVersion", + "version": "2.149.0" + } + } + }, + "constructInfo": { + "fqn": "aws-cdk-lib.lambda_layer_awscli.AwsCliLayer", + "version": "2.149.0" + } + }, + "CustomResourceHandler": { + "id": "CustomResourceHandler", + "path": "teststack/DeployWebsite/CustomResourceHandler", + "constructInfo": { + "fqn": "aws-cdk-lib.aws_lambda.SingletonFunction", + "version": "2.149.0" + } + }, + "Asset1": { + "id": "Asset1", + "path": "teststack/DeployWebsite/Asset1", + "children": { + "Stage": { + "id": "Stage", + "path": "teststack/DeployWebsite/Asset1/Stage", + "constructInfo": { + "fqn": "aws-cdk-lib.AssetStaging", + "version": "2.149.0" + } + }, + "AssetBucket": { + "id": "AssetBucket", + "path": "teststack/DeployWebsite/Asset1/AssetBucket", + "constructInfo": { + "fqn": "aws-cdk-lib.aws_s3.BucketBase", + "version": "2.149.0" + } + } + }, + "constructInfo": { + "fqn": "aws-cdk-lib.aws_s3_assets.Asset", + "version": "2.149.0" + } + }, + "CustomResource": { + "id": "CustomResource", + "path": "teststack/DeployWebsite/CustomResource", + "children": { + "Default": { + "id": "Default", + "path": "teststack/DeployWebsite/CustomResource/Default", + "constructInfo": { + "fqn": "aws-cdk-lib.CfnResource", + "version": "2.149.0" + } + } + }, + "constructInfo": { + "fqn": "aws-cdk-lib.CustomResource", + "version": "2.149.0" + } + } + }, + "constructInfo": { + "fqn": "aws-cdk-lib.aws_s3_deployment.BucketDeployment", + "version": "2.149.0" + } + }, + "Custom::CDKBucketDeployment8693BB64968944B69AAFB0CC9EB8756C": { + "id": "Custom::CDKBucketDeployment8693BB64968944B69AAFB0CC9EB8756C", + "path": "teststack/Custom::CDKBucketDeployment8693BB64968944B69AAFB0CC9EB8756C", + "children": { + "ServiceRole": { + "id": "ServiceRole", + "path": "teststack/Custom::CDKBucketDeployment8693BB64968944B69AAFB0CC9EB8756C/ServiceRole", + "children": { + "ImportServiceRole": { + "id": "ImportServiceRole", + "path": "teststack/Custom::CDKBucketDeployment8693BB64968944B69AAFB0CC9EB8756C/ServiceRole/ImportServiceRole", + "constructInfo": { + "fqn": "aws-cdk-lib.Resource", + "version": "2.149.0" + } + }, + "Resource": { + "id": "Resource", + "path": "teststack/Custom::CDKBucketDeployment8693BB64968944B69AAFB0CC9EB8756C/ServiceRole/Resource", + "attributes": { + "aws:cdk:cloudformation:type": "AWS::IAM::Role", + "aws:cdk:cloudformation:props": { + "assumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "lambda.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + }, + "managedPolicyArns": [ + { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":iam::aws:policy/service-role/AWSLambdaBasicExecutionRole" + ] + ] + } + ] + } + }, + "constructInfo": { + "fqn": "aws-cdk-lib.aws_iam.CfnRole", + "version": "2.149.0" + } + }, + "DefaultPolicy": { + "id": "DefaultPolicy", + "path": "teststack/Custom::CDKBucketDeployment8693BB64968944B69AAFB0CC9EB8756C/ServiceRole/DefaultPolicy", + "children": { + "Resource": { + "id": "Resource", + "path": "teststack/Custom::CDKBucketDeployment8693BB64968944B69AAFB0CC9EB8756C/ServiceRole/DefaultPolicy/Resource", + "attributes": { + "aws:cdk:cloudformation:type": "AWS::IAM::Policy", + "aws:cdk:cloudformation:props": { + "policyDocument": { + "Statement": [ + { + "Action": [ + "s3:GetObject*", + "s3:GetBucket*", + "s3:List*" + ], + "Effect": "Allow", + "Resource": [ + { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":s3:::", + { + "Ref": { + "PulumiOutput": 14 + } + } + ] + ] + }, + { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":s3:::", + { + "Ref": { + "PulumiOutput": 14 + } + }, + "/*" + ] + ] + } + ] + }, + { + "Action": [ + "s3:GetObject*", + "s3:GetBucket*", + "s3:List*", + "s3:DeleteObject*", + "s3:PutObject", + "s3:PutObjectLegalHold", + "s3:PutObjectRetention", + "s3:PutObjectTagging", + "s3:PutObjectVersionTagging", + "s3:Abort*" + ], + "Effect": "Allow", + "Resource": [ + { + "Fn::GetAtt": [ + "nestyNestedStacknestyNestedStackResource", + "Outputs.teststacknestybucket3B9EFE19Arn" + ] + }, + { + "Fn::Join": [ + "", + [ + { + "Fn::GetAtt": [ + "nestyNestedStacknestyNestedStackResource", + "Outputs.teststacknestybucket3B9EFE19Arn" + ] + }, + "/*" + ] + ] + } + ] + } + ], + "Version": "2012-10-17" + }, + "policyName": "CustomCDKBucketDeployment8693BB64968944B69AAFB0CC9EB8756CServiceRoleDefaultPolicy", + "roles": [ + { + "Ref": "CustomCDKBucketDeployment8693BB64968944B69AAFB0CC9EB8756CServiceRole" + } + ] + } + }, + "constructInfo": { + "fqn": "aws-cdk-lib.aws_iam.CfnPolicy", + "version": "2.149.0" + } + } + }, + "constructInfo": { + "fqn": "aws-cdk-lib.aws_iam.Policy", + "version": "2.149.0" + } + } + }, + "constructInfo": { + "fqn": "aws-cdk-lib.aws_iam.Role", + "version": "2.149.0" + } + }, + "Code": { + "id": "Code", + "path": "teststack/Custom::CDKBucketDeployment8693BB64968944B69AAFB0CC9EB8756C/Code", + "children": { + "Stage": { + "id": "Stage", + "path": "teststack/Custom::CDKBucketDeployment8693BB64968944B69AAFB0CC9EB8756C/Code/Stage", + "constructInfo": { + "fqn": "aws-cdk-lib.AssetStaging", + "version": "2.149.0" + } + }, + "AssetBucket": { + "id": "AssetBucket", + "path": "teststack/Custom::CDKBucketDeployment8693BB64968944B69AAFB0CC9EB8756C/Code/AssetBucket", + "constructInfo": { + "fqn": "aws-cdk-lib.aws_s3.BucketBase", + "version": "2.149.0" + } + } + }, + "constructInfo": { + "fqn": "aws-cdk-lib.aws_s3_assets.Asset", + "version": "2.149.0" + } + }, + "Resource": { + "id": "Resource", + "path": "teststack/Custom::CDKBucketDeployment8693BB64968944B69AAFB0CC9EB8756C/Resource", + "attributes": { + "aws:cdk:cloudformation:type": "AWS::Lambda::Function", + "aws:cdk:cloudformation:props": { + "code": { + "s3Bucket": { + "Ref": { + "PulumiOutput": 10 + } + }, + "s3Key": { + "Ref": { + "PulumiOutput": 11 + } + } + }, + "environment": { + "variables": { + "AWS_CA_BUNDLE": "/etc/pki/ca-trust/extracted/pem/tls-ca-bundle.pem" + } + }, + "handler": "index.handler", + "layers": [ + { + "Ref": "DeployWebsiteAwsCliLayer" + } + ], + "role": { + "Fn::GetAtt": [ + "CustomCDKBucketDeployment8693BB64968944B69AAFB0CC9EB8756CServiceRole", + "Arn" + ] + }, + "runtime": "python3.9", + "timeout": 900 + } + }, + "constructInfo": { + "fqn": "aws-cdk-lib.aws_lambda.CfnFunction", + "version": "2.149.0" + } + } + }, + "constructInfo": { + "fqn": "aws-cdk-lib.aws_lambda.Function", + "version": "2.149.0" + } + } + }, + "constructInfo": { + "fqn": "aws-cdk-lib.Stack", + "version": "2.156.0" + } + }, + "Tree": { + "id": "Tree", + "path": "Tree", + "constructInfo": { + "fqn": "constructs.Construct", + "version": "10.3.0" + } + } + }, + "constructInfo": { + "fqn": "aws-cdk-lib.App", + "version": "2.156.0" + } + } +} \ No newline at end of file diff --git a/tests/utils.ts b/tests/utils.ts index 51577e1e..fe915426 100644 --- a/tests/utils.ts +++ b/tests/utils.ts @@ -14,8 +14,8 @@ export function createStackManifest(props: CreateStackManifestProps): StackManif id: 'stack', templatePath: 'template', metadata: { - 'stack/resource-1': 'resource1', - 'stack/resource-2': 'resource2', + 'stack/resource-1': { stackPath: 'stack', id: 'resource1' }, + 'stack/resource-2': { stackPath: 'stack', id: 'resource2' }, }, tree: { path: 'stack', @@ -37,6 +37,7 @@ export function createStackManifest(props: CreateStackManifestProps): StackManif }, }, }, + nestedStacks: {}, template: { Mappings: props.mappings, Resources: { From 158dc335efdb7e70cf1acea68ca1e97344fc1d7b Mon Sep 17 00:00:00 2001 From: Florian Stadler Date: Tue, 17 Dec 2024 19:25:49 +0100 Subject: [PATCH 2/4] Add tests for asOutputValue --- src/converters/app-converter.ts | 21 +- tests/converters/app-converter.test.ts | 562 ++++++++++++++++++++++++- 2 files changed, 571 insertions(+), 12 deletions(-) diff --git a/src/converters/app-converter.ts b/src/converters/app-converter.ts index 87ca7b39..97628297 100644 --- a/src/converters/app-converter.ts +++ b/src/converters/app-converter.ts @@ -458,18 +458,23 @@ export class StackConverter extends ArtifactConverter implements intrinsics.Intr for (const [stackPath, _] of this.graph.nestedStackNodes) { if (stackPath === this.stack.constructTree.path) continue; + let result; try { - const result = this.processIntrinsics(value, stackPath); - if (foundValue !== null) { - throw new CdkAdapterError( - `Value found in multiple stacks: ${foundStack} and ${stackPath}. Pulumi cannot resolve this value.`, - ); - } - foundValue = result; - foundStack = stackPath; + result = this.processIntrinsics(value, stackPath); } catch { // Continue searching other stacks + continue; + } + if (!result) { + continue; + } + if (foundValue !== null) { + throw new CdkAdapterError( + `Value found in multiple stacks: ${foundStack} and ${stackPath}. Pulumi cannot resolve this value.`, + ); } + foundValue = result; + foundStack = stackPath; } if (foundValue !== null) { diff --git a/tests/converters/app-converter.test.ts b/tests/converters/app-converter.test.ts index dfe9413d..de16f4c1 100644 --- a/tests/converters/app-converter.test.ts +++ b/tests/converters/app-converter.test.ts @@ -652,28 +652,582 @@ describe('Stack Converter', () => { const converter = new StackConverter(app, manifest); converter.convert(new Set()); - const rootBucket = converter.resources.get({ stackPath: manifest.id, id: 'bucket'})?.resource as native.s3.Bucket; + const rootBucket = converter.resources.get({ stackPath: manifest.id, id: 'bucket' })?.resource as native.s3.Bucket; expect(rootBucket).toBeDefined(); const rootBucketName = await promiseOf(rootBucket.bucketName); // nested stack resource should be mapped - const nestedStackResource = converter.resources.get({ stackPath: manifest.id, id: 'nestyNestedStacknestyNestedStackResource'}); + const nestedStackResource = converter.resources.get({ stackPath: manifest.id, id: 'nestyNestedStacknestyNestedStackResource' }); expect(nestedStackResource).toBeDefined(); expect(nestedStackResource?.resourceType).toEqual('AWS::CloudFormation::Stack'); expect(NestedStackConstruct.isNestedStackConstruct(nestedStackResource?.resource)).toBeTruthy(); // resources of the nested stack should be mapped // this tests that properties are correctly passed to the nested stack - const nestedBucket = converter.resources.get({ stackPath: `${manifest.id}/nesty`, id: 'bucket43879C71'})?.resource as native.s3.Bucket; + const nestedBucket = converter.resources.get({ stackPath: `${manifest.id}/nesty`, id: 'bucket43879C71' })?.resource as native.s3.Bucket; expect(nestedBucket).toBeDefined(); const nestedBucketName = await promiseOf(nestedBucket.bucketName); expect(nestedBucketName).toEqual(`${rootBucketName}-nested`); - const policy = converter.resources.get({ stackPath: manifest.id, id: 'CustomCDKBucketDeployment8693BB64968944B69AAFB0CC9EB8756CServiceRoleDefaultPolicy'})?.resource as Policy; + const policy = converter.resources.get({ stackPath: manifest.id, id: 'CustomCDKBucketDeployment8693BB64968944B69AAFB0CC9EB8756CServiceRoleDefaultPolicy' })?.resource as Policy; const policyDocument = await promiseOf(policy.policy) as any; expect(policyDocument.Statement[1].Resource).toEqual(expect.arrayContaining(['bucket43879c71_arn', 'bucket43879c71_arn/*'])); }); + + describe('asOutputValue', () => { + test('can convert tokens to outputs', async () => { + const manifest = new StackManifest({ + id: 'stack', + templatePath: 'test/stack', + metadata: { + 'stack/vpc': { stackPath: 'stack', id: 'vpc' }, + 'stack/cidr': { stackPath: 'stack', id: 'cidr' }, + }, + tree: { + path: 'stack', + id: 'stack', + children: { + vpc: { + id: 'vpc', + path: 'stack/vpc', + attributes: { + 'aws:cdk:cloudformation:type': 'AWS::EC2::VPC', + }, + }, + cidr: { + id: 'cidr', + path: 'stack/cidr', + attributes: { + 'aws:cdk:cloudformation:type': 'AWS::EC2::VPCCidrBlock', + }, + }, + }, + constructInfo: { + fqn: 'aws-cdk-lib.Stack', + version: '2.149.0', + }, + }, + nestedStacks: {}, + template: { + Resources: { + vpc: { + Type: 'AWS::EC2::VPC', + Properties: {}, + }, + cidr: { + Type: 'AWS::EC2::VPCCidrBlock', + Properties: { + VpcId: { Ref: 'vpc' }, + Ipv6CidrBlock: 'cidr_ipv6AddressAttribute', + }, + }, + }, + }, + dependencies: [], + }); + + const app = new MockAppComponent('/tmp/foo/bar/does/not/exist'); + const stagingBucket = 'my-bucket'; + const customResourcePrefix = 'my-prefix'; + app.stacks[manifest.id] = { + synthesizer: new MockSynth(stagingBucket, customResourcePrefix), + node: { + id: 'my-stack', + }, + resolve: (obj: any) => ({ Ref: 'vpc' }), + } as unknown as CdkStack; + const converter = new StackConverter(app, manifest); + converter.convert(new Set()); + + const result = await promiseOf(converter.asOutputValue("DUMMY") as any); + expect(result).toEqual("vpc_id"); + }); + + test('throws if token is not found in any stack', async () => { + const manifest = new StackManifest({ + id: 'stack', + templatePath: 'test/stack', + metadata: { + 'stack/vpc': { stackPath: 'stack', id: 'vpc' }, + 'stack/cidr': { stackPath: 'stack', id: 'cidr' }, + 'stack/nested.NestedStack/nested.NestedStackResource': { stackPath: 'stack', id: 'nested.NestedStackResource' }, + 'stack/nested/vpc': { stackPath: 'stack/nested', id: 'vpc' }, + 'stack/nested/cidr': { stackPath: 'stack/nested', id: 'cidr' }, + }, + tree: { + path: 'stack', + id: 'stack', + children: { + vpc: { + id: 'vpc', + path: 'stack/vpc', + attributes: { + 'aws:cdk:cloudformation:type': 'AWS::EC2::VPC', + }, + }, + cidr: { + id: 'cidr', + path: 'stack/cidr', + attributes: { + 'aws:cdk:cloudformation:type': 'AWS::EC2::VPCCidrBlock', + }, + }, + nested: { + id: 'nested', + path: 'stack/nested', + children: { + vpc: { + id: 'vpc', + path: 'stack/nested/vpc', + attributes: { + 'aws:cdk:cloudformation:type': 'AWS::EC2::VPC', + }, + }, + cidr: { + id: 'cidr', + path: 'stack/nested/cidr', + attributes: { + 'aws:cdk:cloudformation:type': 'AWS::EC2::VPCCidrBlock', + }, + }, + }, + }, + "nested.NestedStack": { + id: 'nested.NestedStack', + path: 'stack/nested.NestedStack', + children: { + 'nested.NestedStackResource': { + id: 'nested.NestedStackResource', + path: 'stack/nested.NestedStack/nested.NestedStackResource', + attributes: { + 'aws:cdk:cloudformation:type': 'AWS::CloudFormation::Stack', + }, + }, + } + }, + }, + constructInfo: { + fqn: 'aws-cdk-lib.Stack', + version: '2.149.0', + }, + }, + nestedStacks: { + 'stack/nested': { + logicalId: 'nested', + Resources: { + vpc: { + Type: 'AWS::EC2::VPC', + Properties: {}, + }, + cidr: { + Type: 'AWS::EC2::VPCCidrBlock', + Properties: { + VpcId: { Ref: 'vpc' }, + Ipv6CidrBlock: 'cidr_ipv6AddressAttribute', + }, + }, + }, + }, + }, + template: { + Resources: { + vpc: { + Type: 'AWS::EC2::VPC', + Properties: {}, + }, + cidr: { + Type: 'AWS::EC2::VPCCidrBlock', + Properties: { + VpcId: { Ref: 'vpc' }, + Ipv6CidrBlock: 'cidr_ipv6AddressAttribute', + }, + }, + "nested.NestedStackResource": { + Type: 'AWS::CloudFormation::Stack', + Properties: {}, + }, + }, + }, + dependencies: [], + }); + + const app = new MockAppComponent('/tmp/foo/bar/does/not/exist'); + const stagingBucket = 'my-bucket'; + const customResourcePrefix = 'my-prefix'; + app.stacks[manifest.id] = { + synthesizer: new MockSynth(stagingBucket, customResourcePrefix), + node: { + id: 'my-stack', + }, + resolve: (obj: any) => ({ Ref: 'not-found' }), + } as unknown as CdkStack; + const converter = new StackConverter(app, manifest); + converter.convert(new Set()); + + expect(() => converter.asOutputValue("DUMMY")).toThrow("Ref intrinsic unable to resolve not-found in stack stack: not a known logical resource or parameter reference"); + }); + + test('finds value in correct nested stack', async () => { + const manifest = new StackManifest({ + id: 'stack', + templatePath: 'test/stack', + metadata: { + 'stack/vpc': { stackPath: 'stack', id: 'vpc' }, + 'stack/cidr': { stackPath: 'stack', id: 'cidr' }, + 'stack/nested.NestedStack/nested.NestedStackResource': { stackPath: 'stack', id: 'nested.NestedStackResource' }, + 'stack/nested/nestedVpc': { stackPath: 'stack/nested', id: 'nestedVpc' }, + 'stack/nested/cidr': { stackPath: 'stack/nested', id: 'cidr' }, + 'stack/otherNested.NestedStack/otherNested.NestedStackResource': { stackPath: 'stack', id: 'otherNested.NestedStackResource' }, + 'stack/otherNested/nestedVpc2': { stackPath: 'stack/otherNested', id: 'nestedVpc2' }, + 'stack/otherNested/cidr': { stackPath: 'stack/otherNested', id: 'cidr' }, + }, + tree: { + path: 'stack', + id: 'stack', + children: { + vpc: { + id: 'vpc', + path: 'stack/vpc', + attributes: { + 'aws:cdk:cloudformation:type': 'AWS::EC2::VPC', + }, + }, + cidr: { + id: 'cidr', + path: 'stack/cidr', + attributes: { + 'aws:cdk:cloudformation:type': 'AWS::EC2::VPCCidrBlock', + }, + }, + nested: { + id: 'nested', + path: 'stack/nested', + children: { + nestedVpc: { + id: 'nestedVpc', + path: 'stack/nested/nestedVpc', + attributes: { + 'aws:cdk:cloudformation:type': 'AWS::EC2::VPC', + }, + }, + cidr: { + id: 'cidr', + path: 'stack/nested/cidr', + attributes: { + 'aws:cdk:cloudformation:type': 'AWS::EC2::VPCCidrBlock', + }, + }, + }, + }, + "nested.NestedStack": { + id: 'nested.NestedStack', + path: 'stack/nested.NestedStack', + children: { + 'nested.NestedStackResource': { + id: 'nested.NestedStackResource', + path: 'stack/nested.NestedStack/nested.NestedStackResource', + attributes: { + 'aws:cdk:cloudformation:type': 'AWS::CloudFormation::Stack', + }, + }, + } + }, + otherNested: { + id: 'otherNested', + path: 'stack/otherNested', + children: { + nestedVpc2: { + id: 'nestedVpc2', + path: 'stack/otherNested/nestedVpc2', + attributes: { + 'aws:cdk:cloudformation:type': 'AWS::EC2::VPC', + }, + }, + cidr: { + id: 'cidr2', + path: 'stack/otherNested/cidr', + attributes: { + 'aws:cdk:cloudformation:type': 'AWS::EC2::VPCCidrBlock', + }, + }, + } + }, + "otherNested.NestedStack": { + id: 'otherNested.NestedStack', + path: 'stack/otherNested.NestedStack', + children: { + 'otherNested.NestedStackResource': { + id: 'otherNested.NestedStackResource', + path: 'stack/otherNested.NestedStack/otherNested.NestedStackResource', + attributes: { + 'aws:cdk:cloudformation:type': 'AWS::CloudFormation::Stack', + }, + }, + } + } + }, + constructInfo: { + fqn: 'aws-cdk-lib.Stack', + version: '2.149.0', + }, + }, + nestedStacks: { + 'stack/nested': { + logicalId: 'nested', + Resources: { + nestedVpc: { + Type: 'AWS::EC2::VPC', + Properties: {}, + }, + cidr: { + Type: 'AWS::EC2::VPCCidrBlock', + Properties: { + VpcId: { Ref: 'nestedVpc' }, + Ipv6CidrBlock: 'cidr_ipv6AddressAttribute', + }, + }, + }, + }, + 'stack/otherNested': { + logicalId: 'nested', + Resources: { + nestedVpc2: { + Type: 'AWS::EC2::VPC', + Properties: {}, + }, + cidr: { + Type: 'AWS::EC2::VPCCidrBlock', + Properties: { + VpcId: { Ref: 'nestedVpc2' }, + Ipv6CidrBlock: 'cidr_ipv6AddressAttribute', + }, + }, + }, + } + }, + template: { + Resources: { + vpc: { + Type: 'AWS::EC2::VPC', + Properties: {}, + }, + cidr: { + Type: 'AWS::EC2::VPCCidrBlock', + Properties: { + VpcId: { Ref: 'vpc' }, + Ipv6CidrBlock: 'cidr_ipv6AddressAttribute', + }, + }, + "nested.NestedStackResource": { + Type: 'AWS::CloudFormation::Stack', + Properties: {}, + }, + "otherNested.NestedStackResource": { + Type: 'AWS::CloudFormation::Stack', + Properties: {}, + }, + }, + }, + dependencies: [], + }); + + const app = new MockAppComponent('/tmp/foo/bar/does/not/exist'); + const stagingBucket = 'my-bucket'; + const customResourcePrefix = 'my-prefix'; + app.stacks[manifest.id] = { + synthesizer: new MockSynth(stagingBucket, customResourcePrefix), + node: { + id: 'my-stack', + }, + resolve: (obj: any) => ({ Ref: 'nestedVpc' }), + } as unknown as CdkStack; + const converter = new StackConverter(app, manifest); + converter.convert(new Set()); + + const result = await promiseOf(converter.asOutputValue("DUMMY") as any); + expect(result).toEqual("nestedVpc_id"); + }); + + test('throws if token is found in multiple stacks', async () => { + const manifest = new StackManifest({ + id: 'stack', + templatePath: 'test/stack', + metadata: { + 'stack/vpc': { stackPath: 'stack', id: 'vpc' }, + 'stack/cidr': { stackPath: 'stack', id: 'cidr' }, + 'stack/nested.NestedStack/nested.NestedStackResource': { stackPath: 'stack', id: 'nested.NestedStackResource' }, + 'stack/nested/nestedVpc': { stackPath: 'stack/nested', id: 'nestedVpc' }, + 'stack/nested/cidr': { stackPath: 'stack/nested', id: 'cidr' }, + 'stack/otherNested.NestedStack/otherNested.NestedStackResource': { stackPath: 'stack', id: 'otherNested.NestedStackResource' }, + 'stack/otherNested/nestedVpc': { stackPath: 'stack/otherNested', id: 'nestedVpc' }, + 'stack/otherNested/cidr': { stackPath: 'stack/otherNested', id: 'cidr' }, + }, + tree: { + path: 'stack', + id: 'stack', + children: { + vpc: { + id: 'vpc', + path: 'stack/vpc', + attributes: { + 'aws:cdk:cloudformation:type': 'AWS::EC2::VPC', + }, + }, + cidr: { + id: 'cidr', + path: 'stack/cidr', + attributes: { + 'aws:cdk:cloudformation:type': 'AWS::EC2::VPCCidrBlock', + }, + }, + nested: { + id: 'nested', + path: 'stack/nested', + children: { + nestedVpc: { + id: 'vpc', + path: 'stack/nested/nestedVpc', + attributes: { + 'aws:cdk:cloudformation:type': 'AWS::EC2::VPC', + }, + }, + cidr: { + id: 'cidr', + path: 'stack/nested/cidr', + attributes: { + 'aws:cdk:cloudformation:type': 'AWS::EC2::VPCCidrBlock', + }, + }, + }, + }, + "nested.NestedStack": { + id: 'nested.NestedStack', + path: 'stack/nested.NestedStack', + children: { + 'nested.NestedStackResource': { + id: 'nested.NestedStackResource', + path: 'stack/nested.NestedStack/nested.NestedStackResource', + attributes: { + 'aws:cdk:cloudformation:type': 'AWS::CloudFormation::Stack', + }, + }, + } + }, + otherNested: { + id: 'otherNested', + path: 'stack/otherNested', + children: { + nestedVpc: { + id: 'vpc', + path: 'stack/otherNested/nestedVpc', + attributes: { + 'aws:cdk:cloudformation:type': 'AWS::EC2::VPC', + }, + }, + cidr: { + id: 'cidr', + path: 'stack/otherNested/cidr', + attributes: { + 'aws:cdk:cloudformation:type': 'AWS::EC2::VPCCidrBlock', + }, + }, + } + }, + "otherNested.NestedStack": { + id: 'otherNested.NestedStack', + path: 'stack/otherNested.NestedStack', + children: { + 'otherNested.NestedStackResource': { + id: 'otherNested.NestedStackResource', + path: 'stack/otherNested.NestedStack/otherNested.NestedStackResource', + attributes: { + 'aws:cdk:cloudformation:type': 'AWS::CloudFormation::Stack', + }, + }, + } + } + }, + constructInfo: { + fqn: 'aws-cdk-lib.Stack', + version: '2.149.0', + }, + }, + nestedStacks: { + 'stack/nested': { + logicalId: 'nested', + Resources: { + nestedVpc: { + Type: 'AWS::EC2::VPC', + Properties: {}, + }, + cidr: { + Type: 'AWS::EC2::VPCCidrBlock', + Properties: { + VpcId: { Ref: 'nestedVpc' }, + Ipv6CidrBlock: 'cidr_ipv6AddressAttribute', + }, + }, + }, + }, + 'stack/otherNested': { + logicalId: 'nested', + Resources: { + nestedVpc: { + Type: 'AWS::EC2::VPC', + Properties: {}, + }, + cidr: { + Type: 'AWS::EC2::VPCCidrBlock', + Properties: { + VpcId: { Ref: 'nestedVpc' }, + Ipv6CidrBlock: 'cidr_ipv6AddressAttribute', + }, + }, + }, + } + }, + template: { + Resources: { + vpc: { + Type: 'AWS::EC2::VPC', + Properties: {}, + }, + cidr: { + Type: 'AWS::EC2::VPCCidrBlock', + Properties: { + VpcId: { Ref: 'vpc' }, + Ipv6CidrBlock: 'cidr_ipv6AddressAttribute', + }, + }, + "nested.NestedStackResource": { + Type: 'AWS::CloudFormation::Stack', + Properties: {}, + }, + "otherNested.NestedStackResource": { + Type: 'AWS::CloudFormation::Stack', + Properties: {}, + }, + }, + }, + dependencies: [], + }); + + const app = new MockAppComponent('/tmp/foo/bar/does/not/exist'); + const stagingBucket = 'my-bucket'; + const customResourcePrefix = 'my-prefix'; + app.stacks[manifest.id] = { + synthesizer: new MockSynth(stagingBucket, customResourcePrefix), + node: { + id: 'my-stack', + }, + resolve: (obj: any) => ({ Ref: 'nestedVpc' }), + } as unknown as CdkStack; + const converter = new StackConverter(app, manifest); + converter.convert(new Set()); + expect(() => converter.asOutputValue("DUMMY")).toThrow("[CDK Adapter] Value found in multiple stacks: stack/nested and stack/otherNested. Pulumi cannot resolve this value."); + }); + }); }); function createUrn(resource: string, logicalId: string): string { From e7d4e7033664cce7f428a091c27385ac14f860e0 Mon Sep 17 00:00:00 2001 From: Florian Stadler Date: Wed, 18 Dec 2024 13:48:46 +0100 Subject: [PATCH 3/4] Add unit tests for graph --- src/graph.ts | 8 +- tests/graph.test.ts | 599 +++++++++++++++++++++++++++++++++++++++++++- tests/utils.ts | 242 ++++++++++++++++++ 3 files changed, 830 insertions(+), 19 deletions(-) diff --git a/src/graph.ts b/src/graph.ts index 7bed7a83..d4a6ec09 100644 --- a/src/graph.ts +++ b/src/graph.ts @@ -567,15 +567,11 @@ export class GraphBuilder { stackPath, }); if (targetNode === undefined) { - // If this is a nested stack, we need to check if the parameter is set by the parent stack and add an edge for the stack resource in the parent stack + // If this is a nested stack, we need to check if the parameter is set by the parent stack if (!this.stack.isRootStack(stackPath)) { const nestedStackNode = this.nestedStackNodes.get(stackPath); if (nestedStackNode?.resource?.Properties?.Parameters?.[targetLogicalId]) { - debug( - `Parameter ${targetLogicalId} is set by the parent stack, adding edge from ${source.construct.path} to ${nestedStackNode.construct.path}`, - ); - source.outgoingEdges.add(nestedStackNode); - nestedStackNode.incomingEdges.add(source); + debug(`Parameter ${targetLogicalId} is set by the parent stack ${stackPath}`); return; } } diff --git a/tests/graph.test.ts b/tests/graph.test.ts index 8280713f..dfb9ea30 100644 --- a/tests/graph.test.ts +++ b/tests/graph.test.ts @@ -14,7 +14,7 @@ import { GraphBuilder, GraphNode } from '../src/graph'; import { ConstructTree, StackManifest, StackManifestProps } from '../src/assembly'; -import { createStackManifest } from './utils'; +import { createNestedStackManifest, createStackManifest } from './utils'; import * as fs from 'fs'; import * as path from 'path'; @@ -26,8 +26,55 @@ describe('GraphBuilder', () => { metadata: { 'stack/example-bucket/Resource': { stackPath: 'stack', id: 'examplebucketC9DFA43E' }, 'stack/example-bucket/Policy/Resource': { stackPath: 'stack', id: 'examplebucketPolicyE09B485E' }, + 'stack/nested.NestedStack/nested.NestedStackResource': { stackPath: 'stack', id: 'nested.NestedStackResource' }, + 'stack/nested/example-bucket/Resource': { stackPath: 'stack/nested', id: 'examplebucketdDE4DBE4F' }, + 'stack/nested/example-bucket/Policy/Resource': { stackPath: 'stack/nested', id: 'examplebucketPolicyC4E3BBE2F' }, + 'stack/output-bucket/Resource': { stackPath: 'stack', id: 'outputbucketC9DFA43E' }, + 'stack/output-bucket/Policy/Resource': { stackPath: 'stack', id: 'outputbucketPolicyE09B485E' }, + }, + nestedStacks: { + 'stack/nested': { + logicalId: 'nested.NestedStack', + Resources: { + examplebucketdDE4DBE4F: { + Type: 'AWS::S3::Bucket', + Properties: { + BucketName: { + "Fn::Join": [ + "", + [ + { + "Ref": "referencetostackexamplebucketC9DFA43ERef" + }, + "-nested" + ] + ] + } + }, + }, + examplebucketPolicyC4E3BBE2F: { + Type: 'AWS::S3::BucketPolicy', + Properties: { + Bucket: { + Ref: 'examplebucketdDE4DBE4F', + }, + }, + }, + }, + Outputs: { + stacknestedexamplebucketdDE4DBE4FRef: { + Value: { + Ref: 'examplebucketdDE4DBE4F', + }, + }, + }, + Parameters: { + referencetostackexamplebucketC9DFA43ERef: { + Type: 'String', + }, + }, + }, }, - nestedStacks: {}, tree: { path: 'stack', id: 'stack', @@ -74,6 +121,117 @@ describe('GraphBuilder', () => { }, }, }, + nested: { + id: 'nested', + path: 'stack/nested', + children: { + 'example-bucket': { + id: 'example-bucket', + path: 'stack/nested/example-bucket', + constructInfo: { + fqn: 'aws-cdk-lib.aws_s3.Bucket', + version: '2.149.0', + }, + children: { + Resource: { + id: 'Resource', + path: 'stack/nested/example-bucket/Resource', + attributes: { + 'aws:cdk:cloudformation:type': 'AWS::S3::Bucket', + }, + constructInfo: { + fqn: 'aws-cdk-lib.aws_s3.CfnBucket', + version: '2.149.0', + }, + }, + Policy: { + id: 'Policy', + path: 'stack/nested/example-bucket/Policy', + children: { + Resource: { + id: 'Resource', + path: 'stack/nested/example-bucket/Policy/Resource', + attributes: { + 'aws:cdk:cloudformation:type': 'AWS::S3::BucketPolicy', + }, + constructInfo: { + fqn: 'aws-cdk-lib.aws_s3.CfnBucketPolicy', + version: '2.149.0', + }, + }, + }, + constructInfo: { + fqn: 'aws-cdk-lib.aws_s3.BucketPolicy', + version: '2.149.0', + }, + }, + }, + }, + }, + constructInfo: { + fqn: 'aws-cdk-lib.NestedStack', + version: '2.149.0', + }, + }, + "nested.NestedStack": { + id: 'nested.NestedStack', + path: 'stack/nested.NestedStack', + children: { + 'nested.NestedStackResource': { + id: 'nested.NestedStackResource', + path: 'stack/nested.NestedStack/nested.NestedStackResource', + attributes: { + 'aws:cdk:cloudformation:type': 'AWS::CloudFormation::Stack', + }, + constructInfo: { + fqn: 'aws-cdk-lib.CfnStack', + version: '2.149.0', + }, + }, + } + }, + 'output-bucket': { + id: 'output-bucket', + path: 'stack/output-bucket', + constructInfo: { + fqn: 'aws-cdk-lib.aws_s3.Bucket', + version: '2.149.0', + }, + children: { + Resource: { + id: 'Resource', + path: 'stack/output-bucket/Resource', + attributes: { + 'aws:cdk:cloudformation:type': 'AWS::S3::Bucket', + }, + constructInfo: { + fqn: 'aws-cdk-lib.aws_s3.CfnBucket', + version: '2.149.0', + }, + }, + Policy: { + id: 'Policy', + path: 'stack/output-bucket/Policy', + children: { + Resource: { + id: 'Resource', + path: 'stack/output-bucket/Policy/Resource', + attributes: { + 'aws:cdk:cloudformation:type': 'AWS::S3::BucketPolicy', + }, + constructInfo: { + fqn: 'aws-cdk-lib.aws_s3.CfnBucketPolicy', + version: '2.149.0', + }, + }, + }, + constructInfo: { + fqn: 'aws-cdk-lib.aws_s3.BucketPolicy', + version: '2.149.0', + }, + }, + }, + }, }, constructInfo: { fqn: 'aws-cdk-lib.Stack', @@ -94,6 +252,43 @@ describe('GraphBuilder', () => { }, }, }, + "nested.NestedStackResource": { + Type: 'AWS::CloudFormation::Stack', + Properties: { + Parameters: { + referencetostackexamplebucketC9DFA43ERef: { + Ref: 'examplebucketC9DFA43E', + } + } + }, + }, + outputbucketC9DFA43E: { + Type: 'AWS::S3::Bucket', + Properties: { + BucketName: { + "Fn::Join": [ + "", + [ + { + "Fn::GetAtt": [ + "nested.NestedStackResource", + "Outputs.stacknestedexamplebucketdDE4DBE4FRef" + ] + }, + "-output" + ] + ] + } + }, + }, + outputbucketPolicyE09B485E: { + Type: 'AWS::S3::BucketPolicy', + Properties: { + Bucket: { + Ref: 'outputbucketC9DFA43E', + }, + }, + }, }, }, dependencies: [], @@ -101,7 +296,6 @@ describe('GraphBuilder', () => { ); test.each([ [ - nodes, 'stack', { construct: { @@ -112,12 +306,11 @@ describe('GraphBuilder', () => { }, resourceAddress: undefined, resource: undefined, - incomingEdges: ['stack/example-bucket'], + incomingEdges: ['stack/example-bucket', 'stack/nested', 'stack/output-bucket'], outgoingEdges: [], }, ], [ - nodes, 'stack/example-bucket', { construct: { @@ -133,7 +326,6 @@ describe('GraphBuilder', () => { }, ], [ - nodes, 'stack/example-bucket/Resource', { construct: { @@ -147,12 +339,11 @@ describe('GraphBuilder', () => { Properties: {}, }, resourceAddress: { stackPath: 'stack', id: 'examplebucketC9DFA43E' }, - incomingEdges: ['stack/example-bucket/Policy/Resource'], + incomingEdges: ['stack/example-bucket/Policy/Resource', 'stack/nested'], outgoingEdges: ['stack/example-bucket'], }, ], [ - nodes, 'stack/example-bucket/Policy', { construct: { @@ -168,7 +359,6 @@ describe('GraphBuilder', () => { }, ], [ - nodes, 'stack/example-bucket/Policy/Resource', { construct: { @@ -190,15 +380,191 @@ describe('GraphBuilder', () => { outgoingEdges: ['stack/example-bucket/Policy', 'stack/example-bucket/Resource'], }, ], - ])('Parses the graph correctly', (graph, path, expected) => { - const actual = graph.nodes.find((node) => node.construct.path === path); + [ + 'stack/nested', + { + construct: { + parent: 'stack', + path: 'stack/nested', + id: 'nested', + type: 'Stack', + }, + resourceAddress: { stackPath: 'stack', id: 'nested.NestedStackResource' }, + resource: { + Type: 'AWS::CloudFormation::Stack', + Properties: { + Parameters: { + referencetostackexamplebucketC9DFA43ERef: { + Ref: 'examplebucketC9DFA43E', + } + } + }, + }, + incomingEdges: ['stack/nested/example-bucket', 'stack/output-bucket/Resource'], + outgoingEdges: ['stack', 'stack/example-bucket/Resource'], // the outgoing edge for 'stack/example-bucket/Resource' is the stack parameter + }, + ], + [ + 'stack/nested/example-bucket', + { + construct: { + parent: 'nested', + path: 'stack/nested/example-bucket', + id: 'example-bucket', + type: 'aws-cdk-lib/aws_s3:Bucket', + }, + resourceAddress: undefined, + resource: undefined, + incomingEdges: ['stack/nested/example-bucket/Resource', 'stack/nested/example-bucket/Policy'], + outgoingEdges: ['stack/nested'], + }, + ], + [ + 'stack/nested/example-bucket/Resource', + { + construct: { + parent: 'example-bucket', + path: 'stack/nested/example-bucket/Resource', + id: 'Resource', + type: 'Bucket', + }, + resourceAddress: { stackPath: 'stack/nested', id: 'examplebucketdDE4DBE4F' }, + resource: { + Type: 'AWS::S3::Bucket', + Properties: { + BucketName: { + "Fn::Join": [ + "", + [ + { + "Ref": "referencetostackexamplebucketC9DFA43ERef" + }, + "-nested" + ] + ] + } + }, + }, + incomingEdges: ['stack/nested/example-bucket/Policy/Resource', 'stack/output-bucket/Resource'], // the incoming edge for 'stack/output-bucket/Resource' is the stack output + outgoingEdges: ['stack/nested/example-bucket'], + }, + ], + [ + 'stack/nested/example-bucket/Policy/Resource', + { + construct: { + parent: 'Policy', + path: 'stack/nested/example-bucket/Policy/Resource', + id: 'Resource', + type: 'BucketPolicy', + }, + resourceAddress: { stackPath: 'stack/nested', id: 'examplebucketPolicyC4E3BBE2F' }, + resource: { + Type: 'AWS::S3::BucketPolicy', + Properties: { + Bucket: { + Ref: 'examplebucketdDE4DBE4F', + }, + }, + }, + incomingEdges: [], + outgoingEdges: ['stack/nested/example-bucket/Policy', 'stack/nested/example-bucket/Resource'], + }, + ], + [ + 'stack/output-bucket', + { + construct: { + parent: 'stack', + path: 'stack/output-bucket', + id: 'output-bucket', + type: 'aws-cdk-lib/aws_s3:Bucket', + }, + resourceAddress: undefined, + resource: undefined, + incomingEdges: ['stack/output-bucket/Resource', 'stack/output-bucket/Policy'], + outgoingEdges: ['stack'], + }, + ], + [ + 'stack/output-bucket/Resource', + { + construct: { + parent: 'output-bucket', + path: 'stack/output-bucket/Resource', + id: 'Resource', + type: 'Bucket', + }, + resourceAddress: { stackPath: 'stack', id: 'outputbucketC9DFA43E' }, + resource: { + Type: 'AWS::S3::Bucket', + Properties: { + BucketName: { + "Fn::Join": [ + "", + [ + { + "Fn::GetAtt": [ + "nested.NestedStackResource", + "Outputs.stacknestedexamplebucketdDE4DBE4FRef" + ] + }, + "-output" + ] + ] + } + }, + }, + incomingEdges: ['stack/output-bucket/Policy/Resource'], + outgoingEdges: ['stack/output-bucket', 'stack/nested', 'stack/nested/example-bucket/Resource'], // the outgoing edge for 'stack/nested/example-bucket/Resource' is the nested stack output + }, + ], + [ + 'stack/output-bucket/Policy', + { + construct: { + parent: 'output-bucket', + path: 'stack/output-bucket/Policy', + id: 'Policy', + type: 'aws-cdk-lib/aws_s3:BucketPolicy', + }, + resourceAddress: undefined, + resource: undefined, + incomingEdges: ['stack/output-bucket/Policy/Resource'], + outgoingEdges: ['stack/output-bucket'], + }, + ], + [ + 'stack/output-bucket/Policy/Resource', + { + construct: { + parent: 'Policy', + path: 'stack/output-bucket/Policy/Resource', + id: 'Resource', + type: 'BucketPolicy', + }, + resourceAddress: { stackPath: 'stack', id: 'outputbucketPolicyE09B485E' }, + resource: { + Type: 'AWS::S3::BucketPolicy', + Properties: { + Bucket: { + Ref: 'outputbucketC9DFA43E', + }, + }, + }, + incomingEdges: [], + outgoingEdges: ['stack/output-bucket/Policy', 'stack/output-bucket/Resource'], + }, + ], + ])('Parses the graph correctly: %s', (path, expected) => { + const actual = nodes.nodes.find((node) => node.construct.path === path); expect(actual).toBeDefined(); expect(actual!.resourceAddress).toEqual(expected.resourceAddress); expect(actual!.resource).toEqual(expected.resource); expect(actual!.construct.parent?.id).toEqual(expected.construct.parent); expect(actual!.construct.type).toEqual(expected.construct.type); - expect(edgesToArray(actual!.incomingEdges)).toEqual(expected.incomingEdges); - expect(edgesToArray(actual!.outgoingEdges)).toEqual(expected.outgoingEdges); + expect(new Set(edgesToArray(actual!.incomingEdges))).toEqual(new Set(expected.incomingEdges)); + expect(new Set(edgesToArray(actual!.outgoingEdges))).toEqual(new Set(expected.outgoingEdges)); }); test.each([ @@ -246,6 +612,213 @@ describe('GraphBuilder', () => { }); }); +test.each([ + [ + 'Ref stack input', + createNestedStackManifest({ + nestedStackResourceProps: { + Parameters: { + parentRef: { Ref: 'parent' }, + } + }, + nestedStackParameters: { + parentRef: { + Type: 'String', + }, + }, + nestedResourceProps: { + BucketName: { + "Fn::Join": [ + "", + [ + { + "Ref": "parentRef" + }, + "-nested" + ] + ] + } + }, + }), + { + stackInput: true, + stackOutput: false, + } + ], + [ + 'GetAtt stack output', + createNestedStackManifest({ + nestedStackOutputs: { + nestedStackOutput: { + Value: { + Ref: 'child', + }, + }, + }, + outputResourceProps: { + BucketName: { + "Fn::Join": [ + "", + [ + { + "Fn::GetAtt": [ + "nested.NestedStackResource", + "Outputs.nestedStackOutput" + ] + }, + "-output" + ] + ] + } + } + }), + { + stackInput: false, + stackOutput: true, + } + ], + [ + 'Ref stack input and GetAtt stack output', + createNestedStackManifest({ + nestedStackResourceProps: { + Parameters: { + parentRef: { Ref: 'parent' }, + } + }, + nestedStackParameters: { + parentRef: { + Type: 'String', + }, + }, + nestedResourceProps: { + BucketName: { + "Fn::Join": [ + "", + [ + { + "Ref": "parentRef" + }, + "-nested" + ] + ] + } + }, + nestedStackOutputs: { + nestedStackOutput: { + Value: { + Ref: 'child', + }, + }, + }, + outputResourceProps: { + BucketName: { + "Fn::Join": [ + "", + [ + { + "Fn::GetAtt": [ + "nested.NestedStackResource", + "Outputs.nestedStackOutput" + ] + }, + "-output" + ] + ] + } + } + }), + { + stackInput: true, + stackOutput: true, + } + ], + [ + 'Sub-Ref stack input', + createNestedStackManifest({ + nestedStackResourceProps: { + Parameters: { + parentRef: { 'Fn::Sub': ['test.${Domain}', { Domain: { Ref: 'parent' } }] }, + } + }, + nestedStackParameters: { + parentRef: { + Type: 'String', + }, + }, + nestedResourceProps: { + BucketName: { 'Fn::Sub': ['sub.${Domain}', { Domain: { Ref: 'parentRef' } }] } + }, + }), + { + stackInput: true, + stackOutput: false, + } + ], + [ + 'Sub-GetAtt stack output', + createNestedStackManifest({ + nestedStackOutputs: { + nestedStackOutput: { + Value: { 'Fn::Sub': ['test.${Domain}', { Domain: { Ref: 'child' } }] }, + }, + }, + outputResourceProps: { + BucketName: { + 'Fn::Sub': ['sub.${Domain}', { + Domain: { + "Fn::GetAtt": [ + "nested.NestedStackResource", + "Outputs.nestedStackOutput" + ] + } + }] + }, + } + }), + { + stackInput: false, + stackOutput: true, + } + ] +])('nested stack adds edge for %s', (_name, stackManifest, expected) => { + const graph = GraphBuilder.build(stackManifest).nodes; + const childNode = graph.find((node) => node.construct.path === 'stack/nested/example-bucket/Resource'); + const parentNode = graph.find((node) => node.construct.path === 'stack/example-bucket/Resource'); + const outputNode = graph.find((node) => node.construct.path === 'stack/output-bucket/Resource'); + const nestedStackNode = graph.find((node) => node.construct.path === 'stack/nested'); + + expect(new Set(edgesToArray(parentNode!.outgoingEdges))).toEqual(new Set(['stack/example-bucket'])); + expect(new Set(edgesToArray(childNode!.outgoingEdges))).toEqual(new Set(['stack/nested/example-bucket'])); + expect(new Set(edgesToArray(outputNode!.incomingEdges))).toEqual(new Set(['stack/output-bucket/Policy/Resource'])); + + if (expected.stackInput) { + // The nested stack should depend on its inputs + expect(new Set(edgesToArray(parentNode!.incomingEdges))).toEqual(new Set(['stack/nested', 'stack/example-bucket/Policy/Resource'])); + } else { + expect(new Set(edgesToArray(parentNode!.incomingEdges))).toEqual(new Set(['stack/example-bucket/Policy/Resource'])); + } + + if (expected.stackOutput) { + // the resource using the nested stack output should depend on the nested stack and the child resource + expect(new Set(edgesToArray(nestedStackNode!.incomingEdges))).toEqual(new Set(['stack/nested/example-bucket', 'stack/output-bucket/Resource'])); + expect(new Set(edgesToArray(childNode!.incomingEdges))).toEqual(new Set(['stack/nested/example-bucket/Policy/Resource', 'stack/output-bucket/Resource'])); + expect(new Set(edgesToArray(outputNode!.outgoingEdges))).toEqual(new Set(['stack/output-bucket', 'stack/nested', 'stack/nested/example-bucket/Resource'])); + } else { + expect(new Set(edgesToArray(nestedStackNode!.incomingEdges))).toEqual(new Set(['stack/nested/example-bucket'])); + expect(new Set(edgesToArray(childNode!.incomingEdges))).toEqual(new Set(['stack/nested/example-bucket/Policy/Resource'])); + expect(new Set(edgesToArray(outputNode!.outgoingEdges))).toEqual(new Set(['stack/output-bucket'])); + } + + // Nested stack outgoing edges depend on inputs and outputs + if (expected.stackInput && expected.stackOutput) { + expect(new Set(edgesToArray(nestedStackNode!.outgoingEdges))).toEqual(new Set(['stack', 'stack/example-bucket/Resource'])); + } else if (expected.stackInput) { + expect(new Set(edgesToArray(nestedStackNode!.outgoingEdges))).toEqual(new Set(['stack', 'stack/example-bucket/Resource'])); + } else if (expected.stackOutput) { + expect(new Set(edgesToArray(nestedStackNode!.outgoingEdges))).toEqual(new Set(['stack'])); + } +}); + test('vpc with ipv6 cidr block', () => { const nodes = GraphBuilder.build( new StackManifest({ diff --git a/tests/utils.ts b/tests/utils.ts index fe915426..6804a217 100644 --- a/tests/utils.ts +++ b/tests/utils.ts @@ -59,3 +59,245 @@ export function createStackManifest(props: CreateStackManifestProps): StackManif dependencies: [], }); } + +export interface NestedStackManifestProps { + nestedResourceProps?: any; + outputResourceProps?: any; + nestedStackResourceProps?: any; + nestedStackOutputs?: any; + nestedStackProperties?: any; + nestedStackParameters?: any; +} + +export function createNestedStackManifest(props: NestedStackManifestProps): StackManifest { + return new StackManifest({ + id: 'stack', + templatePath: 'test/stack', + metadata: { + 'stack/example-bucket/Resource': { stackPath: 'stack', id: 'parent' }, + 'stack/example-bucket/Policy/Resource': { stackPath: 'stack', id: 'parentPolicy' }, + 'stack/nested.NestedStack/nested.NestedStackResource': { stackPath: 'stack', id: 'nested.NestedStackResource' }, + 'stack/nested/example-bucket/Resource': { stackPath: 'stack/nested', id: 'child' }, + 'stack/nested/example-bucket/Policy/Resource': { stackPath: 'stack/nested', id: 'childPolicy' }, + 'stack/output-bucket/Resource': { stackPath: 'stack', id: 'output' }, + 'stack/output-bucket/Policy/Resource': { stackPath: 'stack', id: 'outputPolicy' }, + }, + nestedStacks: { + 'stack/nested': { + logicalId: 'nested.NestedStack', + Resources: { + child: { + Type: 'AWS::S3::Bucket', + Properties: props.nestedResourceProps, + }, + childPolicy: { + Type: 'AWS::S3::BucketPolicy', + Properties: { + Bucket: { + Ref: 'child', + }, + }, + }, + }, + Outputs: props.nestedStackOutputs, + Parameters: props.nestedStackParameters, + }, + }, + tree: { + path: 'stack', + id: 'stack', + children: { + 'example-bucket': { + id: 'example-bucket', + path: 'stack/example-bucket', + constructInfo: { + fqn: 'aws-cdk-lib.aws_s3.Bucket', + version: '2.149.0', + }, + children: { + Resource: { + id: 'Resource', + path: 'stack/example-bucket/Resource', + attributes: { + 'aws:cdk:cloudformation:type': 'AWS::S3::Bucket', + }, + constructInfo: { + fqn: 'aws-cdk-lib.aws_s3.CfnBucket', + version: '2.149.0', + }, + }, + Policy: { + id: 'Policy', + path: 'stack/example-bucket/Policy', + children: { + Resource: { + id: 'Resource', + path: 'stack/example-bucket/Policy/Resource', + attributes: { + 'aws:cdk:cloudformation:type': 'AWS::S3::BucketPolicy', + }, + constructInfo: { + fqn: 'aws-cdk-lib.aws_s3.CfnBucketPolicy', + version: '2.149.0', + }, + }, + }, + constructInfo: { + fqn: 'aws-cdk-lib.aws_s3.BucketPolicy', + version: '2.149.0', + }, + }, + }, + }, + nested: { + id: 'nested', + path: 'stack/nested', + children: { + 'example-bucket': { + id: 'example-bucket', + path: 'stack/nested/example-bucket', + constructInfo: { + fqn: 'aws-cdk-lib.aws_s3.Bucket', + version: '2.149.0', + }, + children: { + Resource: { + id: 'Resource', + path: 'stack/nested/example-bucket/Resource', + attributes: { + 'aws:cdk:cloudformation:type': 'AWS::S3::Bucket', + }, + constructInfo: { + fqn: 'aws-cdk-lib.aws_s3.CfnBucket', + version: '2.149.0', + }, + }, + Policy: { + id: 'Policy', + path: 'stack/nested/example-bucket/Policy', + children: { + Resource: { + id: 'Resource', + path: 'stack/nested/example-bucket/Policy/Resource', + attributes: { + 'aws:cdk:cloudformation:type': 'AWS::S3::BucketPolicy', + }, + constructInfo: { + fqn: 'aws-cdk-lib.aws_s3.CfnBucketPolicy', + version: '2.149.0', + }, + }, + }, + constructInfo: { + fqn: 'aws-cdk-lib.aws_s3.BucketPolicy', + version: '2.149.0', + }, + }, + }, + }, + }, + constructInfo: { + fqn: 'aws-cdk-lib.NestedStack', + version: '2.149.0', + }, + }, + "nested.NestedStack": { + id: 'nested.NestedStack', + path: 'stack/nested.NestedStack', + children: { + 'nested.NestedStackResource': { + id: 'nested.NestedStackResource', + path: 'stack/nested.NestedStack/nested.NestedStackResource', + attributes: { + 'aws:cdk:cloudformation:type': 'AWS::CloudFormation::Stack', + }, + constructInfo: { + fqn: 'aws-cdk-lib.CfnStack', + version: '2.149.0', + }, + }, + } + }, + 'output-bucket': { + id: 'output-bucket', + path: 'stack/output-bucket', + constructInfo: { + fqn: 'aws-cdk-lib.aws_s3.Bucket', + version: '2.149.0', + }, + children: { + Resource: { + id: 'Resource', + path: 'stack/output-bucket/Resource', + attributes: { + 'aws:cdk:cloudformation:type': 'AWS::S3::Bucket', + }, + constructInfo: { + fqn: 'aws-cdk-lib.aws_s3.CfnBucket', + version: '2.149.0', + }, + }, + Policy: { + id: 'Policy', + path: 'stack/output-bucket/Policy', + children: { + Resource: { + id: 'Resource', + path: 'stack/output-bucket/Policy/Resource', + attributes: { + 'aws:cdk:cloudformation:type': 'AWS::S3::BucketPolicy', + }, + constructInfo: { + fqn: 'aws-cdk-lib.aws_s3.CfnBucketPolicy', + version: '2.149.0', + }, + }, + }, + constructInfo: { + fqn: 'aws-cdk-lib.aws_s3.BucketPolicy', + version: '2.149.0', + }, + }, + }, + }, + }, + constructInfo: { + fqn: 'aws-cdk-lib.Stack', + version: '2.149.0', + }, + }, + template: { + Resources: { + parent: { + Type: 'AWS::S3::Bucket', + Properties: {}, + }, + parentPolicy: { + Type: 'AWS::S3::BucketPolicy', + Properties: { + Bucket: { + Ref: 'parent', + }, + }, + }, + "nested.NestedStackResource": { + Type: 'AWS::CloudFormation::Stack', + Properties: props.nestedStackResourceProps, + }, + output: { + Type: 'AWS::S3::Bucket', + Properties: props.outputResourceProps + }, + outputPolicy: { + Type: 'AWS::S3::BucketPolicy', + Properties: { + Bucket: { + Ref: 'output', + }, + }, + }, + }, + }, + dependencies: [], + }); +} From 1a72004f6d3bb601bd4dab035ad94a5020e003ee Mon Sep 17 00:00:00 2001 From: Florian Stadler Date: Wed, 18 Dec 2024 14:17:08 +0100 Subject: [PATCH 4/4] Add tests for intrinsics to make sure they're picking the right stack --- tests/converters/intrinsics.test.ts | 83 +++++++++++++++++++++++++++++ 1 file changed, 83 insertions(+) diff --git a/tests/converters/intrinsics.test.ts b/tests/converters/intrinsics.test.ts index 57776d17..1f0459b0 100644 --- a/tests/converters/intrinsics.test.ts +++ b/tests/converters/intrinsics.test.ts @@ -35,6 +35,15 @@ describe('Fn::If', () => { const result = runIntrinsic(intrinsics.fnIf, tc, ['MyCondition', 'yes', 'no'], 'test-stack'); expect(result).toEqual(failed(`Expected a boolean, got string`)); }); + + test('picks condition from correct stack', async () => { + const tc = new TestContext({ conditions: { + 'test-stack': { MyCondition: false } , + 'nested-stack': { MyCondition: true } + }}); + const result = runIntrinsic(intrinsics.fnIf, tc, ['MyCondition', 'yes', 'no'], 'nested-stack'); + expect(result).toEqual(ok('yes')); + }); }); describe('Fn::Or', () => { @@ -73,6 +82,15 @@ describe('Fn::Or', () => { const result = runIntrinsic(intrinsics.fnOr, tc, [true, { Condition: 'DoesNotExist' }], 'test-stack'); expect(result).toEqual(ok(true)); }); + + test('picks condition from correct stack', async () => { + const tc = new TestContext({ conditions: { + 'test-stack': { MyCondition: false } , + 'nested-stack': { MyCondition: true } + }}); + const result = runIntrinsic(intrinsics.fnOr, tc, [false, { Condition: 'MyCondition' }], 'nested-stack'); + expect(result).toEqual(ok(true)); + }); }); describe('Fn::And', () => { @@ -111,6 +129,17 @@ describe('Fn::And', () => { const result = runIntrinsic(intrinsics.fnAnd, tc, [false, { Condition: 'DoesNotExist' }], 'test-stack'); expect(result).toEqual(ok(false)); }); + + test('picks condition from correct stack', async () => { + const tc = new TestContext({ conditions: { + 'test-stack': { MyCondition: false } , + 'nested-stack': { MyCondition: true } + }}); + let result = runIntrinsic(intrinsics.fnAnd, tc, [false, { Condition: 'MyCondition' }], 'nested-stack'); + expect(result).toEqual(ok(false)); + result = runIntrinsic(intrinsics.fnAnd, tc, [true, { Condition: 'MyCondition' }], 'nested-stack'); + expect(result).toEqual(ok(true)); + }); }); describe('Fn::Not', () => { @@ -143,6 +172,15 @@ describe('Fn::Not', () => { const result = runIntrinsic(intrinsics.fnNot, tc, ['ok'], 'test-stack'); expect(result).toEqual(failed(`Expected a boolean, got string`)); }); + + test('picks condition from correct stack', async () => { + const tc = new TestContext({ conditions: { + 'test-stack': { MyCondition: false } , + 'nested-stack': { MyCondition: true } + }}); + const result = runIntrinsic(intrinsics.fnNot, tc, [{ Condition: 'MyCondition' }], 'nested-stack'); + expect(result).toEqual(ok(false)); + }); }); describe('Fn::Equals', () => { @@ -175,6 +213,15 @@ describe('Fn::Equals', () => { const result = runIntrinsic(intrinsics.fnEquals, tc, [1], 'test-stack'); expect(result).toEqual(failed(`Fn::Equals expects exactly 2 params, got 1`)); }); + + test('preserves stack path', async () => { + const tc = new TestContext({ conditions: { + 'test-stack': { MyCondition: false } , + 'nested-stack': { MyCondition: true } + }}); + const result = runIntrinsic(intrinsics.fnEquals, tc, ['yes', { 'Fn::If': ['MyCondition', 'yes', 'no'] }], 'nested-stack'); + expect(result).toEqual(ok(true)); + }); }); describe('Ref', () => { @@ -384,6 +431,42 @@ describe('Ref', () => { expect(runIntrinsic(intrinsics.ref, tc, ['AWS::StackId'], 'test-stack')).toEqual(ok(stackNodeId)); expect(runIntrinsic(intrinsics.ref, tc, ['AWS::StackName'], 'test-stack')).toEqual(ok(stackNodeId)); }); + + test('resolves resource in correct stack', async () => { + const tc = new TestContext({ + resources: { + 'test-stack': { + MyRes: { + resource: { + bucketName: "parent-bucket", + __pulumiType: (ccapi.s3.Bucket).__pulumiType, + }, + resourceType: 'AWS::S3::Bucket', + }, + }, + 'nested-stack': { + MyRes: { + resource: { + bucketName: "nested-bucket", + __pulumiType: (ccapi.s3.Bucket).__pulumiType, + }, + resourceType: 'AWS::S3::Bucket', + }, + }, + }, + pulumiMetadata: { + 'AWS::S3::Bucket': { + inputs: {}, + outputs: {}, + cfRef: { + properties: ['BucketName'], + }, + }, + }, + }); + const result = runIntrinsic(intrinsics.ref, tc, ['MyRes'], 'nested-stack'); + expect(result).toEqual(ok('nested-bucket')); + }); }); function runIntrinsic(fn: intrinsics.Intrinsic, tc: TestContext, args: intrinsics.Expression[], stackPath: string): TestResult {