Skip to content

Commit

Permalink
Enhance CloudFormation support with nested stack handling and stack a…
Browse files Browse the repository at this point in the history
…ddress integration

- 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.
  • Loading branch information
flostadler committed Dec 17, 2024
1 parent 83cdafa commit 2caf7b8
Show file tree
Hide file tree
Showing 28 changed files with 4,335 additions and 599 deletions.
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 {
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 = {
id: string;
stackPath: string;
};

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

0 comments on commit 2caf7b8

Please sign in to comment.