From 35275c340d33284f7081af819f7989d82e442bf4 Mon Sep 17 00:00:00 2001 From: Momo Kornher Date: Thu, 16 Jan 2025 06:03:46 +0000 Subject: [PATCH] chore(toolkit): programmatic toolkit for the AWS CDK initial code (#32919) ### Description of changes Initial code for the Programmatic Toolkit. This won't be released just yet. Contains a mix of extensions and hard copies to the current CLI code. After this PR we are moving the appropriate tests over from the CLI. ### Describe any new or updated permissions being added n/a ### Description of how you validated changes For the changes to `aws-cdk` we run the existing tests and the integration tests. ### Checklist - [x] My code adheres to the [CONTRIBUTING GUIDE](https://github.com/aws/aws-cdk/blob/main/CONTRIBUTING.md) and [DESIGN GUIDELINES](https://github.com/aws/aws-cdk/blob/main/docs/DESIGN_GUIDELINES.md) ---- *By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license* --- .../package.json | 22 +- .../aws-api-handler/index.ts | 2 +- packages/@aws-cdk/toolkit/.gitignore | 1 + packages/@aws-cdk/toolkit/.npmignore | 29 +- packages/@aws-cdk/toolkit/bundle.mjs | 44 ++ .../@aws-cdk/toolkit/lib/actions/deploy.ts | 52 +- .../@aws-cdk/toolkit/lib/actions/destroy.ts | 9 +- packages/@aws-cdk/toolkit/lib/actions/diff.ts | 116 +++ .../@aws-cdk/toolkit/lib/actions/index.ts | 7 + packages/@aws-cdk/toolkit/lib/actions/list.ts | 8 + .../@aws-cdk/toolkit/lib/actions/rollback.ts | 44 ++ .../@aws-cdk/toolkit/lib/actions/synth.ts | 2 +- .../@aws-cdk/toolkit/lib/actions/watch.ts | 43 ++ .../toolkit/lib/api/aws-auth/index.ts | 1 + .../toolkit/lib/api/aws-auth/types.ts | 43 ++ packages/@aws-cdk/toolkit/lib/api/aws-cdk.ts | 27 + .../lib/api/cloud-assembly/cached-source.ts | 25 + .../lib/api/cloud-assembly/identity-source.ts | 13 + .../toolkit/lib/api/cloud-assembly/index.ts | 6 + .../private/context-aware-source.ts | 122 +++ .../lib/api/cloud-assembly/private/exec.ts | 45 ++ .../cloud-assembly/private/prepare-source.ts | 175 +++++ .../cloud-assembly/private/source-builder.ts | 222 ++++++ .../lib/api/cloud-assembly/stack-assembly.ts | 110 +++ .../lib/api/cloud-assembly/stack-selector.ts | 101 +++ .../toolkit/lib/api/cloud-assembly/types.ts | 8 + packages/@aws-cdk/toolkit/lib/api/errors.ts | 64 ++ packages/@aws-cdk/toolkit/lib/api/io/index.ts | 1 + .../@aws-cdk/toolkit/lib/api/io/io-host.ts | 96 +++ .../toolkit/lib/api/io/private/index.ts | 4 + .../toolkit/lib/api/io/private/logger.ts | 139 ++++ .../toolkit/lib/api/io/private/messages.ts | 161 ++++ .../toolkit/lib/api/io/private/timer.ts | 32 + .../toolkit/lib/api/io/private/types.ts | 14 + .../toolkit/lib/api/toolkit/private/index.ts | 11 + .../toolkit/lib/cloud-assembly-source.ts | 32 +- packages/@aws-cdk/toolkit/lib/index.ts | 18 +- packages/@aws-cdk/toolkit/lib/io-host.ts | 30 - packages/@aws-cdk/toolkit/lib/toolkit.ts | 715 +++++++++++++++++- packages/@aws-cdk/toolkit/lib/types.ts | 78 -- packages/@aws-cdk/toolkit/package.json | 94 ++- packages/@aws-cdk/toolkit/tsconfig.json | 8 +- 42 files changed, 2587 insertions(+), 187 deletions(-) create mode 100644 packages/@aws-cdk/toolkit/bundle.mjs create mode 100644 packages/@aws-cdk/toolkit/lib/actions/diff.ts create mode 100644 packages/@aws-cdk/toolkit/lib/actions/index.ts create mode 100644 packages/@aws-cdk/toolkit/lib/actions/list.ts create mode 100644 packages/@aws-cdk/toolkit/lib/actions/rollback.ts create mode 100644 packages/@aws-cdk/toolkit/lib/api/aws-auth/index.ts create mode 100644 packages/@aws-cdk/toolkit/lib/api/aws-auth/types.ts create mode 100644 packages/@aws-cdk/toolkit/lib/api/aws-cdk.ts create mode 100644 packages/@aws-cdk/toolkit/lib/api/cloud-assembly/cached-source.ts create mode 100644 packages/@aws-cdk/toolkit/lib/api/cloud-assembly/identity-source.ts create mode 100644 packages/@aws-cdk/toolkit/lib/api/cloud-assembly/index.ts create mode 100644 packages/@aws-cdk/toolkit/lib/api/cloud-assembly/private/context-aware-source.ts create mode 100644 packages/@aws-cdk/toolkit/lib/api/cloud-assembly/private/exec.ts create mode 100644 packages/@aws-cdk/toolkit/lib/api/cloud-assembly/private/prepare-source.ts create mode 100644 packages/@aws-cdk/toolkit/lib/api/cloud-assembly/private/source-builder.ts create mode 100644 packages/@aws-cdk/toolkit/lib/api/cloud-assembly/stack-assembly.ts create mode 100644 packages/@aws-cdk/toolkit/lib/api/cloud-assembly/stack-selector.ts create mode 100644 packages/@aws-cdk/toolkit/lib/api/cloud-assembly/types.ts create mode 100644 packages/@aws-cdk/toolkit/lib/api/errors.ts create mode 100644 packages/@aws-cdk/toolkit/lib/api/io/index.ts create mode 100644 packages/@aws-cdk/toolkit/lib/api/io/io-host.ts create mode 100644 packages/@aws-cdk/toolkit/lib/api/io/private/index.ts create mode 100644 packages/@aws-cdk/toolkit/lib/api/io/private/logger.ts create mode 100644 packages/@aws-cdk/toolkit/lib/api/io/private/messages.ts create mode 100644 packages/@aws-cdk/toolkit/lib/api/io/private/timer.ts create mode 100644 packages/@aws-cdk/toolkit/lib/api/io/private/types.ts create mode 100644 packages/@aws-cdk/toolkit/lib/api/toolkit/private/index.ts delete mode 100644 packages/@aws-cdk/toolkit/lib/io-host.ts delete mode 100644 packages/@aws-cdk/toolkit/lib/types.ts diff --git a/packages/@aws-cdk/aws-custom-resource-sdk-adapter/package.json b/packages/@aws-cdk/aws-custom-resource-sdk-adapter/package.json index 1913c096aa3ca..0b824e5a6e03e 100644 --- a/packages/@aws-cdk/aws-custom-resource-sdk-adapter/package.json +++ b/packages/@aws-cdk/aws-custom-resource-sdk-adapter/package.json @@ -27,7 +27,27 @@ "devDependencies": { "@aws-cdk/cdk-build-tools": "0.0.0", "@aws-cdk/pkglint": "0.0.0", + "@aws-sdk/client-account": "3.632.0", + "@aws-sdk/client-acm": "3.632.0", + "@aws-sdk/client-amplify": "3.632.0", + "@aws-sdk/client-cloudwatch": "3.632.0", + "@aws-sdk/client-cloudwatch-logs": "3.632.0", + "@aws-sdk/client-codepipeline": "3.632.0", + "@aws-sdk/client-dynamodb": "3.632.0", + "@aws-sdk/client-ec2": "3.632.0", + "@aws-sdk/client-ecr": "3.632.0", + "@aws-sdk/client-ecs": "3.632.0", + "@aws-sdk/client-eks": "3.632.0", + "@aws-sdk/client-kinesis": "3.632.0", + "@aws-sdk/client-kms": "3.632.0", + "@aws-sdk/client-lambda": "3.632.0", + "@aws-sdk/client-redshift": "3.632.0", + "@aws-sdk/client-route-53": "3.632.0", "@aws-sdk/client-s3": "3.632.0", + "@aws-sdk/client-ssm": "3.632.0", + "@aws-sdk/client-sts": "3.632.0", + "@aws-sdk/client-synthetics": "3.632.0", + "@aws-sdk/s3-request-presigner": "3.632.0", "@smithy/types": "3.6.0", "@types/jest": "^29.5.14", "jest": "^29.7.0" @@ -51,4 +71,4 @@ "publishConfig": { "tag": "latest" } -} +} \ No newline at end of file diff --git a/packages/@aws-cdk/custom-resource-handlers/lib/aws-events-targets/aws-api-handler/index.ts b/packages/@aws-cdk/custom-resource-handlers/lib/aws-events-targets/aws-api-handler/index.ts index 7797717177466..33fc238b0d303 100644 --- a/packages/@aws-cdk/custom-resource-handlers/lib/aws-events-targets/aws-api-handler/index.ts +++ b/packages/@aws-cdk/custom-resource-handlers/lib/aws-events-targets/aws-api-handler/index.ts @@ -6,7 +6,7 @@ interface AwsApiInput { readonly service: string; readonly action: string; readonly parameters?: { - [param: string]: any, + [param: string]: any; }; readonly apiVersion?: string; readonly catchErrorPattern?: string; diff --git a/packages/@aws-cdk/toolkit/.gitignore b/packages/@aws-cdk/toolkit/.gitignore index 4da3f8ac49de4..9552f19942613 100644 --- a/packages/@aws-cdk/toolkit/.gitignore +++ b/packages/@aws-cdk/toolkit/.gitignore @@ -1,6 +1,7 @@ *.js *.js.map *.d.ts +*.d.ts.map *.gz node_modules dist diff --git a/packages/@aws-cdk/toolkit/.npmignore b/packages/@aws-cdk/toolkit/.npmignore index 745225e30b411..9e5c130c6f27e 100644 --- a/packages/@aws-cdk/toolkit/.npmignore +++ b/packages/@aws-cdk/toolkit/.npmignore @@ -1,24 +1,33 @@ -# Ignore artifacts +# Ignore build artifacts +**/cdk.out +**/*.snapshot dist .LAST_PACKAGE .LAST_BUILD *.snk *.ts -!*.d.ts -!*.js coverage .nyc_output *.tgz -# Ignore configs and test files +# Ignore config files .eslintrc.js tsconfig.json *.tsbuildinfo junit.xml +jest.config.js +bundle.mjs -# Include .jsii -!.jsii - -# exclude cdk artifacts -**/cdk.out -**/*.snapshot +# Explicitly allow all required files +!build-info.json +!db.json.gz +# !lib/main.js +# !lib/bridge.js +# !lib/setup-sandbox.js +# !lib/api/bootstrap/bootstrap-template.yaml +!*.d.ts +!*.d.ts.map +!*.js +!LICENSE +!NOTICE +!THIRD_PARTY_LICENSES diff --git a/packages/@aws-cdk/toolkit/bundle.mjs b/packages/@aws-cdk/toolkit/bundle.mjs new file mode 100644 index 0000000000000..c8905ded69b19 --- /dev/null +++ b/packages/@aws-cdk/toolkit/bundle.mjs @@ -0,0 +1,44 @@ +import { createRequire } from 'node:module'; +import * as path from "node:path"; +import * as esbuild from "esbuild"; +import * as fs from "fs-extra"; + +const require = createRequire(import.meta.url); + +const cliPackage = path.dirname(require.resolve("aws-cdk/package.json")); +let copyFromCli = (from, to = undefined) => { + return fs.copy(path.join(cliPackage, ...from), path.join(process.cwd(), ...(to ?? from))) +} + +await Promise.all([ + copyFromCli(["build-info.json"]), + copyFromCli(["/db.json.gz"]), + copyFromCli(["lib", "index_bg.wasm"]), +]) + +// # Copy all resources that aws_cdk/generate.sh produced, and some othersCall the generator for the +// cp -R $aws_cdk/lib/init-templates ./lib/ +// mkdir -p ./lib/api/bootstrap/ && cp $aws_cdk/lib/api/bootstrap/bootstrap-template.yaml ./lib/api/bootstrap/ + + +let bundleCli = { + name: "bundle-aws-cdk", + setup(build) { + + // Mark all paths inside aws-cdk as internal + build.onResolve({ filter: /^aws-cdk\/lib/ }, (args) => { + return { path: require.resolve(args.path), external: false } + }); + }, +}; + +await esbuild.build({ + entryPoints: ["lib/index.ts"], + target: "node18", + platform: "node", + packages: "external", + plugins: [bundleCli], + sourcemap: true, + bundle: true, + outfile: "lib/main.js", +}); diff --git a/packages/@aws-cdk/toolkit/lib/actions/deploy.ts b/packages/@aws-cdk/toolkit/lib/actions/deploy.ts index 6018ae37a6fe4..c6f0847cfaf85 100644 --- a/packages/@aws-cdk/toolkit/lib/actions/deploy.ts +++ b/packages/@aws-cdk/toolkit/lib/actions/deploy.ts @@ -1,4 +1,5 @@ -import { StackSelector } from '../types'; +import { Deployments, StackActivityProgress, WorkGraph } from '../api/aws-cdk'; +import { StackSelector } from '../api/cloud-assembly/stack-selector'; export type DeploymentMethod = DirectDeploymentMethod | ChangeSetDeploymentMethod; @@ -113,6 +114,7 @@ export interface BaseDeployOptions { readonly stacks: StackSelector; /** + * @deprecated set on toolkit * Name of the toolkit stack to use/deploy * * @default CDKToolkit @@ -130,6 +132,7 @@ export interface BaseDeployOptions { * Always deploy, even if templates are identical. * * @default false + * @deprecated */ readonly force?: boolean; @@ -225,4 +228,51 @@ export interface DeployOptions extends BaseDeployOptions { * @default AssetBuildTime.ALL_BEFORE_DEPLOY */ readonly assetBuildTime?: AssetBuildTime; + + /** + * Change stack watcher output to CI mode. + * + * @deprecated Implement in IoHost instead + */ + readonly ci?: boolean; + + /** + * Display mode for stack deployment progress. + * + * @deprecated Implement in IoHost instead + */ + readonly progress?: StackActivityProgress; +} + +export function buildParameterMap(parameters?: Map): { [name: string]: { [name: string]: string | undefined } } { + const parameterMap: { + [name: string]: { [name: string]: string | undefined }; + } = {}; + parameterMap['*'] = {}; + + const entries = parameters?.entries() ?? []; + for (const [key, value] of entries) { + const [stack, parameter] = key.split(':', 2) as [string, string | undefined]; + if (!parameter) { + parameterMap['*'][stack] = value; + } else { + if (!parameterMap[stack]) { + parameterMap[stack] = {}; + } + parameterMap[stack][parameter] = value; + } + } + + return parameterMap; +} + +/** + * Remove the asset publishing and building from the work graph for assets that are already in place + */ +export async function removePublishedAssets(graph: WorkGraph, deployments: Deployments, options: DeployOptions) { + await graph.removeUnnecessaryAssets(assetNode => deployments.isSingleAssetPublished(assetNode.assetManifest, assetNode.asset, { + stack: assetNode.parentStack, + roleArn: options.roleArn, + stackName: assetNode.parentStack.stackName, + })); } diff --git a/packages/@aws-cdk/toolkit/lib/actions/destroy.ts b/packages/@aws-cdk/toolkit/lib/actions/destroy.ts index c2f566cdc8236..b45ec002ded00 100644 --- a/packages/@aws-cdk/toolkit/lib/actions/destroy.ts +++ b/packages/@aws-cdk/toolkit/lib/actions/destroy.ts @@ -1,4 +1,4 @@ -import { StackSelector } from '../types'; +import { StackSelector } from '../api/cloud-assembly/stack-selector'; export interface DestroyOptions { /** @@ -10,4 +10,11 @@ export interface DestroyOptions { * The arn of the IAM role to use */ readonly roleArn?: string; + + /** + * Change stack watcher output to CI mode. + * + * @deprecated Implement in IoHost instead + */ + readonly ci?: boolean; } diff --git a/packages/@aws-cdk/toolkit/lib/actions/diff.ts b/packages/@aws-cdk/toolkit/lib/actions/diff.ts new file mode 100644 index 0000000000000..6bd52e450722c --- /dev/null +++ b/packages/@aws-cdk/toolkit/lib/actions/diff.ts @@ -0,0 +1,116 @@ +import { StackSelector } from '../api/cloud-assembly/stack-selector'; + +export interface CloudFormationDiffOptions { + /** + * Whether to run the diff against the template after the CloudFormation Transforms inside it have been executed + * (as opposed to the original template, the default, which contains the unprocessed Transforms). + * + * @default false + */ + readonly compareAgainstProcessedTemplate?: boolean; +} + +export interface ChangeSetDiffOptions extends CloudFormationDiffOptions { + /** + * Enable falling back to template-based diff in case creating the changeset is not possible or results in an error. + * + * Should be used for stacks containing nested stacks or when change set permissions aren't available. + * + * @default true + */ + readonly fallbackToTemplate?: boolean; + + /** + * Additional parameters for CloudFormation when creating a diff change set + * + * @default {} + */ + readonly parameters?: { [name: string]: string | undefined }; +} + +export class DiffMethod { + /** + * Use a changeset to compute the diff. + * + * This will create, analyze, and subsequently delete a changeset against the CloudFormation stack. + */ + public static ChangeSet(options: ChangeSetDiffOptions = {}) { + return new class extends DiffMethod { + public override readonly options: ChangeSetDiffOptions; + public constructor(opts: ChangeSetDiffOptions) { + super('change-set', opts); + this.options = opts; + } + }(options); + } + + public static TemplateOnly(options: CloudFormationDiffOptions = {}) { + return new class extends DiffMethod { + public override readonly options: CloudFormationDiffOptions; + public constructor(opts: CloudFormationDiffOptions) { + super('template-only', opts); + this.options = opts; + } + }(options); + } + + public static LocalFile(path: string) { + return new class extends DiffMethod { + public override readonly options: { path: string }; + public constructor(opts: { path: string }) { + super('local-file', opts); + this.options = opts; + } + }({ path }); + }; + + private constructor( + public readonly method: 'change-set' | 'template-only' | 'local-file', + public readonly options: ChangeSetDiffOptions | CloudFormationDiffOptions | { path: string }, + ) {} +} + +export interface DiffOptions { + /** + * Select the stacks + */ + readonly stacks: StackSelector; + + /** + * The mode to create a stack diff. + * + * Use changeset diff for the highest fidelity, including analyze resource replacements. + * In this mode, diff will use the deploy role instead of the lookup role. + * + * Use template-only diff for a faster, less accurate diff that doesn't require + * permissions to create a change-set. + * + * Use local-template diff for a fast, local-only diff that doesn't require + * any permissions or internet access. + * + * @default DiffMode.ChangeSet + */ + readonly method: DiffMethod; + + /** + * Strict diff mode + * When enabled, this will not filter out AWS::CDK::Metadata resources, mangled non-ASCII characters, or the CheckBootstrapVersionRule. + * + * @default false + */ + readonly strict?: boolean; + + /** + * How many lines of context to show in the diff + * + * @default 3 + */ + readonly contextLines?: number; + + /** + * Only include broadened security changes in the diff + * + * @default false + */ + readonly securityOnly?: boolean; +} diff --git a/packages/@aws-cdk/toolkit/lib/actions/index.ts b/packages/@aws-cdk/toolkit/lib/actions/index.ts new file mode 100644 index 0000000000000..13d7acdd4367c --- /dev/null +++ b/packages/@aws-cdk/toolkit/lib/actions/index.ts @@ -0,0 +1,7 @@ +export * from './deploy'; +export * from './destroy'; +export * from './diff'; +export * from './import'; +export * from './list'; +export * from './synth'; +export * from './watch'; diff --git a/packages/@aws-cdk/toolkit/lib/actions/list.ts b/packages/@aws-cdk/toolkit/lib/actions/list.ts new file mode 100644 index 0000000000000..ad1354c44ac77 --- /dev/null +++ b/packages/@aws-cdk/toolkit/lib/actions/list.ts @@ -0,0 +1,8 @@ +import { StackSelector } from '../api/cloud-assembly/stack-selector'; + +export interface ListOptions { + /** + * Select the stacks + */ + readonly stacks: StackSelector; +} diff --git a/packages/@aws-cdk/toolkit/lib/actions/rollback.ts b/packages/@aws-cdk/toolkit/lib/actions/rollback.ts new file mode 100644 index 0000000000000..4548fe26031d3 --- /dev/null +++ b/packages/@aws-cdk/toolkit/lib/actions/rollback.ts @@ -0,0 +1,44 @@ +import { StackSelector } from '../api/cloud-assembly'; + +export interface RollbackOptions { + /** + * Criteria for selecting stacks to rollback + */ + readonly stacks: StackSelector; + + /** + * Role to pass to CloudFormation for deployment + * + * @default - Default stack role + */ + readonly roleArn?: string; + + /** + * Whether to automatically orphan resources that failed the rollback or not + * + * @default false + */ + readonly orphanFailedResources?: boolean; + + /** + * Logical IDs of resources to orphan + * + * These resources will be skipped from the roll back. + * Specify this property to orphan resources that can't be successfully rolled back. + * We recommend that you troubleshoot resources before skipping them. + * After the rollback is complete, the state of the skipped resources will be inconsistent with + * the state of the resources in the stack. Before performing another stack update, + * you must update the stack or resources to be consistent with each other. If you don't + * subsequent stack updates might fail, and the stack will become unrecoverable. + * + * @default - No resources are orphaned + */ + readonly orphanLogicalIds?: string[]; + + /** + * Whether to validate the version of the bootstrap stack permissions + * + * @default true + */ + readonly validateBootstrapStackVersion?: boolean; +} diff --git a/packages/@aws-cdk/toolkit/lib/actions/synth.ts b/packages/@aws-cdk/toolkit/lib/actions/synth.ts index 0fca66440b1f3..3335611b334ec 100644 --- a/packages/@aws-cdk/toolkit/lib/actions/synth.ts +++ b/packages/@aws-cdk/toolkit/lib/actions/synth.ts @@ -1,4 +1,4 @@ -import { StackSelector } from '../types'; +import { StackSelector } from '../api/cloud-assembly/stack-selector'; export interface SynthOptions { /** diff --git a/packages/@aws-cdk/toolkit/lib/actions/watch.ts b/packages/@aws-cdk/toolkit/lib/actions/watch.ts index 1298e993c9fe3..96434424f78ee 100644 --- a/packages/@aws-cdk/toolkit/lib/actions/watch.ts +++ b/packages/@aws-cdk/toolkit/lib/actions/watch.ts @@ -8,4 +8,47 @@ export interface WatchOptions extends BaseDeployOptions { * @default - false */ readonly traceLogs?: boolean; + + /** + * The extra string to append to the User-Agent header when performing AWS SDK calls. + * + * @default - nothing extra is appended to the User-Agent header + */ + readonly extraUserAgent?: string; + + /** + * Watch the files in this list + * + * @default - [] + */ + readonly include?: string[]; + + /** + * Ignore watching the files in this list + * + * @default - [] + */ + readonly exclude?: string[]; + + /** + * The root directory used for watch. + * + * @default process.cwd() + */ + readonly watchDir?: string; + + /** + * The output to write CloudFormation template to + * + * @deprecated this should be grabbed from the cloud assembly itself + */ + readonly output?: string; +} + +export function patternsArrayForWatch( + patterns: string | string[] | undefined, + options: { rootDir: string; returnRootDirIfEmpty: boolean }, +): string[] { + const patternsArray: string[] = patterns !== undefined ? (Array.isArray(patterns) ? patterns : [patterns]) : []; + return patternsArray.length > 0 ? patternsArray : options.returnRootDirIfEmpty ? [options.rootDir] : []; } diff --git a/packages/@aws-cdk/toolkit/lib/api/aws-auth/index.ts b/packages/@aws-cdk/toolkit/lib/api/aws-auth/index.ts new file mode 100644 index 0000000000000..fcb073fefcd6b --- /dev/null +++ b/packages/@aws-cdk/toolkit/lib/api/aws-auth/index.ts @@ -0,0 +1 @@ +export * from './types'; diff --git a/packages/@aws-cdk/toolkit/lib/api/aws-auth/types.ts b/packages/@aws-cdk/toolkit/lib/api/aws-auth/types.ts new file mode 100644 index 0000000000000..a8f2df4e70386 --- /dev/null +++ b/packages/@aws-cdk/toolkit/lib/api/aws-auth/types.ts @@ -0,0 +1,43 @@ + +/** + * Options for the default SDK provider + */ +export interface SdkOptions { + /** + * Profile to read from ~/.aws + * + * @default - No profile + */ + readonly profile?: string; + + /** + * Proxy address to use + * + * @default No proxy + */ + readonly region?: string; + + /** + * HTTP options for SDK + */ + readonly httpOptions?: SdkHttpOptions; +} + +/** + * Options for individual SDKs + */ +export interface SdkHttpOptions { + /** + * Proxy address to use + * + * @default No proxy + */ + readonly proxyAddress?: string; + + /** + * A path to a certificate bundle that contains a cert to be trusted. + * + * @default No certificate bundle + */ + readonly caBundlePath?: string; +} diff --git a/packages/@aws-cdk/toolkit/lib/api/aws-cdk.ts b/packages/@aws-cdk/toolkit/lib/api/aws-cdk.ts new file mode 100644 index 0000000000000..1ca477cf66cd1 --- /dev/null +++ b/packages/@aws-cdk/toolkit/lib/api/aws-cdk.ts @@ -0,0 +1,27 @@ +/* eslint-disable import/no-extraneous-dependencies */ +export { DEFAULT_TOOLKIT_STACK_NAME, SdkProvider } from 'aws-cdk/lib'; +export type { SuccessfulDeployStackResult } from 'aws-cdk/lib'; +export { formatSdkLoggerContent } from 'aws-cdk/lib/api/aws-auth/sdk-logger'; +export { CloudAssembly, sanitizePatterns, StackCollection, ExtendedStackSelection } from 'aws-cdk/lib/api/cxapp/cloud-assembly'; +export { prepareDefaultEnvironment, prepareContext, spaceAvailableForContext } from 'aws-cdk/lib/api/cxapp/exec'; +export { Deployments } from 'aws-cdk/lib/api/deployments'; +export { HotswapMode } from 'aws-cdk/lib/api/hotswap/common'; +export { StackActivityProgress } from 'aws-cdk/lib/api/util/cloudformation/stack-activity-monitor'; +export { RWLock } from 'aws-cdk/lib/api/util/rwlock'; +export type { ILock } from 'aws-cdk/lib/api/util/rwlock'; +export { formatTime } from 'aws-cdk/lib/api/util/string-manipulation'; +export * as contextproviders from 'aws-cdk/lib/context-providers'; +export { ResourceMigrator } from 'aws-cdk/lib/migrator'; +export { obscureTemplate, serializeStructure } from 'aws-cdk/lib/serialize'; +export { Context, Settings, PROJECT_CONTEXT } from 'aws-cdk/lib/settings'; +export { tagsForStack } from 'aws-cdk/lib/tags'; +export { CliIoHost } from 'aws-cdk/lib/toolkit/cli-io-host'; +export { loadTree, some } from 'aws-cdk/lib/tree'; +export { splitBySize } from 'aws-cdk/lib/util'; +export { validateSnsTopicArn } from 'aws-cdk/lib/util/validate-notification-arn'; +export { WorkGraph } from 'aws-cdk/lib/util/work-graph'; +export type { Concurrency } from 'aws-cdk/lib/util/work-graph'; +export { WorkGraphBuilder } from 'aws-cdk/lib/util/work-graph-builder'; +export type { AssetBuildNode, AssetPublishNode, StackNode } from 'aws-cdk/lib/util/work-graph-types'; +export { versionNumber } from 'aws-cdk/lib/version'; +export { guessExecutable } from 'aws-cdk/lib/api/cxapp/exec'; diff --git a/packages/@aws-cdk/toolkit/lib/api/cloud-assembly/cached-source.ts b/packages/@aws-cdk/toolkit/lib/api/cloud-assembly/cached-source.ts new file mode 100644 index 0000000000000..5e2a27788cff7 --- /dev/null +++ b/packages/@aws-cdk/toolkit/lib/api/cloud-assembly/cached-source.ts @@ -0,0 +1,25 @@ +import { CloudAssembly } from '@aws-cdk/cx-api'; +import { ICloudAssemblySource } from './types'; + +/** + * A CloudAssemblySource that is caching its result once produced. + * + * Most Toolkit interactions should use a cached source. + * Not caching is relevant when the source changes frequently + * and it is to expensive to predict if the source has changed. + */ +export class CachedCloudAssemblySource implements ICloudAssemblySource { + private source: ICloudAssemblySource; + private cloudAssembly: CloudAssembly | undefined; + + public constructor(source: ICloudAssemblySource) { + this.source = source; + } + + public async produce(): Promise { + if (!this.cloudAssembly) { + this.cloudAssembly = await this.source.produce(); + } + return this.cloudAssembly; + } +} diff --git a/packages/@aws-cdk/toolkit/lib/api/cloud-assembly/identity-source.ts b/packages/@aws-cdk/toolkit/lib/api/cloud-assembly/identity-source.ts new file mode 100644 index 0000000000000..03af4537d870e --- /dev/null +++ b/packages/@aws-cdk/toolkit/lib/api/cloud-assembly/identity-source.ts @@ -0,0 +1,13 @@ +import type * as cxapi from '@aws-cdk/cx-api'; +import { ICloudAssemblySource } from './types'; + +/** + * A CloudAssemblySource that is representing a already existing and produced CloudAssembly. + */ +export class IdentityCloudAssemblySource implements ICloudAssemblySource { + public constructor(private readonly cloudAssembly: cxapi.CloudAssembly) {} + + public async produce(): Promise { + return this.cloudAssembly; + } +} diff --git a/packages/@aws-cdk/toolkit/lib/api/cloud-assembly/index.ts b/packages/@aws-cdk/toolkit/lib/api/cloud-assembly/index.ts new file mode 100644 index 0000000000000..4fd6dcfdd4e94 --- /dev/null +++ b/packages/@aws-cdk/toolkit/lib/api/cloud-assembly/index.ts @@ -0,0 +1,6 @@ +export * from './cached-source'; +export * from './identity-source'; +export * from './stack-assembly'; +export * from './stack-selector'; +export * from './types'; + diff --git a/packages/@aws-cdk/toolkit/lib/api/cloud-assembly/private/context-aware-source.ts b/packages/@aws-cdk/toolkit/lib/api/cloud-assembly/private/context-aware-source.ts new file mode 100644 index 0000000000000..95d95c80f1de5 --- /dev/null +++ b/packages/@aws-cdk/toolkit/lib/api/cloud-assembly/private/context-aware-source.ts @@ -0,0 +1,122 @@ +import type { MissingContext } from '@aws-cdk/cloud-assembly-schema'; +import * as cxapi from '@aws-cdk/cx-api'; +import { Context, contextproviders, PROJECT_CONTEXT } from '../../aws-cdk'; +import { ToolkitError } from '../../errors'; +import { ActionAwareIoHost, debug } from '../../io/private'; +import { ToolkitServices } from '../../toolkit/private'; +import { ICloudAssemblySource } from '../types'; + +export interface ContextAwareCloudAssemblyProps { + /** + * AWS object (used by contextprovider) + * @deprecated context should be moved to the toolkit itself + */ + readonly services: ToolkitServices; + + /** + * Application context + */ + readonly context: Context; + + /** + * The file used to store application context in (relative to cwd). + * + * @default "cdk.context.json" + */ + readonly contextFile?: string; + + /** + * Enable context lookups. + * + * Producing a `cxapi.CloudAssembly` will fail if this is disabled and context lookups need to be performed. + * + * @default true + */ + readonly lookups?: boolean; +} + +/** + * Represent the Cloud Executable and the synthesis we can do on it + */ +export class ContextAwareCloudAssembly implements ICloudAssemblySource { + private canLookup: boolean; + private context: Context; + private contextFile: string; + private ioHost: ActionAwareIoHost; + + constructor(private readonly source: ICloudAssemblySource, private readonly props: ContextAwareCloudAssemblyProps) { + this.canLookup = props.lookups ?? true; + this.context = props.context; + this.contextFile = props.contextFile ?? PROJECT_CONTEXT; // @todo new feature not needed right now + this.ioHost = props.services.ioHost; + } + + /** + * Produce a Cloud Assembly, i.e. a set of stacks + */ + public async produce(): Promise { + // We may need to run the cloud executable multiple times in order to satisfy all missing context + // (When the executable runs, it will tell us about context it wants to use + // but it missing. We'll then look up the context and run the executable again, and + // again, until it doesn't complain anymore or we've stopped making progress). + let previouslyMissingKeys: Set | undefined; + while (true) { + const assembly = await this.source.produce(); + + if (assembly.manifest.missing && assembly.manifest.missing.length > 0) { + const missingKeys = missingContextKeys(assembly.manifest.missing); + + if (!this.canLookup) { + throw new ToolkitError( + 'Context lookups have been disabled. ' + + 'Make sure all necessary context is already in \'cdk.context.json\' by running \'cdk synth\' on a machine with sufficient AWS credentials and committing the result. ' + + `Missing context keys: '${Array.from(missingKeys).join(', ')}'`); + } + + let tryLookup = true; + if (previouslyMissingKeys && equalSets(missingKeys, previouslyMissingKeys)) { + await this.ioHost.notify(debug('Not making progress trying to resolve environmental context. Giving up.')); + tryLookup = false; + } + + previouslyMissingKeys = missingKeys; + + if (tryLookup) { + await this.ioHost.notify(debug('Some context information is missing. Fetching...')); + await contextproviders.provideContextValues( + assembly.manifest.missing, + this.context, + this.props.services.sdkProvider, + ); + + // Cache the new context to disk + await this.context.save(this.contextFile); + + // Execute again + continue; + } + } + + return assembly; + } + } + +} + +/** + * Return all keys of missing context items + */ +function missingContextKeys(missing?: MissingContext[]): Set { + return new Set((missing || []).map(m => m.key)); +} + +/** + * Are two sets equal to each other + */ +function equalSets(a: Set, b: Set) { + if (a.size !== b.size) { return false; } + for (const x of a) { + if (!b.has(x)) { return false; } + } + return true; +} diff --git a/packages/@aws-cdk/toolkit/lib/api/cloud-assembly/private/exec.ts b/packages/@aws-cdk/toolkit/lib/api/cloud-assembly/private/exec.ts new file mode 100644 index 0000000000000..7c49e98635f8a --- /dev/null +++ b/packages/@aws-cdk/toolkit/lib/api/cloud-assembly/private/exec.ts @@ -0,0 +1,45 @@ +import * as child_process from 'node:child_process'; +import { ToolkitError } from '../../errors'; + +interface ExecOptions { + extraEnv?: { [key: string]: string | undefined }; + cwd?: string; +} + +/** + * Execute a command and args in a child process + */ +export async function execInChildProcess(commandAndArgs: string, options: ExecOptions = {}) { + return new Promise((ok, fail) => { + // We use a slightly lower-level interface to: + // + // - Pass arguments in an array instead of a string, to get around a + // number of quoting issues introduced by the intermediate shell layer + // (which would be different between Linux and Windows). + // + // - Inherit stderr from controlling terminal. We don't use the captured value + // anyway, and if the subprocess is printing to it for debugging purposes the + // user gets to see it sooner. Plus, capturing doesn't interact nicely with some + // processes like Maven. + const proc = child_process.spawn(commandAndArgs, { + stdio: ['ignore', 'inherit', 'inherit'], + detached: false, + shell: true, + cwd: options.cwd, + env: { + ...process.env, + ...(options.extraEnv ?? {}), + }, + }); + + proc.on('error', fail); + + proc.on('exit', code => { + if (code === 0) { + return ok(); + } else { + return fail(new ToolkitError(`Subprocess exited with error ${code}`)); + } + }); + }); +} diff --git a/packages/@aws-cdk/toolkit/lib/api/cloud-assembly/private/prepare-source.ts b/packages/@aws-cdk/toolkit/lib/api/cloud-assembly/private/prepare-source.ts new file mode 100644 index 0000000000000..57131ff6cce0b --- /dev/null +++ b/packages/@aws-cdk/toolkit/lib/api/cloud-assembly/private/prepare-source.ts @@ -0,0 +1,175 @@ +import * as os from 'node:os'; +import * as path from 'node:path'; +import * as cxschema from '@aws-cdk/cloud-assembly-schema'; +import * as cxapi from '@aws-cdk/cx-api'; +import * as fs from 'fs-extra'; +import { lte } from 'semver'; +import type { AppSynthOptions } from './source-builder'; +import { prepareDefaultEnvironment as oldPrepare, prepareContext, spaceAvailableForContext, Settings, loadTree, some, splitBySize, versionNumber } from '../../../api/aws-cdk'; +import { ToolkitError } from '../../errors'; +import { ActionAwareIoHost, asLogger, error } from '../../io/private'; +import { ToolkitServices } from '../../toolkit/private'; + +export { guessExecutable } from '../../../api/aws-cdk'; + +type Env = { [key: string]: string }; +type Context = { [key: string]: any }; + +/** + * 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(services: ToolkitServices, props: { outdir?: string } = {}): Promise { + const logFn = asLogger(services.ioHost, 'ASSEMBLY').debug; + const env = await oldPrepare(services.sdkProvider, logFn); + + if (props.outdir) { + env[cxapi.OUTDIR_ENV] = props.outdir; + await logFn('outdir:', props.outdir); + } + + // CLI version information + env[cxapi.CLI_ASM_VERSION_ENV] = cxschema.Manifest.version(); + env[cxapi.CLI_VERSION_ENV] = versionNumber(); + + await logFn('env:', env); + return env; +} + +/** + * Run code from a different working directory + */ +export async function changeDir(block: () => Promise, workingDir?: string) { + const originalWorkingDir = process.cwd(); + try { + if (workingDir) { + process.chdir(workingDir); + } + + return await block(); + + } finally { + if (workingDir) { + process.chdir(originalWorkingDir); + } + } +} + +/** + * Run code with additional environment variables + */ +export async function withEnv(env: Env = {}, block: () => Promise) { + const originalEnv = process.env; + try { + process.env = { + ...originalEnv, + ...env, + }; + + return await block(); + + } finally { + process.env = originalEnv; + } +} + +/** + * Run code with context setup inside the environment + */ +export async function withContext( + inputContext: Context, + env: Env, + synthOpts: AppSynthOptions = {}, + block: (env: Env, context: Context) => Promise, +) { + const context = await prepareContext(synthOptsDefaults(synthOpts), inputContext, env); + let contextOverflowLocation = null; + + try { + const envVariableSizeLimit = os.platform() === 'win32' ? 32760 : 131072; + const [smallContext, overflow] = splitBySize(context, spaceAvailableForContext(env, envVariableSizeLimit)); + + // Store the safe part in the environment variable + env[cxapi.CONTEXT_ENV] = JSON.stringify(smallContext); + + // If there was any overflow, write it to a temporary file + if (Object.keys(overflow ?? {}).length > 0) { + const contextDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cdk-context')); + contextOverflowLocation = path.join(contextDir, 'context-overflow.json'); + fs.writeJSONSync(contextOverflowLocation, overflow); + env[cxapi.CONTEXT_OVERFLOW_LOCATION_ENV] = contextOverflowLocation; + } + + // call the block code with new environment + return await block(env, context); + } finally { + if (contextOverflowLocation) { + fs.removeSync(path.dirname(contextOverflowLocation)); + } + } +} + +/** + * Checks if a given assembly supports context overflow, warn otherwise. + * + * @param assembly the assembly to check + */ +export async function checkContextOverflowSupport(assembly: cxapi.CloudAssembly, ioHost: ActionAwareIoHost): Promise { + const logFn = asLogger(ioHost, 'ASSEMBLY').warn; + const tree = loadTree(assembly); + const frameworkDoesNotSupportContextOverflow = some(tree, node => { + const fqn = node.constructInfo?.fqn; + const version = node.constructInfo?.version; + return (fqn === 'aws-cdk-lib.App' && version != null && lte(version, '2.38.0')) // v2 + || fqn === '@aws-cdk/core.App'; // v1 + }); + + // We're dealing with an old version of the framework here. It is unaware of the temporary + // file, which means that it will ignore the context overflow. + if (frameworkDoesNotSupportContextOverflow) { + await logFn('Part of the context could not be sent to the application. Please update the AWS CDK library to the latest version.'); + } +} + +/** + * Safely create an assembly from a cloud assembly directory + */ +export async function assemblyFromDirectory(assemblyDir: string, ioHost: ActionAwareIoHost) { + try { + const assembly = new cxapi.CloudAssembly(assemblyDir, { + // We sort as we deploy + topoSort: false, + }); + await checkContextOverflowSupport(assembly, ioHost); + return assembly; + + } catch (err: any) { + if (err.message.includes(cxschema.VERSION_MISMATCH)) { + // this means the CLI version is too old. + // we instruct the user to upgrade. + const message = 'This AWS CDK Toolkit is not compatible with the AWS CDK library used by your application. Please upgrade to the latest version.'; + await ioHost.notify(error(message, 'CDK_ASSEMBLY_E1111', { error: err.message })); + throw new ToolkitError(`${message}\n(${err.message}`); + } + throw err; + } +} +function synthOptsDefaults(synthOpts: AppSynthOptions = {}): Settings { + return new Settings({ + debug: false, + pathMetadata: true, + versionReporting: true, + assetMetadata: true, + assetStaging: true, + ...synthOpts, + }, true); +} + diff --git a/packages/@aws-cdk/toolkit/lib/api/cloud-assembly/private/source-builder.ts b/packages/@aws-cdk/toolkit/lib/api/cloud-assembly/private/source-builder.ts new file mode 100644 index 0000000000000..7f8a953b568dd --- /dev/null +++ b/packages/@aws-cdk/toolkit/lib/api/cloud-assembly/private/source-builder.ts @@ -0,0 +1,222 @@ +import * as cxapi from '@aws-cdk/cx-api'; + +import * as fs from 'fs-extra'; +import type { ICloudAssemblySource } from '../'; +import { ContextAwareCloudAssembly, ContextAwareCloudAssemblyProps } from './context-aware-source'; +import { execInChildProcess } from './exec'; +import { assemblyFromDirectory, changeDir, guessExecutable, prepareDefaultEnvironment, withContext, withEnv } from './prepare-source'; +import { Context, ILock, RWLock } from '../../aws-cdk'; +import { ToolkitError } from '../../errors'; +import { debug } from '../../io/private'; +import { ToolkitServices } from '../../toolkit/private'; + +/** + * Configuration for creating a CLI from an AWS CDK App directory + */ +export interface CdkAppSourceProps { + /** + * @default - current working directory + */ + readonly workingDirectory?: string; + + /** + * Emits the synthesized cloud assembly into a directory + * + * @default cdk.out + */ + readonly output?: string; + + /** + * Perform context lookups. + * + * Synthesis fails if this is disabled and context lookups need to be performed. + * + * @default true + */ + readonly lookups?: boolean; + + /** + * Options that are passed through the context to a CDK app on synth + */ + readonly synthOptions?: AppSynthOptions; +} + +export type AssemblyBuilder = (context: Record) => Promise; + +export abstract class CloudAssemblySourceBuilder { + + /** + * Helper to provide the CloudAssemblySourceBuilder with required toolkit services + * @deprecated this should move to the toolkit really. + */ + protected abstract toolkitServices(): Promise; + + /** + * Create a Cloud Assembly from a Cloud Assembly builder function. + */ + public async fromAssemblyBuilder( + builder: AssemblyBuilder, + props: CdkAppSourceProps = {}, + ): Promise { + const services = await this.toolkitServices(); + const context = new Context(); // @todo check if this needs to read anything + const contextAssemblyProps: ContextAwareCloudAssemblyProps = { + services, + context, + lookups: props.lookups, + }; + + return new ContextAwareCloudAssembly( + { + produce: async () => { + const env = await prepareDefaultEnvironment(services, { outdir: props.output }); + return changeDir(async () => + withContext(context.all, env, props.synthOptions ?? {}, async (envWithContext, ctx) => + withEnv(envWithContext, () => builder(ctx)), + ), props.workingDirectory); + }, + }, + contextAssemblyProps, + ); + } + + /** + * Creates a Cloud Assembly from an existing assembly directory. + * @param directory the directory of the AWS CDK app. Defaults to the current working directory. + * @param props additional configuration properties + * @returns an instance of `AwsCdkCli` + */ + public async fromAssemblyDirectory(directory: string): Promise { + const services: ToolkitServices = await this.toolkitServices(); + const contextAssemblyProps: ContextAwareCloudAssemblyProps = { + services, + context: new Context(), // @todo there is probably a difference between contextaware and contextlookup sources + lookups: false, + }; + + return new ContextAwareCloudAssembly( + { + produce: async () => { + // @todo build + await services.ioHost.notify(debug('--app points to a cloud assembly, so we bypass synth')); + return assemblyFromDirectory(directory, services.ioHost); + + }, + }, + contextAssemblyProps, + ); + } + /** + * Use a directory containing an AWS CDK app as source. + * @param directory the directory of the AWS CDK app. Defaults to the current working directory. + * @param props additional configuration properties + * @returns an instance of `AwsCdkCli` + */ + public async fromCdkApp(app: string, props: CdkAppSourceProps = {}): Promise { + const services: ToolkitServices = await this.toolkitServices(); + const context = new Context(); // @todo this definitely needs to read files + const contextAssemblyProps: ContextAwareCloudAssemblyProps = { + services, + context, + lookups: props.lookups, + }; + + return new ContextAwareCloudAssembly( + { + produce: async () => { + let lock: ILock | undefined = undefined; + try { + // @todo build + // const build = this.props.configuration.settings.get(['build']); + // if (build) { + // await execInChildProcess(build, { cwd: props.workingDirectory }); + // } + + const commandLine = await guessExecutable(app); + const outdir = props.output ?? 'cdk.out'; + + try { + fs.mkdirpSync(outdir); + } catch (e: any) { + throw new ToolkitError(`Could not create output directory at '${outdir}' (${e.message}).`); + } + + lock = await new RWLock(outdir).acquireWrite(); + + const env = await prepareDefaultEnvironment(services, { outdir }); + return await withContext(context.all, env, props.synthOptions, async (envWithContext, _ctx) => { + await execInChildProcess(commandLine.join(' '), { extraEnv: envWithContext, cwd: props.workingDirectory }); + return assemblyFromDirectory(outdir, services.ioHost); + }); + } finally { + await lock?.release(); + } + }, + }, + contextAssemblyProps, + ); + } +} + +/** + * Settings that are passed to a CDK app via the context + */ +export interface AppSynthOptions { + /** + * Debug the CDK app. + * Logs additional information during synthesis, such as creation stack traces of tokens. + * This also sets the `CDK_DEBUG` env variable and will slow down synthesis. + * + * @default false + */ + readonly debug?: boolean; + + /** + * Enables the embedding of the "aws:cdk:path" in CloudFormation template metadata. + * + * @default true + */ + readonly pathMetadata?: boolean; + + /** + * Enable the collection and reporting of version information. + * + * @default true + */ + readonly versionReporting?: boolean; + + /** + * Whe enabled, `aws:asset:xxx` metadata entries are added to the template. + * + * Disabling this can be useful in certain cases like integration tests. + * + * @default true + */ + readonly assetMetadata?: boolean; + + /** + * Enable asset staging. + * + * Disabling asset staging means that copyable assets will not be copied to the + * output directory and will be referenced with absolute paths. + * + * Not copied to the output directory: this is so users can iterate on the + * Lambda source and run SAM CLI without having to re-run CDK (note: we + * cannot achieve this for bundled assets, if assets are bundled they + * will have to re-run CDK CLI to re-bundle updated versions). + * + * Absolute path: SAM CLI expects `cwd`-relative paths in a resource's + * `aws:asset:path` metadata. In order to be predictable, we will always output + * absolute paths. + * + * @default true + */ + readonly assetStaging?: boolean; + + /** + * Select which stacks should have asset bundling enabled + * + * @default ["**"] - all stacks + */ + readonly bundlingForStacks?: string; +} diff --git a/packages/@aws-cdk/toolkit/lib/api/cloud-assembly/stack-assembly.ts b/packages/@aws-cdk/toolkit/lib/api/cloud-assembly/stack-assembly.ts new file mode 100644 index 0000000000000..f2d0b1e3f0095 --- /dev/null +++ b/packages/@aws-cdk/toolkit/lib/api/cloud-assembly/stack-assembly.ts @@ -0,0 +1,110 @@ +import * as cxapi from '@aws-cdk/cx-api'; +import { major } from 'semver'; +import { CloudAssembly, sanitizePatterns, StackCollection, ExtendedStackSelection as CliExtendedStackSelection } from '../aws-cdk'; +import { ExtendedStackSelection, StackSelectionStrategy, StackSelector } from './stack-selector'; +import { ICloudAssemblySource } from './types'; +import { ToolkitError } from '../errors'; + +/** + * A single Cloud Assembly wrapped to provide additional stack operations. + */ +export class StackAssembly extends CloudAssembly implements ICloudAssemblySource { + public async produce(): Promise { + return this.assembly; + } + + /** + * Improved stack selection interface with a single selector + * @returns + * @throws when the assembly does not contain any stacks, unless `selector.failOnEmpty` is `false` + * @throws when individual selection strategies are not satisfied + */ + public selectStacksV2(selector: StackSelector): StackCollection { + const asm = this.assembly; + const topLevelStacks = asm.stacks; + const allStacks = major(asm.version) < 10 ? asm.stacks : asm.stacksRecursively; + + if (allStacks.length === 0 && (selector.failOnEmpty ?? true)) { + throw new ToolkitError('This app contains no stacks'); + } + + const extend = convertExtend(selector.extend); + const patterns = sanitizePatterns(selector.patterns ?? []); + + switch (selector.strategy) { + case StackSelectionStrategy.ALL_STACKS: + return new StackCollection(this, allStacks); + case StackSelectionStrategy.MAIN_ASSEMBLY: + if (topLevelStacks.length < 1) { + //@todo text should probably be handled in io host + throw new ToolkitError('No stack found in the main cloud assembly. Use "list" to print manifest'); + } + return this.extendStacks(topLevelStacks, allStacks, extend); + case StackSelectionStrategy.ONLY_SINGLE: + if (topLevelStacks.length !== 1) { + //@todo text should probably be handled in io host + 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: ${allStacks.map(x => x.hierarchicalId).join(' · ')}`); + } + return new StackCollection(this, topLevelStacks); + default: + const matched = this.selectMatchingStacks(allStacks, patterns, extend); + if ( + selector.strategy === StackSelectionStrategy.PATTERN_MUST_MATCH_SINGLE + && matched.stackCount !== 1 + ) { + //@todo text should probably be handled in io host + throw new ToolkitError( + `Stack selection is ambiguous, please choose a specific stack for import [${allStacks.map(x => x.hierarchicalId).join(',')}]`, + ); + } + if ( + selector.strategy === StackSelectionStrategy.PATTERN_MUST_MATCH + && matched.stackCount < 1 + ) { + //@todo text should probably be handled in io host + throw new ToolkitError( + `Stack selection is ambiguous, please choose a specific stack for import [${allStacks.map(x => x.hierarchicalId).join(',')}]`, + ); + } + + return matched; + } + } + + /** + * Select all stacks. + * + * This method never throws and can safely be used as a basis for other calculations. + * + * @returns a `StackCollection` of all stacks + */ + public selectAllStacks() { + const allStacks = major(this.assembly.version) < 10 ? this.assembly.stacks : this.assembly.stacksRecursively; + return new StackCollection(this, allStacks); + } + + /** + * Select all stacks that have the validateOnSynth flag et. + * + * @param assembly + * @returns a `StackCollection` of all stacks that needs to be validated + */ + public selectStacksForValidation() { + const allStacks = this.selectAllStacks(); + return allStacks.filter((art) => art.validateOnSynth ?? false); + } +} + +function convertExtend(extend?: ExtendedStackSelection): CliExtendedStackSelection | undefined { + switch (extend) { + case ExtendedStackSelection.DOWNSTREAM: + return CliExtendedStackSelection.Downstream; + case ExtendedStackSelection.UPSTREAM: + return CliExtendedStackSelection.Upstream; + case ExtendedStackSelection.NONE: + return CliExtendedStackSelection.None; + default: + return undefined; + } +} diff --git a/packages/@aws-cdk/toolkit/lib/api/cloud-assembly/stack-selector.ts b/packages/@aws-cdk/toolkit/lib/api/cloud-assembly/stack-selector.ts new file mode 100644 index 0000000000000..44b84e7649335 --- /dev/null +++ b/packages/@aws-cdk/toolkit/lib/api/cloud-assembly/stack-selector.ts @@ -0,0 +1,101 @@ +/** + * Which stacks should be selected from a cloud assembly + */ +export enum StackSelectionStrategy { + /** + * Returns all stacks in the app regardless of patterns, + * including stacks inside nested assemblies. + */ + ALL_STACKS = 'ALL_STACKS', + + /** + * Returns all stacks in the main (top level) assembly only. + */ + MAIN_ASSEMBLY = 'MAIN_ASSEMBLY', + + /** + * If the assembly includes a single stack, returns it. + * Otherwise throws an exception. + */ + ONLY_SINGLE = 'ONLY_SINGLE', + + /** + * @todo not currently publicly exposed + * Return stacks matched by patterns. + * If no stacks are found, execution is halted successfully. + * Most likely you don't want to use this but `StackSelectionStrategy.MUST_MATCH_PATTERN` + */ + PATTERN_MATCH = 'PATTERN_MATCH', + + /** + * Return stacks matched by patterns. + * Throws an exception if the patterns don't match at least one stack in the assembly. + */ + PATTERN_MUST_MATCH = 'PATTERN_MUST_MATCH', + + /** + * Returns if exactly one stack is matched by the pattern(s). + * Throws an exception if no stack, or more than exactly one stack are matched. + */ + PATTERN_MUST_MATCH_SINGLE = 'PATTERN_MUST_MATCH_SINGLE', +} + +/** + * When selecting stacks, what other stacks to include because of dependencies + */ +export enum ExtendedStackSelection { + /** + * Don't select any extra stacks + */ + NONE = 'none', + + /** + * Include stacks that this stack depends on + */ + UPSTREAM = 'upstream', + + /** + * Include stacks that depend on this stack + */ + DOWNSTREAM = 'downstream', + + /** + * @TODO + * Include both directions. + * I.e. stacks that this stack depends on, and stacks that depend on this stack. + */ + // FULL = 'full', +} + +/** + * A specification of which stacks should be selected + */ +export interface StackSelector { + /** + * The behavior if if no selectors are provided. + */ + strategy: StackSelectionStrategy; + + /** + * A list of patterns to match the stack hierarchical ids + * Only used with `PATTERN_*` selection strategies. + */ + patterns?: string[]; + + /** + * Extend the selection to upstream/downstream stacks. + * @default ExtendedStackSelection.None only select the specified/matched stacks + */ + extend?: ExtendedStackSelection; + + /** + * By default, we throw an exception if the assembly contains no stacks. + * Set to `false`, to halt execution for empty assemblies without error. + * + * Note that actions can still throw if a stack selection result is empty, + * but the assembly contains stacks in principle. + * + * @default true + */ + failOnEmpty?: boolean; +} diff --git a/packages/@aws-cdk/toolkit/lib/api/cloud-assembly/types.ts b/packages/@aws-cdk/toolkit/lib/api/cloud-assembly/types.ts new file mode 100644 index 0000000000000..99e2fefe9560b --- /dev/null +++ b/packages/@aws-cdk/toolkit/lib/api/cloud-assembly/types.ts @@ -0,0 +1,8 @@ +import type * as cxapi from '@aws-cdk/cx-api'; + +export interface ICloudAssemblySource { + /** + * Produce a CloudAssembly from the current source + */ + produce(): Promise; +} diff --git a/packages/@aws-cdk/toolkit/lib/api/errors.ts b/packages/@aws-cdk/toolkit/lib/api/errors.ts new file mode 100644 index 0000000000000..6cf853a2cd562 --- /dev/null +++ b/packages/@aws-cdk/toolkit/lib/api/errors.ts @@ -0,0 +1,64 @@ +const TOOLKIT_ERROR_SYMBOL = Symbol.for('@aws-cdk/toolkit.ToolkitError'); +const AUTHENTICATION_ERROR_SYMBOL = Symbol.for('@aws-cdk/toolkit.AuthenticationError'); +const ASSEMBLY_ERROR_SYMBOL = Symbol.for('@aws-cdk/toolkit.AssemblyError'); + +/** + * Represents a general toolkit error in the AWS CDK Toolkit. + */ +export class ToolkitError extends Error { + /** + * Determines if a given error is an instance of ToolkitError. + */ + public static isToolkitError(x: any): x is ToolkitError { + return x !== null && typeof(x) === 'object' && TOOLKIT_ERROR_SYMBOL in x; + } + + /** + * Determines if a given error is an instance of AssemblyError. + */ + public static isAssemblyError(x: any): x is AssemblyError { + return this.isToolkitError(x) && ASSEMBLY_ERROR_SYMBOL in x; + } + + /** + * Determines if a given error is an instance of AuthenticationError. + */ + public static isAuthenticationError(x: any): x is AuthenticationError { + return this.isToolkitError(x) && AUTHENTICATION_ERROR_SYMBOL in x; + } + + /** + * The type of the error, defaults to "toolkit". + */ + public readonly type: string; + + constructor(message: string, type: string = 'toolkit') { + super(message); + Object.setPrototypeOf(this, ToolkitError.prototype); + Object.defineProperty(this, TOOLKIT_ERROR_SYMBOL, { value: true }); + this.name = new.target.name; + this.type = type; + } +} + +/** + * Represents an authentication-specific error in the AWS CDK Toolkit. + */ +export class AuthenticationError extends ToolkitError { + constructor(message: string) { + super(message, 'authentication'); + Object.setPrototypeOf(this, AuthenticationError.prototype); + Object.defineProperty(this, AUTHENTICATION_ERROR_SYMBOL, { value: true }); + } +} + +/** + * Represents an authentication-specific error in the AWS CDK Toolkit. + */ +export class AssemblyError extends ToolkitError { + constructor(message: string) { + super(message, 'assembly'); + Object.setPrototypeOf(this, AssemblyError.prototype); + Object.defineProperty(this, ASSEMBLY_ERROR_SYMBOL, { value: true }); + } +} diff --git a/packages/@aws-cdk/toolkit/lib/api/io/index.ts b/packages/@aws-cdk/toolkit/lib/api/io/index.ts new file mode 100644 index 0000000000000..7ee0b514c8639 --- /dev/null +++ b/packages/@aws-cdk/toolkit/lib/api/io/index.ts @@ -0,0 +1 @@ +export * from './io-host'; diff --git a/packages/@aws-cdk/toolkit/lib/api/io/io-host.ts b/packages/@aws-cdk/toolkit/lib/api/io/io-host.ts new file mode 100644 index 0000000000000..03aff9f114819 --- /dev/null +++ b/packages/@aws-cdk/toolkit/lib/api/io/io-host.ts @@ -0,0 +1,96 @@ +import type { ToolkitAction } from '../../toolkit'; + +/** + * The reporting level of the message. + * All messages are always reported, it's up to the IoHost to decide what to log. + */ +export type IoMessageLevel = 'error' | 'warn' | 'info' | 'debug' | 'trace'; + +/** + * Valid reporting categories for messages. + */ +export type IoMessageCodeCategory = 'TOOLKIT' | 'SDK' | 'ASSETS' | 'ASSEMBLY'; + +/** + * Code level matching the reporting level. + */ +export type IoCodeLevel = 'E' | 'W' | 'I'; + +/** + * A message code at a specific level + */ +export type IoMessageSpecificCode = `CDK_${IoMessageCodeCategory}_${L}${number}${number}${number}${number}`; + +/** + * A valid message code + */ +export type IoMessageCode = IoMessageSpecificCode; + +export interface IoMessage { + /** + * The time the message was emitted. + */ + readonly time: Date; + + /** + * The log level of the message. + */ + readonly level: IoMessageLevel; + + /** + * The action that triggered the message. + */ + readonly action: ToolkitAction; + + /** + * A short message code uniquely identifying a message type using the format CDK_[CATEGORY]_[E/W/I][0000-9999]. + * + * The level indicator follows these rules: + * - 'E' for error level messages + * - 'W' for warning level messages + * - 'I' for info/debug/trace level messages + * + * Codes ending in 000 0 are generic messages, while codes ending in 0001-9999 are specific to a particular message. + * The following are examples of valid and invalid message codes: + * ```ts + * 'CDK_ASSETS_I0000' // valid: generic assets info message + * 'CDK_TOOLKIT_E0002' // valid: specific toolkit error message + * 'CDK_SDK_W0023' // valid: specific sdk warning message + * ``` + */ + readonly code: IoMessageCode; + + /** + * The message text. + * This is safe to print to an end-user. + */ + readonly message: string; + + /** + * The data attached to the message. + */ + readonly data?: T; +} + +export interface IoRequest extends IoMessage { + /** + * The default response that will be used if no data is returned. + */ + readonly defaultResponse: U; +} + +export interface IIoHost { + /** + * Notifies the host of a message. + * The caller waits until the notification completes. + */ + notify(msg: IoMessage): Promise; + + /** + * Notifies the host of a message that requires a response. + * + * If the host does not return a response the suggested + * default response from the input message will be used. + */ + requestResponse(msg: IoRequest): Promise; +} diff --git a/packages/@aws-cdk/toolkit/lib/api/io/private/index.ts b/packages/@aws-cdk/toolkit/lib/api/io/private/index.ts new file mode 100644 index 0000000000000..476d4d9b28b60 --- /dev/null +++ b/packages/@aws-cdk/toolkit/lib/api/io/private/index.ts @@ -0,0 +1,4 @@ +export * from './logger'; +export * from './messages'; +export * from './timer'; +export * from './types'; diff --git a/packages/@aws-cdk/toolkit/lib/api/io/private/logger.ts b/packages/@aws-cdk/toolkit/lib/api/io/private/logger.ts new file mode 100644 index 0000000000000..0d3632d504837 --- /dev/null +++ b/packages/@aws-cdk/toolkit/lib/api/io/private/logger.ts @@ -0,0 +1,139 @@ +import * as util from 'node:util'; +import type { Logger } from '@smithy/types'; +import type { IIoHost, IoMessage, IoMessageCodeCategory, IoMessageLevel, IoRequest } from '../io-host'; +import { debug, error, info, messageCode, trace, warn } from './messages'; +import { ActionAwareIoHost } from './types'; +import type { ToolkitAction } from '../../../toolkit'; +import { formatSdkLoggerContent } from '../../aws-cdk'; + +export function withAction(ioHost: IIoHost, action: ToolkitAction) { + return { + notify: async (msg: Omit, 'action'>) => { + await ioHost.notify({ + ...msg, + action, + }); + }, + requestResponse: async (msg: Omit, 'action'>) => { + return ioHost.requestResponse({ + ...msg, + action, + }); + }, + }; +} + +// @todo these cannot be awaited WTF +export function asSdkLogger(ioHost: IIoHost, action: ToolkitAction): Logger { + return new class implements Logger { + // This is too much detail for our logs + public trace(..._content: any[]) {} + public debug(..._content: any[]) {} + + /** + * Info is called mostly (exclusively?) for successful API calls + * + * Payload: + * + * (Note the input contains entire CFN templates, for example) + * + * ``` + * { + * clientName: 'S3Client', + * commandName: 'GetBucketLocationCommand', + * input: { + * Bucket: '.....', + * ExpectedBucketOwner: undefined + * }, + * output: { LocationConstraint: 'eu-central-1' }, + * metadata: { + * httpStatusCode: 200, + * requestId: '....', + * extendedRequestId: '...', + * cfId: undefined, + * attempts: 1, + * totalRetryDelay: 0 + * } + * } + * ``` + */ + public info(...content: any[]) { + void ioHost.notify({ + action, + ...trace(`[sdk info] ${formatSdkLoggerContent(content)}`), + data: { + sdkLevel: 'info', + content, + }, + }); + } + + public warn(...content: any[]) { + void ioHost.notify({ + action, + ...trace(`[sdk warn] ${formatSdkLoggerContent(content)}`), + data: { + sdkLevel: 'warn', + content, + }, + }); + } + + /** + * Error is called mostly (exclusively?) for failing API calls + * + * Payload (input would be the entire API call arguments). + * + * ``` + * { + * clientName: 'STSClient', + * commandName: 'GetCallerIdentityCommand', + * input: {}, + * error: AggregateError [ECONNREFUSED]: + * at internalConnectMultiple (node:net:1121:18) + * at afterConnectMultiple (node:net:1688:7) { + * code: 'ECONNREFUSED', + * '$metadata': { attempts: 3, totalRetryDelay: 600 }, + * [errors]: [ [Error], [Error] ] + * }, + * metadata: { attempts: 3, totalRetryDelay: 600 } + * } + * ``` + */ + public error(...content: any[]) { + void ioHost.notify({ + action, + ...trace(`[sdk error] ${formatSdkLoggerContent(content)}`), + data: { + sdkLevel: 'error', + content, + }, + }); + } + }; +} + +/** + * Turn an ActionAwareIoHost into a logger that is compatible with older code, but doesn't support data + */ +export function asLogger(ioHost: ActionAwareIoHost, category?: IoMessageCodeCategory) { + const code = (level: IoMessageLevel) => messageCode(level, category); + + return { + trace: async (msg: string, ...args: any[]) => { + await ioHost.notify(trace(util.format(msg, args), code('trace'))); + }, + debug: async (msg: string, ...args: any[]) => { + await ioHost.notify(debug(util.format(msg, args), code('debug'))); + }, + info: async (msg: string, ...args: any[]) => { + await ioHost.notify(info(util.format(msg, args), code('info'))); + }, + warn: async (msg: string, ...args: any[]) => { + await ioHost.notify(warn(util.format(msg, args), code('warn'))); + }, + error: async (msg: string, ...args: any[]) => { + await ioHost.notify(error(util.format(msg, args), code('error'))); + }, + }; +} diff --git a/packages/@aws-cdk/toolkit/lib/api/io/private/messages.ts b/packages/@aws-cdk/toolkit/lib/api/io/private/messages.ts new file mode 100644 index 0000000000000..55cfde9ef7248 --- /dev/null +++ b/packages/@aws-cdk/toolkit/lib/api/io/private/messages.ts @@ -0,0 +1,161 @@ +import * as chalk from 'chalk'; +import type { IoMessageCode, IoMessageCodeCategory, IoMessageLevel } from '../io-host'; +import type { ActionLessMessage, ActionLessRequest, Optional, SimplifiedMessage } from './types'; + +/** + * Internal helper that processes log inputs into a consistent format. + * Handles string interpolation, format strings, and object parameter styles. + * Applies optional styling and prepares the final message for logging. + */ +export function formatMessage(msg: Optional, 'code'>, category: IoMessageCodeCategory = 'TOOLKIT'): ActionLessMessage { + return { + time: new Date(), + level: msg.level, + code: msg.code ?? messageCode(msg.level, category), + message: msg.message, + data: msg.data, + }; +} + +/** + * Build a message code from level and category + */ +export function messageCode(level: IoMessageLevel, category: IoMessageCodeCategory = 'TOOLKIT', number?: `${number}${number}${number}${number}`): IoMessageCode { + const levelIndicator = level === 'error' ? 'E' : + level === 'warn' ? 'W' : + 'I'; + return `CDK_${category}_${levelIndicator}${number ?? '0000'}`; +} + +/** + * Requests a yes/no confirmation from the IoHost. + */ +export const confirm = ( + code: IoMessageCode, + question: string, + motivation: string, + defaultResponse: boolean, + concurrency?: number, +): ActionLessRequest<{ + motivation: string; + concurrency?: number; +}, boolean> => { + return prompt(code, `${chalk.cyan(question)} (y/n)?`, defaultResponse, { + motivation, + concurrency, + }); +}; + +/** + * Prompt for a a response from the IoHost. + */ +export const prompt = (code: IoMessageCode, message: string, defaultResponse: U, payload?: T): ActionLessRequest => { + return { + defaultResponse, + ...formatMessage({ + level: 'info', + code, + message, + data: payload, + }), + }; +}; + +/** + * Logs an error level message. + */ +export const error = (message: string, code?: IoMessageCode, payload?: T) => { + return formatMessage({ + level: 'error', + code, + message, + data: payload, + }); +}; + +/** + * Logs an warning level message. + */ +export const warn = (message: string, code?: IoMessageCode, payload?: T) => { + return formatMessage({ + level: 'warn', + code, + message, + data: payload, + }); +}; + +/** + * Logs an info level message. + */ +export const info = (message: string, code?: IoMessageCode, payload?: T) => { + return formatMessage({ + level: 'info', + code, + message, + data: payload, + }); +}; + +/** + * Logs an info level message to stdout. + * @deprecated + */ +export const data = (message: string, code?: IoMessageCode, payload?: T) => { + return formatMessage({ + level: 'info', + code, + message, + data: payload, + }); +}; + +/** + * Logs a debug level message. + */ +export const debug = (message: string, code?: IoMessageCode, payload?: T) => { + return formatMessage({ + level: 'debug', + code, + message, + data: payload, + }); +}; + +/** + * Logs a trace level message. + */ +export const trace = (message: string, code?: IoMessageCode, payload?: T) => { + return formatMessage({ + level: 'trace', + code, + message, + data: payload, + }); +}; + +/** + * Logs an info level success message in green text. + * @deprecated + */ +export const success = (message: string, code?: IoMessageCode, payload?: T) => { + return formatMessage({ + level: 'info', + code, + message: chalk.green(message), + data: payload, + }); +}; + +/** + * Logs an info level message in bold text. + * @deprecated + */ +export const highlight = (message: string, code?: IoMessageCode, payload?: T) => { + return formatMessage({ + level: 'info', + code, + message: chalk.bold(message), + data: payload, + }); +}; diff --git a/packages/@aws-cdk/toolkit/lib/api/io/private/timer.ts b/packages/@aws-cdk/toolkit/lib/api/io/private/timer.ts new file mode 100644 index 0000000000000..9fc4dcdae47d1 --- /dev/null +++ b/packages/@aws-cdk/toolkit/lib/api/io/private/timer.ts @@ -0,0 +1,32 @@ +import { formatTime } from '../../aws-cdk'; + +/** + * Helper class to measure the time of code. + */ +export class Timer { + /** + * Start the timer. + * @return the timer instance + */ + public static start(): Timer { + return new Timer(); + } + + private readonly startTime: number; + + private constructor() { + this.startTime = new Date().getTime(); + } + + /** + * End the current timer. + * @returns the elapsed time + */ + public end() { + const elapsedTime = new Date().getTime() - this.startTime; + return { + asMs: elapsedTime, + asSec: formatTime(elapsedTime), + }; + } +} diff --git a/packages/@aws-cdk/toolkit/lib/api/io/private/types.ts b/packages/@aws-cdk/toolkit/lib/api/io/private/types.ts new file mode 100644 index 0000000000000..27f14c55e8d0a --- /dev/null +++ b/packages/@aws-cdk/toolkit/lib/api/io/private/types.ts @@ -0,0 +1,14 @@ +import { IIoHost, IoMessage, IoRequest } from '../io-host'; + +export type Optional = Pick, K> & Omit; +export type SimplifiedMessage = Pick, 'level' | 'code' | 'message' | 'data'>; +export type ActionLessMessage = Omit, 'action'>; +export type ActionLessRequest = Omit, 'action'>; + +/** + * Helper type for IoHosts that are action aware + */ +export interface ActionAwareIoHost extends IIoHost { + notify(msg: ActionLessMessage): Promise; + requestResponse(msg: ActionLessRequest): Promise; +} diff --git a/packages/@aws-cdk/toolkit/lib/api/toolkit/private/index.ts b/packages/@aws-cdk/toolkit/lib/api/toolkit/private/index.ts new file mode 100644 index 0000000000000..4b31c5a486f44 --- /dev/null +++ b/packages/@aws-cdk/toolkit/lib/api/toolkit/private/index.ts @@ -0,0 +1,11 @@ + +import { SdkProvider } from '../../aws-cdk'; +import { ActionAwareIoHost } from '../../io/private'; + +/** + * Helper struct to pass internal services around. + */ +export interface ToolkitServices { + sdkProvider: SdkProvider; + ioHost: ActionAwareIoHost; +} diff --git a/packages/@aws-cdk/toolkit/lib/cloud-assembly-source.ts b/packages/@aws-cdk/toolkit/lib/cloud-assembly-source.ts index 5973d0cc144e1..0aa0a8aaf4bd6 100644 --- a/packages/@aws-cdk/toolkit/lib/cloud-assembly-source.ts +++ b/packages/@aws-cdk/toolkit/lib/cloud-assembly-source.ts @@ -1,12 +1,5 @@ import { CloudAssembly } from '@aws-cdk/cx-api'; - -export interface ICloudAssemblySource { - /** - * produce - */ - produce(): Promise; -} - +import { ICloudAssemblySource } from './api/cloud-assembly'; /** * Configuration for creating a CLI from an AWS CDK App directory */ @@ -47,26 +40,3 @@ export class CloudAssemblySource implements ICloudAssemblySource { throw new Error('Method not implemented.'); } } - -/** - * A CloudAssemblySource that is caching its result once produced. - * - * Most Toolkit interactions should use a cached source. - * Not caching is relevant when the source changes frequently - * and it is to expensive to predict if the source has changed. - */ -export class CachedCloudAssemblySource implements ICloudAssemblySource { - private source: ICloudAssemblySource; - private cloudAssembly: CloudAssembly | undefined; - - public constructor(source: ICloudAssemblySource) { - this.source = source; - } - - public async produce(): Promise { - if (!this.cloudAssembly) { - this.cloudAssembly = await this.source.produce(); - } - return this.cloudAssembly; - } -} diff --git a/packages/@aws-cdk/toolkit/lib/index.ts b/packages/@aws-cdk/toolkit/lib/index.ts index 567c3c4ae0e7e..34173d660ce9a 100644 --- a/packages/@aws-cdk/toolkit/lib/index.ts +++ b/packages/@aws-cdk/toolkit/lib/index.ts @@ -1,10 +1,12 @@ +// The main show export * from './toolkit'; -export * from './cloud-assembly-source'; -export * from './actions/deploy'; -export * from './actions/destroy'; -export * from './actions/import'; -export * from './actions/synth'; -export * from './actions/watch'; +export * from './actions'; -export * from './io-host'; -export * from './types'; +// Supporting acts +export * from './api/aws-auth'; +export * from './api/cloud-assembly'; +export * from './api/io'; +export * from './api/errors'; + +// shared types +export * from './api/cloud-assembly/stack-selector'; diff --git a/packages/@aws-cdk/toolkit/lib/io-host.ts b/packages/@aws-cdk/toolkit/lib/io-host.ts deleted file mode 100644 index 7038bf89138a2..0000000000000 --- a/packages/@aws-cdk/toolkit/lib/io-host.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { MessageLevel, ToolkitAction } from './types'; - -export interface IoMessage { - time: string; - level: MessageLevel; - action: ToolkitAction; - code: string; - message: string; - data?: T; -} - -export interface IoRequest extends IoMessage { - defaultResponse: U; -} - -export interface IIoHost { - /** - * Notifies the host of a message. - * The caller waits until the notification completes. - */ - notify(msg: IoMessage): Promise; - - /** - * Notifies the host of a message that requires a response. - * - * If the host does not return a response the suggested - * default response from the input message will be used. - */ - requestResponse(msg: IoRequest): Promise; -} diff --git a/packages/@aws-cdk/toolkit/lib/toolkit.ts b/packages/@aws-cdk/toolkit/lib/toolkit.ts index e442cc28a7bf9..c162bcb292abc 100644 --- a/packages/@aws-cdk/toolkit/lib/toolkit.ts +++ b/packages/@aws-cdk/toolkit/lib/toolkit.ts @@ -1,31 +1,722 @@ -import { DeployOptions } from './actions/deploy'; +import * as path from 'node:path'; +import * as cxapi from '@aws-cdk/cx-api'; +import * as chalk from 'chalk'; +import * as chokidar from 'chokidar'; +import * as fs from 'fs-extra'; +import { AssetBuildTime, buildParameterMap, DeployOptions, removePublishedAssets, RequireApproval } from './actions/deploy'; import { DestroyOptions } from './actions/destroy'; +import { DiffOptions } from './actions/diff'; +import { ListOptions } from './actions/list'; +import { RollbackOptions } from './actions/rollback'; import { SynthOptions } from './actions/synth'; -import { WatchOptions } from './actions/watch'; -import { ICloudAssemblySource } from './cloud-assembly-source'; -import { IIoHost } from './io-host'; +import { patternsArrayForWatch, WatchOptions } from './actions/watch'; +import { SdkOptions } from './api/aws-auth'; +import { DEFAULT_TOOLKIT_STACK_NAME, SdkProvider, SuccessfulDeployStackResult, StackCollection, Deployments, HotswapMode, StackActivityProgress, ResourceMigrator, obscureTemplate, serializeStructure, tagsForStack, CliIoHost, validateSnsTopicArn, Concurrency, WorkGraphBuilder, AssetBuildNode, AssetPublishNode, StackNode } from './api/aws-cdk'; +import { CachedCloudAssemblySource, IdentityCloudAssemblySource, StackAssembly, ICloudAssemblySource } from './api/cloud-assembly'; +import { CloudAssemblySourceBuilder } from './api/cloud-assembly/private/source-builder'; +import { StackSelectionStrategy } from './api/cloud-assembly/stack-selector'; +import { ToolkitError } from './api/errors'; +import { IIoHost, IoMessageCode, IoMessageLevel } from './api/io'; +import { asSdkLogger, withAction, Timer, confirm, data, error, highlight, info, success, warn, ActionAwareIoHost, debug } from './api/io/private'; +import { ToolkitServices } from './api/toolkit/private'; + +/** + * The current action being performed by the CLI. 'none' represents the absence of an action. + */ +export type ToolkitAction = +| 'assembly' +| 'bootstrap' +| 'synth' +| 'list' +| 'diff' +| 'deploy' +| 'rollback' +| 'watch' +| 'destroy'; export interface ToolkitOptions { - ioHost: IIoHost; + /** + * The IoHost implementation, handling the inline interactions between the Toolkit and an integration. + */ + // ioHost: IIoHost; + + /** + * Configuration options for the SDK. + */ + sdkOptions?: SdkOptions; + + /** + * Name of the toolkit stack to be used. + * + * @default "CDKToolkit" + */ + toolkitStackName?: string; + + /** + * Fail Cloud Assemblies + * + * @default "error" + */ + assemblyFailureAt?: 'error' | 'warn' | 'none'; } -export class Toolkit { - public constructor(_options: ToolkitOptions) {} +/** + * The AWS CDK Programmatic Toolkit + */ +export class Toolkit extends CloudAssemblySourceBuilder implements AsyncDisposable { + /** + * The toolkit stack name used for bootstrapping resources. + */ + public readonly toolkitStackName: string; - public async synth(_cx: ICloudAssemblySource, _options: SynthOptions): Promise { - throw new Error('Not implemented yet'); + /** + * @todo should probably be public in one way or the other. + */ + private readonly ioHost: IIoHost; + private _sdkProvider?: SdkProvider; + + public constructor(private readonly props: ToolkitOptions = {}) { + super(); + + // @todo open ioHost up + this.ioHost = CliIoHost.getIoHost(); + // this.ioHost = options.ioHost; + + this.toolkitStackName = props.toolkitStackName ?? DEFAULT_TOOLKIT_STACK_NAME; + } + + public async dispose(): Promise { + // nothing to do yet } - public async deploy(_cx: ICloudAssemblySource, _options: DeployOptions): Promise { + public async [Symbol.asyncDispose](): Promise { + await this.dispose(); + } + + /** + * Access to the AWS SDK + */ + private async sdkProvider(action: ToolkitAction): Promise { + // @todo this needs to be different instance per action + if (!this._sdkProvider) { + this._sdkProvider = await SdkProvider.withAwsCliCompatibleDefaults({ + ...this.props.sdkOptions, + logger: asSdkLogger(this.ioHost, action), + }); + } + + return this._sdkProvider; + } + + /** + * Helper to provide the CloudAssemblySourceBuilder with required toolkit services + */ + protected override async toolkitServices(): Promise { + return { + ioHost: this.ioHost, + sdkProvider: await this.sdkProvider('assembly'), + }; + } + + /** + * Synth Action + */ + public async synth(cx: ICloudAssemblySource, options: SynthOptions): Promise { + const ioHost = withAction(this.ioHost, 'synth'); + const assembly = await this.assemblyFromSource(cx); + const stacks = assembly.selectStacksV2(options.stacks); + const autoValidateStacks = options.validateStacks ? [assembly.selectStacksForValidation()] : []; + await this.validateStacksMetadata(stacks.concat(...autoValidateStacks), ioHost); + + // if we have a single stack, print it to STDOUT + if (stacks.stackCount === 1) { + const template = stacks.firstStack?.template; + const obscuredTemplate = obscureTemplate(template); + await ioHost.notify(info('', 'CDK_TOOLKIT_I0001', { + raw: template, + json: serializeStructure(obscuredTemplate, true), + yaml: serializeStructure(obscuredTemplate, false), + }, + )); + } else { + // not outputting template to stdout, let's explain things to the user a little bit... + await ioHost.notify(success(`Successfully synthesized to ${chalk.blue(path.resolve(stacks.assembly.directory))}`)); + await ioHost.notify(info(`Supply a stack id (${stacks.stackArtifacts.map((s) => chalk.green(s.hierarchicalId)).join(', ')}) to display its template.`)); + } + + return new IdentityCloudAssemblySource(assembly.assembly); + } + + /** + * List Action + * + * List out selected stacks + */ + public async list(cx: ICloudAssemblySource, _options: ListOptions): Promise { + const ioHost = withAction(this.ioHost, 'list'); + const assembly = await this.assemblyFromSource(cx); + ioHost; + assembly; throw new Error('Not implemented yet'); } - public async watch(_cx: ICloudAssemblySource, _options: WatchOptions): Promise { + /** + * Compares the specified stack with the deployed stack or a local template file and returns a structured diff. + */ + public async diff(cx: ICloudAssemblySource, options: DiffOptions): Promise { + const ioHost = withAction(this.ioHost, 'diff'); + const assembly = await this.assemblyFromSource(cx); + const stacks = await assembly.selectStacksV2(options.stacks); + await this.validateStacksMetadata(stacks, ioHost); throw new Error('Not implemented yet'); } - public async destroy(_cx: ICloudAssemblySource, _options: DestroyOptions): Promise { + /** + * Deploys the selected stacks into an AWS account + */ + public async deploy(cx: ICloudAssemblySource, options: DeployOptions): Promise { + const ioHost = withAction(this.ioHost, 'deploy'); + const timer = Timer.start(); + const assembly = await this.assemblyFromSource(cx); + const stackCollection = assembly.selectStacksV2(options.stacks); + await this.validateStacksMetadata(stackCollection, ioHost); + + const synthTime = timer.end(); + await ioHost.notify(info(`\n✨ Synthesis time: ${synthTime.asSec}s\n`, 'CDK_TOOLKIT_I5001', { + time: synthTime.asMs, + })); + + if (stackCollection.stackCount === 0) { + await ioHost.notify(error('This app contains no stacks')); + return; + } + + const deployments = await this.deploymentsForAction('deploy'); + + const migrator = new ResourceMigrator({ + deployments, + }); + await migrator.tryMigrateResources(stackCollection, options); + + // const requireApproval = options.requireApproval ?? RequireApproval.BROADENING; + + const parameterMap = buildParameterMap(options.parameters?.parameters); + + if (options.hotswap !== HotswapMode.FULL_DEPLOYMENT) { + await ioHost.notify(warn( + '⚠️ The --hotswap and --hotswap-fallback flags deliberately introduce CloudFormation drift to speed up deployments', + )); + await ioHost.notify(warn('⚠️ They should only be used for development - never use them for your production Stacks!\n')); + } + + // @TODO + // let hotswapPropertiesFromSettings = this.props.configuration.settings.get(['hotswap']) || {}; + + // let hotswapPropertyOverrides = new HotswapPropertyOverrides(); + // hotswapPropertyOverrides.ecsHotswapProperties = new EcsHotswapProperties( + // hotswapPropertiesFromSettings.ecs?.minimumHealthyPercent, + // hotswapPropertiesFromSettings.ecs?.maximumHealthyPercent, + // ); + + const stacks = stackCollection.stackArtifacts; + + const stackOutputs: { [key: string]: any } = {}; + const outputsFile = options.outputsFile; + + const buildAsset = async (assetNode: AssetBuildNode) => { + await deployments.buildSingleAsset( + assetNode.assetManifestArtifact, + assetNode.assetManifest, + assetNode.asset, + { + stack: assetNode.parentStack, + roleArn: options.roleArn, + stackName: assetNode.parentStack.stackName, + }, + ); + }; + + const publishAsset = async (assetNode: AssetPublishNode) => { + await deployments.publishSingleAsset(assetNode.assetManifest, assetNode.asset, { + stack: assetNode.parentStack, + roleArn: options.roleArn, + stackName: assetNode.parentStack.stackName, + }); + }; + + const deployStack = async (stackNode: StackNode) => { + const stack = stackNode.stack; + if (stackCollection.stackCount !== 1) { + await ioHost.notify(highlight(stack.displayName)); + } + + if (!stack.environment) { + // eslint-disable-next-line max-len + throw new ToolkitError( + `Stack ${stack.displayName} does not define an environment, and AWS credentials could not be obtained from standard locations or no region was configured.`, + ); + } + + if (Object.keys(stack.template.Resources || {}).length === 0) { + // The generated stack has no resources + if (!(await deployments.stackExists({ stack }))) { + await ioHost.notify(warn(`${chalk.bold(stack.displayName)}: stack has no resources, skipping deployment.`)); + } else { + await ioHost.notify(warn(`${chalk.bold(stack.displayName)}: stack has no resources, deleting existing stack.`)); + await this._destroy(assembly, 'deploy', { + stacks: { patterns: [stack.hierarchicalId], strategy: StackSelectionStrategy.PATTERN_MUST_MATCH_SINGLE }, + roleArn: options.roleArn, + ci: options.ci, + }); + } + return; + } + + // @TODO + // if (requireApproval !== RequireApproval.NEVER) { + // const currentTemplate = await deployments.readCurrentTemplate(stack); + // if (printSecurityDiff(currentTemplate, stack, requireApproval)) { + // await askUserConfirmation( + // concurrency, + // '"--require-approval" is enabled and stack includes security-sensitive updates', + // 'Do you wish to deploy these changes', + // ); + // } + // } + + // Following are the same semantics we apply with respect to Notification ARNs (dictated by the SDK) + // + // - undefined => cdk ignores it, as if it wasn't supported (allows external management). + // - []: => cdk manages it, and the user wants to wipe it out. + // - ['arn-1'] => cdk manages it, and the user wants to set it to ['arn-1']. + const notificationArns = (!!options.notificationArns || !!stack.notificationArns) + ? (options.notificationArns ?? []).concat(stack.notificationArns ?? []) + : undefined; + + for (const notificationArn of notificationArns ?? []) { + if (!validateSnsTopicArn(notificationArn)) { + throw new ToolkitError(`Notification arn ${notificationArn} is not a valid arn for an SNS topic`); + } + } + + const stackIndex = stacks.indexOf(stack) + 1; + await ioHost.notify( + info(`${chalk.bold(stack.displayName)}: deploying... [${stackIndex}/${stackCollection.stackCount}]`), + ); + const startDeployTime = Timer.start(); + + let tags = options.tags; + if (!tags || tags.length === 0) { + tags = tagsForStack(stack); + } + + let elapsedDeployTime; + try { + let deployResult: SuccessfulDeployStackResult | undefined; + + let rollback = options.rollback; + let iteration = 0; + while (!deployResult) { + if (++iteration > 2) { + throw new ToolkitError('This loop should have stabilized in 2 iterations, but didn\'t. If you are seeing this error, please report it at https://github.com/aws/aws-cdk/issues/new/choose'); + } + + const r = await deployments.deployStack({ + stack, + deployName: stack.stackName, + roleArn: options.roleArn, + toolkitStackName: options.toolkitStackName, + reuseAssets: options.reuseAssets, + notificationArns, + tags, + deploymentMethod: options.deploymentMethod, + force: options.force, + parameters: Object.assign({}, parameterMap['*'], parameterMap[stack.stackName]), + usePreviousParameters: options.parameters?.keepExistingParameters, + progress, + ci: options.ci, + rollback, + hotswap: options.hotswap, + // hotswapPropertyOverrides: hotswapPropertyOverrides, + + assetParallelism: options.assetParallelism, + }); + + switch (r.type) { + case 'did-deploy-stack': + deployResult = r; + break; + + case 'failpaused-need-rollback-first': { + const motivation = r.reason === 'replacement' + ? `Stack is in a paused fail state (${r.status}) and change includes a replacement which cannot be deployed with "--no-rollback"` + : `Stack is in a paused fail state (${r.status}) and command line arguments do not include "--no-rollback"`; + const question = `${motivation}. Perform a regular deployment`; + + if (options.force) { + await ioHost.notify(warn(`${motivation}. Rolling back first (--force).`)); + } else { + // @todo reintroduce concurrency and corked logging in CliHost + const confirmed = await ioHost.requestResponse(confirm('CDK_TOOLKIT_I5050', question, motivation, true, concurrency)); + if (!confirmed) { throw new ToolkitError('Aborted by user'); } + } + + // Perform a rollback + await this.rollback(cx, { + stacks: { patterns: [stack.hierarchicalId], strategy: StackSelectionStrategy.PATTERN_MUST_MATCH_SINGLE }, + orphanFailedResources: options.force, + }); + + // Go around through the 'while' loop again but switch rollback to true. + rollback = true; + break; + } + + case 'replacement-requires-rollback': { + const motivation = 'Change includes a replacement which cannot be deployed with "--no-rollback"'; + const question = `${motivation}. Perform a regular deployment`; + + // @todo no force here + if (options.force) { + await ioHost.notify(warn(`${motivation}. Proceeding with regular deployment (--force).`)); + } else { + // @todo reintroduce concurrency and corked logging in CliHost + const confirmed = await ioHost.requestResponse(confirm('CDK_TOOLKIT_I5050', question, motivation, true, concurrency)); + if (!confirmed) { throw new ToolkitError('Aborted by user'); } + } + + // Go around through the 'while' loop again but switch rollback to false. + rollback = true; + break; + } + + default: + throw new ToolkitError(`Unexpected result type from deployStack: ${JSON.stringify(r)}. If you are seeing this error, please report it at https://github.com/aws/aws-cdk/issues/new/choose`); + } + } + + const message = deployResult.noOp + ? ` ✅ ${stack.displayName} (no changes)` + : ` ✅ ${stack.displayName}`; + + await ioHost.notify(success('\n' + message)); + elapsedDeployTime = startDeployTime.end(); + await ioHost.notify(info(`\n✨ Deployment time: ${elapsedDeployTime.asSec}s\n`)); + + if (Object.keys(deployResult.outputs).length > 0) { + await ioHost.notify(info('Outputs:')); + + stackOutputs[stack.stackName] = deployResult.outputs; + } + + for (const name of Object.keys(deployResult.outputs).sort()) { + const value = deployResult.outputs[name]; + await ioHost.notify(info(`${chalk.cyan(stack.id)}.${chalk.cyan(name)} = ${chalk.underline(chalk.cyan(value))}`)); + } + + await ioHost.notify(info('Stack ARN:')); + + await ioHost.notify(data(deployResult.stackArn)); + } catch (e: any) { + // It has to be exactly this string because an integration test tests for + // "bold(stackname) failed: ResourceNotReady: " + throw new ToolkitError( + [`❌ ${chalk.bold(stack.stackName)} failed:`, ...(e.name ? [`${e.name}:`] : []), e.message].join(' '), + ); + } finally { + // @todo + // if (options.cloudWatchLogMonitor) { + // const foundLogGroupsResult = await findCloudWatchLogGroups(await this.sdkProvider('deploy'), stack); + // options.cloudWatchLogMonitor.addLogGroups( + // foundLogGroupsResult.env, + // foundLogGroupsResult.sdk, + // foundLogGroupsResult.logGroupNames, + // ); + // } + + // If an outputs file has been specified, create the file path and write stack outputs to it once. + // Outputs are written after all stacks have been deployed. If a stack deployment fails, + // all of the outputs from successfully deployed stacks before the failure will still be written. + if (outputsFile) { + fs.ensureFileSync(outputsFile); + await fs.writeJson(outputsFile, stackOutputs, { + spaces: 2, + encoding: 'utf8', + }); + } + } + await ioHost.notify(info(`\n✨ Total time: ${synthTime.asSec + elapsedDeployTime.asSec}s\n`)); + }; + + const assetBuildTime = options.assetBuildTime ?? AssetBuildTime.ALL_BEFORE_DEPLOY; + const prebuildAssets = assetBuildTime === AssetBuildTime.ALL_BEFORE_DEPLOY; + const concurrency = options.concurrency || 1; + const progress = concurrency > 1 ? StackActivityProgress.EVENTS : options.progress; + if (concurrency > 1 && options.progress && options.progress != StackActivityProgress.EVENTS) { + await ioHost.notify(warn('⚠️ The --concurrency flag only supports --progress "events". Switching to "events".')); + } + + const stacksAndTheirAssetManifests = stacks.flatMap((stack) => [ + stack, + ...stack.dependencies.filter(cxapi.AssetManifestArtifact.isAssetManifestArtifact), + ]); + const workGraph = new WorkGraphBuilder(prebuildAssets).build(stacksAndTheirAssetManifests); + + // Unless we are running with '--force', skip already published assets + if (!options.force) { + await removePublishedAssets(workGraph, deployments, options); + } + + const graphConcurrency: Concurrency = { + 'stack': concurrency, + 'asset-build': 1, // This will be CPU-bound/memory bound, mostly matters for Docker builds + 'asset-publish': (options.assetParallelism ?? true) ? 8 : 1, // This will be I/O-bound, 8 in parallel seems reasonable + }; + + await workGraph.doParallel(graphConcurrency, { + deployStack, + buildAsset, + publishAsset, + }); + } + + /** + * Watch Action + * + * Continuously observe project files and deploy the selected stacks automatically when changes are detected. + * Implies hotswap deployments. + */ + public async watch(cx: ICloudAssemblySource, options: WatchOptions): Promise { + const ioHost = withAction(this.ioHost, 'watch'); + const rootDir = options.watchDir ?? process.cwd(); + await ioHost.notify(debug(`root directory used for 'watch' is: ${rootDir}`)); + + if (options.include === undefined && options.exclude === undefined) { + throw new ToolkitError( + "Cannot use the 'watch' command without specifying at least one directory to monitor. " + + 'Make sure to add a "watch" key to your cdk.json', + ); + } + + // For the "include" subkey under the "watch" key, the behavior is: + // 1. No "watch" setting? We error out. + // 2. "watch" setting without an "include" key? We default to observing "./**". + // 3. "watch" setting with an empty "include" key? We default to observing "./**". + // 4. Non-empty "include" key? Just use the "include" key. + const watchIncludes = patternsArrayForWatch(options.include, { + rootDir, + returnRootDirIfEmpty: true, + }); + await ioHost.notify(debug(`'include' patterns for 'watch': ${watchIncludes}`)); + + // For the "exclude" subkey under the "watch" key, + // the behavior is to add some default excludes in addition to the ones specified by the user: + // 1. The CDK output directory. + // 2. Any file whose name starts with a dot. + // 3. Any directory's content whose name starts with a dot. + // 4. Any node_modules and its content (even if it's not a JS/TS project, you might be using a local aws-cli package) + const watchExcludes = patternsArrayForWatch(options.exclude, { + rootDir, + returnRootDirIfEmpty: false, + }).concat(`${options.output}/**`, '**/.*', '**/.*/**', '**/node_modules/**'); + await ioHost.notify(debug(`'exclude' patterns for 'watch': ${watchExcludes}`)); + + // Since 'cdk deploy' is a relatively slow operation for a 'watch' process, + // introduce a concurrency latch that tracks the state. + // This way, if file change events arrive when a 'cdk deploy' is still executing, + // we will batch them, and trigger another 'cdk deploy' after the current one finishes, + // making sure 'cdk deploy's always execute one at a time. + // Here's a diagram showing the state transitions: + // -------------- -------- file changed -------------- file changed -------------- file changed + // | | ready event | | ------------------> | | ------------------> | | --------------| + // | pre-ready | -------------> | open | | deploying | | queued | | + // | | | | <------------------ | | <------------------ | | <-------------| + // -------------- -------- 'cdk deploy' done -------------- 'cdk deploy' done -------------- + let latch: 'pre-ready' | 'open' | 'deploying' | 'queued' = 'pre-ready'; + + // @todo + // const cloudWatchLogMonitor = options.traceLogs ? new CloudWatchLogEventMonitor() : undefined; + const deployAndWatch = async () => { + latch = 'deploying'; + // cloudWatchLogMonitor?.deactivate(); + + await this.invokeDeployFromWatch(cx, options); + + // If latch is still 'deploying' after the 'await', that's fine, + // but if it's 'queued', that means we need to deploy again + while ((latch as 'deploying' | 'queued') === 'queued') { + // TypeScript doesn't realize latch can change between 'awaits', + // and thinks the above 'while' condition is always 'false' without the cast + latch = 'deploying'; + await ioHost.notify(info("Detected file changes during deployment. Invoking 'cdk deploy' again")); + await this.invokeDeployFromWatch(cx, options); + } + latch = 'open'; + // cloudWatchLogMonitor?.activate(); + }; + + chokidar + .watch(watchIncludes, { + ignored: watchExcludes, + cwd: rootDir, + // ignoreInitial: true, + }) + .on('ready', async () => { + latch = 'open'; + await ioHost.notify(debug("'watch' received the 'ready' event. From now on, all file changes will trigger a deployment")); + await ioHost.notify(info("Triggering initial 'cdk deploy'")); + await deployAndWatch(); + }) + .on('all', async (event: 'add' | 'addDir' | 'change' | 'unlink' | 'unlinkDir', filePath?: string) => { + if (latch === 'pre-ready') { + await ioHost.notify(info(`'watch' is observing ${event === 'addDir' ? 'directory' : 'the file'} '${filePath}' for changes`)); + } else if (latch === 'open') { + await ioHost.notify(info(`Detected change to '${filePath}' (type: ${event}). Triggering 'cdk deploy'`)); + await deployAndWatch(); + } else { + // this means latch is either 'deploying' or 'queued' + latch = 'queued'; + await ioHost.notify(info( + `Detected change to '${filePath}' (type: ${event}) while 'cdk deploy' is still running. Will queue for another deployment after this one finishes'`, + )); + } + }); + } + + /** + * Rollback Action + * + * Rolls back the selected stacks. + */ + public async rollback(cx: ICloudAssemblySource, options: RollbackOptions): Promise { + const ioHost = withAction(this.ioHost, 'rollback'); + const timer = Timer.start(); + const assembly = await this.assemblyFromSource(cx); + const stacks = await assembly.selectStacksV2(options.stacks); + await this.validateStacksMetadata(stacks, ioHost); + const synthTime = timer.end(); + await ioHost.notify(info(`\n✨ Synthesis time: ${synthTime.asSec}s\n`, 'CDK_TOOLKIT_I5001', { + time: synthTime.asMs, + })); + throw new Error('Not implemented yet'); } + + /** + * Destroy Action + * + * Destroys the selected Stacks. + */ + public async destroy(cx: ICloudAssemblySource, options: DestroyOptions): Promise { + const assembly = await this.assemblyFromSource(cx); + return this._destroy(assembly, 'destroy', options); + } + + /** + * Helper to allow destroy being called as part of the deploy action. + */ + private async _destroy(assembly: StackAssembly, action: 'deploy' | 'destroy', options: DestroyOptions): Promise { + const ioHost = withAction(this.ioHost, action); + // The stacks will have been ordered for deployment, so reverse them for deletion. + const stacks = await assembly.selectStacksV2(options.stacks).reversed(); + + const motivation = 'Destroying stacks is an irreversible action'; + const question = `Are you sure you want to delete: ${chalk.red(stacks.hierarchicalIds.join(', '))}`; + const confirmed = await ioHost.requestResponse(confirm('CDK_TOOLKIT_I7010', question, motivation, true)); + if (!confirmed) { + return ioHost.notify(error('Aborted by user')); + } + + for (const [index, stack] of stacks.stackArtifacts.entries()) { + await ioHost.notify(success(`${chalk.blue(stack.displayName)}: destroying... [${index + 1}/${stacks.stackCount}]`)); + try { + const deployments = await this.deploymentsForAction(action); + await deployments.destroyStack({ + stack, + deployName: stack.stackName, + roleArn: options.roleArn, + ci: options.ci, + }); + await ioHost.notify(success(`\n ✅ ${chalk.blue(stack.displayName)}: ${action}ed`)); + } catch (e) { + await ioHost.notify(error(`\n ❌ ${chalk.blue(stack.displayName)}: ${action} failed ${e}`)); + throw e; + } + } + } + + /** + * Validate the stacks for errors and warnings according to the CLI's current settings + */ + private async validateStacksMetadata(stacks: StackCollection, ioHost: ActionAwareIoHost) { + // @TODO define these somewhere central + const code = (level: IoMessageLevel): IoMessageCode => { + switch (level) { + case 'error': return 'CDK_ASSEMBLY_E9999'; + case 'warn': return 'CDK_ASSEMBLY_W9999'; + default: return 'CDK_ASSEMBLY_I9999'; + } + }; + await stacks.validateMetadata(this.props.assemblyFailureAt, async (level, msg) => ioHost.notify({ + time: new Date(), + level, + code: code(level), + message: `[${level} at ${msg.id}] ${msg.entry.data}`, + data: msg, + })); + } + + /** + * Creates a Toolkit internal CloudAssembly from a CloudAssemblySource. + * @param assemblySource the source for the cloud assembly + * @param cache if the assembly should be cached, default: `true` + * @returns the CloudAssembly object + */ + private async assemblyFromSource(assemblySource: ICloudAssemblySource, cache: boolean = true): Promise { + if (assemblySource instanceof StackAssembly) { + return assemblySource; + } + + if (cache) { + return new StackAssembly(await new CachedCloudAssemblySource(assemblySource).produce()); + } + + return new StackAssembly(await assemblySource.produce()); + } + + /** + * Create a deployments class + */ + private async deploymentsForAction(action: ToolkitAction): Promise { + return new Deployments({ + sdkProvider: await this.sdkProvider(action), + toolkitStackName: this.toolkitStackName, + }); + } + + private async invokeDeployFromWatch( + cx: ICloudAssemblySource, + options: WatchOptions, + ): Promise { + const deployOptions: DeployOptions = { + ...options, + requireApproval: RequireApproval.NEVER, + // if 'watch' is called by invoking 'cdk deploy --watch', + // we need to make sure to not call 'deploy' with 'watch' again, + // as that would lead to a cycle + // watch: false, + // cloudWatchLogMonitor, + // cacheCloudAssembly: false, + hotswap: options.hotswap, + // extraUserAgent: `cdk-watch/hotswap-${options.hotswap !== HotswapMode.FALL_BACK ? 'on' : 'off'}`, + concurrency: options.concurrency, + }; + + try { + await this.deploy(cx, deployOptions); + } catch { + // just continue - deploy will show the error + } + } } diff --git a/packages/@aws-cdk/toolkit/lib/types.ts b/packages/@aws-cdk/toolkit/lib/types.ts deleted file mode 100644 index 459c3b9a04f22..0000000000000 --- a/packages/@aws-cdk/toolkit/lib/types.ts +++ /dev/null @@ -1,78 +0,0 @@ -export type ToolkitAction = - | 'bootstrap' - | 'synth' - | 'list' - | 'deploy' - | 'destroy'; - -export type MessageLevel = 'error' | 'warn' | 'info' | 'debug'; - -export enum StackSelectionStrategy { - /** - * Returns an empty selection in case there are no stacks. - */ - NONE = 'none', - - /** - * If the app includes a single stack, returns it. Otherwise throws an exception. - * This behavior is used by "deploy". - */ - ONLY_SINGLE = 'single', - - /** - * Throws an exception if the app doesn't contain at least one stack. - */ - AT_LEAST_ONE = 'at-least-one', - - /** - * Returns all stacks in the main (top level) assembly only. - */ - MAIN_ASSEMBLY = 'main', - - /** - * If no selectors are provided, returns all stacks in the app, - * including stacks inside nested assemblies. - */ - ALL_STACKS = 'all', -} - -/** - * When selecting stacks, what other stacks to include because of dependencies - */ -export enum ExtendedStackSelection { - /** - * Don't select any extra stacks - */ - NONE = 'none', - - /** - * Include stacks that this stack depends on - */ - UPSTREAM = 'upstream', - - /** - * Include stacks that depend on this stack - */ - DOWNSTREAM = 'downstream', -} - -/** - * A specification of which stacks should be selected - */ -export interface StackSelector { - /** - * A list of patterns to match the stack hierarchical ids - */ - patterns: string[]; - - /** - * Extend the selection to upstream/downstream stacks - * @default ExtendedStackSelection.None only select the specified stacks. - */ - extend?: ExtendedStackSelection; - - /** - * The behavior if if no selectors are provided. - */ - strategy: StackSelectionStrategy; -} diff --git a/packages/@aws-cdk/toolkit/package.json b/packages/@aws-cdk/toolkit/package.json index 360f1ce16b743..bac4a77dd6edf 100644 --- a/packages/@aws-cdk/toolkit/package.json +++ b/packages/@aws-cdk/toolkit/package.json @@ -3,22 +3,34 @@ "description": "AWS CDK Programmatic Toolkit Library", "private": true, "version": "0.0.0", - "main": "./lib/index.js", - "exports": "./lib/index.js", + "main": "./lib/main.js", + "exports": { + ".": { + "types": "./lib/index.d.ts", + "default": "./lib/main.js" + } + }, "types": "./lib/index.d.ts", "type": "commonjs", "scripts": { - "build": "cdk-build", - "watch": "cdk-watch", - "lint": "cdk-lint", - "test": "cdk-test", - "pkglint": "pkglint -f", - "package": "cdk-package", "awslint": "cdk-awslint", - "build+test+package": "yarn build+test && yarn package", + "build": "cdk-build", "build+test": "yarn build && yarn test", + "build+test+package": "yarn build+test && yarn package", "build+extract": "yarn build", - "build+test+extract": "yarn build+test" + "build+test+extract": "yarn build+test", + "bundle": "node bundle.mjs", + "lint": "cdk-lint", + "package": "cdk-package", + "pkglint": "pkglint -f", + "test": "cdk-test", + "watch": "cdk-watch" + }, + "cdk-build": { + "post": [ + "yarn bundle", + "node ./lib/main.js >/dev/null 2>/dev/null