packages/aws-cdk/lib/cli/user-configuration.ts (301 lines of code) (raw):

import * as os from 'os'; import * as fs_path from 'path'; import * as fs from 'fs-extra'; import { ToolkitError } from '../../../@aws-cdk/toolkit-lib/lib/api'; import { Context, PROJECT_CONTEXT } from '../api/context'; import { Settings } from '../api/settings'; import type { Tag } from '../api/tags'; import { debug, warning } from '../logging'; export const PROJECT_CONFIG = 'cdk.json'; export { PROJECT_CONTEXT } from '../api/context'; export const USER_DEFAULTS = '~/.cdk.json'; const CONTEXT_KEY = 'context'; export enum Command { LS = 'ls', LIST = 'list', DIFF = 'diff', BOOTSTRAP = 'bootstrap', DEPLOY = 'deploy', DESTROY = 'destroy', SYNTHESIZE = 'synthesize', SYNTH = 'synth', METADATA = 'metadata', INIT = 'init', VERSION = 'version', WATCH = 'watch', GC = 'gc', ROLLBACK = 'rollback', IMPORT = 'import', ACKNOWLEDGE = 'acknowledge', ACK = 'ack', NOTICES = 'notices', MIGRATE = 'migrate', CONTEXT = 'context', DOCS = 'docs', DOC = 'doc', DOCTOR = 'doctor', REFACTOR = 'refactor', } const BUNDLING_COMMANDS = [ Command.DEPLOY, Command.DIFF, Command.SYNTH, Command.SYNTHESIZE, Command.WATCH, Command.IMPORT, ]; export type Arguments = { readonly _: [Command, ...string[]]; readonly exclusively?: boolean; readonly STACKS?: string[]; readonly lookups?: boolean; readonly [name: string]: unknown; }; export interface ConfigurationProps { /** * Configuration passed via command line arguments * * @default - Nothing passed */ readonly commandLineArguments?: Arguments; /** * Whether or not to use context from `.cdk.json` in user home directory * * @default true */ readonly readUserContext?: boolean; } /** * All sources of settings combined */ export class Configuration { public settings = new Settings(); public context = new Context(); public readonly defaultConfig = new Settings({ versionReporting: true, assetMetadata: true, pathMetadata: true, output: 'cdk.out', }); private readonly commandLineArguments: Settings; private readonly commandLineContext: Settings; private _projectConfig?: Settings; private _projectContext?: Settings; private loaded = false; constructor(private readonly props: ConfigurationProps = {}) { this.commandLineArguments = props.commandLineArguments ? commandLineArgumentsToSettings(props.commandLineArguments) : new Settings(); this.commandLineContext = this.commandLineArguments .subSettings([CONTEXT_KEY]) .makeReadOnly(); } private get projectConfig() { if (!this._projectConfig) { throw new ToolkitError('#load has not been called yet!'); } return this._projectConfig; } public get projectContext() { if (!this._projectContext) { throw new ToolkitError('#load has not been called yet!'); } return this._projectContext; } /** * Load all config */ public async load(): Promise<this> { const userConfig = await loadAndLog(USER_DEFAULTS); this._projectConfig = await loadAndLog(PROJECT_CONFIG); this._projectContext = await loadAndLog(PROJECT_CONTEXT); // @todo cannot currently be disabled by cli users const readUserContext = this.props.readUserContext ?? true; if (userConfig.get(['build'])) { throw new ToolkitError( 'The `build` key cannot be specified in the user config (~/.cdk.json), specify it in the project config (cdk.json) instead', ); } const contextSources = [ { bag: this.commandLineContext }, { fileName: PROJECT_CONFIG, bag: this.projectConfig.subSettings([CONTEXT_KEY]).makeReadOnly(), }, { fileName: PROJECT_CONTEXT, bag: this.projectContext }, ]; if (readUserContext) { contextSources.push({ fileName: USER_DEFAULTS, bag: userConfig.subSettings([CONTEXT_KEY]).makeReadOnly(), }); } this.context = new Context(...contextSources); // Build settings from what's left this.settings = this.defaultConfig .merge(userConfig) .merge(this.projectConfig) .merge(this.commandLineArguments) .makeReadOnly(); debug('merged settings:', this.settings.all); this.loaded = true; return this; } /** * Save the project context */ public async saveContext(): Promise<this> { if (!this.loaded) { return this; } // Avoid overwriting files with nothing await this.projectContext.save(PROJECT_CONTEXT); return this; } } async function loadAndLog(fileName: string): Promise<Settings> { const ret = await settingsFromFile(fileName); if (!ret.empty) { debug(fileName + ':', JSON.stringify(ret.all, undefined, 2)); } return ret; } async function settingsFromFile(fileName: string): Promise<Settings> { let settings; const expanded = expandHomeDir(fileName); if (await fs.pathExists(expanded)) { const data = await fs.readJson(expanded); settings = new Settings(data); } else { settings = new Settings(); } // See https://github.com/aws/aws-cdk/issues/59 prohibitContextKeys(settings, ['default-account', 'default-region'], fileName); warnAboutContextKey(settings, 'aws:', fileName); return settings; } function prohibitContextKeys(settings: Settings, keys: string[], fileName: string) { const context = settings.get(['context']); if (!context || typeof context !== 'object') { return; } for (const key of keys) { if (key in context) { throw new ToolkitError( `The 'context.${key}' key was found in ${fs_path.resolve( fileName, )}, but it is no longer supported. Please remove it.`, ); } } } function warnAboutContextKey(settings: Settings, prefix: string, fileName: string) { const context = settings.get(['context']); if (!context || typeof context !== 'object') { return; } for (const contextKey of Object.keys(context)) { if (contextKey.startsWith(prefix)) { warning( `A reserved context key ('context.${prefix}') key was found in ${fs_path.resolve( fileName, )}, it might cause surprising behavior and should be removed.`, ); } } } function expandHomeDir(x: string) { if (x.startsWith('~')) { return fs_path.join(os.homedir(), x.slice(1)); } return x; } /** * Parse CLI arguments into Settings * * CLI arguments in must be accessed in the CLI code via * `configuration.settings.get(['argName'])` instead of via `args.argName`. * * The advantage is that they can be configured via `cdk.json` and * `$HOME/.cdk.json`. Arguments not listed below and accessed via this object * can only be specified on the command line. * * @param argv the received CLI arguments. * @returns a new Settings object. */ export function commandLineArgumentsToSettings(argv: Arguments): Settings { const context = parseStringContextListToObject(argv); const tags = parseStringTagsListToObject(expectStringList(argv.tags)); // Determine bundling stacks let bundlingStacks: string[]; if (BUNDLING_COMMANDS.includes(argv._[0])) { // If we deploy, diff, synth or watch a list of stacks exclusively we skip // bundling for all other stacks. bundlingStacks = argv.exclusively ? argv.STACKS ?? ['**'] : ['**']; } else { // Skip bundling for all stacks bundlingStacks = []; } return new Settings({ app: argv.app, browser: argv.browser, build: argv.build, caBundlePath: argv.caBundlePath, context, debug: argv.debug, tags, language: argv.language, pathMetadata: argv.pathMetadata, assetMetadata: argv.assetMetadata, profile: argv.profile, plugin: argv.plugin, requireApproval: argv.requireApproval, toolkitStackName: argv.toolkitStackName, toolkitBucket: { bucketName: argv.bootstrapBucketName, kmsKeyId: argv.bootstrapKmsKeyId, }, versionReporting: argv.versionReporting, staging: argv.staging, output: argv.output, outputsFile: argv.outputsFile, progress: argv.progress, proxy: argv.proxy, bundlingStacks, lookups: argv.lookups, rollback: argv.rollback, notices: argv.notices, assetParallelism: argv['asset-parallelism'], assetPrebuild: argv['asset-prebuild'], ignoreNoStacks: argv['ignore-no-stacks'], hotswap: { ecs: { minimumEcsHealthyPercent: argv.minimumEcsHealthyPercent, maximumEcsHealthyPercent: argv.maximumEcsHealthyPercent, }, }, unstable: argv.unstable, }); } function expectStringList(x: unknown): string[] | undefined { if (x === undefined) { return undefined; } if (!Array.isArray(x)) { throw new ToolkitError(`Expected array, got '${x}'`); } const nonStrings = x.filter((e) => typeof e !== 'string'); if (nonStrings.length > 0) { throw new ToolkitError(`Expected list of strings, found ${nonStrings}`); } return x; } function parseStringContextListToObject(argv: Arguments): any { const context: any = {}; for (const assignment of (argv as any).context || []) { const parts = assignment.split(/=(.*)/, 2); if (parts.length === 2) { debug('CLI argument context: %s=%s', parts[0], parts[1]); if (parts[0].match(/^aws:.+/)) { throw new ToolkitError( `User-provided context cannot use keys prefixed with 'aws:', but ${parts[0]} was provided.`, ); } context[parts[0]] = parts[1]; } else { warning( 'Context argument is not an assignment (key=value): %s', assignment, ); } } return context; } /** * Parse tags out of arguments * * Return undefined if no tags were provided, return an empty array if only empty * strings were provided */ function parseStringTagsListToObject( argTags: string[] | undefined, ): Tag[] | undefined { if (argTags === undefined) { return undefined; } if (argTags.length === 0) { return undefined; } const nonEmptyTags = argTags.filter((t) => t !== ''); if (nonEmptyTags.length === 0) { return []; } const tags: Tag[] = []; for (const assignment of nonEmptyTags) { const parts = assignment.split(/=(.*)/, 2); if (parts.length === 2) { debug('CLI argument tags: %s=%s', parts[0], parts[1]); tags.push({ Key: parts[0], Value: parts[1], }); } else { warning('Tags argument is not an assignment (key=value): %s', assignment); } } return tags.length > 0 ? tags : undefined; }