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

Refactor to use cloud assembly #167

Merged
merged 8 commits into from
Oct 7, 2024
Merged
Show file tree
Hide file tree
Changes from 5 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
18 changes: 12 additions & 6 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,40 +21,46 @@
}
},
"resolutions": {
"wrap-ansi": "7.0.0",
"string-width": "4.1.0"
"wrap-ansi": "7.0.0",
"string-width": "4.1.0"
},
"devDependencies": {
"@aws-cdk/aws-apprunner-alpha": "2.20.0-alpha.0",
"@pulumi/aws": "^6.32.0",
"@pulumi/aws-native": "0.121.0",
"@pulumi/docker": "^4.5.0",
"@pulumi/pulumi": "^3.117.0",
"@types/archiver": "^6.0.2",
"@types/fs-extra": "^11.0.4",
"@types/jest": "^29.5.2",
"@types/mock-fs": "^4.13.4",
"@types/node": "^20.12.13",
"aws-cdk-lib": "2.149.0",
"constructs": "^10.0.111",
"eslint": "^8.57.0",
"eslint-config-prettier": "^9.1.0",
"typescript-eslint": "^7.16.1",
"jest": "^29.5.0",
"jest-junit": "^15",
"mock-fs": "^5.3.0",
"prettier": "^2.6.2",
"ts-jest": "^29.1.0",
"ts-node": "^10.9.2",
"typescript": "^5.4.5"
"typescript": "^5.4.5",
"typescript-eslint": "^7.16.1"
},
"peerDependencies": {
"@pulumi/aws": "^6.32.0",
"@pulumi/aws-native": "^0.121.0",
"@pulumi/docker": "^4.5.0",
"@pulumi/pulumi": "^3.117.0",
"aws-cdk-lib": "^2.20.0",
"constructs": "^10.0.111"
},
"dependencies": {
"@pulumi/aws-native": "0.121.0",
"@types/glob": "^8.1.0",
"archiver": "^7.0.1"
"archiver": "^7.0.1",
"cdk-assets": "^2.154.8",
"fs-extra": "^11.2.0"
},
"scripts": {
"set-version": "sed -i.bak -e \"s/\\${VERSION}/$(pulumictl get version --language javascript)/g\" package.json && rm package.json.bak",
Expand Down
3 changes: 3 additions & 0 deletions src/assembly/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export * from './stack';
export * from './types';
export * from './manifest';
166 changes: 166 additions & 0 deletions src/assembly/manifest.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
import * as path from 'path';
import {
AssemblyManifest,
Manifest,
ArtifactType,
AwsCloudFormationStackProperties,
ArtifactMetadataEntryType,
} from '@aws-cdk/cloud-assembly-schema';
import * as fs from 'fs-extra';
import { CloudFormationTemplate } from '../cfn';
import { ArtifactManifest, AssetManifestProperties, LogicalIdMetadataEntry } from 'aws-cdk-lib/cloud-assembly-schema';
import { CloudAssembly, CloudFormationStackArtifact } from 'aws-cdk-lib/cx-api';
import { AssetManifest, DockerImageManifestEntry, FileManifestEntry } from 'cdk-assets';
import { StackManifest } from './stack';
import { ConstructTree, StackAsset, StackMetadata } from './types';

/**
* Reads a Cloud Assembly manifest
*/
export class AssemblyManifestReader {
public static readonly DEFAULT_FILENAME = 'manifest.json';

/**
* Reads a Cloud Assembly manifest from a file
*/
public static fromFile(fileName: string): AssemblyManifestReader {
try {
const obj = Manifest.loadAssemblyManifest(fileName);
return new AssemblyManifestReader(path.dirname(fileName), obj);
} catch (e: any) {
throw new Error(`Cannot read manifest '${fileName}': ${e.message}`);
corymhall marked this conversation as resolved.
Show resolved Hide resolved
}
}

/**
* Reads a Cloud Assembly manifest from a file or a directory
* If the given filePath is a directory then it will look for
* a file within the directory with the DEFAULT_FILENAME
*/
public static fromPath(filePath: string): AssemblyManifestReader {
corymhall marked this conversation as resolved.
Show resolved Hide resolved
let st;
try {
st = fs.statSync(filePath);
} catch (e: any) {
throw new Error(`Cannot read manifest at '${filePath}': ${e.message}`);
}
if (st.isDirectory()) {
return AssemblyManifestReader.fromFile(path.join(filePath, AssemblyManifestReader.DEFAULT_FILENAME));
}
return AssemblyManifestReader.fromFile(filePath);
}

/**
* The directory where the manifest was found
*/
public readonly directory: string;
corymhall marked this conversation as resolved.
Show resolved Hide resolved

private readonly assembly: CloudAssembly;
private readonly stacks = new Map<string, CloudFormationStackArtifact>();
corymhall marked this conversation as resolved.
Show resolved Hide resolved
private readonly _stackManifests = new Map<string, StackManifest>();
private readonly tree: ConstructTree;

constructor(directory: string, private readonly manifest: AssemblyManifest) {
this.directory = directory;
this.assembly = new CloudAssembly(directory, {
// we don't need version checking / version checking would mean we would have to
// publish a new version of the library everytime the cdk version increases
skipVersionCheck: true,
corymhall marked this conversation as resolved.
Show resolved Hide resolved
});
this.tree = fs.readJsonSync(path.resolve(this.directory, 'tree.json')).tree;
corymhall marked this conversation as resolved.
Show resolved Hide resolved
if (!this.tree.children) {
throw new Error('Invalid tree.json found');
}
this.renderStackManifest();
}

private renderStackManifest() {
corymhall marked this conversation as resolved.
Show resolved Hide resolved
for (const [artifactId, artifact] of Object.entries(this.manifest.artifacts ?? {})) {
if (artifact.type === ArtifactType.AWS_CLOUDFORMATION_STACK) {
const stackArtifact = this.assembly.getStackArtifact(artifactId);
this.stacks.set(artifactId, stackArtifact);
const metadata: StackMetadata = {};
const props = artifact.properties as AwsCloudFormationStackProperties;
corymhall marked this conversation as resolved.
Show resolved Hide resolved
const template: CloudFormationTemplate = fs.readJSONSync(
corymhall marked this conversation as resolved.
Show resolved Hide resolved
path.resolve(this.directory, props.templateFile),
);
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 `/`
corymhall marked this conversation as resolved.
Show resolved Hide resolved
const path = metadataId.startsWith('/') ? metadataId.substring(1) : metadataId;
metadata[path] = meta.data as LogicalIdMetadataEntry;
}
});
}
const assets = this.getAssetsForStack(artifactId);
const stackTree = this.tree.children![artifactId];
corymhall marked this conversation as resolved.
Show resolved Hide resolved
const stackManifest = new StackManifest(
this.directory,
artifactId,
props.templateFile,
metadata,
stackTree,
template,
assets,
);
this._stackManifests.set(artifactId, stackManifest);
}
}
}

