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

fix(pipelines): stack tags #10533

Merged
merged 5 commits into from
Sep 28, 2020
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,13 @@ export interface AwsCloudFormationStackProperties {
*/
readonly parameters?: { [id: string]: string };

/**
* Values for CloudFormation stack tags that should be passed when the stack is deployed.
*
* @default - No tags
*/
readonly tags?: { [id: string]: string };

/**
* The name to use for the CloudFormation stack.
* @default - name derived from artifact ID
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -275,6 +275,13 @@
"type": "string"
}
},
"tags": {
"description": "Values for CloudFormation stack tags that should be passed when the stack is deployed. (Default - No tags)",
"type": "object",
"additionalProperties": {
"type": "string"
}
},
"stackName": {
"description": "The name to use for the CloudFormation stack. (Default - name derived from artifact ID)",
"type": "string"
Expand Down
Original file line number Diff line number Diff line change
@@ -1 +1 @@
{"version":"5.0.0"}
{"version":"6.0.0"}
2 changes: 1 addition & 1 deletion packages/@aws-cdk/core/lib/stack-synthesizers/_shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,6 @@ export function addStackArtifactToAssembly(

// nested stack tags are applied at the AWS::CloudFormation::Stack resource
// level and are not needed in the cloud assembly.
// TODO: move these to the cloud assembly artifact properties instead of metadata
if (stack.tags.hasTags()) {
stack.node.addMetadata(cxschema.ArtifactMetadataEntryType.STACK_TAGS, stack.tags.renderTags());
}
Expand All @@ -46,6 +45,7 @@ export function addStackArtifactToAssembly(
const properties: cxschema.AwsCloudFormationStackProperties = {
templateFile: stack.templateFile,
terminationProtection: stack.terminationProtection,
tags: stack.tags.tagValues(),
...stackProps,
...stackNameProperty,
};
Expand Down
18 changes: 16 additions & 2 deletions packages/@aws-cdk/core/lib/tag-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -279,8 +279,18 @@ export class TagManager {
* Renders tags into the proper format based on TagType
*/
public renderTags(): any {
const sortedTags = Array.from(this.tags.values()).sort((a, b) => a.key.localeCompare(b.key));
return this.tagFormatter.formatTags(sortedTags);
return this.tagFormatter.formatTags(this.sortedTags);
}

/**
* Render the tags in a readable format
*/
public tagValues(): Record<string, string> {
const ret: Record<string, string> = {};
for (const tag of this.sortedTags) {
ret[tag.key] = tag.value;
}
return ret;
}

/**
Expand Down Expand Up @@ -315,4 +325,8 @@ export class TagManager {
}
}
}

private get sortedTags() {
return Array.from(this.tags.values()).sort((a, b) => a.key.localeCompare(b.key));
}
}
20 changes: 19 additions & 1 deletion packages/@aws-cdk/core/test/test.stack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -897,7 +897,7 @@ export = {
test.done();
},

'stack tags are reflected in the stack cloud assembly artifact'(test: Test) {
'stack tags are reflected in the stack cloud assembly artifact metadata'(test: Test) {
// GIVEN
const app = new App({ stackTraces: false });
const stack1 = new Stack(app, 'stack1');
Expand All @@ -920,6 +920,24 @@ export = {
test.done();
},

'stack tags are reflected in the stack artifact properties'(test: Test) {
// GIVEN
const app = new App({ stackTraces: false });
const stack1 = new Stack(app, 'stack1');
const stack2 = new Stack(stack1, 'stack2');

// WHEN
Tags.of(app).add('foo', 'bar');

// THEN
const asm = app.synth();
const expected = { foo: 'bar' };

test.deepEqual(asm.getStackArtifact(stack1.artifactId).tags, expected);
test.deepEqual(asm.getStackArtifact(stack2.artifactId).tags, expected);
test.done();
},

'Termination Protection is reflected in Cloud Assembly artifact'(test: Test) {
// if the root is an app, invoke "synth" to avoid double synthesis
const app = new App();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,11 @@ export class CloudFormationStackArtifact extends CloudArtifact {
*/
public readonly parameters: { [id: string]: string };

/**
* CloudFormation tags to pass to the stack.
*/
public readonly tags: { [id: string]: string };

/**
* The physical name of this stack.
*/
Expand Down Expand Up @@ -96,7 +101,8 @@ export class CloudFormationStackArtifact extends CloudArtifact {
}
this.environment = EnvironmentUtils.parse(artifact.environment);
this.templateFile = properties.templateFile;
this.parameters = properties.parameters || { };
this.parameters = properties.parameters ?? {};
this.tags = properties.tags ?? {};
this.assumeRoleArn = properties.assumeRoleArn;
this.cloudFormationExecutionRoleArn = properties.cloudFormationExecutionRoleArn;
this.stackTemplateAssetObjectUrl = properties.stackTemplateAssetObjectUrl;
Expand Down
32 changes: 32 additions & 0 deletions packages/@aws-cdk/cx-api/test/stack-artifact.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import * as cxschema from '@aws-cdk/cloud-assembly-schema';
import * as cxapi from '../lib';
import { rimraf } from './util';

let builder: cxapi.CloudAssemblyBuilder;
beforeEach(() => {
builder = new cxapi.CloudAssemblyBuilder();
});

afterEach(() => {
rimraf(builder.outdir);
});

test('read tags', () => {
// GIVEN
builder.addArtifact('Stack', {
type: cxschema.ArtifactType.AWS_CLOUDFORMATION_STACK,
environment: 'aws://1222344/us-east-1',
properties: {
templateFile: 'bla.json',
tags: {
foo: 'bar',
},
},
});

// WHEN
const assembly = builder.buildAssembly();

// THEN
expect(assembly.getStackByName('Stack').tags).toEqual({ foo: 'bar' });
});
23 changes: 23 additions & 0 deletions packages/@aws-cdk/cx-api/test/util.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import * as fs from 'fs';
import * as path from 'path';

/**
* rm -rf reimplementation, don't want to depend on an NPM package for this
Copy link
Contributor

Choose a reason for hiding this comment

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

What's wrong with using an npm package for tests?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Let's see. I can either have this 10-line function or

└─┬ rimraf@3.0.2
  └─┬ glob@7.1.6
    ├── fs.realpath@1.0.0
    ├─┬ inflight@1.0.6
    │ ├── once@1.4.0 deduped
    │ └── wrappy@1.0.2
    ├── inherits@2.0.4
    ├─┬ minimatch@3.0.4
    │ └─┬ brace-expansion@1.1.11
    │   ├── balanced-match@1.0.0
    │   └── concat-map@0.0.1
    ├─┬ once@1.4.0
    │ └── wrappy@1.0.2 deduped
    └── path-is-absolute@1.0.1

*/
export function rimraf(fsPath: string) {
try {
const isDir = fs.lstatSync(fsPath).isDirectory();

if (isDir) {
for (const file of fs.readdirSync(fsPath)) {
rimraf(path.join(fsPath, file));
}
fs.rmdirSync(fsPath);
} else {
fs.unlinkSync(fsPath);
}
} catch (e) {
// We will survive ENOENT
if (e.code !== 'ENOENT') { throw e; }
}
}
44 changes: 42 additions & 2 deletions packages/@aws-cdk/pipelines/lib/actions/deploy-cdk-stack-action.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import * as fs from 'fs';
import * as path from 'path';
import * as cfn from '@aws-cdk/aws-cloudformation';
import * as codepipeline from '@aws-cdk/aws-codepipeline';
Expand Down Expand Up @@ -112,6 +113,11 @@ export interface DeployCdkStackActionProps extends DeployCdkStackActionOptions {
* @default - No dependencies
*/
readonly dependencyStackArtifactIds?: string[];

/**
* Template configuration path relative to the input artifact
*/
readonly templateConfigurationPath?: string;
}

/**
Expand Down Expand Up @@ -156,12 +162,25 @@ export class DeployCdkStackAction implements codepipeline.IAction {
// It should be easier to get this, but for now it is what it is.
const appAsmRoot = assemblyBuilderOf(appOf(scope)).outdir;
const fullTemplatePath = path.join(artifact.assembly.directory, artifact.templateFile);
const templatePath = path.relative(appAsmRoot, fullTemplatePath);

let fullConfigPath;
if (Object.keys(artifact.tags).length > 0) {
fullConfigPath = `${fullTemplatePath}.config.json`;

// Write the template configuration file (for parameters into CreateChangeSet call that
// cannot be configured any other way). They must come from a file, and there's unfortunately
Copy link
Contributor

Choose a reason for hiding this comment

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

What's wrong with this way?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Feels like this would be an excellent case for a construct adding an artifact to the synthesized output of a cloud assembly, as an implementation detail of the construct.

What's happening now is that we're writing a file which is right next to another file that we assume is going to be in a cloud assembly, of which we kinda guess the name is probably going to be unused, which is invisible any of the rest of the framework.

// no better hook to write this file (`construct.onSynthesize()` would have been the prime candidate
// but that is being deprecated--and DeployCdkStackAction isn't even a construct).
writeTemplateConfiguration(fullConfigPath, {
Tags: artifact.tags,
});
}

return new DeployCdkStackAction({
actionRole,
cloudFormationExecutionRole,
templatePath,
templatePath: path.relative(appAsmRoot, fullTemplatePath),
templateConfigurationPath: fullConfigPath ? path.relative(appAsmRoot, fullConfigPath) : undefined,
region,
stackArtifactId: artifact.id,
dependencyStackArtifactIds: artifact.dependencies.filter(isStackArtifact).map(s => s.id),
Expand Down Expand Up @@ -223,6 +242,7 @@ export class DeployCdkStackAction implements codepipeline.IAction {
deploymentRole: props.cloudFormationExecutionRole,
region: props.region,
capabilities: [cfn.CloudFormationCapabilities.NAMED_IAM, cfn.CloudFormationCapabilities.AUTO_EXPAND],
templateConfiguration: props.templateConfigurationPath ? props.cloudAssemblyInput.atPath(props.templateConfigurationPath) : undefined,
});
this.executeChangeSetAction = new cpactions.CloudFormationExecuteChangeSetAction({
actionName: `${baseActionName}.Deploy`,
Expand Down Expand Up @@ -331,3 +351,23 @@ function isStackArtifact(a: cxapi.CloudArtifact): a is cxapi.CloudFormationStack
// return a instanceof cxapi.CloudFormationStackArtifact;
return a.constructor.name === 'CloudFormationStackArtifact';
}

/**
* Template configuration in a CodePipeline
*
* @see https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/continuous-delivery-codepipeline-cfn-artifacts.html#w2ab1c13c17c15
*/
interface TemplateConfiguration {
readonly Parameters?: Record<string, string>;
readonly Tags?: Record<string, string>;
readonly StackPolicy?: {
readonly Statements: Array<Record<string, string>>;
};
}

/**
* Write template configuration to the given file
*/
function writeTemplateConfiguration(filename: string, config: TemplateConfiguration) {
fs.writeFileSync(filename, JSON.stringify(config, undefined, 2), { encoding: 'utf-8' });
}
16 changes: 1 addition & 15 deletions packages/aws-cdk/lib/cdk-toolkit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -626,21 +626,7 @@ export interface DestroyOptions {
* @returns an array with the tags available in the stack metadata.
*/
function tagsForStack(stack: cxapi.CloudFormationStackArtifact): Tag[] {
const tagLists = stack.findMetadataByType(cxschema.ArtifactMetadataEntryType.STACK_TAGS).map(
// the tags in the cloud assembly are stored differently
// unfortunately.
x => toCloudFormationTags(x.data as cxschema.Tag[]));
return Array.prototype.concat([], ...tagLists);
}

/**
* Transform tags as they are retrieved from the cloud assembly,
* to the way that CloudFormation expects them. (Different casing).
*/
function toCloudFormationTags(tags: cxschema.Tag[]): Tag[] {
return tags.map(t => {
return { Key: t.key, Value: t.value };
});
return Object.entries(stack.tags).map(([Key, Value]) => ({ Key, Value }));
}

export interface Tag {
Expand Down