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 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
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 @@ -54,11 +54,19 @@ export interface FileAssetMetadataEntry extends BaseAssetMetadataEntry {
export interface Tag {
/**
* Tag key.
*
* (In the actual file on disk this will be cased as "Key", and the structure is
* patched to match this structure upon loading:
* https://github.com/aws/aws-cdk/blob/4aadaa779b48f35838cccd4e25107b2338f05547/packages/%40aws-cdk/cloud-assembly-schema/lib/manifest.ts#L137)
*/
readonly key: string

/**
* Tag value.
*
* (In the actual file on disk this will be cased as "Value", and the structure is
* patched to match this structure upon loading:
* https://github.com/aws/aws-cdk/blob/4aadaa779b48f35838cccd4e25107b2338f05547/packages/%40aws-cdk/cloud-assembly-schema/lib/manifest.ts#L137)
*/
readonly value: string
}
Expand Down
87 changes: 68 additions & 19 deletions packages/@aws-cdk/cloud-assembly-schema/lib/manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ export class Manifest {
* @param filePath - output file path.
*/
public static saveAssemblyManifest(manifest: assembly.AssemblyManifest, filePath: string) {
Manifest.saveManifest(manifest, filePath, ASSEMBLY_SCHEMA);
Manifest.saveManifest(manifest, filePath, ASSEMBLY_SCHEMA, Manifest.patchStackTagsOnWrite);
}

/**
Expand All @@ -41,7 +41,7 @@ export class Manifest {
* @param filePath - path to the manifest file.
*/
public static loadAssemblyManifest(filePath: string): assembly.AssemblyManifest {
return Manifest.loadManifest(filePath, ASSEMBLY_SCHEMA, obj => Manifest.patchStackTags(obj));
return Manifest.loadManifest(filePath, ASSEMBLY_SCHEMA, Manifest.patchStackTagsOnRead);
}

/**
Expand All @@ -51,7 +51,7 @@ export class Manifest {
* @param filePath - output file path.
*/
public static saveAssetManifest(manifest: assets.AssetManifest, filePath: string) {
Manifest.saveManifest(manifest, filePath, ASSETS_SCHEMA);
Manifest.saveManifest(manifest, filePath, ASSETS_SCHEMA, Manifest.patchStackTagsOnRead);
}

/**
Expand Down Expand Up @@ -118,9 +118,12 @@ export class Manifest {

}

private static saveManifest(manifest: any, filePath: string, schema: jsonschema.Schema) {
const withVersion = { ...manifest, version: Manifest.version() };
private static saveManifest(manifest: any, filePath: string, schema: jsonschema.Schema, preprocess?: (obj: any) => any) {
let withVersion = { ...manifest, version: Manifest.version() };
Manifest.validate(withVersion, schema);
if (preprocess) {
withVersion = preprocess(withVersion);
}
fs.writeFileSync(filePath, JSON.stringify(withVersion, undefined, 2));
}

Expand Down Expand Up @@ -148,23 +151,69 @@ export class Manifest {
* Ideally, we would start writing the `camelCased` and translate to how CloudFormation expects it when needed. But this requires nasty
* backwards-compatibility code and it just doesn't seem to be worth the effort.
*/
private static patchStackTags(manifest: assembly.AssemblyManifest) {
for (const artifact of Object.values(manifest.artifacts || [])) {
if (artifact.type === assembly.ArtifactType.AWS_CLOUDFORMATION_STACK) {
for (const metadataEntries of Object.values(artifact.metadata || [])) {
for (const metadataEntry of metadataEntries) {
if (metadataEntry.type === assembly.ArtifactMetadataEntryType.STACK_TAGS && metadataEntry.data) {
const metadataAny = metadataEntry as any;
metadataAny.data = metadataAny.data.map((t: any) => ({ key: t.Key, value: t.Value }));
}
}
}
}
}
private static patchStackTagsOnRead(manifest: assembly.AssemblyManifest) {
return Manifest.replaceStackTags(manifest, tags => tags.map((diskTag: any) => ({
key: diskTag.Key,
value: diskTag.Value,
})));
}

/**
* See explanation on `patchStackTagsOnRead`
*
* Translate stack tags metadata if it has the "right" casing.
*/
private static patchStackTagsOnWrite(manifest: assembly.AssemblyManifest) {
return Manifest.replaceStackTags(manifest, tags => tags.map(memTag =>
// Might already be uppercased (because stack synthesis generates it in final form yet)
('Key' in memTag ? memTag : { Key: memTag.key, Value: memTag.value }) as any,
));
}

return manifest;
/**
* Recursively replace stack tags in the stack metadata
*/
private static replaceStackTags(manifest: assembly.AssemblyManifest, fn: Endofunctor<assembly.StackTagsMetadataEntry>): assembly.AssemblyManifest {
// Need to add in the `noUndefined`s because otherwise jest snapshot tests are going to freak out
// about the keys with values that are `undefined` (even though they would never be JSON.stringified)
return noUndefined({
...manifest,
artifacts: mapValues(manifest.artifacts, artifact => {
if (artifact.type !== assembly.ArtifactType.AWS_CLOUDFORMATION_STACK) { return artifact; }
return noUndefined({
...artifact,
metadata: mapValues(artifact.metadata, metadataEntries => metadataEntries.map(metadataEntry => {
if (metadataEntry.type !== assembly.ArtifactMetadataEntryType.STACK_TAGS || !metadataEntry.data) { return metadataEntry; }
return {
...metadataEntry,
data: fn(metadataEntry.data as assembly.StackTagsMetadataEntry),
};
})),
} as assembly.ArtifactManifest);
}),
});
}

private constructor() {}
}

type Endofunctor<A> = (x: A) => A;

function mapValues<A, B>(xs: Record<string, A> | undefined, fn: (x: A) => B): Record<string, B> | undefined {
if (!xs) { return undefined; }
const ret: Record<string, B> | undefined = {};
for (const [k, v] of Object.entries(xs)) {
ret[k] = fn(v);
}
return ret;
}

function noUndefined<A extends object>(xs: A): A {
const ret: any = {};
for (const [k, v] of Object.entries(xs)) {
if (v !== undefined) {
ret[k] = v;
}
}
return ret;
}
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"}
6 changes: 5 additions & 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: nonEmptyDict(stack.tags.tagValues()),
...stackProps,
...stackNameProperty,
};
Expand Down Expand Up @@ -116,4 +116,8 @@ export function assertBound<A>(x: A | undefined): asserts x is NonNullable<A> {
if (x === null && x === undefined) {
throw new Error('You must call bindStack() first');
}
}

function nonEmptyDict<A>(xs: Record<string, A>) {
return Object.keys(xs).length > 0 ? xs : undefined;
}
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
23 changes: 21 additions & 2 deletions packages/@aws-cdk/cx-api/lib/artifacts/cloudformation-artifact.ts
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,11 @@ export class CloudFormationStackArtifact extends CloudArtifact {
}
this.environment = EnvironmentUtils.parse(artifact.environment);
this.templateFile = properties.templateFile;
this.parameters = properties.parameters || { };
this.parameters = properties.parameters ?? {};

// We get the tags from 'properties' if available (cloud assembly format >= 6.0.0), otherwise
// from the stack metadata
this.tags = properties.tags ?? this.tagsFromMetadata();
this.assumeRoleArn = properties.assumeRoleArn;
this.cloudFormationExecutionRoleArn = properties.cloudFormationExecutionRoleArn;
this.stackTemplateAssetObjectUrl = properties.stackTemplateAssetObjectUrl;
Expand Down Expand Up @@ -130,4 +139,14 @@ export class CloudFormationStackArtifact extends CloudArtifact {
}
return this._template;
}
}

private tagsFromMetadata() {
const ret: Record<string, string> = {};
for (const metadataEntry of this.findMetadataByType(cxschema.ArtifactMetadataEntryType.STACK_TAGS)) {
for (const tag of (metadataEntry.data ?? []) as cxschema.StackTagsMetadataEntry) {
ret[tag.key] = tag.value;
}
}
return ret;
}
}
Loading