/**
* Get the stacks from the Cloud Assembly
*
* @returns List of CloudFormationStackArtifacts available in the Cloud Assembly
*/
public get stackManifests(): StackManifest[] {
return Array.from(this._stackManifests.values());
}

/**
* Return a list of assets for a given stack
*
* @param stackId - The artifactId of the stack to find assets for
* @returns a list of `StackAsset` for the given stack
*/
private getAssetsForStack(stackId: string): StackAsset[] {
const assets: (FileManifestEntry | DockerImageManifestEntry)[] = [];
for (const artifact of Object.values(this.manifest.artifacts ?? {})) {
if (
artifact.type === ArtifactType.ASSET_MANIFEST &&
(artifact.properties as AssetManifestProperties)?.file === `${stackId}.assets.json`
) {
assets.push(...this.assetsFromAssetManifest(artifact));
}
}
return assets;
}

/**
* Get a list of assets from the asset manifest.
*
* @param artifact - An ArtifactManifest to extract individual assets from
* @returns a list of file and docker assets found in the manifest
corymhall marked this conversation as resolved.
Show resolved Hide resolved
*/
private assetsFromAssetManifest(artifact: ArtifactManifest): StackAsset[] {
t0yv0 marked this conversation as resolved.
Show resolved Hide resolved
const assets: (FileManifestEntry | DockerImageManifestEntry)[] = [];
const fileName = (artifact.properties as AssetManifestProperties).file;
const assetManifest = AssetManifest.fromFile(path.join(this.directory, fileName));
assetManifest.entries.forEach((entry) => {
if (entry.type === 'file') {
const source = (entry as FileManifestEntry).source;
// This will ignore template assets
if (source.path && source.path.startsWith('asset.')) {
assets.push(entry as FileManifestEntry);
}
} else if (entry.type === 'docker-image') {
const source = (entry as DockerImageManifestEntry).source;
if (source.directory && source.directory.startsWith('asset.')) {
assets.push(entry as DockerImageManifestEntry);
}
}
});
return assets;
}
}
162 changes: 162 additions & 0 deletions src/assembly/stack.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
import * as path from 'path';
import { DestinationIdentifier, FileManifestEntry } from 'cdk-assets';
import { CloudFormationParameter, CloudFormationResource, CloudFormationTemplate } from '../cfn';
import { ConstructTree, StackAsset, StackMetadata } from './types';
import { FileAssetPackaging, FileDestination } from 'aws-cdk-lib/cloud-assembly-schema';

