diff --git a/packages/@aws-cdk/tmp-toolkit-helpers/src/api/cloud-assembly/environment.ts b/packages/@aws-cdk/tmp-toolkit-helpers/src/api/cloud-assembly/environment.ts new file mode 100644 index 000000000..3ddf916e1 --- /dev/null +++ b/packages/@aws-cdk/tmp-toolkit-helpers/src/api/cloud-assembly/environment.ts @@ -0,0 +1,153 @@ +import * as path from 'path'; +import { format } from 'util'; +import * as cxapi from '@aws-cdk/cx-api'; +import * as fs from 'fs-extra'; +import type { SdkProvider } from '../aws-auth'; +import type { Settings } from '../settings'; + +/** + * If we don't have region/account defined in context, we fall back to the default SDK behavior + * where region is retrieved from ~/.aws/config and account is based on default credentials provider + * chain and then STS is queried. + * + * This is done opportunistically: for example, if we can't access STS for some reason or the region + * is not configured, the context value will be 'null' and there could failures down the line. In + * some cases, synthesis does not require region/account information at all, so that might be perfectly + * fine in certain scenarios. + * + * @param context The context key/value bash. + */ +export async function prepareDefaultEnvironment( + aws: SdkProvider, + debugFn: (msg: string) => Promise, +): Promise<{ [key: string]: string }> { + const env: { [key: string]: string } = { }; + + env[cxapi.DEFAULT_REGION_ENV] = aws.defaultRegion; + await debugFn(`Setting "${cxapi.DEFAULT_REGION_ENV}" environment variable to ${env[cxapi.DEFAULT_REGION_ENV]}`); + + const accountId = (await aws.defaultAccount())?.accountId; + if (accountId) { + env[cxapi.DEFAULT_ACCOUNT_ENV] = accountId; + await debugFn(`Setting "${cxapi.DEFAULT_ACCOUNT_ENV}" environment variable to ${env[cxapi.DEFAULT_ACCOUNT_ENV]}`); + } + + return env; +} + +/** + * Settings related to synthesis are read from context. + * The merging of various configuration sources like cli args or cdk.json has already happened. + * We now need to set the final values to the context. + */ +export async function prepareContext( + settings: Settings, + context: {[key: string]: any}, + env: { [key: string]: string | undefined}, + debugFn: (msg: string) => Promise, +) { + const debugMode: boolean = settings.get(['debug']) ?? true; + if (debugMode) { + env.CDK_DEBUG = 'true'; + } + + const pathMetadata: boolean = settings.get(['pathMetadata']) ?? true; + if (pathMetadata) { + context[cxapi.PATH_METADATA_ENABLE_CONTEXT] = true; + } + + const assetMetadata: boolean = settings.get(['assetMetadata']) ?? true; + if (assetMetadata) { + context[cxapi.ASSET_RESOURCE_METADATA_ENABLED_CONTEXT] = true; + } + + const versionReporting: boolean = settings.get(['versionReporting']) ?? true; + if (versionReporting) { + context[cxapi.ANALYTICS_REPORTING_ENABLED_CONTEXT] = true; + } + // We need to keep on doing this for framework version from before this flag was deprecated. + if (!versionReporting) { + context['aws:cdk:disable-version-reporting'] = true; + } + + const stagingEnabled = settings.get(['staging']) ?? true; + if (!stagingEnabled) { + context[cxapi.DISABLE_ASSET_STAGING_CONTEXT] = true; + } + + const bundlingStacks = settings.get(['bundlingStacks']) ?? ['**']; + context[cxapi.BUNDLING_STACKS] = bundlingStacks; + + await debugFn(format('context:', context)); + + return context; +} + +export function spaceAvailableForContext(env: { [key: string]: string }, limit: number) { + const size = (value: string) => value != null ? Buffer.byteLength(value) : 0; + + const usedSpace = Object.entries(env) + .map(([k, v]) => k === cxapi.CONTEXT_ENV ? size(k) : size(k) + size(v)) + .reduce((a, b) => a + b, 0); + + return Math.max(0, limit - usedSpace); +} + +/** + * Guess the executable from the command-line argument + * + * Only do this if the file is NOT marked as executable. If it is, + * we'll defer to the shebang inside the file itself. + * + * If we're on Windows, we ALWAYS take the handler, since it's hard to + * verify if registry associations have or have not been set up for this + * file type, so we'll assume the worst and take control. + */ +export async function guessExecutable(app: string, debugFn: (msg: string) => Promise) { + const commandLine = appToArray(app); + if (commandLine.length === 1) { + let fstat; + + try { + fstat = await fs.stat(commandLine[0]); + } catch { + await debugFn(`Not a file: '${commandLine[0]}'. Using '${commandLine}' as command-line`); + return commandLine; + } + + // eslint-disable-next-line no-bitwise + const isExecutable = (fstat.mode & fs.constants.X_OK) !== 0; + const isWindows = process.platform === 'win32'; + + const handler = EXTENSION_MAP.get(path.extname(commandLine[0])); + if (handler && (!isExecutable || isWindows)) { + return handler(commandLine[0]); + } + } + return commandLine; +} + +/** + * Mapping of extensions to command-line generators + */ +const EXTENSION_MAP = new Map([ + ['.js', executeNode], +]); + +type CommandGenerator = (file: string) => string[]; + +/** + * Execute the given file with the same 'node' process as is running the current process + */ +function executeNode(scriptFile: string): string[] { + return [process.execPath, scriptFile]; +} + +/** + * Make sure the 'app' is an array + * + * If it's a string, split on spaces as a trivial way of tokenizing the command line. + */ +function appToArray(app: any) { + return typeof app === 'string' ? app.split(' ') : app; +} diff --git a/packages/@aws-cdk/tmp-toolkit-helpers/src/api/cloud-assembly/index.ts b/packages/@aws-cdk/tmp-toolkit-helpers/src/api/cloud-assembly/index.ts index 99fd72d8d..7ed5f6288 100644 --- a/packages/@aws-cdk/tmp-toolkit-helpers/src/api/cloud-assembly/index.ts +++ b/packages/@aws-cdk/tmp-toolkit-helpers/src/api/cloud-assembly/index.ts @@ -1 +1,4 @@ +export * from './environment'; +export * from './stack-assembly'; +export * from './stack-collection'; export * from './stack-selector'; diff --git a/packages/@aws-cdk/tmp-toolkit-helpers/src/api/cloud-assembly/stack-assembly.ts b/packages/@aws-cdk/tmp-toolkit-helpers/src/api/cloud-assembly/stack-assembly.ts new file mode 100644 index 000000000..959ef377c --- /dev/null +++ b/packages/@aws-cdk/tmp-toolkit-helpers/src/api/cloud-assembly/stack-assembly.ts @@ -0,0 +1,186 @@ +import type * as cxapi from '@aws-cdk/cx-api'; +import * as chalk from 'chalk'; +import { minimatch } from 'minimatch'; +import { StackCollection } from './stack-collection'; +import { flatten } from '../../util'; +import { IO } from '../io/private'; +import type { IoHelper } from '../io/private/io-helper'; + +export interface IStackAssembly { + /** + * The directory this CloudAssembly was read from + */ + directory: string; + + /** + * Select a single stack by its ID + */ + stackById(stackId: string): StackCollection; +} + +/** + * When selecting stacks, what other stacks to include because of dependencies + */ +export enum ExtendedStackSelection { + /** + * Don't select any extra stacks + */ + None, + + /** + * Include stacks that this stack depends on + */ + Upstream, + + /** + * Include stacks that depend on this stack + */ + Downstream, +} + +/** + * A single Cloud Assembly and the operations we do on it to deploy the artifacts inside + */ +export abstract class BaseStackAssembly implements IStackAssembly { + /** + * Sanitize a list of stack match patterns + */ + protected static sanitizePatterns(patterns: string[]): string[] { + let sanitized = patterns.filter(s => s != null); // filter null/undefined + sanitized = [...new Set(sanitized)]; // make them unique + return sanitized; + } + + /** + * The directory this CloudAssembly was read from + */ + public readonly directory: string; + + /** + * The IoHelper used for messaging + */ + protected readonly ioHelper: IoHelper; + + constructor(public readonly assembly: cxapi.CloudAssembly, ioHelper: IoHelper) { + this.directory = assembly.directory; + this.ioHelper = ioHelper; + } + + /** + * Select a single stack by its ID + */ + public stackById(stackId: string) { + return new StackCollection(this, [this.assembly.getStackArtifact(stackId)]); + } + + protected async selectMatchingStacks( + stacks: cxapi.CloudFormationStackArtifact[], + patterns: string[], + extend: ExtendedStackSelection = ExtendedStackSelection.None, + ): Promise { + const matchingPattern = (pattern: string) => (stack: cxapi.CloudFormationStackArtifact) => minimatch(stack.hierarchicalId, pattern); + const matchedStacks = flatten(patterns.map(pattern => stacks.filter(matchingPattern(pattern)))); + + return this.extendStacks(matchedStacks, stacks, extend); + } + + protected async extendStacks( + matched: cxapi.CloudFormationStackArtifact[], + all: cxapi.CloudFormationStackArtifact[], + extend: ExtendedStackSelection = ExtendedStackSelection.None, + ) { + const allStacks = new Map(); + for (const stack of all) { + allStacks.set(stack.hierarchicalId, stack); + } + + const index = indexByHierarchicalId(matched); + + switch (extend) { + case ExtendedStackSelection.Downstream: + await includeDownstreamStacks(this.ioHelper, index, allStacks); + break; + case ExtendedStackSelection.Upstream: + await includeUpstreamStacks(this.ioHelper, index, allStacks); + break; + } + + // Filter original array because it is in the right order + const selectedList = all.filter(s => index.has(s.hierarchicalId)); + + return new StackCollection(this, selectedList); + } +} + +function indexByHierarchicalId(stacks: cxapi.CloudFormationStackArtifact[]): Map { + const result = new Map(); + + for (const stack of stacks) { + result.set(stack.hierarchicalId, stack); + } + + return result; +} + +/** + * Calculate the transitive closure of stack dependents. + * + * Modifies `selectedStacks` in-place. + */ +async function includeDownstreamStacks( + ioHelper: IoHelper, + selectedStacks: Map, + allStacks: Map, +) { + const added = new Array(); + + let madeProgress; + do { + madeProgress = false; + + for (const [id, stack] of allStacks) { + // Select this stack if it's not selected yet AND it depends on a stack that's in the selected set + if (!selectedStacks.has(id) && (stack.dependencies || []).some(dep => selectedStacks.has(dep.id))) { + selectedStacks.set(id, stack); + added.push(id); + madeProgress = true; + } + } + } while (madeProgress); + + if (added.length > 0) { + await ioHelper.notify(IO.DEFAULT_ASSEMBLY_INFO.msg(`Including depending stacks: ${chalk.bold(added.join(', '))}`)); + } +} + +/** + * Calculate the transitive closure of stack dependencies. + * + * Modifies `selectedStacks` in-place. + */ +async function includeUpstreamStacks( + ioHelper: IoHelper, + selectedStacks: Map, + allStacks: Map, +) { + const added = new Array(); + let madeProgress = true; + while (madeProgress) { + madeProgress = false; + + for (const stack of selectedStacks.values()) { + // Select an additional stack if it's not selected yet and a dependency of a selected stack (and exists, obviously) + for (const dependencyId of stack.dependencies.map(x => x.manifest.displayName ?? x.id)) { + if (!selectedStacks.has(dependencyId) && allStacks.has(dependencyId)) { + added.push(dependencyId); + selectedStacks.set(dependencyId, allStacks.get(dependencyId)!); + madeProgress = true; + } + } + } + } + + if (added.length > 0) { + await ioHelper.notify(IO.DEFAULT_ASSEMBLY_INFO.msg(`Including dependency stacks: ${chalk.bold(added.join(', '))}`)); + } +} diff --git a/packages/@aws-cdk/tmp-toolkit-helpers/src/api/cloud-assembly/stack-collection.ts b/packages/@aws-cdk/tmp-toolkit-helpers/src/api/cloud-assembly/stack-collection.ts new file mode 100644 index 000000000..e0fd03e72 --- /dev/null +++ b/packages/@aws-cdk/tmp-toolkit-helpers/src/api/cloud-assembly/stack-collection.ts @@ -0,0 +1,127 @@ +import type * as cxapi from '@aws-cdk/cx-api'; +import { SynthesisMessageLevel } from '@aws-cdk/cx-api'; +import { AssemblyError, ToolkitError } from '../toolkit-error'; +import type { IStackAssembly } from './stack-assembly'; +import { type StackDetails } from '../../payloads/stack-details'; + +/** + * A collection of stacks and related artifacts + * + * In practice, not all artifacts in the CloudAssembly are created equal; + * stacks can be selected independently, but other artifacts such as asset + * bundles cannot. + */ +export class StackCollection { + constructor(public readonly assembly: IStackAssembly, public readonly stackArtifacts: cxapi.CloudFormationStackArtifact[]) { + } + + public get stackCount() { + return this.stackArtifacts.length; + } + + public get firstStack() { + if (this.stackCount < 1) { + throw new ToolkitError('StackCollection contains no stack artifacts (trying to access the first one)'); + } + return this.stackArtifacts[0]; + } + + public get stackIds(): string[] { + return this.stackArtifacts.map(s => s.id); + } + + public get hierarchicalIds(): string[] { + return this.stackArtifacts.map(s => s.hierarchicalId); + } + + public withDependencies(): StackDetails[] { + const allData: StackDetails[] = []; + + for (const stack of this.stackArtifacts) { + const data: StackDetails = { + id: stack.displayName ?? stack.id, + name: stack.stackName, + environment: stack.environment, + dependencies: [], + }; + + for (const dependencyId of stack.dependencies.map(x => x.id)) { + if (dependencyId.includes('.assets')) { + continue; + } + + const depStack = this.assembly.stackById(dependencyId); + + if (depStack.firstStack.dependencies.filter((dep) => !(dep.id).includes('.assets')).length > 0) { + for (const stackDetail of depStack.withDependencies()) { + data.dependencies.push({ + id: stackDetail.id, + dependencies: stackDetail.dependencies, + }); + } + } else { + data.dependencies.push({ + id: depStack.firstStack.displayName ?? depStack.firstStack.id, + dependencies: [], + }); + } + } + + allData.push(data); + } + + return allData; + } + + public reversed() { + const arts = [...this.stackArtifacts]; + arts.reverse(); + return new StackCollection(this.assembly, arts); + } + + public filter(predicate: (art: cxapi.CloudFormationStackArtifact) => boolean): StackCollection { + return new StackCollection(this.assembly, this.stackArtifacts.filter(predicate)); + } + + public concat(...others: StackCollection[]): StackCollection { + return new StackCollection(this.assembly, this.stackArtifacts.concat(...others.map(o => o.stackArtifacts))); + } + + /** + * Extracts 'aws:cdk:warning|info|error' metadata entries from the stack synthesis + */ + public async validateMetadata( + failAt: 'warn' | 'error' | 'none' = 'error', + logger: (level: 'info' | 'error' | 'warn', msg: cxapi.SynthesisMessage) => Promise = async () => { + }, + ) { + let warnings = false; + let errors = false; + + for (const stack of this.stackArtifacts) { + for (const message of stack.messages) { + switch (message.level) { + case SynthesisMessageLevel.WARNING: + warnings = true; + await logger('warn', message); + break; + case SynthesisMessageLevel.ERROR: + errors = true; + await logger('error', message); + break; + case SynthesisMessageLevel.INFO: + await logger('info', message); + break; + } + } + } + + if (errors && failAt != 'none') { + throw AssemblyError.withStacks('Found errors', this.stackArtifacts); + } + + if (warnings && failAt === 'warn') { + throw AssemblyError.withStacks('Found warnings (--strict mode)', this.stackArtifacts); + } + } +} diff --git a/packages/@aws-cdk/tmp-toolkit-helpers/src/api/index.ts b/packages/@aws-cdk/tmp-toolkit-helpers/src/api/index.ts index f6caf572a..5b80bd835 100644 --- a/packages/@aws-cdk/tmp-toolkit-helpers/src/api/index.ts +++ b/packages/@aws-cdk/tmp-toolkit-helpers/src/api/index.ts @@ -7,3 +7,4 @@ export * from './plugin'; export * from './require-approval'; export * from './resource-import'; export * from './toolkit-error'; +export * from './settings'; diff --git a/packages/@aws-cdk/tmp-toolkit-helpers/src/api/private.ts b/packages/@aws-cdk/tmp-toolkit-helpers/src/api/private.ts new file mode 100644 index 000000000..f4eb4921b --- /dev/null +++ b/packages/@aws-cdk/tmp-toolkit-helpers/src/api/private.ts @@ -0,0 +1 @@ +export * from './io/private'; diff --git a/packages/@aws-cdk/tmp-toolkit-helpers/src/api/settings.ts b/packages/@aws-cdk/tmp-toolkit-helpers/src/api/settings.ts new file mode 100644 index 000000000..46bde4a68 --- /dev/null +++ b/packages/@aws-cdk/tmp-toolkit-helpers/src/api/settings.ts @@ -0,0 +1,120 @@ +import * as os from 'os'; +import * as fs_path from 'path'; +import * as fs from 'fs-extra'; +import { ToolkitError } from './toolkit-error'; +import * as util from '../util'; + +export type SettingsMap = { [key: string]: any }; + +/** + * If a context value is an object with this key set to a truthy value, it won't be saved to cdk.context.json + */ +export const TRANSIENT_CONTEXT_KEY = '$dontSaveContext'; + +/** + * A single bag of settings + */ +export class Settings { + public static mergeAll(...settings: Settings[]): Settings { + let ret = new Settings(); + for (const setting of settings) { + ret = ret.merge(setting); + } + return ret; + } + + constructor( + private settings: SettingsMap = {}, + public readonly readOnly = false, + ) { + } + + public async save(fileName: string): Promise { + const expanded = expandHomeDir(fileName); + await fs.writeJson(expanded, stripTransientValues(this.settings), { + spaces: 2, + }); + return this; + } + + public get all(): any { + return this.get([]); + } + + public merge(other: Settings): Settings { + return new Settings(util.deepMerge(this.settings, other.settings)); + } + + public subSettings(keyPrefix: string[]) { + return new Settings(this.get(keyPrefix) || {}, false); + } + + public makeReadOnly(): Settings { + return new Settings(this.settings, true); + } + + public clear() { + if (this.readOnly) { + throw new ToolkitError('Cannot clear(): settings are readonly'); + } + this.settings = {}; + } + + public get empty(): boolean { + return Object.keys(this.settings).length === 0; + } + + public get(path: string[]): any { + return util.deepClone(util.deepGet(this.settings, path)); + } + + public set(path: string[], value: any): Settings { + if (this.readOnly) { + throw new ToolkitError(`Can't set ${path}: settings object is readonly`); + } + if (path.length === 0) { + // deepSet can't handle this case + this.settings = value; + } else { + util.deepSet(this.settings, path, value); + } + return this; + } + + public unset(path: string[]) { + this.set(path, undefined); + } +} + +function expandHomeDir(x: string) { + if (x.startsWith('~')) { + return fs_path.join(os.homedir(), x.slice(1)); + } + return x; +} + +/** + * Return all context value that are not transient context values + */ +function stripTransientValues(obj: { [key: string]: any }) { + const ret: any = {}; + for (const [key, value] of Object.entries(obj)) { + if (!isTransientValue(value)) { + ret[key] = value; + } + } + return ret; +} + +/** + * Return whether the given value is a transient context value + * + * Values that are objects with a magic key set to a truthy value are considered transient. + */ +function isTransientValue(value: any) { + return ( + typeof value === 'object' && + value !== null && + (value as any)[TRANSIENT_CONTEXT_KEY] + ); +} diff --git a/packages/@aws-cdk/toolkit-lib/lib/api/aws-cdk.ts b/packages/@aws-cdk/toolkit-lib/lib/api/aws-cdk.ts index 8c1e50c40..189a19fee 100644 --- a/packages/@aws-cdk/toolkit-lib/lib/api/aws-cdk.ts +++ b/packages/@aws-cdk/toolkit-lib/lib/api/aws-cdk.ts @@ -4,7 +4,6 @@ export { SdkProvider } from '../../../../aws-cdk/lib/api/aws-auth'; export { Context, PROJECT_CONTEXT } from '../../../../aws-cdk/lib/api/context'; export { createDiffChangeSet, Deployments, type SuccessfulDeployStackResult, type DeployStackOptions, type DeployStackResult } from '../../../../aws-cdk/lib/api/deployments'; -export { Settings } from '../../../../aws-cdk/lib/api/settings'; export { type Tag, tagsForStack } from '../../../../aws-cdk/lib/api/tags'; export { DEFAULT_TOOLKIT_STACK_NAME } from '../../../../aws-cdk/lib/api/toolkit-info'; export { ResourceMigrator } from '../../../../aws-cdk/lib/api/resource-import'; @@ -18,7 +17,3 @@ export { HotswapMode, HotswapPropertyOverrides, EcsHotswapProperties } from '../ // Context Providers export * as contextproviders from '../../../../aws-cdk/lib/context-providers'; - -// @todo Cloud Assembly and Executable - this is a messy API right now -export { CloudAssembly, sanitizePatterns, StackCollection, ExtendedStackSelection } from '../../../../aws-cdk/lib/api/cxapp/cloud-assembly'; -export { guessExecutable, prepareDefaultEnvironment, prepareContext, spaceAvailableForContext } from '../../../../aws-cdk/lib/api/cxapp/exec'; diff --git a/packages/@aws-cdk/toolkit-lib/lib/api/cloud-assembly/private/prepare-source.ts b/packages/@aws-cdk/toolkit-lib/lib/api/cloud-assembly/private/prepare-source.ts index 72d531724..254672ec9 100644 --- a/packages/@aws-cdk/toolkit-lib/lib/api/cloud-assembly/private/prepare-source.ts +++ b/packages/@aws-cdk/toolkit-lib/lib/api/cloud-assembly/private/prepare-source.ts @@ -6,7 +6,8 @@ import * as cxapi from '@aws-cdk/cx-api'; import * as fs from 'fs-extra'; import { lte } from 'semver'; import type { SdkProvider } from '../../../api/aws-cdk'; -import { prepareDefaultEnvironment as oldPrepare, prepareContext, spaceAvailableForContext, Settings, loadTree, some, guessExecutable } from '../../../api/aws-cdk'; +import { loadTree, some } from '../../../api/aws-cdk'; +import { prepareDefaultEnvironment as oldPrepare, prepareContext, spaceAvailableForContext, Settings, guessExecutable } from '../../../api/shared-private'; import { splitBySize, versionNumber } from '../../../private/util'; import type { ToolkitServices } from '../../../toolkit/private'; import { IO } from '../../io/private'; diff --git a/packages/@aws-cdk/toolkit-lib/lib/api/cloud-assembly/private/stack-assembly.ts b/packages/@aws-cdk/toolkit-lib/lib/api/cloud-assembly/private/stack-assembly.ts index 62418ee8f..072eb1dcc 100644 --- a/packages/@aws-cdk/toolkit-lib/lib/api/cloud-assembly/private/stack-assembly.ts +++ b/packages/@aws-cdk/toolkit-lib/lib/api/cloud-assembly/private/stack-assembly.ts @@ -1,6 +1,6 @@ import type * as cxapi from '@aws-cdk/cx-api'; import { major } from 'semver'; -import { CloudAssembly, sanitizePatterns, StackCollection, ExtendedStackSelection as CliExtendedStackSelection } from '../../aws-cdk'; +import { BaseStackAssembly, StackCollection, ExtendedStackSelection as CliExtendedStackSelection } from '../../shared-private'; import { ToolkitError } from '../../shared-public'; import type { StackSelector } from '../stack-selector'; import { ExpandStackSelection, StackSelectionStrategy } from '../stack-selector'; @@ -9,7 +9,7 @@ import type { ICloudAssemblySource } from '../types'; /** * A single Cloud Assembly wrapped to provide additional stack operations. */ -export class StackAssembly extends CloudAssembly implements ICloudAssemblySource { +export class StackAssembly extends BaseStackAssembly implements ICloudAssemblySource { public async produce(): Promise { return this.assembly; } @@ -29,7 +29,7 @@ export class StackAssembly extends CloudAssembly implements ICloudAssemblySource } const extend = expandToExtendEnum(selector.expand); - const patterns = sanitizePatterns(selector.patterns ?? []); + const patterns = StackAssembly.sanitizePatterns(selector.patterns ?? []); switch (selector.strategy) { case StackSelectionStrategy.ALL_STACKS: diff --git a/packages/aws-cdk/lib/api-private.ts b/packages/aws-cdk/lib/api-private.ts new file mode 100644 index 000000000..0e4dabd15 --- /dev/null +++ b/packages/aws-cdk/lib/api-private.ts @@ -0,0 +1,3 @@ +export * from '../../@aws-cdk/tmp-toolkit-helpers/src/api/private'; +// export { deployStack } from '../../@aws-cdk/tmp-toolkit-helpers/src/api/deployments/deploy-stack'; +// export * as cfnApi from '../../@aws-cdk/tmp-toolkit-helpers/src/api/deployments/cfn-api'; diff --git a/packages/aws-cdk/lib/api/cxapp/cloud-assembly.ts b/packages/aws-cdk/lib/api/cxapp/cloud-assembly.ts deleted file mode 100644 index 4d678ee9b..000000000 --- a/packages/aws-cdk/lib/api/cxapp/cloud-assembly.ts +++ /dev/null @@ -1,434 +0,0 @@ -import type * as cxapi from '@aws-cdk/cx-api'; -import { SynthesisMessageLevel } from '@aws-cdk/cx-api'; -import * as chalk from 'chalk'; -import { minimatch } from 'minimatch'; -import * as semver from 'semver'; -import { type StackDetails } from '../../../../@aws-cdk/tmp-toolkit-helpers'; -import { AssemblyError, ToolkitError } from '../../../../@aws-cdk/tmp-toolkit-helpers/src/api'; -import { IO, type IoHelper } from '../../../../@aws-cdk/tmp-toolkit-helpers/src/api/io/private'; -import { flatten } from '../../util'; - -export enum DefaultSelection { - /** - * Returns an empty selection in case there are no selectors. - */ - None = 'none', - - /** - * If the app includes a single stack, returns it. Otherwise throws an exception. - * This behavior is used by "deploy". - */ - OnlySingle = 'single', - - /** - * Returns all stacks in the main (top level) assembly only. - */ - MainAssembly = 'main', - - /** - * If no selectors are provided, returns all stacks in the app, - * including stacks inside nested assemblies. - */ - AllStacks = 'all', -} - -export interface SelectStacksOptions { - /** - * Extend the selection to upstread/downstream stacks - * @default ExtendedStackSelection.None only select the specified stacks. - */ - extend?: ExtendedStackSelection; - - /** - * The behavior if no selectors are provided. - */ - defaultBehavior: DefaultSelection; - - /** - * Whether to deploy if the app contains no stacks. - * - * @default false - */ - ignoreNoStacks?: boolean; -} - -/** - * When selecting stacks, what other stacks to include because of dependencies - */ -export enum ExtendedStackSelection { - /** - * Don't select any extra stacks - */ - None, - - /** - * Include stacks that this stack depends on - */ - Upstream, - - /** - * Include stacks that depend on this stack - */ - Downstream, -} - -/** - * A specification of which stacks should be selected - */ -export interface StackSelector { - /** - * Whether all stacks at the top level assembly should - * be selected and nothing else - */ - allTopLevel?: boolean; - - /** - * A list of patterns to match the stack hierarchical ids - */ - patterns: string[]; -} - -/** - * A single Cloud Assembly and the operations we do on it to deploy the artifacts inside - */ -export class CloudAssembly { - /** - * The directory this CloudAssembly was read from - */ - public readonly directory: string; - - private readonly ioHelper: IoHelper; - - constructor(public readonly assembly: cxapi.CloudAssembly, ioHelper: IoHelper) { - this.directory = assembly.directory; - this.ioHelper = ioHelper; - } - - public async selectStacks(selector: StackSelector, options: SelectStacksOptions): Promise { - const asm = this.assembly; - const topLevelStacks = asm.stacks; - const stacks = semver.major(asm.version) < 10 ? asm.stacks : asm.stacksRecursively; - const allTopLevel = selector.allTopLevel ?? false; - const patterns = sanitizePatterns(selector.patterns); - - if (stacks.length === 0) { - if (options.ignoreNoStacks) { - return new StackCollection(this, []); - } - throw new ToolkitError('This app contains no stacks'); - } - - if (allTopLevel) { - return this.selectTopLevelStacks(stacks, topLevelStacks, options.extend); - } else if (patterns.length > 0) { - return this.selectMatchingStacks(stacks, patterns, options.extend); - } else { - return this.selectDefaultStacks(stacks, topLevelStacks, options.defaultBehavior); - } - } - - private async selectTopLevelStacks( - stacks: cxapi.CloudFormationStackArtifact[], - topLevelStacks: cxapi.CloudFormationStackArtifact[], - extend: ExtendedStackSelection = ExtendedStackSelection.None, - ): Promise { - if (topLevelStacks.length > 0) { - return this.extendStacks(topLevelStacks, stacks, extend); - } else { - throw new ToolkitError('No stack found in the main cloud assembly. Use "list" to print manifest'); - } - } - - protected async selectMatchingStacks( - stacks: cxapi.CloudFormationStackArtifact[], - patterns: string[], - extend: ExtendedStackSelection = ExtendedStackSelection.None, - ): Promise { - const matchingPattern = (pattern: string) => (stack: cxapi.CloudFormationStackArtifact) => minimatch(stack.hierarchicalId, pattern); - const matchedStacks = flatten(patterns.map(pattern => stacks.filter(matchingPattern(pattern)))); - - return this.extendStacks(matchedStacks, stacks, extend); - } - - private selectDefaultStacks( - stacks: cxapi.CloudFormationStackArtifact[], - topLevelStacks: cxapi.CloudFormationStackArtifact[], - defaultSelection: DefaultSelection, - ) { - switch (defaultSelection) { - case DefaultSelection.MainAssembly: - return new StackCollection(this, topLevelStacks); - case DefaultSelection.AllStacks: - return new StackCollection(this, stacks); - case DefaultSelection.None: - return new StackCollection(this, []); - case DefaultSelection.OnlySingle: - if (topLevelStacks.length === 1) { - return new StackCollection(this, topLevelStacks); - } else { - throw new ToolkitError('Since this app includes more than a single stack, specify which stacks to use (wildcards are supported) or specify `--all`\n' + - `Stacks: ${stacks.map(x => x.hierarchicalId).join(' · ')}`); - } - default: - throw new ToolkitError(`invalid default behavior: ${defaultSelection}`); - } - } - - protected async extendStacks( - matched: cxapi.CloudFormationStackArtifact[], - all: cxapi.CloudFormationStackArtifact[], - extend: ExtendedStackSelection = ExtendedStackSelection.None, - ) { - const allStacks = new Map(); - for (const stack of all) { - allStacks.set(stack.hierarchicalId, stack); - } - - const index = indexByHierarchicalId(matched); - - switch (extend) { - case ExtendedStackSelection.Downstream: - await includeDownstreamStacks(this.ioHelper, index, allStacks); - break; - case ExtendedStackSelection.Upstream: - await includeUpstreamStacks(this.ioHelper, index, allStacks); - break; - } - - // Filter original array because it is in the right order - const selectedList = all.filter(s => index.has(s.hierarchicalId)); - - return new StackCollection(this, selectedList); - } - - /** - * Select a single stack by its ID - */ - public stackById(stackId: string) { - return new StackCollection(this, [this.assembly.getStackArtifact(stackId)]); - } -} - -/** - * A collection of stacks and related artifacts - * - * In practice, not all artifacts in the CloudAssembly are created equal; - * stacks can be selected independently, but other artifacts such as asset - * bundles cannot. - */ -export class StackCollection { - constructor(public readonly assembly: CloudAssembly, public readonly stackArtifacts: cxapi.CloudFormationStackArtifact[]) { - } - - public get stackCount() { - return this.stackArtifacts.length; - } - - public get firstStack() { - if (this.stackCount < 1) { - throw new ToolkitError('StackCollection contains no stack artifacts (trying to access the first one)'); - } - return this.stackArtifacts[0]; - } - - public get stackIds(): string[] { - return this.stackArtifacts.map(s => s.id); - } - - public get hierarchicalIds(): string[] { - return this.stackArtifacts.map(s => s.hierarchicalId); - } - - public withDependencies(): StackDetails[] { - const allData: StackDetails[] = []; - - for (const stack of this.stackArtifacts) { - const data: StackDetails = { - id: stack.displayName ?? stack.id, - name: stack.stackName, - environment: stack.environment, - dependencies: [], - }; - - for (const dependencyId of stack.dependencies.map(x => x.id)) { - if (dependencyId.includes('.assets')) { - continue; - } - - const depStack = this.assembly.stackById(dependencyId); - - if (depStack.firstStack.dependencies.filter((dep) => !(dep.id).includes('.assets')).length > 0) { - for (const stackDetail of depStack.withDependencies()) { - data.dependencies.push({ - id: stackDetail.id, - dependencies: stackDetail.dependencies, - }); - } - } else { - data.dependencies.push({ - id: depStack.firstStack.displayName ?? depStack.firstStack.id, - dependencies: [], - }); - } - } - - allData.push(data); - } - - return allData; - } - - public reversed() { - const arts = [...this.stackArtifacts]; - arts.reverse(); - return new StackCollection(this.assembly, arts); - } - - public filter(predicate: (art: cxapi.CloudFormationStackArtifact) => boolean): StackCollection { - return new StackCollection(this.assembly, this.stackArtifacts.filter(predicate)); - } - - public concat(...others: StackCollection[]): StackCollection { - return new StackCollection(this.assembly, this.stackArtifacts.concat(...others.map(o => o.stackArtifacts))); - } - - /** - * Extracts 'aws:cdk:warning|info|error' metadata entries from the stack synthesis - */ - public async validateMetadata( - failAt: 'warn' | 'error' | 'none' = 'error', - logger: (level: 'info' | 'error' | 'warn', msg: cxapi.SynthesisMessage) => Promise = async () => { - }, - ) { - let warnings = false; - let errors = false; - - for (const stack of this.stackArtifacts) { - for (const message of stack.messages) { - switch (message.level) { - case SynthesisMessageLevel.WARNING: - warnings = true; - await logger('warn', message); - break; - case SynthesisMessageLevel.ERROR: - errors = true; - await logger('error', message); - break; - case SynthesisMessageLevel.INFO: - await logger('info', message); - break; - } - } - } - - if (errors && failAt != 'none') { - throw AssemblyError.withStacks('Found errors', this.stackArtifacts); - } - - if (warnings && failAt === 'warn') { - throw AssemblyError.withStacks('Found warnings (--strict mode)', this.stackArtifacts); - } - } -} - -export interface MetadataMessageOptions { - /** - * Whether to be verbose - * - * @default false - */ - verbose?: boolean; - - /** - * Don't stop on error metadata - * - * @default false - */ - ignoreErrors?: boolean; - - /** - * Treat warnings in metadata as errors - * - * @default false - */ - strict?: boolean; -} - -function indexByHierarchicalId(stacks: cxapi.CloudFormationStackArtifact[]): Map { - const result = new Map(); - - for (const stack of stacks) { - result.set(stack.hierarchicalId, stack); - } - - return result; -} - -/** - * Calculate the transitive closure of stack dependents. - * - * Modifies `selectedStacks` in-place. - */ -async function includeDownstreamStacks( - ioHelper: IoHelper, - selectedStacks: Map, - allStacks: Map, -) { - const added = new Array(); - - let madeProgress; - do { - madeProgress = false; - - for (const [id, stack] of allStacks) { - // Select this stack if it's not selected yet AND it depends on a stack that's in the selected set - if (!selectedStacks.has(id) && (stack.dependencies || []).some(dep => selectedStacks.has(dep.id))) { - selectedStacks.set(id, stack); - added.push(id); - madeProgress = true; - } - } - } while (madeProgress); - - if (added.length > 0) { - await ioHelper.notify(IO.DEFAULT_ASSEMBLY_INFO.msg(`Including depending stacks: ${chalk.bold(added.join(', '))}`)); - } -} - -/** - * Calculate the transitive closure of stack dependencies. - * - * Modifies `selectedStacks` in-place. - */ -async function includeUpstreamStacks( - ioHelper: IoHelper, - selectedStacks: Map, - allStacks: Map, -) { - const added = new Array(); - let madeProgress = true; - while (madeProgress) { - madeProgress = false; - - for (const stack of selectedStacks.values()) { - // Select an additional stack if it's not selected yet and a dependency of a selected stack (and exists, obviously) - for (const dependencyId of stack.dependencies.map(x => x.manifest.displayName ?? x.id)) { - if (!selectedStacks.has(dependencyId) && allStacks.has(dependencyId)) { - added.push(dependencyId); - selectedStacks.set(dependencyId, allStacks.get(dependencyId)!); - madeProgress = true; - } - } - } - } - - if (added.length > 0) { - await ioHelper.notify(IO.DEFAULT_ASSEMBLY_INFO.msg(`Including dependency stacks: ${chalk.bold(added.join(', '))}`)); - } -} - -export function sanitizePatterns(patterns: string[]): string[] { - let sanitized = patterns.filter(s => s != null); // filter null/undefined - sanitized = [...new Set(sanitized)]; // make them unique - return sanitized; -} diff --git a/packages/aws-cdk/lib/api/index.ts b/packages/aws-cdk/lib/api/index.ts index 0b8b64e2b..3bb4c5a3a 100644 --- a/packages/aws-cdk/lib/api/index.ts +++ b/packages/aws-cdk/lib/api/index.ts @@ -3,3 +3,8 @@ export * from './garbage-collection/garbage-collector'; export * from './deployments'; export * from './toolkit-info'; export * from './aws-auth'; +export * from './rwlock'; +export * from './tree'; + +export * from '../../../@aws-cdk/tmp-toolkit-helpers/src/api/toolkit-error'; +export * from '../../../@aws-cdk/tmp-toolkit-helpers/src/api/cloud-assembly'; diff --git a/packages/aws-cdk/lib/api/settings.ts b/packages/aws-cdk/lib/api/settings.ts index 61e27f962..c0db168e2 100644 --- a/packages/aws-cdk/lib/api/settings.ts +++ b/packages/aws-cdk/lib/api/settings.ts @@ -1,120 +1 @@ -import * as os from 'os'; -import * as fs_path from 'path'; -import * as fs from 'fs-extra'; -import { ToolkitError } from '../../../@aws-cdk/tmp-toolkit-helpers/src/api'; -import * as util from '../util'; - -export type SettingsMap = { [key: string]: any }; - -/** - * If a context value is an object with this key set to a truthy value, it won't be saved to cdk.context.json - */ -export const TRANSIENT_CONTEXT_KEY = '$dontSaveContext'; - -/** - * A single bag of settings - */ -export class Settings { - public static mergeAll(...settings: Settings[]): Settings { - let ret = new Settings(); - for (const setting of settings) { - ret = ret.merge(setting); - } - return ret; - } - - constructor( - private settings: SettingsMap = {}, - public readonly readOnly = false, - ) { - } - - public async save(fileName: string): Promise { - const expanded = expandHomeDir(fileName); - await fs.writeJson(expanded, stripTransientValues(this.settings), { - spaces: 2, - }); - return this; - } - - public get all(): any { - return this.get([]); - } - - public merge(other: Settings): Settings { - return new Settings(util.deepMerge(this.settings, other.settings)); - } - - public subSettings(keyPrefix: string[]) { - return new Settings(this.get(keyPrefix) || {}, false); - } - - public makeReadOnly(): Settings { - return new Settings(this.settings, true); - } - - public clear() { - if (this.readOnly) { - throw new ToolkitError('Cannot clear(): settings are readonly'); - } - this.settings = {}; - } - - public get empty(): boolean { - return Object.keys(this.settings).length === 0; - } - - public get(path: string[]): any { - return util.deepClone(util.deepGet(this.settings, path)); - } - - public set(path: string[], value: any): Settings { - if (this.readOnly) { - throw new ToolkitError(`Can't set ${path}: settings object is readonly`); - } - if (path.length === 0) { - // deepSet can't handle this case - this.settings = value; - } else { - util.deepSet(this.settings, path, value); - } - return this; - } - - public unset(path: string[]) { - this.set(path, undefined); - } -} - -function expandHomeDir(x: string) { - if (x.startsWith('~')) { - return fs_path.join(os.homedir(), x.slice(1)); - } - return x; -} - -/** - * Return all context value that are not transient context values - */ -function stripTransientValues(obj: { [key: string]: any }) { - const ret: any = {}; - for (const [key, value] of Object.entries(obj)) { - if (!isTransientValue(value)) { - ret[key] = value; - } - } - return ret; -} - -/** - * Return whether the given value is a transient context value - * - * Values that are objects with a magic key set to a truthy value are considered transient. - */ -function isTransientValue(value: any) { - return ( - typeof value === 'object' && - value !== null && - (value as any)[TRANSIENT_CONTEXT_KEY] - ); -} +export * from '../../../@aws-cdk/tmp-toolkit-helpers/src/api/settings'; diff --git a/packages/aws-cdk/lib/cxapp/cloud-assembly.ts b/packages/aws-cdk/lib/cxapp/cloud-assembly.ts new file mode 100644 index 000000000..89cbffd4d --- /dev/null +++ b/packages/aws-cdk/lib/cxapp/cloud-assembly.ts @@ -0,0 +1,160 @@ +import type * as cxapi from '@aws-cdk/cx-api'; +import { minimatch } from 'minimatch'; +import * as semver from 'semver'; +import { BaseStackAssembly, StackCollection, ToolkitError } from '../api'; +import { flatten } from '../util'; + +export enum DefaultSelection { + /** + * Returns an empty selection in case there are no selectors. + */ + None = 'none', + + /** + * If the app includes a single stack, returns it. Otherwise throws an exception. + * This behavior is used by "deploy". + */ + OnlySingle = 'single', + + /** + * Returns all stacks in the main (top level) assembly only. + */ + MainAssembly = 'main', + + /** + * If no selectors are provided, returns all stacks in the app, + * including stacks inside nested assemblies. + */ + AllStacks = 'all', +} + +export interface SelectStacksOptions { + /** + * Extend the selection to upstread/downstream stacks + * @default ExtendedStackSelection.None only select the specified stacks. + */ + extend?: ExtendedStackSelection; + + /** + * The behavior if no selectors are provided. + */ + defaultBehavior: DefaultSelection; + + /** + * Whether to deploy if the app contains no stacks. + * + * @default false + */ + ignoreNoStacks?: boolean; +} + +/** + * When selecting stacks, what other stacks to include because of dependencies + */ +export enum ExtendedStackSelection { + /** + * Don't select any extra stacks + */ + None, + + /** + * Include stacks that this stack depends on + */ + Upstream, + + /** + * Include stacks that depend on this stack + */ + Downstream, +} + +/** + * A specification of which stacks should be selected + */ +export interface StackSelector { + /** + * Whether all stacks at the top level assembly should + * be selected and nothing else + */ + allTopLevel?: boolean; + + /** + * A list of patterns to match the stack hierarchical ids + */ + patterns: string[]; +} + +/** + * A single Cloud Assembly and the operations we do on it to deploy the artifacts inside + */ +export class CloudAssembly extends BaseStackAssembly { + public async selectStacks(selector: StackSelector, options: SelectStacksOptions): Promise { + const asm = this.assembly; + const topLevelStacks = asm.stacks; + const stacks = semver.major(asm.version) < 10 ? asm.stacks : asm.stacksRecursively; + const allTopLevel = selector.allTopLevel ?? false; + const patterns = CloudAssembly.sanitizePatterns(selector.patterns); + + if (stacks.length === 0) { + if (options.ignoreNoStacks) { + return new StackCollection(this, []); + } + throw new ToolkitError('This app contains no stacks'); + } + + if (allTopLevel) { + return this.selectTopLevelStacks(stacks, topLevelStacks, options.extend); + } else if (patterns.length > 0) { + return this.selectMatchingStacks(stacks, patterns, options.extend); + } else { + return this.selectDefaultStacks(stacks, topLevelStacks, options.defaultBehavior); + } + } + + private async selectTopLevelStacks( + stacks: cxapi.CloudFormationStackArtifact[], + topLevelStacks: cxapi.CloudFormationStackArtifact[], + extend: ExtendedStackSelection = ExtendedStackSelection.None, + ): Promise { + if (topLevelStacks.length > 0) { + return this.extendStacks(topLevelStacks, stacks, extend); + } else { + throw new ToolkitError('No stack found in the main cloud assembly. Use "list" to print manifest'); + } + } + + protected async selectMatchingStacks( + stacks: cxapi.CloudFormationStackArtifact[], + patterns: string[], + extend: ExtendedStackSelection = ExtendedStackSelection.None, + ): Promise { + const matchingPattern = (pattern: string) => (stack: cxapi.CloudFormationStackArtifact) => minimatch(stack.hierarchicalId, pattern); + const matchedStacks = flatten(patterns.map(pattern => stacks.filter(matchingPattern(pattern)))); + + return this.extendStacks(matchedStacks, stacks, extend); + } + + private selectDefaultStacks( + stacks: cxapi.CloudFormationStackArtifact[], + topLevelStacks: cxapi.CloudFormationStackArtifact[], + defaultSelection: DefaultSelection, + ) { + switch (defaultSelection) { + case DefaultSelection.MainAssembly: + return new StackCollection(this, topLevelStacks); + case DefaultSelection.AllStacks: + return new StackCollection(this, stacks); + case DefaultSelection.None: + return new StackCollection(this, []); + case DefaultSelection.OnlySingle: + if (topLevelStacks.length === 1) { + return new StackCollection(this, topLevelStacks); + } else { + throw new ToolkitError('Since this app includes more than a single stack, specify which stacks to use (wildcards are supported) or specify `--all`\n' + + `Stacks: ${stacks.map(x => x.hierarchicalId).join(' · ')}`); + } + default: + throw new ToolkitError(`invalid default behavior: ${defaultSelection}`); + } + } +} diff --git a/packages/aws-cdk/lib/api/cxapp/cloud-executable.ts b/packages/aws-cdk/lib/cxapp/cloud-executable.ts similarity index 92% rename from packages/aws-cdk/lib/api/cxapp/cloud-executable.ts rename to packages/aws-cdk/lib/cxapp/cloud-executable.ts index db8775043..2309d177a 100644 --- a/packages/aws-cdk/lib/api/cxapp/cloud-executable.ts +++ b/packages/aws-cdk/lib/cxapp/cloud-executable.ts @@ -1,10 +1,10 @@ import type * as cxapi from '@aws-cdk/cx-api'; import { CloudAssembly } from './cloud-assembly'; -import { ToolkitError } from '../../../../@aws-cdk/tmp-toolkit-helpers/src/api'; -import { IO, type IoHelper } from '../../../../@aws-cdk/tmp-toolkit-helpers/src/api/io/private'; -import type { Configuration } from '../../cli/user-configuration'; -import * as contextproviders from '../../context-providers'; -import type { SdkProvider } from '../aws-auth'; +import { ToolkitError } from '../api'; +import type { SdkProvider } from '../api/aws-auth'; +import { IO, type IoHelper } from '../api-private'; +import type { Configuration } from '../cli/user-configuration'; +import * as contextproviders from '../context-providers'; /** * @returns output directory diff --git a/packages/aws-cdk/lib/api/cxapp/environments.ts b/packages/aws-cdk/lib/cxapp/environments.ts similarity index 94% rename from packages/aws-cdk/lib/api/cxapp/environments.ts rename to packages/aws-cdk/lib/cxapp/environments.ts index 2e381e858..9f47f9dbc 100644 --- a/packages/aws-cdk/lib/api/cxapp/environments.ts +++ b/packages/aws-cdk/lib/cxapp/environments.ts @@ -1,8 +1,8 @@ import type * as cxapi from '@aws-cdk/cx-api'; import { minimatch } from 'minimatch'; import type { StackCollection } from './cloud-assembly'; -import { ToolkitError } from '../../../../@aws-cdk/tmp-toolkit-helpers/src/api'; -import type { SdkProvider } from '../aws-auth'; +import { ToolkitError } from '../api'; +import type { SdkProvider } from '../api/aws-auth'; export function looksLikeGlob(environment: string) { return environment.indexOf('*') > -1; diff --git a/packages/aws-cdk/lib/api/cxapp/exec.ts b/packages/aws-cdk/lib/cxapp/exec.ts similarity index 53% rename from packages/aws-cdk/lib/api/cxapp/exec.ts rename to packages/aws-cdk/lib/cxapp/exec.ts index 8645da2fe..cee3f5cbc 100644 --- a/packages/aws-cdk/lib/api/cxapp/exec.ts +++ b/packages/aws-cdk/lib/cxapp/exec.ts @@ -6,17 +6,13 @@ import * as cxschema from '@aws-cdk/cloud-assembly-schema'; import * as cxapi from '@aws-cdk/cx-api'; import * as fs from 'fs-extra'; import * as semver from 'semver'; -import { ToolkitError } from '../../../../@aws-cdk/tmp-toolkit-helpers/src/api'; -import { IO, type IoHelper } from '../../../../@aws-cdk/tmp-toolkit-helpers/src/api/io/private'; -import { loadTree, some } from '../../api/tree'; -import type { Configuration } from '../../cli/user-configuration'; -import { PROJECT_CONFIG, USER_DEFAULTS } from '../../cli/user-configuration'; -import { versionNumber } from '../../cli/version'; -import { splitBySize } from '../../util'; -import type { SdkProvider } from '../aws-auth'; -import type { ILock } from '../rwlock'; -import { RWLock } from '../rwlock'; -import type { Settings } from '../settings'; +import type { SdkProvider, ILock } from '../api'; +import { RWLock, ToolkitError, guessExecutable, loadTree, prepareContext, prepareDefaultEnvironment, some, spaceAvailableForContext } from '../api'; +import { IO, type IoHelper } from '../api-private'; +import type { Configuration } from '../cli/user-configuration'; +import { PROJECT_CONFIG, USER_DEFAULTS } from '../cli/user-configuration'; +import { versionNumber } from '../cli/version'; +import { splitBySize } from '../util'; export interface ExecProgramResult { readonly assembly: cxapi.CloudAssembly; @@ -163,143 +159,6 @@ export function createAssembly(appDir: string) { } } -/** - * If we don't have region/account defined in context, we fall back to the default SDK behavior - * where region is retrieved from ~/.aws/config and account is based on default credentials provider - * chain and then STS is queried. - * - * This is done opportunistically: for example, if we can't access STS for some reason or the region - * is not configured, the context value will be 'null' and there could failures down the line. In - * some cases, synthesis does not require region/account information at all, so that might be perfectly - * fine in certain scenarios. - * - * @param context The context key/value bash. - */ -export async function prepareDefaultEnvironment( - aws: SdkProvider, - debugFn: (msg: string) => Promise, -): Promise<{ [key: string]: string }> { - const env: { [key: string]: string } = { }; - - env[cxapi.DEFAULT_REGION_ENV] = aws.defaultRegion; - await debugFn(`Setting "${cxapi.DEFAULT_REGION_ENV}" environment variable to ${env[cxapi.DEFAULT_REGION_ENV]}`); - - const accountId = (await aws.defaultAccount())?.accountId; - if (accountId) { - env[cxapi.DEFAULT_ACCOUNT_ENV] = accountId; - await debugFn(`Setting "${cxapi.DEFAULT_ACCOUNT_ENV}" environment variable to ${env[cxapi.DEFAULT_ACCOUNT_ENV]}`); - } - - return env; -} - -/** - * Settings related to synthesis are read from context. - * The merging of various configuration sources like cli args or cdk.json has already happened. - * We now need to set the final values to the context. - */ -export async function prepareContext( - settings: Settings, - context: {[key: string]: any}, - env: { [key: string]: string | undefined}, - debugFn: (msg: string) => Promise, -) { - const debugMode: boolean = settings.get(['debug']) ?? true; - if (debugMode) { - env.CDK_DEBUG = 'true'; - } - - const pathMetadata: boolean = settings.get(['pathMetadata']) ?? true; - if (pathMetadata) { - context[cxapi.PATH_METADATA_ENABLE_CONTEXT] = true; - } - - const assetMetadata: boolean = settings.get(['assetMetadata']) ?? true; - if (assetMetadata) { - context[cxapi.ASSET_RESOURCE_METADATA_ENABLED_CONTEXT] = true; - } - - const versionReporting: boolean = settings.get(['versionReporting']) ?? true; - if (versionReporting) { - context[cxapi.ANALYTICS_REPORTING_ENABLED_CONTEXT] = true; - } - // We need to keep on doing this for framework version from before this flag was deprecated. - if (!versionReporting) { - context['aws:cdk:disable-version-reporting'] = true; - } - - const stagingEnabled = settings.get(['staging']) ?? true; - if (!stagingEnabled) { - context[cxapi.DISABLE_ASSET_STAGING_CONTEXT] = true; - } - - const bundlingStacks = settings.get(['bundlingStacks']) ?? ['**']; - context[cxapi.BUNDLING_STACKS] = bundlingStacks; - - await debugFn(format('context:', context)); - - return context; -} - -/** - * Make sure the 'app' is an array - * - * If it's a string, split on spaces as a trivial way of tokenizing the command line. - */ -function appToArray(app: any) { - return typeof app === 'string' ? app.split(' ') : app; -} - -type CommandGenerator = (file: string) => string[]; - -/** - * Execute the given file with the same 'node' process as is running the current process - */ -function executeNode(scriptFile: string): string[] { - return [process.execPath, scriptFile]; -} - -/** - * Mapping of extensions to command-line generators - */ -const EXTENSION_MAP = new Map([ - ['.js', executeNode], -]); - -/** - * Guess the executable from the command-line argument - * - * Only do this if the file is NOT marked as executable. If it is, - * we'll defer to the shebang inside the file itself. - * - * If we're on Windows, we ALWAYS take the handler, since it's hard to - * verify if registry associations have or have not been set up for this - * file type, so we'll assume the worst and take control. - */ -export async function guessExecutable(app: string, debugFn: (msg: string) => Promise) { - const commandLine = appToArray(app); - if (commandLine.length === 1) { - let fstat; - - try { - fstat = await fs.stat(commandLine[0]); - } catch { - await debugFn(`Not a file: '${commandLine[0]}'. Using '${commandLine}' as command-line`); - return commandLine; - } - - // eslint-disable-next-line no-bitwise - const isExecutable = (fstat.mode & fs.constants.X_OK) !== 0; - const isWindows = process.platform === 'win32'; - - const handler = EXTENSION_MAP.get(path.extname(commandLine[0])); - if (handler && (!isExecutable || isWindows)) { - return handler(commandLine[0]); - } - } - return commandLine; -} - async function contextOverflowCleanup( location: string | undefined, assembly: cxapi.CloudAssembly, @@ -323,13 +182,3 @@ async function contextOverflowCleanup( } } } - -export function spaceAvailableForContext(env: { [key: string]: string }, limit: number) { - const size = (value: string) => value != null ? Buffer.byteLength(value) : 0; - - const usedSpace = Object.entries(env) - .map(([k, v]) => k === cxapi.CONTEXT_ENV ? size(k) : size(k) + size(v)) - .reduce((a, b) => a + b, 0); - - return Math.max(0, limit - usedSpace); -} diff --git a/packages/aws-cdk/lib/cxapp/index.ts b/packages/aws-cdk/lib/cxapp/index.ts new file mode 100644 index 000000000..1fc49a040 --- /dev/null +++ b/packages/aws-cdk/lib/cxapp/index.ts @@ -0,0 +1,4 @@ +export * from './cloud-assembly'; +export * from './cloud-executable'; +export * from './environments'; +export * from './exec'; diff --git a/packages/aws-cdk/test/_helpers/assembly.ts b/packages/aws-cdk/test/_helpers/assembly.ts index 4d9f93a9c..4dfbf6d61 100644 --- a/packages/aws-cdk/test/_helpers/assembly.ts +++ b/packages/aws-cdk/test/_helpers/assembly.ts @@ -2,9 +2,9 @@ import * as fs from 'fs'; import * as path from 'path'; import { ArtifactMetadataEntryType, ArtifactType, type AssetManifest, type AssetMetadataEntry, type AwsCloudFormationStackProperties, type MetadataEntry, type MissingContext } from '@aws-cdk/cloud-assembly-schema'; import { type CloudAssembly, CloudAssemblyBuilder, type CloudFormationStackArtifact, type StackMetadata } from '@aws-cdk/cx-api'; -import { cxapiAssemblyWithForcedVersion } from '../api/cxapp/assembly-versions'; +import { cxapiAssemblyWithForcedVersion } from '../cxapp/assembly-versions'; import { MockSdkProvider } from '../_helpers/mock-sdk'; -import { CloudExecutable } from '../../lib/api/cxapp/cloud-executable'; +import { CloudExecutable } from '../../lib/cxapp/cloud-executable'; import { Configuration } from '../../lib/cli/user-configuration'; import { TestIoHost } from './io-host'; import { IIoHost } from '../../lib/cli/io-host'; diff --git a/packages/aws-cdk/test/api/cxapp/assembly-versions.ts b/packages/aws-cdk/test/cxapp/assembly-versions.ts similarity index 91% rename from packages/aws-cdk/test/api/cxapp/assembly-versions.ts rename to packages/aws-cdk/test/cxapp/assembly-versions.ts index b9218af91..3acecbb87 100644 --- a/packages/aws-cdk/test/api/cxapp/assembly-versions.ts +++ b/packages/aws-cdk/test/cxapp/assembly-versions.ts @@ -1,6 +1,7 @@ import * as fs from 'fs'; import * as cxapi from '@aws-cdk/cx-api'; -import { CloudAssembly } from '../../../lib/api/cxapp/cloud-assembly'; +import { CloudAssembly } from '../../lib/cxapp/cloud-assembly'; +import { TestIoHost } from '../_helpers/io-host'; /** * The cloud-assembly-schema in the new monorepo will use its own package version as the schema version, which is always `0.0.0` when tests are running. @@ -23,7 +24,7 @@ export function cxapiAssemblyWithForcedVersion(asm: cxapi.CloudAssembly, version */ export function cliAssemblyWithForcedVersion(asm: CloudAssembly, version: string) { rewriteManifestVersion(asm.directory, version); - return new CloudAssembly(new cxapi.CloudAssembly(asm.directory, { skipVersionCheck: true })); + return new CloudAssembly(new cxapi.CloudAssembly(asm.directory, { skipVersionCheck: true }), new TestIoHost().asHelper('synth')); } export function rewriteManifestVersion(directory: string, version: string) { diff --git a/packages/aws-cdk/test/api/cxapp/cloud-assembly.test.ts b/packages/aws-cdk/test/cxapp/cloud-assembly.test.ts similarity index 98% rename from packages/aws-cdk/test/api/cxapp/cloud-assembly.test.ts rename to packages/aws-cdk/test/cxapp/cloud-assembly.test.ts index 58a750b99..111fda9db 100644 --- a/packages/aws-cdk/test/api/cxapp/cloud-assembly.test.ts +++ b/packages/aws-cdk/test/cxapp/cloud-assembly.test.ts @@ -1,7 +1,7 @@ /* eslint-disable import/order */ import * as cxschema from '@aws-cdk/cloud-assembly-schema'; -import { DefaultSelection } from '../../../lib/api/cxapp/cloud-assembly'; -import { MockCloudExecutable } from '../../_helpers/assembly'; +import { DefaultSelection } from '../../lib/cxapp/cloud-assembly'; +import { MockCloudExecutable } from '../_helpers/assembly'; import { cliAssemblyWithForcedVersion } from './assembly-versions'; test('select all top level stacks in the presence of nested assemblies', async () => { diff --git a/packages/aws-cdk/test/api/cxapp/cloud-executable.test.ts b/packages/aws-cdk/test/cxapp/cloud-executable.test.ts similarity index 93% rename from packages/aws-cdk/test/api/cxapp/cloud-executable.test.ts rename to packages/aws-cdk/test/cxapp/cloud-executable.test.ts index ca66dda83..ea3171546 100644 --- a/packages/aws-cdk/test/api/cxapp/cloud-executable.test.ts +++ b/packages/aws-cdk/test/cxapp/cloud-executable.test.ts @@ -1,8 +1,8 @@ /* eslint-disable import/order */ import * as cxschema from '@aws-cdk/cloud-assembly-schema'; -import { DefaultSelection } from '../../../lib/api/cxapp/cloud-assembly'; -import { registerContextProvider } from '../../../lib/context-providers'; -import { MockCloudExecutable } from '../../_helpers/assembly'; +import { DefaultSelection } from '../../lib/cxapp/cloud-assembly'; +import { registerContextProvider } from '../../lib/context-providers'; +import { MockCloudExecutable } from '../_helpers/assembly'; describe('AWS::CDK::Metadata', () => { test('is not generated for new frameworks', async () => { diff --git a/packages/aws-cdk/test/api/cxapp/exec.test.ts b/packages/aws-cdk/test/cxapp/exec.test.ts similarity index 94% rename from packages/aws-cdk/test/api/cxapp/exec.test.ts rename to packages/aws-cdk/test/cxapp/exec.test.ts index e715cbc25..1df0ec518 100644 --- a/packages/aws-cdk/test/api/cxapp/exec.test.ts +++ b/packages/aws-cdk/test/cxapp/exec.test.ts @@ -1,19 +1,19 @@ jest.mock('child_process'); -import bockfs from '../../_helpers/bockfs'; +import bockfs from '../_helpers/bockfs'; import * as cxschema from '@aws-cdk/cloud-assembly-schema'; import * as cdk from 'aws-cdk-lib'; import * as semver from 'semver'; import * as sinon from 'sinon'; import { ImportMock } from 'ts-mock-imports'; -import { execProgram } from '../../../lib/api/cxapp/exec'; -import { Configuration } from '../../../lib/cli/user-configuration'; -import { testAssembly } from '../../_helpers/assembly'; -import { mockSpawn } from '../../util/mock-child_process'; -import { MockSdkProvider } from '../../_helpers/mock-sdk'; -import { RWLock } from '../../../lib/api/rwlock'; +import { execProgram } from '../../lib/cxapp/exec'; +import { Configuration } from '../../lib/cli/user-configuration'; +import { testAssembly } from '../_helpers/assembly'; +import { mockSpawn } from '../util/mock-child_process'; +import { MockSdkProvider } from '../_helpers/mock-sdk'; +import { RWLock } from '../../lib/api/rwlock'; import { rewriteManifestMinimumCliVersion, rewriteManifestVersion } from './assembly-versions'; -import { TestIoHost } from '../../_helpers/io-host'; -import { ToolkitError } from '../../../../@aws-cdk/tmp-toolkit-helpers/src/api'; +import { TestIoHost } from '../_helpers/io-host'; +import { ToolkitError } from '../../../@aws-cdk/tmp-toolkit-helpers/src/api'; let sdkProvider: MockSdkProvider; let config: Configuration;