diff --git a/.projenrc.ts b/.projenrc.ts index 3d99457a9..8fd28e037 100644 --- a/.projenrc.ts +++ b/.projenrc.ts @@ -811,6 +811,7 @@ const toolkitLib = configureProject( isolatedModules: false, // we use the respective tsc setting }, }, + majorVersion: 1, nextVersionCommand: 'tsx ../../../projenrc/next-version.ts maybeRc', }), ); @@ -1343,6 +1344,7 @@ new JsiiBuild(cliLibAlpha, { packageName: 'awscdkclilibalpha', }, rosettaStrict: true, + rosettaDependencies: ['aws-cdk-lib@^2'], stability: Stability.DEPRECATED, composite: true, excludeTypescript: CLI_LIB_EXCLUDE_PATTERNS, diff --git a/README.md b/README.md index bc5b8f80e..40379998e 100644 --- a/README.md +++ b/README.md @@ -29,9 +29,9 @@ If you have a support plan with AWS Support, you can also create a new [support You may also find help on these community resources: -* Look through the [API Reference](https://docs.aws.amazon.com/cdk/api/latest/docs/aws-construct-library.html) or [Developer Guide](https://docs.aws.amazon.com/cdk/latest/guide) -* The #aws-cdk Slack channel in [cdk.dev](https://cdk.dev) -* Ask a question on [Stack Overflow](https://stackoverflow.com/questions/tagged/aws-cdk) +- Look through the [API Reference](https://docs.aws.amazon.com/cdk/api/latest/docs/aws-construct-library.html) or [Developer Guide](https://docs.aws.amazon.com/cdk/latest/guide) +- The #aws-cdk Slack channel in [cdk.dev](https://cdk.dev) +- Ask a question on [Stack Overflow](https://stackoverflow.com/questions/tagged/aws-cdk) and tag it with `aws-cdk` ## Contributing @@ -44,20 +44,20 @@ environment and submit code. Here are the packages in this repository. See the README of each package for more information about it: -| Package | Description | Published? | Maintained? | -|---------|-------------|------------|-------------| -| [aws-cdk](./packages/aws-cdk/) | The CDK Toolkit CLI, main CLI interface to CDK projects. | Yes | Yes | -| [cdk](./packages/cdk/) | An alias for `aws-cdk` so you can run `npx cdk` even if it's not installed. | Yes | Yes | -| [cdk-assets v3](./packages/cdk-assets/) | CLI component handling asset uploads, also used as a CLI in [CDK Pipelines](https://docs.aws.amazon.com/cdk/api/v2/docs/aws-cdk-lib.pipelines-readme.html) | Yes | Yes | -| [@aws-cdk/cloud-assembly-schema](./packages/@aws-cdk//cloud-assembly-schema/) | The contract between the CDK construct library and the CDK toolkit | Yes | Yes | -| [@aws-cdk/cloudformation-diff](./packages/@aws-cdk/cloudformation-diff/) | CLI component for diffing CloudFormation templates | Yes | Yes | -| [@aws-cdk/cli-lib-alpha](./packages/@aws-cdk/cli-lib-alpha/) | A deprecated attempt at building a programmatic interface for the CLI | Yes | No | -| [@aws-cdk/toolkit-lib](./packages/@aws-cdk/toolkit-lib/) | A programmatic interface for the CDK Toolkit | Yes | Yes | -| [@aws-cdk/cli-plugin-contract](./packages/@aws-cdk/cli-plugin-contract/) | TypeScript types for CLI plugins. | No | Yes | -| [@aws-cdk/cdk-cli-wrapper](./packages/@aws-cdk/cdk-cli-wrapper/) | A deprecated attempt at building a programmatic interface for the CLI | No | No | -| [@aws-cdk/node-bundle](./packages/@aws-cdk/node-bundle/) | A tool to build CLI bundles that include license attributions. | No | Yes | -| [@aws-cdk/user-input-gen](./packages/@aws-cdk/user-input-gen/) | A build tool for the CLI and toolkit-lib. | No | Yes | -| [@aws-cdk/yarn-cling](./packages/@aws-cdk/yarn-cling/) | A deprecated build tool for the CLI. | No | No | +| Package | Description | Published? | Maintained? | +| ----------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------- | ----------- | +| [aws-cdk](./packages/aws-cdk/) | The CDK Toolkit CLI, main CLI interface to CDK projects. | Yes | Yes | +| [@aws-cdk/toolkit-lib](./packages/@aws-cdk/toolkit-lib/) | A programmatic interface for the CDK Toolkit. | Yes | Yes | +| [cdk](./packages/cdk/) | An alias for `aws-cdk` so you can run `npx cdk` even if it's not installed. | Yes | Yes | +| [cdk-assets v3](./packages/cdk-assets/) | CLI component handling asset uploads, also used as a CLI in [CDK Pipelines](https://docs.aws.amazon.com/cdk/api/v2/docs/aws-cdk-lib.pipelines-readme.html) | Yes | Yes | +| [@aws-cdk/cloud-assembly-schema](./packages/@aws-cdk//cloud-assembly-schema/) | The contract between the CDK construct library and the CDK toolkit | Yes | Yes | +| [@aws-cdk/cloudformation-diff](./packages/@aws-cdk/cloudformation-diff/) | CLI component for diffing CloudFormation templates | Yes | Yes | +| [@aws-cdk/cli-lib-alpha](./packages/@aws-cdk/cli-lib-alpha/) | A deprecated attempt at building a programmatic interface for the CLI | Yes | No | +| [@aws-cdk/cli-plugin-contract](./packages/@aws-cdk/cli-plugin-contract/) | TypeScript types for CLI plugins. | Yes | Yes | +| [@aws-cdk/cdk-cli-wrapper](./packages/@aws-cdk/cdk-cli-wrapper/) | A deprecated attempt at building a programmatic interface for the CLI | No | No | +| [@aws-cdk/node-bundle](./packages/@aws-cdk/node-bundle/) | A tool to build CLI bundles that include license attributions. | No | Yes | +| [@aws-cdk/user-input-gen](./packages/@aws-cdk/user-input-gen/) | A build tool for the CLI and toolkit-lib. | No | Yes | +| [@aws-cdk/yarn-cling](./packages/@aws-cdk/yarn-cling/) | A deprecated build tool for the CLI. | No | No | Every package comes with its own unit tests. There is a companion repository to this one containing the integration tests. You can find it here: @@ -70,4 +70,4 @@ released before it. We recommend you always keep the CLI version up-to-date to g latest features. For more details on the compatibility model and specific versions that work together, -see [COMPATIBILITY.md](COMPATIBILITY.md). \ No newline at end of file +see [COMPATIBILITY.md](COMPATIBILITY.md). diff --git a/packages/@aws-cdk-testing/cli-integ/README.md b/packages/@aws-cdk-testing/cli-integ/README.md index 8798f15b6..21477989a 100644 --- a/packages/@aws-cdk-testing/cli-integ/README.md +++ b/packages/@aws-cdk-testing/cli-integ/README.md @@ -124,6 +124,30 @@ $ publib-ca delete 2. In the `"args"` value after `"-t"`, place the name of the test that you'd like to run. 3. Press the VS code green arrow to launch the debugger. +### Running during PRs + +Integration tests are executed automatically during PRs. Every workflow run generates a markdown summary +of the suite, detailing which tests passed/failed, and some additional statistics. + +> For exmaple: https://github.com/aws/aws-cdk-cli/actions/runs/15305859516 + +To debug a failing test, navigate to the execution logs and search for the name of the test. +You'll find a verbose log that displays all operations taken during the test. + +Unlike running locally, PRs make use of the *Atmosphere* service, an internal CDK service designed +to provide integration tests with clean AWS environments. It allows us to run many concurrent tests, +and significantly reduce suite durations. Most of the time, *Atmosphere* should be transparent to you, +but sometimes, tests that pass locally may fail during PRs because of additional restrictions +it imposes: + +- **Service Control Policy (SCP):** AWS environments (i.e accounts) are subject to an SCP that denies access +to specific services. For example, you might see a failure similar to: + + ``` + User: arn:aws:sts::111111111111:assumed-role/cdk-hnb659fds-cfn-exec-role-111111111111-eu-central-1/AWSCloudFormation is not authorized to perform: logs:CreateLogGroup on resource: arn:aws:logs:eu-central-1:111111111111:log-group:/aws/lambda/cdktest-00cyqupxno939-imp-cdkimportnodejslambdates-6X36hssZOiZk:log-stream: with an explicit deny in a service control policy + ``` + This means that your PR introduces a need to invoke a new service, or deploy a new type of resource, that wasn't previously required. When this happens - reach out to a maintainer through the PR. They will evaluate if the new requirement is justified, and grant the necessary permissions. + ## Tools There are a number of tools in the `bin/` directory. They are: diff --git a/packages/@aws-cdk/cli-lib-alpha/.gitignore b/packages/@aws-cdk/cli-lib-alpha/.gitignore index e108ae76c..a9f088520 100644 --- a/packages/@aws-cdk/cli-lib-alpha/.gitignore +++ b/packages/@aws-cdk/cli-lib-alpha/.gitignore @@ -54,6 +54,8 @@ lib/**/*.yaml lib/**/*.yml lib/init-templates/** cdk.out +.jsii.tabl.json +!/rosetta/default.ts-fixture .jsii tsconfig.json !/API.md diff --git a/packages/@aws-cdk/cli-lib-alpha/.projen/tasks.json b/packages/@aws-cdk/cli-lib-alpha/.projen/tasks.json index fe4fadecc..0b8446b2a 100644 --- a/packages/@aws-cdk/cli-lib-alpha/.projen/tasks.json +++ b/packages/@aws-cdk/cli-lib-alpha/.projen/tasks.json @@ -231,6 +231,9 @@ "name": "post-compile", "description": "Runs after successful compilation", "steps": [ + { + "spawn": "rosetta:extract" + }, { "spawn": "docgen" }, @@ -266,6 +269,15 @@ } ] }, + "rosetta:extract": { + "name": "rosetta:extract", + "description": "Test rosetta extract", + "steps": [ + { + "exec": "yarn --silent jsii-rosetta extract --strict" + } + ] + }, "test": { "name": "test", "description": "Run tests", diff --git a/packages/@aws-cdk/cli-lib-alpha/package.json b/packages/@aws-cdk/cli-lib-alpha/package.json index ab802be15..33c3eff8c 100644 --- a/packages/@aws-cdk/cli-lib-alpha/package.json +++ b/packages/@aws-cdk/cli-lib-alpha/package.json @@ -27,6 +27,7 @@ "package:python": "npx projen package:python", "post-compile": "npx projen post-compile", "pre-compile": "npx projen pre-compile", + "rosetta:extract": "npx projen rosetta:extract", "test": "npx projen test", "test:watch": "npx projen test:watch", "unbump": "npx projen unbump", @@ -121,13 +122,11 @@ "excludeTypescript": [ "lib/init-templates/*/typescript/*/*.template.ts" ], - "projectReferences": true, - "metadata": { - "jsii": { - "rosetta": { - "strict": true - } - } + "projectReferences": true + }, + "jsiiRosetta": { + "exampleDependencies": { + "aws-cdk-lib": "^2" } }, "//": "~~ Generated by projen. To modify, edit .projenrc.js and run \"npx projen\"." diff --git a/packages/@aws-cdk/cloud-assembly-schema/.gitignore b/packages/@aws-cdk/cloud-assembly-schema/.gitignore index 382afa8f2..976c094da 100644 --- a/packages/@aws-cdk/cloud-assembly-schema/.gitignore +++ b/packages/@aws-cdk/cloud-assembly-schema/.gitignore @@ -44,6 +44,8 @@ jspm_packages/ /dist/changelog.md /dist/version.txt !/.eslintrc.js +.jsii.tabl.json +!/rosetta/default.ts-fixture .jsii tsconfig.json cli-version.json diff --git a/packages/@aws-cdk/cloud-assembly-schema/.projen/tasks.json b/packages/@aws-cdk/cloud-assembly-schema/.projen/tasks.json index 21aa28116..ec059fc89 100644 --- a/packages/@aws-cdk/cloud-assembly-schema/.projen/tasks.json +++ b/packages/@aws-cdk/cloud-assembly-schema/.projen/tasks.json @@ -219,7 +219,12 @@ }, "post-compile": { "name": "post-compile", - "description": "Runs after successful compilation" + "description": "Runs after successful compilation", + "steps": [ + { + "spawn": "rosetta:extract" + } + ] }, "pre-compile": { "name": "pre-compile", @@ -236,6 +241,15 @@ } ] }, + "rosetta:extract": { + "name": "rosetta:extract", + "description": "Test rosetta extract", + "steps": [ + { + "exec": "yarn --silent jsii-rosetta extract --strict" + } + ] + }, "test": { "name": "test", "description": "Run tests", diff --git a/packages/@aws-cdk/cloud-assembly-schema/lib/cloud-assembly/context-queries.ts b/packages/@aws-cdk/cloud-assembly-schema/lib/cloud-assembly/context-queries.ts index 5a997c965..564907523 100644 --- a/packages/@aws-cdk/cloud-assembly-schema/lib/cloud-assembly/context-queries.ts +++ b/packages/@aws-cdk/cloud-assembly-schema/lib/cloud-assembly/context-queries.ts @@ -363,6 +363,8 @@ export interface KeyContextQuery extends ContextLookupRoleOptions { * to generate a validly compiling example). * * @example + * import { CcApiContextQuery } from '@aws-cdk/cloud-assembly-schema'; + * * const x: CcApiContextQuery = { * typeName: 'AWS::Some::Type', * expectedMatchCount: 'exactly-one', @@ -370,7 +372,6 @@ export interface KeyContextQuery extends ContextLookupRoleOptions { * account: '11111111111', * region: 'us-east-1', * }; - * console.log(x); */ export interface CcApiContextQuery extends ContextLookupRoleOptions { /** diff --git a/packages/@aws-cdk/cloud-assembly-schema/package.json b/packages/@aws-cdk/cloud-assembly-schema/package.json index 1187e07ce..cbd824d57 100644 --- a/packages/@aws-cdk/cloud-assembly-schema/package.json +++ b/packages/@aws-cdk/cloud-assembly-schema/package.json @@ -26,6 +26,7 @@ "package:python": "npx projen package:python", "post-compile": "npx projen post-compile", "pre-compile": "npx projen pre-compile", + "rosetta:extract": "npx projen rosetta:extract", "test": "npx projen test", "test:watch": "npx projen test:watch", "unbump": "npx projen unbump", diff --git a/packages/@aws-cdk/cloud-assembly-schema/rosetta/default.ts-fixture b/packages/@aws-cdk/cloud-assembly-schema/rosetta/default.ts-fixture new file mode 100644 index 000000000..7b2106a96 --- /dev/null +++ b/packages/@aws-cdk/cloud-assembly-schema/rosetta/default.ts-fixture @@ -0,0 +1,3 @@ +// Fixture intentionally empty + +/// here diff --git a/packages/@aws-cdk/toolkit-lib/.projen/tasks.json b/packages/@aws-cdk/toolkit-lib/.projen/tasks.json index 049407b3f..725fb94f6 100644 --- a/packages/@aws-cdk/toolkit-lib/.projen/tasks.json +++ b/packages/@aws-cdk/toolkit-lib/.projen/tasks.json @@ -33,7 +33,8 @@ "VERSIONRCOPTIONS": "{\"path\":\".\"}", "BUMP_PACKAGE": "commit-and-tag-version@^12", "NEXT_VERSION_COMMAND": "tsx ../../../projenrc/next-version.ts maybeRc", - "RELEASABLE_COMMITS": "git log --no-merges --oneline $LATEST_TAG..HEAD -E --grep \"^(feat|fix){1}(\\([^()[:space:]]+\\))?(!)?:[[:blank:]]+.+\" -- . ../../aws-cdk ../cloud-assembly-schema ../cloudformation-diff" + "RELEASABLE_COMMITS": "git log --no-merges --oneline $LATEST_TAG..HEAD -E --grep \"^(feat|fix){1}(\\([^()[:space:]]+\\))?(!)?:[[:blank:]]+.+\" -- . ../../aws-cdk ../cloud-assembly-schema ../cloudformation-diff", + "MAJOR": "1" }, "steps": [ { diff --git a/packages/@aws-cdk/toolkit-lib/README.md b/packages/@aws-cdk/toolkit-lib/README.md index eed638f57..079b5a078 100644 --- a/packages/@aws-cdk/toolkit-lib/README.md +++ b/packages/@aws-cdk/toolkit-lib/README.md @@ -1,20 +1,4 @@ # AWS CDK Toolkit Library - - ---- - -![@aws-cdk/toolkit-lib: Developer Preview](./assets/toolkit--lib-developer_preview-important.svg) - -> The APIs in this module are experimental and under active development. -> They are subject to non-backward compatible changes or removal in future versions. -> The package follows the [Semantic Versioning](https://semver.org/) model for [major version zero](https://semver.org/#spec-item-4). -> Accordingly, breaking changes will be introduced in minor versions and announced in the release notes. -> This means that while you may use them, you may need to update -> your source code when upgrading to a newer version of this package. - ---- - - The AWS Cloud Development Kit (AWS CDK) Toolkit Library enables you to perform CDK actions requiring programmatic access on AWS. You can use the AWS CDK Toolkit Library to implement actions such as bootstrapping, synthesizing, and deploying through code rather than command-line interface (CLI) commands. With this library, you can create custom tools, build specialized CLI applications, and integrate CDK programmatic access capabilities into your development workflows. @@ -54,7 +38,7 @@ The _Cloud Assembly Source_ is a fundamental CDK Toolkit component that defines For example, CDK apps may need to be synthesized multiple times with additional context values before they are ready. Once created, you can use your _Cloud Assembly Source_ to perform actions with the CDK Toolkit. -The following is an example of creating a _Cloud Assembly Source_ using an inline _assembly builder function_: +The following is an example of creating a _Cloud Assembly Source_ using the _assembly builder function_: ```ts import * as core from 'aws-cdk-lib/core'; @@ -112,24 +96,25 @@ because the _Cloud Assembly_ does not have to be produced multiple times. ```ts declare const cdk: Toolkit; -declare const cx: ICloudAssemblySource; +declare const source: ICloudAssemblySource; // Will run the CDK app defined in the Cloud Assembly Source // This is an expensive and slow operation -const cxSnap = await cdk.synth(cx, { +// But its result can be stored and re-used +const cx = await cdk.synth(source, { validateStacks: true, // set to `false` to not throw an error if stacks in the assembly contain error annotations }) // Will use the previously synthesized Cloud Assembly // This is now a cheap and fast operation -const cxSnap = await cdk.list(cxSnap); +const appDetails = await cdk.list(cx); ``` -You can also use the snapshot to query information about the synthesized _Cloud Assembly_: +You can also query information about the synthesized _Cloud Assembly_: ```ts -declare const cloudAssembly = await cxSnap.produce(); -declare const template = cloudAssembly.getStack("my-stack").template; +const cloudAssembly = await cx.produce(); +const template = cloudAssembly.getStack("my-stack").template; ``` ### list @@ -149,6 +134,23 @@ const details = await cdk.list(cx, { }); ``` +### diff + +You can create a diff of your app to gain detailed insights into what will be changing in the next deployment. + +```ts +declare const cdk: Toolkit; +declare const cx: ICloudAssemblySource; + +const stackDiffs = await cdk.diff(cx, { + // optionally provide a stack selector to control which stacks you want to create a diff for + stacks: { + strategy: StackSelectionStrategy.PATTERN_MUST_MATCH, + patterns: ["my-stack"], + } +}); +``` + ### deploy Deployment is the process of provisioning or updating your infrastructure in AWS using the CloudFormation templates and assets produced during synthesis. @@ -194,6 +196,28 @@ await cdk.watch(cx, { }) ``` +### drift + +Drift detection is crucial for maintaining infrastructure reliability. +It identifies when your deployed resources have been modified outside of your CDK code. +This allows you to quickly address unauthorized changes. +Such changes could lead to security vulnerabilities, compliance issues, or unexpected behavior in your cloud environment. + +Use the drift operation to detect such drift in your CDK applications: + +```ts +declare const cdk: Toolkit; +declare const cx: ICloudAssemblySource; + +const stackDrifts = await cdk.drift(cx, { + // optionally provide a stack selector to control which stacks are checked for drift + stacks: { + strategy: StackSelectionStrategy.PATTERN_MUST_MATCH, + patterns: ["my-stack"], + } +}); +``` + ### destroy Use the destroy feature to remove CDK stacks and their associated resources from AWS: @@ -247,36 +271,40 @@ The CDK Toolkit awaits the completion of each call, allowing clients to perform When you implement an `IoHost` interface, you can either process these communications (for example, logging to CloudWatch or prompting users for input) or return immediately without taking action. If your implementation doesn’t provide a response to a request, the CDK Toolkit proceeds with a default value. -#### Default IoHost +#### Default `NonInteractiveIoHost` -By default the CDK Toolkit Library will use a `IoHost` implantation that mimics the behavior of the AWS CDK Toolkit CLI. +By default the CDK Toolkit Library will use a `NonInteractiveIoHost ` implementation that mimics the behavior of the AWS CDK Toolkit CLI. +This `NonInteractiveIoHost` is available for you as a base implementation to extend on. ### Configure your AWS profile The Toolkit internally uses AWS SDK Clients to make necessary API calls to AWS. -Authentication configuration is loaded automatically from the environment, but you can explicitly specify the profile to be used: +Authentication configuration is loaded automatically from the environment, but you can explicitly specify the base credentials to be used: ```ts -import { Toolkit } from '@aws-cdk/toolkit-lib'; +import { Toolkit, BaseCredentials } from '@aws-cdk/toolkit-lib'; const cdk = new Toolkit({ - sdkConfig: { profile: "my-profile" }, + sdkConfig: { + baseCredentials: BaseCredentials.awsCliCompatible({ profile: "my-profile" }) + }, }); ``` ### Configure a proxy for SDK calls The Toolkit internally uses AWS SDK Clients to make necessary API calls to AWS. -You can specify a proxy configuration for all SDK calls: +You can specify a Node.js Agent to configure SDK calls. +This allows you to configure third-party packages like [`proxy-agent`](https://www.npmjs.com/package/proxy-agent) to proxy all SDK calls: ```ts +import { ProxyAgent } from 'proxy-agent'; import { Toolkit } from '@aws-cdk/toolkit-lib'; const cdk = new Toolkit({ sdkConfig: { httpOptions: { - proxyAddress: "https://example.com", - caBundlePath: "path/to/ca/bundle", + agent: new ProxyAgent(), }, }, }); @@ -316,7 +344,8 @@ await cdk.fromCdkApp("ts-node app.ts"); await cdk.fromCdkApp("python app.py"); ``` -Alternatively a inline `AssemblyBuilder` function can be used to build a CDK app on-the-fly. +The `AssemblyBuilder` function provides basic Toolkit features like Lookups, but otherwise allows you to implement any custom source. +For example you build an inline CDK app on-the-fly: ```ts declare const cdk: Toolkit; @@ -412,26 +441,11 @@ try { }); } catch (error) { - - if (ToolkitError.isAuthenticationError(error)) { - // Handle credential issues - console.error('AWS credentials error:', error.message); - - } else if (ToolkitError.isAssemblyError(error)) { - // Handle errors from your CDK app - console.error('CDK app error:', error.message); - - } else if (ToolkitError.isContextProviderError(error)) { - // Handle errors from context providers - console.error('Context provider error:', error.message); - - } else if (ToolkitError.isToolkitError(error)) { - // Handle all other Toolkit errors - console.error('Generic Toolkit error:', error.message); - + // Handle user error + if (ToolkitError.isToolkitError(error) && error.source === 'user') { + console.error('Hey, something is wrong with your app!', error.message); } else { - // Handle unexpected errors - console.error('Unexpected error:', error); + throw error; } } ``` diff --git a/packages/@aws-cdk/toolkit-lib/assets/.gitkeep b/packages/@aws-cdk/toolkit-lib/assets/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/packages/@aws-cdk/toolkit-lib/assets/toolkit--lib-developer_preview-important.svg b/packages/@aws-cdk/toolkit-lib/assets/toolkit--lib-developer_preview-important.svg deleted file mode 100644 index a6cf5f9b1..000000000 --- a/packages/@aws-cdk/toolkit-lib/assets/toolkit--lib-developer_preview-important.svg +++ /dev/null @@ -1 +0,0 @@ -@AWS-CDK/TOOLKIT-LIB: DEVELOPER PREVIEW@AWS-CDK/TOOLKIT-LIBDEVELOPER PREVIEW \ No newline at end of file diff --git a/packages/@aws-cdk/toolkit-lib/lib/actions/drift/index.ts b/packages/@aws-cdk/toolkit-lib/lib/actions/drift/index.ts index cf113c8df..eba463c35 100644 --- a/packages/@aws-cdk/toolkit-lib/lib/actions/drift/index.ts +++ b/packages/@aws-cdk/toolkit-lib/lib/actions/drift/index.ts @@ -2,9 +2,11 @@ import type { StackSelector } from '../../api/cloud-assembly'; export interface DriftOptions { /** - * Criteria for selecting stacks to check for drift + * Select stacks to check for drift + * + * @default - all stacks */ - readonly stacks: StackSelector; + readonly stacks?: StackSelector; } /** diff --git a/packages/@aws-cdk/toolkit-lib/lib/api/refactoring/execution.ts b/packages/@aws-cdk/toolkit-lib/lib/api/refactoring/execution.ts index e9b838f79..54b32ddc0 100644 --- a/packages/@aws-cdk/toolkit-lib/lib/api/refactoring/execution.ts +++ b/packages/@aws-cdk/toolkit-lib/lib/api/refactoring/execution.ts @@ -1,53 +1,98 @@ import type { StackDefinition } from '@aws-sdk/client-cloudformation'; -import type { CloudFormationStack, ResourceMapping } from './cloudformation'; +import type { + CloudFormationResource, + CloudFormationStack, + CloudFormationTemplate, + ResourceMapping, +} from './cloudformation'; import { ToolkitError } from '../../toolkit/toolkit-error'; /** * Generates a list of stack definitions to be sent to the CloudFormation API * by applying each mapping to the corresponding stack template(s). */ -export function generateStackDefinitions(mappings: ResourceMapping[], deployedStacks: CloudFormationStack[]): StackDefinition[] { - const templates = Object.fromEntries( - deployedStacks - .filter((s) => - mappings.some( - (m) => - // We only care about stacks that are part of the mappings - m.source.stack.stackName === s.stackName || m.destination.stack.stackName === s.stackName, - ), - ) - .map((s) => [s.stackName, JSON.parse(JSON.stringify(s.template))]), +export function generateStackDefinitions( + mappings: ResourceMapping[], + deployedStacks: CloudFormationStack[], + localStacks: CloudFormationStack[], +): StackDefinition[] { + const localTemplates = Object.fromEntries( + localStacks.map((s) => [s.stackName, JSON.parse(JSON.stringify(s.template)) as CloudFormationTemplate]), ); + const deployedTemplates = Object.fromEntries( + deployedStacks.map((s) => [s.stackName, JSON.parse(JSON.stringify(s.template)) as CloudFormationTemplate]), + ); + + // First, remove from the local templates any resources that are not in the deployed templates + iterate(localTemplates, (stackName, logicalResourceId) => { + const location = searchLocation(stackName, logicalResourceId, 'destination', 'source'); + + const deployedResource = deployedStacks.find((s) => s.stackName === location.stackName)?.template + .Resources?.[location.logicalResourceId]; - mappings.forEach((mapping) => { - const sourceStackName = mapping.source.stack.stackName; - const sourceLogicalId = mapping.source.logicalResourceId; - const sourceTemplate = templates[sourceStackName]; - - const destinationStackName = mapping.destination.stack.stackName; - const destinationLogicalId = mapping.destination.logicalResourceId; - if (templates[destinationStackName] == null) { - // The API doesn't allow anything in the template other than the resources - // that are part of the mappings. So we need to create an empty template - // to start adding resources to. - templates[destinationStackName] = { Resources: {} }; + if (deployedResource == null) { + delete localTemplates[stackName].Resources?.[logicalResourceId]; } - const destinationTemplate = templates[destinationStackName]; + }); + + // Now do the opposite: add to the local templates any resources that are in the deployed templates + iterate(deployedTemplates, (stackName, logicalResourceId, deployedResource) => { + const location = searchLocation(stackName, logicalResourceId, 'source', 'destination'); + + const resources = Object + .entries(localTemplates) + .find(([name, _]) => name === location.stackName)?.[1].Resources; + const localResource = resources?.[location.logicalResourceId]; - // Do the move - destinationTemplate.Resources[destinationLogicalId] = sourceTemplate.Resources[sourceLogicalId]; - delete sourceTemplate.Resources[sourceLogicalId]; + if (localResource == null) { + if (localTemplates[stackName]?.Resources) { + localTemplates[stackName].Resources[logicalResourceId] = deployedResource; + } + } else { + // This is temporary, until CloudFormation supports CDK construct path updates in the refactor API + if (localResource.Metadata != null) { + localResource.Metadata['aws:cdk:path'] = deployedResource.Metadata?.['aws:cdk:path']; + } + } }); - // CloudFormation doesn't allow empty stacks - for (const [stackName, template] of Object.entries(templates)) { + function searchLocation(stackName: string, logicalResourceId: string, from: 'source' | 'destination', to: 'source' | 'destination') { + const mapping = mappings.find( + (m) => m[from].stack.stackName === stackName && m[from].logicalResourceId === logicalResourceId, + ); + return mapping != null + ? { stackName: mapping[to].stack.stackName, logicalResourceId: mapping[to].logicalResourceId } + : { stackName, logicalResourceId }; + } + + function iterate( + templates: Record, + cb: (stackName: string, logicalResourceId: string, resource: CloudFormationResource) => void, + ) { + Object.entries(templates).forEach(([stackName, template]) => { + Object.entries(template.Resources ?? {}).forEach(([logicalResourceId, resource]) => { + cb(stackName, logicalResourceId, resource); + }); + }); + } + + for (const [stackName, template] of Object.entries(localTemplates)) { if (Object.keys(template.Resources ?? {}).length === 0) { - throw new ToolkitError(`Stack ${stackName} has no resources after refactor. You must add a resource to this stack. This resource can be a simple one, like a waitCondition resource type.`); + throw new ToolkitError( + `Stack ${stackName} has no resources after refactor. You must add a resource to this stack. This resource can be a simple one, like a waitCondition resource type.`, + ); } } - return Object.entries(templates).map(([stackName, template]) => ({ - StackName: stackName, - TemplateBody: JSON.stringify(template), - })); + return Object.entries(localTemplates) + .filter(([stackName, _]) => + mappings.some((m) => { + // Only send templates for stacks that are part of the mappings + return m.source.stack.stackName === stackName || m.destination.stack.stackName === stackName; + }), + ) + .map(([stackName, template]) => ({ + StackName: stackName, + TemplateBody: JSON.stringify(template), + })); } diff --git a/packages/@aws-cdk/toolkit-lib/lib/toolkit/toolkit.ts b/packages/@aws-cdk/toolkit-lib/lib/toolkit/toolkit.ts index e5549fa86..6a1545412 100644 --- a/packages/@aws-cdk/toolkit-lib/lib/toolkit/toolkit.ts +++ b/packages/@aws-cdk/toolkit-lib/lib/toolkit/toolkit.ts @@ -382,7 +382,7 @@ export class Toolkit extends CloudAssemblySourceBuilder { /** * Drift Action */ - public async drift(cx: ICloudAssemblySource, options: DriftOptions): Promise<{ [name: string]: DriftResult }> { + public async drift(cx: ICloudAssemblySource, options: DriftOptions = {}): Promise<{ [name: string]: DriftResult }> { const ioHelper = asIoHelper(this.ioHost, 'drift'); const selectStacks = options.stacks ?? ALL_STACKS; const synthSpan = await ioHelper.span(SPAN.SYNTH_ASSEMBLY).begin({ stacks: selectStacks }); diff --git a/packages/@aws-cdk/toolkit-lib/test/actions/drift.test.ts b/packages/@aws-cdk/toolkit-lib/test/actions/drift.test.ts index 0fbd26c3c..692961511 100644 --- a/packages/@aws-cdk/toolkit-lib/test/actions/drift.test.ts +++ b/packages/@aws-cdk/toolkit-lib/test/actions/drift.test.ts @@ -73,4 +73,19 @@ describe('drift', () => { ioHost.expectMessage({ containing: 'Modified Resources', level: 'info' }); ioHost.expectMessage({ containing: '[~] AWS::S3::Bucket MyBucket MyBucketF68F3FF0', level: 'info' }); }); + + test('can invoke drift action without options', async () => { + // GIVEN + mockCloudFormationClient.on(DetectStackDriftCommand).resolves({ StackDriftDetectionId: '12345' }); + mockCloudFormationClient.on(DescribeStackDriftDetectionStatusCommand).resolves({ DetectionStatus: 'DETECTION_COMPLETE' }); + mockCloudFormationClient.on(DescribeStackResourceDriftsCommand).resolvesOnce({}); + + // WHEN + const cx = await builderFixture(toolkit, 'stack-with-bucket'); + const result = await toolkit.drift(cx); + + // THEN + expect(Object.keys(result).length).toBe(0); + ioHost.expectMessage({ containing: 'No drift results available' }); + }); }); diff --git a/packages/@aws-cdk/toolkit-lib/test/api/refactoring/refactoring.test.ts b/packages/@aws-cdk/toolkit-lib/test/api/refactoring/refactoring.test.ts index b8127c610..4ebd946bf 100644 --- a/packages/@aws-cdk/toolkit-lib/test/api/refactoring/refactoring.test.ts +++ b/packages/@aws-cdk/toolkit-lib/test/api/refactoring/refactoring.test.ts @@ -1835,7 +1835,7 @@ describe(generateStackDefinitions, () => { }; test('renames a resource within the same stack', () => { - const stack: CloudFormationStack = { + const stack1: CloudFormationStack = { environment: environment, stackName: 'Foo', template: { @@ -1846,27 +1846,61 @@ describe(generateStackDefinitions, () => { NotInvolved: { Type: 'AWS::X::Y', }, + Consumer: { + Type: 'AWS::X::Y', + Properties: { + Bucket: { Ref: 'Bucket1' }, + }, + }, + }, + }, + }; + + const stack2: CloudFormationStack = { + environment: environment, + stackName: 'Foo', + template: { + Resources: { + Bucket2: { + Type: 'AWS::S3::Bucket', + }, + NotInvolved: { + Type: 'AWS::X::Y', + }, + Consumer: { + Type: 'AWS::X::Y', + Properties: { + Bucket: { Ref: 'Bucket2' }, + }, + }, }, }, }; const mappings: ResourceMapping[] = [ - new ResourceMapping(new ResourceLocation(stack, 'Bucket1'), new ResourceLocation(stack, 'Bucket2')), + new ResourceMapping(new ResourceLocation(stack1, 'Bucket1'), new ResourceLocation(stack1, 'Bucket2')), ]; - const result = generateStackDefinitions(mappings, [stack]); + const result = generateStackDefinitions(mappings, [stack1], [stack2]); expect(result).toEqual([ { StackName: 'Foo', TemplateBody: JSON.stringify({ Resources: { + Bucket2: { + Type: 'AWS::S3::Bucket', + }, // Not involved in the refactor, but still part of the // original template. Should be included. NotInvolved: { Type: 'AWS::X::Y', }, - Bucket2: { - Type: 'AWS::S3::Bucket', + Consumer: { + Type: 'AWS::X::Y', + Properties: { + // The reference has also been updated + Bucket: { Ref: 'Bucket2' }, + }, }, }, }), @@ -1875,7 +1909,7 @@ describe(generateStackDefinitions, () => { }); test('moves a resource to another stack that has already been deployed', () => { - const stack1: CloudFormationStack = { + const deployedStack1: CloudFormationStack = { environment, stackName: 'Stack1', template: { @@ -1890,7 +1924,30 @@ describe(generateStackDefinitions, () => { }, }; - const stack2: CloudFormationStack = { + const deployedStack2: CloudFormationStack = { + environment, + stackName: 'Stack2', + template: { + Resources: { + B: { + Type: 'AWS::B::B', + }, + }, + }, + }; + const localStack1: CloudFormationStack = { + environment, + stackName: 'Stack1', + template: { + Resources: { + A: { + Type: 'AWS::A::A', + }, + }, + }, + }; + + const localStack2: CloudFormationStack = { environment, stackName: 'Stack2', template: { @@ -1898,15 +1955,25 @@ describe(generateStackDefinitions, () => { B: { Type: 'AWS::B::B', }, + Bucket2: { + Type: 'AWS::S3::Bucket', + }, }, }, }; const mappings: ResourceMapping[] = [ - new ResourceMapping(new ResourceLocation(stack1, 'Bucket1'), new ResourceLocation(stack2, 'Bucket2')), + new ResourceMapping( + new ResourceLocation(deployedStack1, 'Bucket1'), + new ResourceLocation(deployedStack2, 'Bucket2'), + ), ]; - const result = generateStackDefinitions(mappings, [stack1, stack2]); + const result = generateStackDefinitions( + mappings, + [deployedStack1, deployedStack2], + [localStack1, localStack2], + ); expect(result).toEqual([ { StackName: 'Stack1', @@ -1941,7 +2008,7 @@ describe(generateStackDefinitions, () => { }); test('moves a resource to another stack that has not been deployed', () => { - const stack1: CloudFormationStack = { + const deployedStack: CloudFormationStack = { environment, stackName: 'Stack1', template: { @@ -1956,45 +2023,57 @@ describe(generateStackDefinitions, () => { }, }; - const stack2: CloudFormationStack = { + const localStack1: CloudFormationStack = { environment, stackName: 'Stack2', template: { Resources: { - B: { - Type: 'AWS::B::B', + Bucket2: { + Type: 'AWS::S3::Bucket', + }, + }, + }, + }; + + const localStack2: CloudFormationStack = { + environment, + stackName: 'Stack1', + template: { + Resources: { + A: { + Type: 'AWS::A::A', }, }, }, }; const mappings: ResourceMapping[] = [ - new ResourceMapping(new ResourceLocation(stack1, 'Bucket1'), new ResourceLocation(stack2, 'Bucket2')), + new ResourceMapping(new ResourceLocation(deployedStack, 'Bucket1'), new ResourceLocation(localStack1, 'Bucket2')), ]; - const result = generateStackDefinitions(mappings, [stack1]); + const result = generateStackDefinitions(mappings, [deployedStack], [localStack1, localStack2]); expect(result).toEqual([ { - StackName: 'Stack1', + StackName: 'Stack2', TemplateBody: JSON.stringify({ Resources: { - // Wasn't touched by the refactor - A: { - Type: 'AWS::A::A', + // Old Bucket1 is now Bucket2 here + Bucket2: { + Type: 'AWS::S3::Bucket', }, - - // Bucket1 doesn't exist anymore }, }), }, { - StackName: 'Stack2', + StackName: 'Stack1', TemplateBody: JSON.stringify({ Resources: { - // Old Bucket1 is now Bucket2 here - Bucket2: { - Type: 'AWS::S3::Bucket', + // Wasn't touched by the refactor + A: { + Type: 'AWS::A::A', }, + + // Bucket1 doesn't exist anymore }, }), }, @@ -2002,7 +2081,7 @@ describe(generateStackDefinitions, () => { }); test('multiple mappings', () => { - const stack1: CloudFormationStack = { + const deployedStack1: CloudFormationStack = { environment, stackName: 'Stack1', template: { @@ -2017,7 +2096,7 @@ describe(generateStackDefinitions, () => { }, }; - const stack2: CloudFormationStack = { + const deployedStack2: CloudFormationStack = { environment, stackName: 'Stack2', template: { @@ -2029,13 +2108,53 @@ describe(generateStackDefinitions, () => { }, }; + const localStack1: CloudFormationStack = { + environment, + stackName: 'Stack1', + template: { + Resources: { + Bucket6: { + Type: 'AWS::S3::Bucket', + }, + }, + }, + }; + + const localStack2: CloudFormationStack = { + environment, + stackName: 'Stack2', + template: { + Resources: { + Bucket4: { + Type: 'AWS::S3::Bucket', + }, + Bucket5: { + Type: 'AWS::S3::Bucket', + }, + }, + }, + }; + const mappings: ResourceMapping[] = [ - new ResourceMapping(new ResourceLocation(stack1, 'Bucket1'), new ResourceLocation(stack2, 'Bucket4')), - new ResourceMapping(new ResourceLocation(stack1, 'Bucket2'), new ResourceLocation(stack2, 'Bucket5')), - new ResourceMapping(new ResourceLocation(stack2, 'Bucket3'), new ResourceLocation(stack1, 'Bucket6')), + new ResourceMapping( + new ResourceLocation(deployedStack1, 'Bucket1'), + new ResourceLocation(deployedStack2, 'Bucket4'), + ), + new ResourceMapping( + new ResourceLocation(deployedStack1, 'Bucket2'), + new ResourceLocation(deployedStack2, 'Bucket5'), + ), + new ResourceMapping( + new ResourceLocation(deployedStack2, 'Bucket3'), + new ResourceLocation(deployedStack1, 'Bucket6'), + ), ]; - const result = generateStackDefinitions(mappings, [stack1, stack2]); + const result = generateStackDefinitions( + mappings, + [deployedStack1, deployedStack2], + [localStack1, localStack2], + ); expect(result).toEqual([ { StackName: 'Stack1', @@ -2064,7 +2183,7 @@ describe(generateStackDefinitions, () => { }); test('deployed stacks that are not in any mapping', () => { - const stack1: CloudFormationStack = { + const deployedStack1: CloudFormationStack = { environment, stackName: 'Stack1', template: { @@ -2076,7 +2195,31 @@ describe(generateStackDefinitions, () => { }, }; - const stack2: CloudFormationStack = { + const deployedStack2: CloudFormationStack = { + environment, + stackName: 'Stack2', + template: { + Resources: { + Bucket2: { + Type: 'AWS::S3::Bucket', + }, + }, + }, + }; + + const localStack1: CloudFormationStack = { + environment, + stackName: 'Stack1', + template: { + Resources: { + Bucket3: { + Type: 'AWS::S3::Bucket', + }, + }, + }, + }; + + const localStack2: CloudFormationStack = { environment, stackName: 'Stack2', template: { @@ -2089,10 +2232,17 @@ describe(generateStackDefinitions, () => { }; const mappings: ResourceMapping[] = [ - new ResourceMapping(new ResourceLocation(stack1, 'Bucket1'), new ResourceLocation(stack1, 'Bucket3')), + new ResourceMapping( + new ResourceLocation(deployedStack1, 'Bucket1'), + new ResourceLocation(deployedStack1, 'Bucket3'), + ), ]; - const result = generateStackDefinitions(mappings, [stack1, stack2]); + const result = generateStackDefinitions( + mappings, + [deployedStack1, deployedStack2], + [localStack1, localStack2], + ); expect(result).toEqual([ { StackName: 'Stack1', @@ -2108,7 +2258,7 @@ describe(generateStackDefinitions, () => { }); test('refactor should not create empty templates', () => { - const stack1: CloudFormationStack = { + const deployedStack1: CloudFormationStack = { environment, stackName: 'Stack1', template: { @@ -2120,7 +2270,7 @@ describe(generateStackDefinitions, () => { }, }; - const stack2: CloudFormationStack = { + const deployedStack2: CloudFormationStack = { environment, stackName: 'Stack2', template: { @@ -2128,12 +2278,999 @@ describe(generateStackDefinitions, () => { }, }; + const localStack1: CloudFormationStack = { + environment, + stackName: 'Stack1', + template: { + Resources: {}, + }, + }; + + const localStack2: CloudFormationStack = { + environment, + stackName: 'Stack2', + template: { + Resources: { + Bucket2: { + Type: 'AWS::S3::Bucket', + }, + }, + }, + }; + + const mappings: ResourceMapping[] = [ + new ResourceMapping( + new ResourceLocation(deployedStack1, 'Bucket1'), + new ResourceLocation(deployedStack2, 'Bucket2'), + ), + ]; + + expect(() => + generateStackDefinitions(mappings, [deployedStack1, deployedStack2], [localStack1, localStack2]), + ).toThrow(/Stack Stack1 has no resources after refactor/); + }); + + test('local stacks have more resources than deployed stacks', async () => { + const deployedStack: CloudFormationStack = { + environment, + stackName: 'Stack1', + template: { + Resources: { + Bucket1: { + Type: 'AWS::S3::Bucket', + }, + }, + }, + }; + + const localStack: CloudFormationStack = { + environment, + stackName: 'Stack1', + template: { + Resources: { + Bucket2: { + Type: 'AWS::S3::Bucket', + }, + ExtraStuff: { + Type: 'AWS::X::Y', + }, + }, + }, + }; + + const mappings: ResourceMapping[] = [ + new ResourceMapping( + new ResourceLocation(deployedStack, 'Bucket1'), + new ResourceLocation(deployedStack, 'Bucket2'), + ), + ]; + + const result = generateStackDefinitions(mappings, [deployedStack], [localStack]); + expect(result).toEqual([ + { + StackName: 'Stack1', + TemplateBody: JSON.stringify({ + Resources: { + Bucket2: { + Type: 'AWS::S3::Bucket', + }, + // ExtraStuff is not involved in the refactor and was not part of the deployed stack, so we keep it out. + }, + }), + }, + ]); + }); + + test('local stacks have fewer resources than deployed stacks', () => { + const deployedStack: CloudFormationStack = { + environment, + stackName: 'Stack1', + template: { + Resources: { + Bucket1: { + Type: 'AWS::S3::Bucket', + }, + ExtraStuff: { + Type: 'AWS::X::Y', + }, + }, + }, + }; + + const localStack: CloudFormationStack = { + environment, + stackName: 'Stack1', + template: { + Resources: { + Bucket2: { + Type: 'AWS::S3::Bucket', + }, + }, + }, + }; + const mappings: ResourceMapping[] = [ - new ResourceMapping(new ResourceLocation(stack1, 'Bucket1'), new ResourceLocation(stack2, 'Bucket2')), + new ResourceMapping( + new ResourceLocation(deployedStack, 'Bucket1'), + new ResourceLocation(deployedStack, 'Bucket2'), + ), ]; - expect(() => generateStackDefinitions(mappings, [stack1, stack2])) - .toThrow(/Stack Stack1 has no resources after refactor/); + const result = generateStackDefinitions(mappings, [deployedStack], [localStack]); + expect(result).toEqual([ + { + StackName: 'Stack1', + TemplateBody: JSON.stringify({ + Resources: { + Bucket2: { + Type: 'AWS::S3::Bucket', + }, + // ExtraStuff is not involved in the refactor, but it was part of the deployed stack, so we keep it in. + ExtraStuff: { + Type: 'AWS::X::Y', + }, + }, + }), + }, + ]); + }); + + test('CDK path in Metadata is preserved', () => { + const deployedStack: CloudFormationStack = { + environment, + stackName: 'Stack1', + template: { + Resources: { + Bucket1: { + Type: 'AWS::S3::Bucket', + Metadata: { + 'aws:cdk:path': 'Stack1/Bucket1/Resource', + }, + }, + }, + }, + }; + + const localStack: CloudFormationStack = { + environment, + stackName: 'Stack1', + template: { + Resources: { + Bucket2: { + Type: 'AWS::S3::Bucket', + Metadata: { + // Here the CDK path is consistent with the new logical ID... + 'aws:cdk:path': 'Stack1/Bucket2/Resource', + }, + }, + }, + }, + }; + + const mappings: ResourceMapping[] = [ + new ResourceMapping( + new ResourceLocation(deployedStack, 'Bucket1'), + new ResourceLocation(deployedStack, 'Bucket2'), + ), + ]; + + const result = generateStackDefinitions(mappings, [deployedStack], [localStack]); + expect(result).toEqual([ + { + StackName: 'Stack1', + TemplateBody: JSON.stringify({ + Resources: { + Bucket2: { + Type: 'AWS::S3::Bucket', + Metadata: { + // ...but we keep the original CDK path from the deployed stack, to make CloudFormation happy. + 'aws:cdk:path': 'Stack1/Bucket1/Resource', + }, + }, + }, + }), + }, + ]); + }); + + describe('With references between resources', () => { + describe('Divergence - reference starts within the same stack and, in some cases, crosses stacks', () => { + test('No stack move', () => { + const deployedStacks: CloudFormationStack[] = [ + { + environment, + stackName: 'StackX', + template: { + Resources: { + A: { + Type: 'AWS::A::A', + Properties: { + Props: { Ref: 'B' }, + }, + }, + B: { + Type: 'AWS::B::B', + }, + }, + }, + }, + ]; + + const localStacks: CloudFormationStack[] = [ + { + environment, + stackName: 'StackX', + template: { + Resources: { + A: { + Type: 'AWS::A::A', + Properties: { + Props: { Ref: 'Bn' }, + }, + }, + Bn: { + Type: 'AWS::B::B', + }, + }, + }, + }, + ]; + + const mappings: ResourceMapping[] = [ + new ResourceMapping(new ResourceLocation(deployedStacks[0], 'B'), new ResourceLocation(localStacks[0], 'Bn')), + ]; + + const result = generateStackDefinitions(mappings, deployedStacks, localStacks); + expect(result).toEqual([ + { + StackName: 'StackX', + TemplateBody: JSON.stringify({ + Resources: { + A: { + Type: 'AWS::A::A', + Properties: { + Props: { Ref: 'Bn' }, // Updated reference + }, + }, + Bn: { + Type: 'AWS::B::B', + }, + }, + }), + }, + ]); + }); + + test('tail of the reference moved', () => { + const deployedStacks: CloudFormationStack[] = [ + { + environment, + stackName: 'StackX', + template: { + Resources: { + A: { + Type: 'AWS::A::A', + Properties: { + Props: { Ref: 'B' }, + }, + }, + B: { + Type: 'AWS::B::B', + }, + }, + }, + }, + ]; + + const localStacks: CloudFormationStack[] = [ + { + environment, + stackName: 'StackY', + template: { + Resources: { + A: { + Type: 'AWS::A::A', + Properties: { + Props: { 'Fn::ImportValue': 'BFromOtherStack' }, + }, + }, + }, + }, + }, + { + environment, + stackName: 'StackX', + template: { + Outputs: { + Bout: { + Value: { Ref: 'B' }, + Export: { + Name: 'BFromOtherStack', + }, + }, + }, + Resources: { + B: { Type: 'AWS::B::B' }, + }, + }, + }, + ]; + + const mappings: ResourceMapping[] = [ + new ResourceMapping(new ResourceLocation(deployedStacks[0], 'A'), new ResourceLocation(localStacks[0], 'A')), + ]; + + const result = generateStackDefinitions(mappings, deployedStacks, localStacks); + expect(result).toEqual([ + { + StackName: 'StackY', + TemplateBody: JSON.stringify({ + Resources: { + A: { + Type: 'AWS::A::A', + Properties: { + Props: { 'Fn::ImportValue': 'BFromOtherStack' }, // Reference to the moved resource + }, + }, + }, + }), + }, + { + StackName: 'StackX', + TemplateBody: JSON.stringify({ + Outputs: { + Bout: { + Value: { Ref: 'B' }, + Export: { + Name: 'BFromOtherStack', + }, + }, + }, + Resources: { + B: { Type: 'AWS::B::B' }, // The moved resource + }, + }), + }, + ]); + }); + + test('head of the reference moved', () => { + const deployedStacks: CloudFormationStack[] = [ + { + environment, + stackName: 'StackX', + template: { + Resources: { + A: { + Type: 'AWS::A::A', + Properties: { + Props: { Ref: 'B' }, + }, + }, + B: { + Type: 'AWS::B::B', + }, + }, + }, + }, + ]; + + const localStacks: CloudFormationStack[] = [ + { + environment, + stackName: 'StackX', + template: { + Resources: { + A: { + Type: 'AWS::A::A', + Properties: { + Props: { 'Fn::ImportValue': 'BFromOtherStack' }, + }, + }, + }, + }, + }, + { + environment, + stackName: 'StackY', + template: { + Outputs: { + Bout: { + Value: { Ref: 'B' }, + Export: { + Name: 'BFromOtherStack', + }, + }, + }, + Resources: { + B: { Type: 'AWS::B::B' }, + }, + }, + }, + ]; + + const mappings: ResourceMapping[] = [ + new ResourceMapping(new ResourceLocation(deployedStacks[0], 'B'), new ResourceLocation(localStacks[1], 'B')), + ]; + + const result = generateStackDefinitions(mappings, deployedStacks, localStacks); + expect(result).toEqual([ + { + StackName: 'StackX', + TemplateBody: JSON.stringify({ + Resources: { + // A was moved + A: { + Type: 'AWS::A::A', + Properties: { + Props: { 'Fn::ImportValue': 'BFromOtherStack' }, // Reference to the resource that stayed behind + }, + }, + }, + }), + }, + { + StackName: 'StackY', + TemplateBody: JSON.stringify({ + Outputs: { + Bout: { + Value: { Ref: 'B' }, + Export: { + Name: 'BFromOtherStack', + }, + }, + }, + Resources: { + B: { Type: 'AWS::B::B' }, + }, + }), + }, + ]); + }); + + test('both moved to the same stack', () => { + const deployedStacks: CloudFormationStack[] = [ + { + environment, + stackName: 'StackX', + template: { + Resources: { + A: { + Type: 'AWS::A::A', + Properties: { + Props: { Ref: 'B' }, + }, + }, + B: { + Type: 'AWS::B::B', + }, + C: { + Type: 'AWS::C::C', + }, + }, + }, + }, + ]; + + const localStacks: CloudFormationStack[] = [ + { + environment, + stackName: 'StackX', + template: { + Resources: { + C: { + Type: 'AWS::C::C', + }, + }, + }, + }, + { + environment, + stackName: 'StackY', + template: { + Resources: { + A: { + Type: 'AWS::A::A', + Properties: { + Props: { Ref: 'B' }, + }, + }, + B: { + Type: 'AWS::B::B', + }, + }, + }, + }, + ]; + + const mappings: ResourceMapping[] = [ + new ResourceMapping(new ResourceLocation(deployedStacks[0], 'B'), new ResourceLocation(localStacks[1], 'B')), + new ResourceMapping(new ResourceLocation(deployedStacks[0], 'A'), new ResourceLocation(localStacks[1], 'A')), + ]; + + const result = generateStackDefinitions(mappings, deployedStacks, localStacks); + expect(result).toEqual([ + { + StackName: 'StackX', + TemplateBody: JSON.stringify({ + Resources: { + C: { + Type: 'AWS::C::C', + }, + }, + }), + }, + { + StackName: 'StackY', + TemplateBody: JSON.stringify({ + Resources: { + A: { + Type: 'AWS::A::A', + Properties: { + Props: { Ref: 'B' }, + }, + }, + B: { Type: 'AWS::B::B' }, + }, + }), + }, + ]); + }); + + test('both moved to different stacks', () => { + const deployedStacks: CloudFormationStack[] = [ + { + environment, + stackName: 'StackX', + template: { + Resources: { + A: { + Type: 'AWS::A::A', + Properties: { + Props: { Ref: 'B' }, + }, + }, + B: { + Type: 'AWS::B::B', + }, + C: { + Type: 'AWS::C::C', + }, + }, + }, + }, + ]; + + const localStacks: CloudFormationStack[] = [ + { + environment, + stackName: 'StackX', + template: { + Resources: { + C: { + Type: 'AWS::C::C', + }, + }, + }, + }, + { + environment, + stackName: 'StackY', + template: { + Resources: { + A: { + Type: 'AWS::A::A', + Properties: { + Props: { 'Fn::ImportValue': 'BFromOtherStack' }, + }, + }, + }, + }, + }, + { + environment, + stackName: 'StackZ', + template: { + Outputs: { + Bout: { + Value: { Ref: 'B' }, + Export: { + Name: 'BFromOtherStack', + }, + }, + }, + Resources: { + B: { + Type: 'AWS::B::B', + }, + }, + }, + }, + ]; + + const mappings: ResourceMapping[] = [ + new ResourceMapping(new ResourceLocation(deployedStacks[0], 'A'), new ResourceLocation(localStacks[1], 'A')), + new ResourceMapping(new ResourceLocation(deployedStacks[0], 'B'), new ResourceLocation(localStacks[2], 'B')), + ]; + + const result = generateStackDefinitions(mappings, deployedStacks, localStacks); + expect(result).toEqual([ + { + StackName: 'StackX', + TemplateBody: JSON.stringify({ + Resources: { + C: { + Type: 'AWS::C::C', + }, + }, + }), + }, + { + StackName: 'StackY', + TemplateBody: JSON.stringify({ + Resources: { + A: { + Type: 'AWS::A::A', + Properties: { + Props: { 'Fn::ImportValue': 'BFromOtherStack' }, + }, + }, + }, + }), + }, + { + StackName: 'StackZ', + TemplateBody: JSON.stringify({ + Outputs: { + Bout: { + Value: { Ref: 'B' }, + Export: { + Name: 'BFromOtherStack', + }, + }, + }, + Resources: { + B: { + Type: 'AWS::B::B', + }, + }, + }), + }, + ]); + }); + }); + + describe('Convergence - reference starts cross-stack and, in some cases, moves to within the same stack', () => { + test('No stack move', () => { + const deployedStacks: CloudFormationStack[] = [ + { + environment, + stackName: 'StackX', + template: { + Resources: { + A: { + Type: 'AWS::A::A', + Properties: { + Props: { 'Fn::ImportValue': 'BFromOtherStack' }, + }, + }, + }, + }, + }, + { + environment, + stackName: 'StackY', + template: { + Outputs: { + Bout: { + Value: { Ref: 'B' }, + Export: { + Name: 'BFromOtherStack', + }, + }, + }, + Resources: { + B: { Type: 'AWS::B::B' }, + }, + }, + }, + ]; + + const localStacks: CloudFormationStack[] = [ + { + environment, + stackName: 'StackX', + template: { + Resources: { + A: { + Type: 'AWS::A::A', + Properties: { + Props: { 'Fn::ImportValue': 'BnFromOtherStack' }, + }, + }, + }, + }, + }, + { + environment, + stackName: 'StackY', + template: { + Outputs: { + Bout: { + Value: { Ref: 'Bn' }, + Export: { + Name: 'BnFromOtherStack', + }, + }, + }, + Resources: { + Bn: { Type: 'AWS::B::B' }, + }, + }, + }, + ]; + + const mappings: ResourceMapping[] = [ + new ResourceMapping( + new ResourceLocation(deployedStacks[1], 'B'), + new ResourceLocation(deployedStacks[1], 'Bn'), + ), + ]; + + const result = generateStackDefinitions(mappings, deployedStacks, localStacks); + expect(result).toEqual([ + // StackX was not part of the mappings + { + StackName: 'StackY', + TemplateBody: JSON.stringify({ + Outputs: { + Bout: { + Value: { Ref: 'Bn' }, + Export: { + Name: 'BnFromOtherStack', + }, + }, + }, + Resources: { + Bn: { Type: 'AWS::B::B' }, + }, + }), + }, + ]); + }); + + test('tail of the reference moved', () => { + const deployedStacks: CloudFormationStack[] = [ + { + environment, + stackName: 'StackX', + template: { + Resources: { + A: { + Type: 'AWS::A::A', + Properties: { + Props: { 'Fn::ImportValue': 'BFromOtherStack' }, + }, + }, + }, + }, + }, + { + environment, + stackName: 'StackY', + template: { + Outputs: { + Bout: { + Value: { Ref: 'B' }, + Export: { + Name: 'BFromOtherStack', + }, + }, + }, + Resources: { + B: { Type: 'AWS::B::B' }, + }, + }, + }, + ]; + + const localStacks: CloudFormationStack[] = [ + { + environment, + stackName: 'StackY', + template: { + Resources: { + A: { + Type: 'AWS::A::A', + Properties: { + Props: { Ref: 'B' }, + }, + }, + B: { Type: 'AWS::B::B' }, + }, + }, + }, + ]; + + const mappings: ResourceMapping[] = [ + new ResourceMapping(new ResourceLocation(deployedStacks[0], 'A'), new ResourceLocation(localStacks[0], 'A')), + ]; + + const result = generateStackDefinitions(mappings, deployedStacks, localStacks); + expect(result).toEqual([ + { + StackName: 'StackY', + TemplateBody: JSON.stringify({ + Resources: { + A: { + Type: 'AWS::A::A', + Properties: { + // The reference has been updated as the resource was moved + Props: { Ref: 'B' }, + }, + }, + B: { Type: 'AWS::B::B' }, + }, + }), + }, + ]); + }); + + test('head of the reference moved', () => { + const deployedStacks: CloudFormationStack[] = [ + { + environment, + stackName: 'StackX', + template: { + Resources: { + A: { + Type: 'AWS::A::A', + Properties: { + Props: { 'Fn::ImportValue': 'BFromOtherStack' }, + }, + }, + }, + }, + }, + { + environment, + stackName: 'StackY', + template: { + Outputs: { + Bout: { + Value: { Ref: 'B' }, + Export: { + Name: 'BFromOtherStack', + }, + }, + }, + Resources: { + B: { Type: 'AWS::B::B' }, + }, + }, + }, + ]; + + const localStacks: CloudFormationStack[] = [ + { + environment, + stackName: 'StackX', + template: { + Resources: { + A: { + Type: 'AWS::A::A', + Properties: { + Props: { Ref: 'B' }, + }, + }, + B: { Type: 'AWS::B::B' }, + }, + }, + }, + ]; + + const mappings: ResourceMapping[] = [ + new ResourceMapping(new ResourceLocation(deployedStacks[1], 'B'), new ResourceLocation(localStacks[0], 'B')), + ]; + + const result = generateStackDefinitions(mappings, deployedStacks, localStacks); + expect(result).toEqual([ + { + StackName: 'StackX', + TemplateBody: JSON.stringify({ + Resources: { + A: { + Type: 'AWS::A::A', + Properties: { + // The reference has been updated to the moved resource + Props: { Ref: 'B' }, + }, + }, + B: { Type: 'AWS::B::B' }, + }, + }), + }, + ]); + }); + + test('both moved', () => { + const deployedStacks: CloudFormationStack[] = [ + { + environment, + stackName: 'StackX', + template: { + Resources: { + A: { + Type: 'AWS::A::A', + Properties: { + Props: { 'Fn::ImportValue': 'BFromOtherStack' }, + }, + }, + }, + }, + }, + { + environment, + stackName: 'StackY', + template: { + Outputs: { + Bout: { + Value: { Ref: 'B' }, + Export: { + Name: 'BFromOtherStack', + }, + }, + }, + Resources: { + B: { Type: 'AWS::B::B' }, + }, + }, + }, + ]; + + const localStacks: CloudFormationStack[] = [ + { + environment, + // This is a third stack that will receive both resources + stackName: 'StackZ', + template: { + Resources: { + A: { + Type: 'AWS::A::A', + Properties: { + Props: { Ref: 'B' }, + }, + }, + B: { Type: 'AWS::B::B' }, + }, + }, + }, + ]; + + const mappings: ResourceMapping[] = [ + new ResourceMapping(new ResourceLocation(deployedStacks[0], 'A'), new ResourceLocation(localStacks[0], 'A')), + new ResourceMapping(new ResourceLocation(deployedStacks[1], 'B'), new ResourceLocation(localStacks[0], 'B')), + ]; + + const result = generateStackDefinitions(mappings, deployedStacks, localStacks); + expect(result).toEqual([ + { + StackName: 'StackZ', + TemplateBody: JSON.stringify({ + Resources: { + A: { + Type: 'AWS::A::A', + Properties: { + Props: { Ref: 'B' }, + }, + }, + B: { Type: 'AWS::B::B' }, + }, + }), + }, + ]); + }); + }); }); }); diff --git a/projenrc/jsii.ts b/projenrc/jsii.ts index 06df0e94d..4f3c82a22 100644 --- a/projenrc/jsii.ts +++ b/projenrc/jsii.ts @@ -1,4 +1,4 @@ -import { yarn } from 'cdklabs-projen-project-types'; +import { Rosetta, yarn } from 'cdklabs-projen-project-types'; import * as pj from 'projen'; import { Stability } from 'projen/lib/cdk'; import { WorkflowSteps } from 'projen/lib/github'; @@ -109,10 +109,19 @@ export interface JsiiBuildOptions { /** * Whether to turn on 'strict' mode for Rosetta * - * @default false + * @default true */ readonly rosettaStrict?: boolean; + /** + * Additional example dependencies + * + * @see https://github.com/aws/jsii-rosetta?tab=readme-ov-file#dependencies + * + * @default [] + */ + readonly rosettaDependencies?: string[]; + /** * Whether to turn on composite mode for the TypeScript project * @@ -320,19 +329,26 @@ export class JsiiBuild extends pj.Component { this.addTargetToRelease('go', task, golang); } - const jsiiSuffix = - options.jsiiVersion === '*' - ? // If jsiiVersion is "*", don't specify anything so the user can manage. - '' - : // Otherwise, use `jsiiVersion` or fall back to `5.7` - `@${options.jsiiVersion ?? '5.7'}`; + // If jsiiVersion is "*", don't specify anything so the user can manage. + // Otherwise, use `jsiiVersion` or fall back to `5.7` + const jsiiVersion = (options.jsiiVersion === '*' ? undefined : options.jsiiVersion) ?? '5.7'; + const jsiiSuffix = jsiiVersion ? `@${jsiiVersion}` : ''; + tsProject.addDevDeps( `jsii${jsiiSuffix}`, - `jsii-rosetta${jsiiSuffix}`, 'jsii-diff', 'jsii-pacmak', ); + new Rosetta(project as any, { + strict: options.rosettaStrict ?? true, + version: jsiiVersion, + }); + if (options.rosettaDependencies?.length) { + const deps = Object.fromEntries(options.rosettaDependencies.map(d => pj.Dependencies.parseDependency(d)).map(d => [d.name, d.version ?? '*'])); + tsProject.package.file.addOverride('jsiiRosetta.exampleDependencies', deps); + } + tsProject.gitignore.exclude('.jsii', 'tsconfig.json'); tsProject.npmignore?.include('.jsii'); @@ -351,22 +367,11 @@ export class JsiiBuild extends pj.Component { tsProject.npmignore.readonly = false; } - const packageJson = tsProject.package.file; - if ((options.pypiClassifiers ?? []).length > 0) { - packageJson.patch( + tsProject.package.file.patch( pj.JsonPatch.add('/jsii/targets/python/classifiers', options.pypiClassifiers), ); } - - if (options.rosettaStrict) { - packageJson.patch( - pj.JsonPatch.add('/jsii/metadata', {}), - pj.JsonPatch.add('/jsii/metadata/jsii', {}), - pj.JsonPatch.add('/jsii/metadata/jsii/rosetta', {}), - pj.JsonPatch.add('/jsii/metadata/jsii/rosetta/strict', true), - ); - } } /**