/**
* FileAssetManifest represents a CDK File asset.
* It is a helper class that is used to better represent a file asset
* in a way that this library requires
*/
export class FileAssetManifest {
/**
* The destination of the file asset (i.e. where the file needs to be published)
*/
public readonly destination: FileDestination;

/**
* The destination id
*/
public readonly id: DestinationIdentifier;

/**
* Absolute path to the asset
*/
public readonly path: string;
public readonly packaging: FileAssetPackaging;

/**
* @param directory - The directory in which the file manifest is found
* @param asset - The file asset
*/
constructor(directory: string, asset: FileManifestEntry) {
this.destination = asset.destination;
this.id = asset.id;
if (asset.source.executable) {
throw new Error(`file assets produced by commands are not yet supported`);
}
this.path = path.join(directory, asset.source.path!);
this.packaging = asset.source.packaging ?? FileAssetPackaging.FILE;
}
}

/**
* StackManifest represents a single Stack that needs to be converted
* It contains all the necessary information for this library to fully convert
* the resources and assets in the stack to pulumi resources
*/
export class StackManifest {
/**
* The artifactId / stackId of the stack
*/
public id: string;

/**
* The construct tree for the stack
*/
public readonly constructTree: ConstructTree;

/**
* The relative path to the stack template file
*/
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
*/
private readonly resources: { [logicalId: string]: CloudFormationResource };

/**
*
*/
private readonly metadata: StackMetadata;
private readonly assets: StackAsset[];
private readonly directory: string;
constructor(
directory: string,
id: string,
templatePath: string,
metadata: StackMetadata,
tree: ConstructTree,
template: CloudFormationTemplate,
assets: StackAsset[],
) {
this.directory = directory;
this.assets = assets;
this.outputs = template.Outputs;
this.parameters = template.Parameters;
this.metadata = metadata;
this.templatePath = templatePath;
this.id = id;
this.constructTree = tree;
if (!template.Resources) {
throw new Error('CloudFormation template has no resources!');
}
this.resources = template.Resources;
}

public get fileAssets(): FileAssetManifest[] {
return this.assets
.filter((asset) => asset.type === 'file')
.flatMap((asset) => new FileAssetManifest(this.directory, asset));
}

// TODO: implement docker assets
corymhall marked this conversation as resolved.
Show resolved Hide resolved
// public get dockerAssets(): DockerAssetManifest[] {
//
// }

/**
* Get the CloudFormation logicalId 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
*/
public logicalIdForPath(path: string): string {
if (path in this.metadata) {
return this.metadata[path];
}
throw new Error(`Could not find logicalId for path ${path}`);
}

/**
* Get the CloudFormation template fragment of the resource with the given
* logicalId
*
* @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];
}
throw new Error(`Could not find resource with logicalId '${logicalId}'`);
}

/**
* Get the CloudFormation template fragment of the resource with the given
* CDK construct path
*
* @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];
}
throw new Error(`Could not find resource with logicalId '${logicalId}'`);
}
}
Loading
Loading