Skip to content

Commit

Permalink
feat(core): make StackSynthesizer easier to subclass (#22308)
Browse files Browse the repository at this point in the history
`StackSynthesizer`s used to make heavy use of private APIs, which make it impossible for people outside the CDK core framework to implement their own Stack Synthesizers and modify the way assets are referenced. This question comes up more and more, so this PR opens up the facilities for doing that.

The changes are as follows:

- Implement `bind()` in `StackSynthesizer`, and add a getter called `boundStack` which will return the bound stack.
- Change `synthesizeStackTemplate -> synthesizeTemplate`, `emitStackArtifact -> emitArtifact`, both of which will implicitly operate on the bound stack.
- Add `addBootstrapVersionRule` so that any subclass can choose to use this or not.
- Expose `AssetManifestBuilder`, which stack synthesizers can use to build an asset manifest. Refactor the class to do less at the same time.
- Expose `cloudFormationLocationFrom...Asset` functions to translate a published asset location into a CloudFormation location.
- Document the contract of each method better.

The end result is that it will be easier to mix and match the different behaviors of the existing synthesizers together into new custom behavior.

Two new unit test files have been added to show how it is possible to write ONE intruction to the asset manifest (to upload assets to a given location) while returning a completely different instruction to the template (referencing the asset bucket using a CloudFormation parameter).


----

*By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license*
  • Loading branch information
rix0rrr committed Oct 3, 2022
1 parent ae7b150 commit 8b2b381
Show file tree
Hide file tree
Showing 17 changed files with 771 additions and 408 deletions.
94 changes: 94 additions & 0 deletions packages/@aws-cdk/aws-ecr-assets/test/custom-synthesis.test.ts
@@ -0,0 +1,94 @@
/**
* This file asserts that it is possible to write a custom stacksynthesizer that will synthesize
* ONE thing to the asset manifest, while returning another thing (including tokens) to the
* CloudFormation template -- without reaching into the library internals
*/

import * as path from 'path';
import { Template } from '@aws-cdk/assertions';
import { StackSynthesizer, FileAssetSource, FileAssetLocation, DockerImageAssetSource, DockerImageAssetLocation, ISynthesisSession, App, Stack, AssetManifestBuilder, CfnParameter, CfnResource } from '@aws-cdk/core';
import { AssetManifestArtifact } from '@aws-cdk/cx-api';
import { DockerImageAsset } from '../lib';

test('use custom synthesizer', () => {
// GIVEN
const app = new App();
const stack = new Stack(app, 'Stack', {
synthesizer: new CustomSynthesizer(),
});

// WHEN
const asset = new DockerImageAsset(stack, 'MyAsset', {
directory: path.join(__dirname, 'demo-image'),
});
new CfnResource(stack, 'TestResource', {
type: 'CDK::TestResource',
properties: {
ImageUri: asset.imageUri,
ImageTag: asset.imageTag,
},
});

// THEN
const assembly = app.synth();
const stackArtifact = assembly.getStackArtifact(stack.artifactId);
const assetArtifact = stackArtifact.dependencies[0] as AssetManifestArtifact;

const stackTemplate = Template.fromJSON(stackArtifact.template);
stackTemplate.hasResourceProperties('CDK::TestResource', {
ImageUri: { 'Fn::Sub': '${AWS::AccountId}.dkr.ecr.${AWS::Region}.${AWS::URLSuffix}/${RepositoryName}:0a3355be12051c9984bf2b0b2bba4e6ea535968e5b6e7396449701732fe5ed14' },
ImageTag: '0a3355be12051c9984bf2b0b2bba4e6ea535968e5b6e7396449701732fe5ed14',
});

expect(assetArtifact.contents).toEqual(expect.objectContaining({
dockerImages: expect.objectContaining({
'0a3355be12051c9984bf2b0b2bba4e6ea535968e5b6e7396449701732fe5ed14': {
destinations: {
'current_account-current_region': {
repositoryName: 'write-repo',
imageTag: '0a3355be12051c9984bf2b0b2bba4e6ea535968e5b6e7396449701732fe5ed14',
},
},
source: {
directory: 'asset.0a3355be12051c9984bf2b0b2bba4e6ea535968e5b6e7396449701732fe5ed14',
},
},
}),
}));
});

class CustomSynthesizer extends StackSynthesizer {
private readonly manifest = new AssetManifestBuilder();
private parameter?: CfnParameter;

bind(stack: Stack) {
super.bind(stack);

this.parameter = new CfnParameter(stack, 'RepositoryName');
}

addFileAsset(asset: FileAssetSource): FileAssetLocation {
void(asset);
throw new Error('file assets not supported here');
}

addDockerImageAsset(asset: DockerImageAssetSource): DockerImageAssetLocation {
const dest = this.manifest.defaultAddDockerImageAsset(this.boundStack, asset, {
repositoryName: 'write-repo',
});
return this.cloudFormationLocationFromDockerImageAsset({
...dest,
repositoryName: ['${', this.parameter!.logicalId, '}'].join(''),
});
}

synthesize(session: ISynthesisSession): void {
// NOTE: Explicitly not adding template to asset manifest
this.synthesizeTemplate(session);
const assetManifestId = this.manifest.emitManifest(this.boundStack, session);

this.emitArtifact(session, {
additionalDependencies: [assetManifestId],
});
}
}
99 changes: 99 additions & 0 deletions packages/@aws-cdk/aws-s3-assets/test/custom-synthesis.test.ts
@@ -0,0 +1,99 @@
/**
* This file asserts that it is possible to write a custom stacksynthesizer that will synthesize
* ONE thing to the asset manifest, while returning another thing (including tokens) to the
* CloudFormation template -- without reaching into the library internals
*/

import * as path from 'path';
import { Template } from '@aws-cdk/assertions';
import { StackSynthesizer, FileAssetSource, FileAssetLocation, DockerImageAssetSource, DockerImageAssetLocation, ISynthesisSession, App, Stack, AssetManifestBuilder, CfnParameter, CfnResource } from '@aws-cdk/core';
import { AssetManifestArtifact } from '@aws-cdk/cx-api';
import { Asset } from '../lib';

test('use custom synthesizer', () => {
// GIVEN
const app = new App();
const stack = new Stack(app, 'Stack', {
synthesizer: new CustomSynthesizer(),
});

// WHEN
const asset = new Asset(stack, 'MyAsset', {
path: path.join(__dirname, 'file-asset.txt'),
});
new CfnResource(stack, 'TestResource', {
type: 'CDK::TestResource',
properties: {
Bucket: asset.s3BucketName,
ObjectKey: asset.s3ObjectKey,
S3Url: asset.s3ObjectUrl,
HttpUrl: asset.httpUrl,
},
});

// THEN
const assembly = app.synth();
const stackArtifact = assembly.getStackArtifact(stack.artifactId);
const assetArtifact = stackArtifact.dependencies[0] as AssetManifestArtifact;

const stackTemplate = Template.fromJSON(stackArtifact.template);
stackTemplate.hasResourceProperties('CDK::TestResource', {
Bucket: { 'Fn::Sub': '${BucketName}' },
ObjectKey: '78add9eaf468dfa2191da44a7da92a21baba4c686cf6053d772556768ef21197.txt',
S3Url: { 'Fn::Sub': 's3://${BucketName}/78add9eaf468dfa2191da44a7da92a21baba4c686cf6053d772556768ef21197.txt' },
HttpUrl: { 'Fn::Sub': 'https://s3.${AWS::Region}.${AWS::URLSuffix}/${BucketName}/78add9eaf468dfa2191da44a7da92a21baba4c686cf6053d772556768ef21197.txt' },
});

expect(assetArtifact.contents).toEqual(expect.objectContaining({
files: expect.objectContaining({
'78add9eaf468dfa2191da44a7da92a21baba4c686cf6053d772556768ef21197': {
destinations: {
'current_account-current_region': {
bucketName: 'write-bucket',
objectKey: '78add9eaf468dfa2191da44a7da92a21baba4c686cf6053d772556768ef21197.txt',
},
},
source: {
packaging: 'file',
path: 'asset.78add9eaf468dfa2191da44a7da92a21baba4c686cf6053d772556768ef21197.txt',
},
},
}),
}));
});

class CustomSynthesizer extends StackSynthesizer {
private readonly manifest = new AssetManifestBuilder();
private parameter?: CfnParameter;

bind(stack: Stack) {
super.bind(stack);

this.parameter = new CfnParameter(stack, 'BucketName');
}

addFileAsset(asset: FileAssetSource): FileAssetLocation {
const dest = this.manifest.defaultAddFileAsset(this.boundStack, asset, {
bucketName: 'write-bucket',
});
return this.cloudFormationLocationFromFileAsset({
...dest,
bucketName: ['${', this.parameter!.logicalId, '}'].join(''),
});
}

addDockerImageAsset(asset: DockerImageAssetSource): DockerImageAssetLocation {
void(asset);
throw new Error('Docker images are not supported here');
}

synthesize(session: ISynthesisSession): void {
const templateAsset = this.addFileAsset(this.synthesizeTemplate(session));
const assetManifestId = this.manifest.emitManifest(this.boundStack, session);

this.emitArtifact(session, {
stackTemplateAssetObjectUrl: templateAsset.s3ObjectUrlWithPlaceholders,
additionalDependencies: [assetManifestId],
});
}
}
Expand Up @@ -6,15 +6,6 @@ import * as cdk from '@aws-cdk/core';
* Interoperates with the StackSynthesizer of the parent stack.
*/
export class ProductStackSynthesizer extends cdk.StackSynthesizer {
private stack?: cdk.Stack;

public bind(stack: cdk.Stack): void {
if (this.stack !== undefined) {
throw new Error('A Stack Synthesizer can only be bound once, create a new instance to use with a different Stack');
}
this.stack = stack;
}

public addFileAsset(_asset: cdk.FileAssetSource): cdk.FileAssetLocation {
throw new Error('Service Catalog Product Stacks cannot use Assets');
}
Expand All @@ -24,11 +15,8 @@ export class ProductStackSynthesizer extends cdk.StackSynthesizer {
}

public synthesize(session: cdk.ISynthesisSession): void {
if (!this.stack) {
throw new Error('You must call bindStack() first');
}
// Synthesize the template, but don't emit as a cloud assembly artifact.
// It will be registered as an S3 asset of its parent instead.
this.synthesizeStackTemplate(this.stack, session);
this.synthesizeTemplate(session);
}
}
Expand Up @@ -134,14 +134,9 @@ export interface AwsCloudFormationStackProperties {
}

/**
* Artifact properties for the Asset Manifest
* Configuration options for the Asset Manifest
*/
export interface AssetManifestProperties {
/**
* Filename of the asset manifest
*/
readonly file: string;

export interface AssetManifestOptions {
/**
* Version of bootstrap stack required to deploy this stack
*
Expand All @@ -163,6 +158,16 @@ export interface AssetManifestProperties {
readonly bootstrapStackVersionSsmParameter?: string;
}

/**
* Artifact properties for the Asset Manifest
*/
export interface AssetManifestProperties extends AssetManifestOptions {
/**
* Filename of the asset manifest
*/
readonly file: string;
}

/**
* Artifact properties for the Construct Tree Artifact
*/
Expand Down

0 comments on commit 8b2b381

Please sign in to comment.