From 97bfd10fef4c23404ec3f5c6a6ca68604fc78aa8 Mon Sep 17 00:00:00 2001 From: Rico Huijbers Date: Mon, 28 Sep 2020 16:01:59 +0200 Subject: [PATCH] fix(pipelines): stack tags (#10533) Apply stack tags to the stacks deployed using CDK Pipelines. Taking this opportunity to make tags easier to work with -- move them from metadata into cloud artifact properties. Fixes #9260. ---- *By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license* --- .../lib/cloud-assembly/artifact-schema.ts | 7 + .../lib/cloud-assembly/metadata-schema.ts | 8 ++ .../cloud-assembly-schema/lib/manifest.ts | 87 +++++++++--- .../schema/cloud-assembly.schema.json | 7 + .../schema/cloud-assembly.version.json | 2 +- .../core/lib/stack-synthesizers/_shared.ts | 6 +- packages/@aws-cdk/core/lib/tag-manager.ts | 18 ++- packages/@aws-cdk/core/test/test.stack.ts | 20 ++- .../lib/artifacts/cloudformation-artifact.ts | 23 ++- .../cx-api/test/stack-artifact.test.ts | 132 ++++++++++++++++++ packages/@aws-cdk/cx-api/test/util.ts | 23 +++ .../lib/actions/deploy-cdk-stack-action.ts | 46 +++++- .../@aws-cdk/pipelines/test/pipeline.test.ts | 40 +++++- packages/aws-cdk/lib/cdk-toolkit.ts | 17 +-- 14 files changed, 390 insertions(+), 46 deletions(-) create mode 100644 packages/@aws-cdk/cx-api/test/stack-artifact.test.ts create mode 100644 packages/@aws-cdk/cx-api/test/util.ts diff --git a/packages/@aws-cdk/cloud-assembly-schema/lib/cloud-assembly/artifact-schema.ts b/packages/@aws-cdk/cloud-assembly-schema/lib/cloud-assembly/artifact-schema.ts index 6a43b187475f5..cea67ab76263d 100644 --- a/packages/@aws-cdk/cloud-assembly-schema/lib/cloud-assembly/artifact-schema.ts +++ b/packages/@aws-cdk/cloud-assembly-schema/lib/cloud-assembly/artifact-schema.ts @@ -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 diff --git a/packages/@aws-cdk/cloud-assembly-schema/lib/cloud-assembly/metadata-schema.ts b/packages/@aws-cdk/cloud-assembly-schema/lib/cloud-assembly/metadata-schema.ts index 54cabf83554fc..5e9e4e1517145 100644 --- a/packages/@aws-cdk/cloud-assembly-schema/lib/cloud-assembly/metadata-schema.ts +++ b/packages/@aws-cdk/cloud-assembly-schema/lib/cloud-assembly/metadata-schema.ts @@ -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 } diff --git a/packages/@aws-cdk/cloud-assembly-schema/lib/manifest.ts b/packages/@aws-cdk/cloud-assembly-schema/lib/manifest.ts index 4c7c5229f0fc0..1e3ea7afa62e7 100644 --- a/packages/@aws-cdk/cloud-assembly-schema/lib/manifest.ts +++ b/packages/@aws-cdk/cloud-assembly-schema/lib/manifest.ts @@ -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); } /** @@ -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); } /** @@ -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); } /** @@ -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)); } @@ -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.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 = (x: A) => A; +function mapValues(xs: Record | undefined, fn: (x: A) => B): Record | undefined { + if (!xs) { return undefined; } + const ret: Record | undefined = {}; + for (const [k, v] of Object.entries(xs)) { + ret[k] = fn(v); + } + return ret; } + +function noUndefined(xs: A): A { + const ret: any = {}; + for (const [k, v] of Object.entries(xs)) { + if (v !== undefined) { + ret[k] = v; + } + } + return ret; +} \ No newline at end of file diff --git a/packages/@aws-cdk/cloud-assembly-schema/schema/cloud-assembly.schema.json b/packages/@aws-cdk/cloud-assembly-schema/schema/cloud-assembly.schema.json index 8c3e58485b12b..a154b78a0b508 100644 --- a/packages/@aws-cdk/cloud-assembly-schema/schema/cloud-assembly.schema.json +++ b/packages/@aws-cdk/cloud-assembly-schema/schema/cloud-assembly.schema.json @@ -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" diff --git a/packages/@aws-cdk/cloud-assembly-schema/schema/cloud-assembly.version.json b/packages/@aws-cdk/cloud-assembly-schema/schema/cloud-assembly.version.json index 78d33700c0698..42cb403235c06 100644 --- a/packages/@aws-cdk/cloud-assembly-schema/schema/cloud-assembly.version.json +++ b/packages/@aws-cdk/cloud-assembly-schema/schema/cloud-assembly.version.json @@ -1 +1 @@ -{"version":"5.0.0"} +{"version":"6.0.0"} \ No newline at end of file diff --git a/packages/@aws-cdk/core/lib/stack-synthesizers/_shared.ts b/packages/@aws-cdk/core/lib/stack-synthesizers/_shared.ts index 5a57c423779df..233490968b31d 100644 --- a/packages/@aws-cdk/core/lib/stack-synthesizers/_shared.ts +++ b/packages/@aws-cdk/core/lib/stack-synthesizers/_shared.ts @@ -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()); } @@ -46,6 +45,7 @@ export function addStackArtifactToAssembly( const properties: cxschema.AwsCloudFormationStackProperties = { templateFile: stack.templateFile, terminationProtection: stack.terminationProtection, + tags: nonEmptyDict(stack.tags.tagValues()), ...stackProps, ...stackNameProperty, }; @@ -116,4 +116,8 @@ export function assertBound(x: A | undefined): asserts x is NonNullable { if (x === null && x === undefined) { throw new Error('You must call bindStack() first'); } +} + +function nonEmptyDict(xs: Record) { + return Object.keys(xs).length > 0 ? xs : undefined; } \ No newline at end of file diff --git a/packages/@aws-cdk/core/lib/tag-manager.ts b/packages/@aws-cdk/core/lib/tag-manager.ts index b1db219ef4440..2189af4bc4929 100644 --- a/packages/@aws-cdk/core/lib/tag-manager.ts +++ b/packages/@aws-cdk/core/lib/tag-manager.ts @@ -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 { + const ret: Record = {}; + for (const tag of this.sortedTags) { + ret[tag.key] = tag.value; + } + return ret; } /** @@ -315,4 +325,8 @@ export class TagManager { } } } + + private get sortedTags() { + return Array.from(this.tags.values()).sort((a, b) => a.key.localeCompare(b.key)); + } } diff --git a/packages/@aws-cdk/core/test/test.stack.ts b/packages/@aws-cdk/core/test/test.stack.ts index a27a992b5fdfd..49a7ac671b0ad 100644 --- a/packages/@aws-cdk/core/test/test.stack.ts +++ b/packages/@aws-cdk/core/test/test.stack.ts @@ -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'); @@ -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(); diff --git a/packages/@aws-cdk/cx-api/lib/artifacts/cloudformation-artifact.ts b/packages/@aws-cdk/cx-api/lib/artifacts/cloudformation-artifact.ts index 167d589753b63..807e58f1a411c 100644 --- a/packages/@aws-cdk/cx-api/lib/artifacts/cloudformation-artifact.ts +++ b/packages/@aws-cdk/cx-api/lib/artifacts/cloudformation-artifact.ts @@ -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. */ @@ -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; @@ -130,4 +139,14 @@ export class CloudFormationStackArtifact extends CloudArtifact { } return this._template; } -} + + private tagsFromMetadata() { + const ret: Record = {}; + 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; + } +} \ No newline at end of file diff --git a/packages/@aws-cdk/cx-api/test/stack-artifact.test.ts b/packages/@aws-cdk/cx-api/test/stack-artifact.test.ts new file mode 100644 index 0000000000000..1f4591f00177a --- /dev/null +++ b/packages/@aws-cdk/cx-api/test/stack-artifact.test.ts @@ -0,0 +1,132 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import * as cxschema from '@aws-cdk/cloud-assembly-schema'; +import * as cxapi from '../lib'; +import { rimraf } from './util'; + +const stackBase = { + type: cxschema.ArtifactType.AWS_CLOUDFORMATION_STACK, + environment: 'aws://1222344/us-east-1', + properties: { + templateFile: 'bla.json', + }, +}; + +let builder: cxapi.CloudAssemblyBuilder; +beforeEach(() => { + builder = new cxapi.CloudAssemblyBuilder(); +}); + +afterEach(() => { + rimraf(builder.outdir); +}); + +test('read tags from artifact properties', () => { + // GIVEN + builder.addArtifact('Stack', { + ...stackBase, + properties: { + ...stackBase.properties, + tags: { + foo: 'bar', + }, + }, + }); + + // WHEN + const assembly = builder.buildAssembly(); + + // THEN + expect(assembly.getStackByName('Stack').tags).toEqual({ foo: 'bar' }); +}); + +test('stack tags get uppercased when written to Cloud Assembly', () => { + // Backwards compatibility test + // GIVEN + builder.addArtifact('Stack', { + ...stackBase, + metadata: { + '/Stack': [ + { + type: 'aws:cdk:stack-tags', + data: [{ key: 'foo', value: 'bar' }], + }, + ], + }, + }); + + // WHEN + const assembly = builder.buildAssembly(); + + // THEN + const manifestStructure = JSON.parse(fs.readFileSync(path.join(assembly.directory, 'manifest.json'), { encoding: 'utf-8' })); + expect(manifestStructure.artifacts.Stack.metadata['/Stack']).toEqual([ + { + type: 'aws:cdk:stack-tags', + data: [ + { + // Note: uppercase due to historical accident + Key: 'foo', + Value: 'bar', + }, + ], + }, + ]); +}); + +test('already uppercased stack tags get left alone', () => { + // Backwards compatibility test + // GIVEN + builder.addArtifact('Stack', { + ...stackBase, + metadata: { + '/Stack': [ + { + type: 'aws:cdk:stack-tags', + data: [{ Key: 'foo', Value: 'bar' } as any], + }, + ], + }, + }); + + // WHEN + const assembly = builder.buildAssembly(); + + // THEN + const manifestStructure = JSON.parse(fs.readFileSync(path.join(assembly.directory, 'manifest.json'), { encoding: 'utf-8' })); + expect(manifestStructure.artifacts.Stack.metadata['/Stack']).toEqual([ + { + type: 'aws:cdk:stack-tags', + data: [ + { + // Note: uppercase due to historical accident + Key: 'foo', + Value: 'bar', + }, + ], + }, + ]); +}); + + +test('read tags from stack metadata', () => { + // Backwards compatibility test + // GIVEN + builder.addArtifact('Stack', { + ...stackBase, + metadata: { + '/Stack': [ + { + type: 'aws:cdk:stack-tags', + data: [{ key: 'foo', value: 'bar' }], + }, + ], + }, + }); + + // WHEN + const assembly = builder.buildAssembly(); + + // THEN + expect(assembly.getStackByName('Stack').tags).toEqual({ foo: 'bar' }); +}); diff --git a/packages/@aws-cdk/cx-api/test/util.ts b/packages/@aws-cdk/cx-api/test/util.ts new file mode 100644 index 0000000000000..58cee8acf89c7 --- /dev/null +++ b/packages/@aws-cdk/cx-api/test/util.ts @@ -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 + */ +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; } + } +} diff --git a/packages/@aws-cdk/pipelines/lib/actions/deploy-cdk-stack-action.ts b/packages/@aws-cdk/pipelines/lib/actions/deploy-cdk-stack-action.ts index 902409c43a331..ee3038ad4372c 100644 --- a/packages/@aws-cdk/pipelines/lib/actions/deploy-cdk-stack-action.ts +++ b/packages/@aws-cdk/pipelines/lib/actions/deploy-cdk-stack-action.ts @@ -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'; @@ -112,6 +113,13 @@ export interface DeployCdkStackActionProps extends DeployCdkStackActionOptions { * @default - No dependencies */ readonly dependencyStackArtifactIds?: string[]; + + /** + * Template configuration path relative to the input artifact + * + * @default - No template configuration + */ + readonly templateConfigurationPath?: string; } /** @@ -156,12 +164,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 + // 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), @@ -223,6 +244,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`, @@ -331,3 +353,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; + readonly Tags?: Record; + readonly StackPolicy?: { + readonly Statements: Array>; + }; +} + +/** + * Write template configuration to the given file + */ +function writeTemplateConfiguration(filename: string, config: TemplateConfiguration) { + fs.writeFileSync(filename, JSON.stringify(config, undefined, 2), { encoding: 'utf-8' }); +} \ No newline at end of file diff --git a/packages/@aws-cdk/pipelines/test/pipeline.test.ts b/packages/@aws-cdk/pipelines/test/pipeline.test.ts index 7d4455959a8eb..f47149e5f8395 100644 --- a/packages/@aws-cdk/pipelines/test/pipeline.test.ts +++ b/packages/@aws-cdk/pipelines/test/pipeline.test.ts @@ -1,8 +1,10 @@ -import { anything, arrayWith, deepObjectLike, encodedJson, objectLike, stringLike } from '@aws-cdk/assert'; +import * as fs from 'fs'; +import * as path from 'path'; +import { anything, arrayWith, Capture, deepObjectLike, encodedJson, objectLike, stringLike } from '@aws-cdk/assert'; import '@aws-cdk/assert/jest'; import * as cp from '@aws-cdk/aws-codepipeline'; import * as cpa from '@aws-cdk/aws-codepipeline-actions'; -import { Construct, Stack, Stage, StageProps, SecretValue } from '@aws-cdk/core'; +import { Construct, Stack, Stage, StageProps, SecretValue, Tags } from '@aws-cdk/core'; import * as cdkp from '../lib'; import { BucketStack, PIPELINE_ENV, stackTemplate, TestApp, TestGitHubNpmPipeline } from './testutil'; @@ -404,6 +406,40 @@ test('add another action to an existing stage', () => { }); }); +test('tags get reflected in pipeline', () => { + // WHEN + const stage = new OneStackApp(app, 'App'); + Tags.of(stage).add('CostCenter', 'F00B4R'); + pipeline.addApplicationStage(stage); + + // THEN + const templateConfig = Capture.aString(); + expect(pipelineStack).toHaveResourceLike('AWS::CodePipeline::Pipeline', { + Stages: arrayWith({ + Name: 'App', + Actions: arrayWith( + objectLike({ + Name: 'Stack.Prepare', + InputArtifacts: [objectLike({})], + Configuration: objectLike({ + StackName: 'App-Stack', + TemplateConfiguration: templateConfig.capture(stringLike('*::assembly-App/*.template.*json')), + }), + }), + ), + }), + }); + + const [, relConfigFile] = templateConfig.capturedValue.split('::'); + const absConfigFile = path.join(app.outdir, relConfigFile); + const configFile = JSON.parse(fs.readFileSync(absConfigFile, { encoding: 'utf-8' })); + expect(configFile).toEqual(expect.objectContaining({ + Tags: { + CostCenter: 'F00B4R', + }, + })); +}); + class OneStackApp extends Stage { constructor(scope: Construct, id: string, props?: StageProps) { super(scope, id, props); diff --git a/packages/aws-cdk/lib/cdk-toolkit.ts b/packages/aws-cdk/lib/cdk-toolkit.ts index 62cd54f1d2068..c0ae230879a80 100644 --- a/packages/aws-cdk/lib/cdk-toolkit.ts +++ b/packages/aws-cdk/lib/cdk-toolkit.ts @@ -1,6 +1,5 @@ import * as path from 'path'; import { format } from 'util'; -import * as cxschema from '@aws-cdk/cloud-assembly-schema'; import * as cxapi from '@aws-cdk/cx-api'; import * as colors from 'colors/safe'; import * as fs from 'fs-extra'; @@ -626,21 +625,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 {