From 81102dfc21ac2439978a708f8d06e8468c0cda3b Mon Sep 17 00:00:00 2001 From: Clemens Akens Date: Wed, 14 Dec 2022 12:47:41 +0100 Subject: [PATCH] Re-introduce the `tag` command to update the tags of a stack without the need for synthesizing and re-deploying --- README.md | 1 + src/index.ts | 2 + src/sdk/update-tags.ts | 34 ++++++++++++++++ src/tag-command.ts | 89 ++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 126 insertions(+) create mode 100644 src/sdk/update-tags.ts create mode 100644 src/tag-command.ts diff --git a/README.md b/README.md index e33edb1..f9010a6 100644 --- a/README.md +++ b/README.md @@ -81,6 +81,7 @@ Commands: aws-simple synthesize [options] Synthesize the configured stack using the CDK. [aliases: synth] aws-simple upload [options] Upload all referenced files to the S3 bucket of the configured stack. aws-simple list [options] List all deployed stacks filtered by the specified hosted zone name. + aws-simple tag [options] Update the tags of the specified stack. aws-simple delete [options] Delete the specified stack. aws-simple purge [options] Delete all expired stacks filtered by the specified hosted zone name. aws-simple flush-cache [options] Flush the REST API cache of the specified stack. diff --git a/src/index.ts b/src/index.ts index 5595227..19c703f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -16,6 +16,7 @@ import type { import {redeployCommand} from './redeploy-command.js'; import {startCommand} from './start-command.js'; import {synthesizeCommand} from './synthesize-command.js'; +import {tagCommand} from './tag-command.js'; import {uploadCommand} from './upload-command.js'; import {print} from './utils/print.js'; @@ -38,6 +39,7 @@ export type ConfigFileDefaultExport = (port?: number) => StackConfig; .command(synthesizeCommand) .command(uploadCommand) .command(listCommand) + .command(tagCommand) .command(deleteCommand) .command(purgeCommand) .command(flushCacheCommand) diff --git a/src/sdk/update-tags.ts b/src/sdk/update-tags.ts new file mode 100644 index 0000000..67f9ed6 --- /dev/null +++ b/src/sdk/update-tags.ts @@ -0,0 +1,34 @@ +import { + CloudFormationClient, + UpdateStackCommand, +} from '@aws-sdk/client-cloudformation'; +import {findStack} from './find-stack.js'; + +export interface UpdateTagsOptions { + readonly stackName: string; + readonly tagsToAdd: [string, string | undefined][]; + readonly tagKeysToRemove: string[]; +} + +export async function updateTags(options: UpdateTagsOptions): Promise { + const {stackName, tagsToAdd, tagKeysToRemove} = options; + const stack = await findStack(stackName); + const client = new CloudFormationClient({}); + + const tagObjects = [ + ...(stack.Tags ?? []).filter(({Key}) => + tagsToAdd.every(([key]) => key !== Key), + ), + ...tagsToAdd.map(([key, value]) => ({Key: key, Value: value || `true`})), + ].filter(({Key}) => Key && !tagKeysToRemove.includes(Key)); + + await client.send( + new UpdateStackCommand({ + StackName: stackName, + UsePreviousTemplate: true, + Capabilities: stack.Capabilities, + Parameters: stack.Parameters, + Tags: tagObjects, + }), + ); +} diff --git a/src/tag-command.ts b/src/tag-command.ts new file mode 100644 index 0000000..ddea4bd --- /dev/null +++ b/src/tag-command.ts @@ -0,0 +1,89 @@ +import type {CommandModule} from 'yargs'; +import {readStackConfig} from './read-stack-config.js'; +import {updateTags} from './sdk/update-tags.js'; +import {getDomainName} from './utils/get-domain-name.js'; +import {getStackName} from './utils/get-stack-name.js'; +import {print} from './utils/print.js'; + +const commandName = `tag`; + +export const tagCommand: CommandModule< + {}, + { + readonly 'stack-name': string | undefined; + readonly 'add': readonly (string | number)[]; + readonly 'remove': readonly (string | number)[]; + readonly 'yes': boolean; + } +> = { + command: `${commandName} [options]`, + describe: `Update the tags of the specified stack.`, + + builder: (argv) => + argv + .options(`stack-name`, { + describe: `An optional stack name, if not specified it will be determined from the config file`, + string: true, + }) + .options(`add`, { + describe: `Tags to add to the specified stack`, + array: true, + default: [], + }) + .options(`remove`, { + describe: `Tags to remove from the specified stack`, + array: true, + default: [], + }) + .options(`yes`, { + describe: `Confirm to add or remove the specified tags automatically`, + boolean: true, + default: false, + }) + .example([ + [ + `npx $0 ${commandName} --stack-name ${getStackName( + `example.com`, + )} --add latest release --remove prerelease`, + ], + [`npx $0 ${commandName} --add foo=something bar="something else"`], + [`npx $0 ${commandName} --add prerelease --yes`], + ]), + + handler: async (args): Promise => { + const stackName = + args.stackName || getStackName(getDomainName(await readStackConfig())); + + print.warning(`Stack: ${stackName}`); + + if (args.yes) { + print.warning(`The tags of the specified stack will be updated.`); + } else { + const confirmed = await print.confirmation( + `Confirm to update the tags of the specified stack.`, + ); + + if (!confirmed) { + return; + } + } + + print.info(`Updating tags...`); + + await updateTags({ + stackName, + tagsToAdd: args.add.map((arg) => parseTag(arg.toString())), + tagKeysToRemove: args.remove.map((arg) => arg.toString()), + }); + + print.success( + `The tags of the specified stack have been successfully updated.`, + ); + }, +}; + +function parseTag(arg: string): [string, string | undefined] { + const [key, value] = arg.split(`=`) as [string, ...string[]]; + + return [key, value]; +}