Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Enhance CDK support with nested stack handling #295

Merged
merged 4 commits into from
Dec 18, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
100 changes: 95 additions & 5 deletions src/assembly/manifest.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -72,39 +72,129 @@ 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,
metadata,
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),
};
}
});
}
Expand Down
143 changes: 84 additions & 59 deletions src/assembly/stack.ts
Original file line number Diff line number Diff line change
@@ -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';

/**
Expand Down Expand Up @@ -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 };
}

/**
Expand All @@ -100,93 +99,119 @@ 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;
this.constructTree = props.tree;
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 {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: type aliaseses instead of string for these things can be helpful to clarify at type system level what's what.

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}'`);
}
}
11 changes: 10 additions & 1 deletion src/assembly/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Perhaps ResourceAddress? StackAddress makes it sound like it identifies a Stack. Doc comments on props are also nice, with examples.

id: string;
stackPath: string;
};

/**
* ConstructTree is a tree of the current CDK construct
Expand Down
Loading
Loading