From 8f36d5c70e326efe702c1e94c9e5c90dd0f595bc Mon Sep 17 00:00:00 2001 From: Hogan Bobertz Date: Fri, 2 Feb 2024 02:30:35 -0500 Subject: [PATCH] feat(migrate): Add CDK Migrate `--from-scan` functionality (#28962) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ### `cdk migrate` ⚠️**CAUTION**⚠️: CDK Migrate is currently experimental and may have breaking changes in the future. CDK Migrate generates a CDK app from deployed AWS resources using `--from-scan`, deployed AWS CloudFormation stacks using `--from-stack`, and local AWS CloudFormation templates using `--from-path`. To learn more about the CDK Migrate feature, see [Migrate to AWS CDK](https://docs.aws.amazon.com/cdk/v2/guide/migrate.html). For more information on `cdk migrate` command options, see [cdk migrate command reference](https://docs.aws.amazon.com/cdk/v2/guide/ref-cli-cdk-migrate.html). The new CDK app will be initialized in the current working directory and will include a single stack that is named with the value you provide using `--stack-name`. The new stack, app, and directory will all use this name. To specify a different output directory, use `--output-path`. You can create the new CDK app in any CDK supported programming language using `--language`. #### Migrate from an AWS CloudFormation stack Migrate from a deployed AWS CloudFormation stack in a specific AWS account and AWS Region using `--from-stack`. Provide `--stack-name` to identify the name of your stack. Account and Region information are retrieved from default CDK CLI sources. Use `--account` and `--region` options to provide other values. The following is an example that migrates **myCloudFormationStack** to a new CDK app using TypeScript: ```console $ cdk migrate --language typescript --from-stack --stack-name 'myCloudFormationStack' ``` #### Migrate from a local AWS CloudFormation template Migrate from a local `YAML` or `JSON` AWS CloudFormation template using `--from-path`. Provide a name for the stack that will be created in your new CDK app using `--stack-name`. Account and Region information are retrieved from default CDK CLI sources. Use `--account` and `--region` options to provide other values. The following is an example that creates a new CDK app using TypeScript that includes a **myCloudFormationStack** stack from a local `template.json` file: ```console $ cdk migrate --language typescript --from-path "./template.json" --stack-name "myCloudFormationStack" ``` #### Migrate from deployed AWS resources Migrate from deployed AWS resources in a specific AWS account and Region that are not associated with an AWS CloudFormation stack using `--from-scan`. These would be resources that were provisioned outside of an IaC tool. CDK Migrate utilizes the IaC generator service to scan for resources and generate a template. Then, the CDK CLI references the template to create a new CDK app. To learn more about IaC generator, see [Generating templates for existing resources](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/generate-IaC.html). Account and Region information are retrieved from default CDK CLI sources. Use `--account` and `--region` options to provide other values. The following is an example that creates a new CDK app using TypeScript that includes a new **myCloudFormationStack** stack from deployed resources: ```console $ cdk migrate --language typescript --from-scan --stack-name "myCloudFormationStack" ``` Since CDK Migrate relies on the IaC generator service, any limitations of IaC generator will apply to CDK Migrate. For general limitations, see [Considerations](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/generate-IaC.html#generate-template-considerations). IaC generator limitations with discovering resource and property values will also apply here. As a result, CDK Migrate will only migrate resources supported by IaC generator. Some of your resources may not be supported and some property values may not be accessible. For more information, see [Iac generator and write-only properties](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/generate-IaC-write-only-properties.html) and [Supported resource types](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/generate-IaC-supported-resources.html). You can specify filters using `--filter` to specify which resources to migrate. This is a good option to use if you are over the IaC generator total resource limit. After migration, you must resolve any write-only properties that were detected by IaC generator from your deployed resources. To learn more, see [Resolve write-only properties](https://docs.aws.amazon.com/cdk/v2/guide/migrate.html#migrate-resources-writeonly). #### Examples ##### Generate a TypeScript CDK app from a local AWS CloudFormation template.json file ```console $ # template.json is a valid cloudformation template in the local directory $ cdk migrate --stack-name MyAwesomeApplication --language typescript --from-path MyTemplate.json ``` This command generates a new directory named `MyAwesomeApplication` within your current working directory, and then initializes a new CDK application within that directory. The CDK app contains a `MyAwesomeApplication` stack with resources configured to match those in your local CloudFormation template. This results in a CDK application with the following structure, where the lib directory contains a stack definition with the same resource configuration as the provided template.json. ```console ├── README.md ├── bin │ └── my_awesome_application.ts ├── cdk.json ├── jest.config.js ├── lib │ └── my_awesome_application-stack.ts ├── package.json ├── tsconfig.json ``` ##### Generate a Python CDK app from a deployed stack If you already have a CloudFormation stack deployed in your account and would like to manage it with CDK, you can migrate the deployed stack to a new CDK app. The value provided with `--stack-name` must match the name of the deployed stack. ```console $ # generate a Python application from MyDeployedStack in your account $ cdk migrate --stack-name MyDeployedStack --language python --from-stack ``` This will generate a Python CDK app which will synthesize the same configuration of resources as the deployed stack. ##### Generate a TypeScript CDK app from deployed AWS resources that are not associated with a stack If you have resources in your account that were provisioned outside AWS IaC tools and would like to manage them with the CDK, you can use the `--from-scan` option to generate the application. In this example, we use the `--filter` option to specify which resources to migrate. You can filter resources to limit the number of resources migrated to only those specified by the `--filter` option, including any resources they depend on, or resources that depend on them (for example A filter which specifies a single Lambda Function, will find that specific table and any alarms that may monitor it). The `--filter` argument offers both AND as well as OR filtering. OR filtering can be specified by passing multiple `--filter` options, and AND filtering can be specified by passing a single `--filter` option with multiple comma separated key/value pairs as seen below (see below for examples). It is recommended to use the `--filter` option to limit the number of resources returned as some resource types provide sample resources by default in all accounts which can add to the resource limits. `--from-scan` takes 3 potential arguments: `--new`, `most-recent`, and undefined. If `--new` is passed, CDK Migrate will initiate a new scan of the account and use that new scan to discover resources. If `--most-recent` is passed, CDK Migrate will use the most recent scan of the account to discover resources. If neither `--new` nor `--most-recent` are passed, CDK Migrate will take the most recent scan of the account to discover resources, unless there is no recent scan, in which case it will initiate a new scan. ``` # Filtering options identifier|id|resource-identifier= type|resource-type-prefix= tag-key= tag-value= ``` ##### Additional examples of migrating from deployed resources ```console $ # Generate a typescript application from all un-managed resources in your account $ cdk migrate --stack-name MyAwesomeApplication --language typescript --from-scan $ # Generate a typescript application from all un-managed resources in your account with the tag key "Environment" AND the tag value "Production" $ cdk migrate --stack-name MyAwesomeApplication --language typescript --from-scan --filter tag-key=Environment,tag-value=Production $ # Generate a python application from any dynamoDB resources with the tag-key "dev" AND the tag-value "true" OR any SQS::Queue $ cdk migrate --stack-name MyAwesomeApplication --language python --from-scan --filter type=AWS::DynamoDb::,tag-key=dev,tag-value=true --filter type=SQS::Queue $ # Generate a typescript application from a specific lambda function by providing it's specific resource identifier $ cdk migrate --stack-name MyAwesomeApplication --language typescript --from-scan --filter identifier=myAwesomeLambdaFunction ``` #### **CDK Migrate Limitations** - CDK Migrate does not currently support nested stacks, custom resources, or the `Fn::ForEach` intrinsic function. - CDK Migrate will only generate L1 constructs and does not currently support any higher level abstractions. - CDK Migrate successfully generating an application does *not* guarantee the application is immediately deployable. It simply generates a CDK application which will synthesize a template that has identical resource configurations to the provided template. - CDK Migrate does not interact with the CloudFormation service to verify the template provided can deploy on its own. This means CDK Migrate will not verify that any resources in the provided template are already managed in other CloudFormation templates, nor will it verify that the resources in the provided template are available in the desired regions, which may impact ADC or Opt-In regions. - If the provided template has parameters without default values, those will need to be provided before deploying the generated application. In practice this is how CDK Migrate generated applications will operate in the following scenarios: | Situation | Result | | ------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------- | | Provided template + stack-name is from a deployed stack in the account/region | The CDK application will deploy as a changeset to the existing stack | | Provided template has no overlap with resources already in the account/region | The CDK application will deploy a new stack successfully | | Provided template has overlap with Cloudformation managed resources already in the account/region | The CDK application will not be deployable unless those resources are removed | | Provided template has overlap with un-managed resources already in the account/region | The CDK application will not be deployable until those resources are adopted with [`cdk import`](#cdk-import) | | No template has been provided and resources exist in the region the scan is done | The CDK application will be immediatly deployable and will import those resources into a new cloudformation stack upon deploy | ##### **The provided template is already deployed to CloudFormation in the account/region** If the provided template came directly from a deployed CloudFormation stack, and that stack has not experienced any drift, then the generated application will be immediately deployable, and will not cause any changes to the deployed resources. Drift might occur if a resource in your template was modified outside of CloudFormation, namely via the AWS Console or AWS CLI. ##### **The provided template is not deployed to CloudFormation in the account/region, and there *is not* overlap with existing resources in the account/region** If the provided template represents a set of resources that have no overlap with resources already deployed in the account/region, then the generated application will be immediately deployable. This could be because the stack has never been deployed, or the application was generated from a stack deployed in another account/region. In practice this means for any resource in the provided template, for example, ```Json "S3Bucket": { "Type": "AWS::S3::Bucket", "Properties": { "BucketName": "MyBucket", "AccessControl": "PublicRead", }, "DeletionPolicy": "Retain" } ``` There must not exist a resource of that type with the same identifier in the desired region. In this example that identfier would be "MyBucket" ##### **The provided template is not deployed to CloudFormation in the account/region, and there *is* overlap with existing resources in the account/region** If the provided template represents a set of resources that overlap with resources already deployed in the account/region, then the generated application will not be immediately deployable. If those overlapped resources are already managed by another CloudFormation stack in that account/region, then those resources will need to be manually removed from the provided template. Otherwise, if the overlapped resources are not managed by another CloudFormation stack, then first remove those resources from your CDK Application Stack, deploy the cdk application successfully, then re-add them and run `cdk import` to import them into your deployed stack. ---- *By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license* --- .../cli-integ/lib/with-cdk-app.ts | 5 + .../cli-integ/resources/cdk-apps/app/app.js | 28 + .../tests/cli-integ-tests/cli.integtest.ts | 118 ++- packages/aws-cdk/lib/cdk-toolkit.ts | 89 +- packages/aws-cdk/lib/cli.ts | 19 +- packages/aws-cdk/lib/commands/migrate.ts | 793 +++++++++++++++++- packages/aws-cdk/test/cdk-toolkit.test.ts | 6 - .../aws-cdk/test/commands/migrate.test.ts | 243 +++++- .../templates/empty-template.yml | 0 9 files changed, 1262 insertions(+), 39 deletions(-) create mode 100644 packages/aws-cdk/test/commands/test-resources/templates/empty-template.yml diff --git a/packages/@aws-cdk-testing/cli-integ/lib/with-cdk-app.ts b/packages/@aws-cdk-testing/cli-integ/lib/with-cdk-app.ts index 8124088e00634..16226c4cde259 100644 --- a/packages/@aws-cdk-testing/cli-integ/lib/with-cdk-app.ts +++ b/packages/@aws-cdk-testing/cli-integ/lib/with-cdk-app.ts @@ -12,6 +12,7 @@ import { AwsContext, withAws } from './with-aws'; import { withTimeout } from './with-timeout'; export const DEFAULT_TEST_TIMEOUT_S = 10 * 60; +export const EXTENDED_TEST_TIMEOUT_S = 30 * 60; /** * Higher order function to execute a block with a CDK app fixture @@ -185,6 +186,10 @@ export function withDefaultFixture(block: (context: TestFixture) => Promise Promise) { + return withAws(withTimeout(EXTENDED_TEST_TIMEOUT_S, withCdkApp(block))); +} + export function withCDKMigrateFixture(language: string, block: (content: TestFixture) => Promise) { return withAws(withTimeout(DEFAULT_TEST_TIMEOUT_S, withCdkMigrateApp(language, block))); } diff --git a/packages/@aws-cdk-testing/cli-integ/resources/cdk-apps/app/app.js b/packages/@aws-cdk-testing/cli-integ/resources/cdk-apps/app/app.js index f8e94bc2920df..ed1117dc9dc94 100755 --- a/packages/@aws-cdk-testing/cli-integ/resources/cdk-apps/app/app.js +++ b/packages/@aws-cdk-testing/cli-integ/resources/cdk-apps/app/app.js @@ -86,7 +86,35 @@ class MigrateStack extends cdk.Stack { value: queue.node.defaultChild.logicalId, }); } + if (process.env.SAMPLE_RESOURCES) { + const myTopic = new sns.Topic(this, 'migratetopic1', { + removalPolicy: cdk.RemovalPolicy.DESTROY, + }); + cdk.Tags.of(myTopic).add('tag1', 'value1'); + const myTopic2 = new sns.Topic(this, 'migratetopic2', { + removalPolicy: cdk.RemovalPolicy.DESTROY, + }); + cdk.Tags.of(myTopic2).add('tag2', 'value2'); + const myQueue = new sqs.Queue(this, 'migratequeue1', { + removalPolicy: cdk.RemovalPolicy.DESTROY, + }); + cdk.Tags.of(myQueue).add('tag3', 'value3'); + } + if (process.env.LAMBDA_RESOURCES) { + const myFunction = new lambda.Function(this, 'migratefunction1', { + code: lambda.Code.fromInline('console.log("hello world")'), + handler: 'index.handler', + runtime: lambda.Runtime.NODEJS_18_X, + }); + cdk.Tags.of(myFunction).add('lambda-tag', 'lambda-value'); + const myFunction2 = new lambda.Function(this, 'migratefunction2', { + code: lambda.Code.fromInline('console.log("hello world2")'), + handler: 'index.handler', + runtime: lambda.Runtime.NODEJS_18_X, + }); + cdk.Tags.of(myFunction2).add('lambda-tag', 'lambda-value'); + } } } diff --git a/packages/@aws-cdk-testing/cli-integ/tests/cli-integ-tests/cli.integtest.ts b/packages/@aws-cdk-testing/cli-integ/tests/cli-integ-tests/cli.integtest.ts index a6a92b0a7cde2..d152adb412fe2 100644 --- a/packages/@aws-cdk-testing/cli-integ/tests/cli-integ-tests/cli.integtest.ts +++ b/packages/@aws-cdk-testing/cli-integ/tests/cli-integ-tests/cli.integtest.ts @@ -1,7 +1,7 @@ import { promises as fs, existsSync } from 'fs'; import * as os from 'os'; import * as path from 'path'; -import { integTest, cloneDirectory, shell, withDefaultFixture, retry, sleep, randomInteger, withSamIntegrationFixture, RESOURCES_DIR, withCDKMigrateFixture } from '../../lib'; +import { integTest, cloneDirectory, shell, withDefaultFixture, retry, sleep, randomInteger, withSamIntegrationFixture, RESOURCES_DIR, withCDKMigrateFixture, withExtendedTimeoutFixture } from '../../lib'; jest.setTimeout(2 * 60 * 60_000); // Includes the time to acquire locks, worst-case single-threaded runtime @@ -571,9 +571,9 @@ integTest('deploy with role', withDefaultFixture(async (fixture) => { } })); -// TODO add go back in when template synths properly +// TODO add more testing that ensures the symmetry of the generated constructs to the resources. ['typescript', 'python', 'csharp', 'java'].forEach(language => { - integTest(`cdk migrate ${language}`, withCDKMigrateFixture(language, async (fixture) => { + integTest(`cdk migrate ${language} deploys successfully`, withCDKMigrateFixture(language, async (fixture) => { if (language === 'python') { await fixture.shell(['pip', 'install', '-r', 'requirements.txt']); } @@ -588,6 +588,118 @@ integTest('deploy with role', withDefaultFixture(async (fixture) => { })); }); +integTest('cdk migrate generates migrate.json', withCDKMigrateFixture('typescript', async (fixture) => { + + const migrateFile = await fs.readFile(path.join(fixture.integTestDir, 'migrate.json'), 'utf8'); + const expectedFile = `{ + \"//\": \"This file is generated by cdk migrate. It will be automatically deleted after the first successful deployment of this app to the environment of the original resources.\", + \"Source\": \"localfile\" + }`; + expect(JSON.parse(migrateFile)).toEqual(JSON.parse(expectedFile)); + await fixture.cdkDestroy(fixture.stackNamePrefix); +})); + +integTest('cdk migrate --from-scan with AND/OR filters correctly filters resources', withExtendedTimeoutFixture(async (fixture) => { + const stackName = `cdk-migrate-integ-${fixture.randomString}`; + + await fixture.cdkDeploy('migrate-stack', { + modEnv: { SAMPLE_RESOURCES: '1' }, + }); + await fixture.cdk( + ['migrate', '--stack-name', stackName, '--from-scan', 'new', '--filter', 'type=AWS::SNS::Topic,tag-key=tag1', 'type=AWS::SQS::Queue,tag-key=tag3'], + { modEnv: { MIGRATE_INTEG_TEST: '1' }, neverRequireApproval: true, verbose: true, captureStderr: false }, + ); + + try { + const response = await fixture.aws.cloudFormation('describeGeneratedTemplate', { + GeneratedTemplateName: stackName, + }); + const resourceNames = []; + for (const resource of response.Resources || []) { + if (resource.LogicalResourceId) { + resourceNames.push(resource.LogicalResourceId); + } + } + fixture.log(`Resources: ${resourceNames}`); + expect(resourceNames.some(ele => ele && ele.includes('migratetopic1'))).toBeTruthy(); + expect(resourceNames.some(ele => ele && ele.includes('migratequeue1'))).toBeTruthy(); + } finally { + await fixture.cdkDestroy('migrate-stack'); + await fixture.aws.cloudFormation('deleteGeneratedTemplate', { + GeneratedTemplateName: stackName, + }); + } +})); + +integTest('cdk migrate --from-scan for resources with Write Only Properties generates warnings', withExtendedTimeoutFixture(async (fixture) => { + const stackName = `cdk-migrate-integ-${fixture.randomString}`; + + await fixture.cdkDeploy('migrate-stack', { + modEnv: { + LAMBDA_RESOURCES: '1', + }, + }); + await fixture.cdk( + ['migrate', '--stack-name', stackName, '--from-scan', 'new', '--filter', 'type=AWS::Lambda::Function,tag-key=lambda-tag'], + { modEnv: { MIGRATE_INTEG_TEST: '1' }, neverRequireApproval: true, verbose: true, captureStderr: false }, + ); + + try { + + const response = await fixture.aws.cloudFormation('describeGeneratedTemplate', { + GeneratedTemplateName: stackName, + }); + const resourceNames = []; + for (const resource of response.Resources || []) { + if (resource.LogicalResourceId && resource.ResourceType === 'AWS::Lambda::Function') { + resourceNames.push(resource.LogicalResourceId); + } + } + fixture.log(`Resources: ${resourceNames}`); + const readmePath = path.join(fixture.integTestDir, stackName, 'README.md'); + const readme = await fs.readFile(readmePath, 'utf8'); + expect(readme).toContain('## Warnings'); + for (const resourceName of resourceNames) { + expect(readme).toContain(`### ${resourceName}`); + } + } finally { + await fixture.cdkDestroy('migrate-stack'); + await fixture.aws.cloudFormation('deleteGeneratedTemplate', { + GeneratedTemplateName: stackName, + }); + } +})); + +['typescript', 'python', 'csharp', 'java'].forEach(language => { + integTest(`cdk migrate --from-stack creates deployable ${language} app`, withExtendedTimeoutFixture(async (fixture) => { + const migrateStackName = fixture.fullStackName('migrate-stack'); + await fixture.aws.cloudFormation('createStack', { + StackName: migrateStackName, + TemplateBody: await fs.readFile(path.join(__dirname, '..', '..', 'resources', 'templates', 'sqs-template.json'), 'utf8'), + }); + try { + let stackStatus = 'CREATE_IN_PROGRESS'; + while (stackStatus === 'CREATE_IN_PROGRESS') { + stackStatus = await (await (fixture.aws.cloudFormation('describeStacks', { StackName: migrateStackName }))).Stacks?.[0].StackStatus!; + await sleep(1000); + } + await fixture.cdk( + ['migrate', '--stack-name', migrateStackName, '--from-stack'], + { modEnv: { MIGRATE_INTEG_TEST: '1' }, neverRequireApproval: true, verbose: true, captureStderr: false }, + ); + await fixture.shell(['cd', path.join(fixture.integTestDir, migrateStackName)]); + await fixture.cdk(['deploy', migrateStackName], { neverRequireApproval: true, verbose: true, captureStderr: false }); + const response = await fixture.aws.cloudFormation('describeStacks', { + StackName: migrateStackName, + }); + + expect(response.Stacks?.[0].StackStatus).toEqual('UPDATE_COMPLETE'); + } finally { + await fixture.cdkDestroy('migrate-stack'); + } + })); +}); + integTest('cdk diff', withDefaultFixture(async (fixture) => { const diff1 = await fixture.cdk(['diff', fixture.fullStackName('test-1')]); expect(diff1).toContain('AWS::SNS::Topic'); diff --git a/packages/aws-cdk/lib/cdk-toolkit.ts b/packages/aws-cdk/lib/cdk-toolkit.ts index 52c01525ee1dd..6e236e742bbb4 100644 --- a/packages/aws-cdk/lib/cdk-toolkit.ts +++ b/packages/aws-cdk/lib/cdk-toolkit.ts @@ -17,7 +17,7 @@ import { findCloudWatchLogGroups } from './api/logs/find-cloudwatch-logs'; import { CloudWatchLogEventMonitor } from './api/logs/logs-monitor'; import { createDiffChangeSet, ResourcesToImport } from './api/util/cloudformation'; import { StackActivityProgress } from './api/util/cloudformation/stack-activity-monitor'; -import { generateCdkApp, generateStack, readFromPath, readFromStack, setEnvironment, validateSourceOptions } from './commands/migrate'; +import { generateCdkApp, generateStack, readFromPath, readFromStack, setEnvironment, parseSourceOptions, generateTemplate, FromScan, TemplateSourceOptions, GenerateTemplateOutput, CfnTemplateGeneratorProvider, writeMigrateJsonFile, buildGenertedTemplateOutput, buildCfnClient, appendWarningsToReadme, isThereAWarning } from './commands/migrate'; import { printSecurityDiff, printStackDiff, RequireApproval } from './diff'; import { ResourceImporter, removeNonImportResources } from './import'; import { data, debug, error, highlight, print, success, warning, withCorkedLogging } from './logging'; @@ -735,19 +735,80 @@ export class CdkToolkit { public async migrate(options: MigrateOptions): Promise { warning('This is an experimental feature and development on it is still in progress. We make no guarantees about the outcome or stability of the functionality.'); const language = options.language?.toLowerCase() ?? 'typescript'; + const environment = setEnvironment(options.account, options.region); + let generateTemplateOutput: GenerateTemplateOutput | undefined; + let cfn: CfnTemplateGeneratorProvider | undefined; + let templateToDelete: string | undefined; try { - validateSourceOptions(options.fromPath, options.fromStack); - const template = readFromPath(options.fromPath) || - await readFromStack(options.stackName, this.props.sdkProvider, setEnvironment(options.account, options.region)); - const stack = generateStack(template!, options.stackName, language); + // if neither fromPath nor fromStack is provided, generate a template using cloudformation + const scanType = parseSourceOptions(options.fromPath, options.fromStack, options.stackName).source; + if (scanType == TemplateSourceOptions.SCAN) { + generateTemplateOutput = await generateTemplate({ + stackName: options.stackName, + filters: options.filter, + fromScan: options.fromScan, + sdkProvider: this.props.sdkProvider, + environment: environment, + }); + templateToDelete = generateTemplateOutput.templateId; + } else if (scanType == TemplateSourceOptions.PATH) { + const templateBody = readFromPath(options.fromPath!); + + const parsedTemplate = deserializeStructure(templateBody); + const templateId = parsedTemplate.Metadata?.TemplateId?.toString(); + if (templateId) { + // if we have a template id, we can call describe generated template to get the resource identifiers + // resource metadata, and template source to generate the template + cfn = new CfnTemplateGeneratorProvider(await buildCfnClient(this.props.sdkProvider, environment)); + const generatedTemplateSummary = await cfn.describeGeneratedTemplate(templateId); + generateTemplateOutput = buildGenertedTemplateOutput(generatedTemplateSummary, templateBody, generatedTemplateSummary.GeneratedTemplateId!); + } else { + generateTemplateOutput = { + migrateJson: { + templateBody: templateBody, + source: 'localfile', + }, + }; + } + } else if (scanType == TemplateSourceOptions.STACK) { + const template = await readFromStack(options.stackName, this.props.sdkProvider, environment); + if (!template) { + throw new Error(`No template found for stack-name: ${options.stackName}`); + } + generateTemplateOutput = { + migrateJson: { + templateBody: template, + source: options.stackName, + }, + }; + } else { + // We shouldn't ever get here, but just in case. + throw new Error(`Invalid source option provided: ${scanType}`); + } + const stack = generateStack(generateTemplateOutput.migrateJson.templateBody, options.stackName, language); success(' ⏳ Generating CDK app for %s...', chalk.blue(options.stackName)); await generateCdkApp(options.stackName, stack!, language, options.outputPath, options.compress); + if (generateTemplateOutput) { + writeMigrateJsonFile(options.outputPath, options.stackName, generateTemplateOutput.migrateJson); + } + if (isThereAWarning(generateTemplateOutput)) { + warning(' ⚠️ Some resources could not be migrated completely. Please review the README.md file for more information.'); + appendWarningsToReadme(`${path.join(options.outputPath ?? process.cwd(), options.stackName)}/README.md`, generateTemplateOutput.resources!); + } } catch (e) { - error(' ❌ Migrate failed for `%s`: %s', chalk.blue(options.stackName), (e as Error).message); + error(' ❌ Migrate failed for `%s`: %s', options.stackName, (e as Error).message); throw e; + } finally { + if (templateToDelete) { + if (!cfn) { + cfn = new CfnTemplateGeneratorProvider(await buildCfnClient(this.props.sdkProvider, environment)); + } + if (!process.env.MIGRATE_INTEG_TEST) { + await cfn.deleteGeneratedTemplate(templateToDelete); + } + } } - } private async selectStacksForList(patterns: string[]) { @@ -1353,6 +1414,20 @@ export interface MigrateOptions { */ readonly region?: string; + /** + * Filtering criteria used to select the resources to be included in the generated CDK app. + * + * @default - Include all resources + */ + readonly filter?: string[]; + + /** + * Whether to initiate a new account scan for generating the CDK app. + * + * @default false + */ + readonly fromScan?: FromScan; + /** * Whether to zip the generated cdk app folder. * diff --git a/packages/aws-cdk/lib/cli.ts b/packages/aws-cdk/lib/cli.ts index 0871e2a42814c..d7465e78693bf 100644 --- a/packages/aws-cdk/lib/cli.ts +++ b/packages/aws-cdk/lib/cli.ts @@ -22,7 +22,7 @@ import { CdkToolkit, AssetBuildTime } from '../lib/cdk-toolkit'; import { realHandler as context } from '../lib/commands/context'; import { realHandler as docs } from '../lib/commands/docs'; import { realHandler as doctor } from '../lib/commands/doctor'; -import { MIGRATE_SUPPORTED_LANGUAGES } from '../lib/commands/migrate'; +import { MIGRATE_SUPPORTED_LANGUAGES, getMigrateScanType } from '../lib/commands/migrate'; import { RequireApproval } from '../lib/diff'; import { availableInitLanguages, cliInit, printAvailableTemplates } from '../lib/init'; import { data, debug, error, print, setLogLevel, setCI } from '../lib/logging'; @@ -281,6 +281,21 @@ async function parseCommandLineArguments(args: string[]) { .option('from-path', { type: 'string', desc: 'The path to the CloudFormation template to migrate. Use this for locally stored templates' }) .option('from-stack', { type: 'boolean', desc: 'Use this flag to retrieve the template for an existing CloudFormation stack' }) .option('output-path', { type: 'string', desc: 'The output path for the migrated CDK app' }) + .option('from-scan', { + type: 'string', + desc: 'Determines if a new scan should be created, or the last successful existing scan should be used ' + + '\n options are "new" or "most-recent"', + }) + .option('filter', { + type: 'array', + desc: 'Filters the resource scan based on the provided criteria in the following format: "key1=value1,key2=value2"' + + '\n This field can be passed multiple times for OR style filtering: ' + + '\n filtering options: ' + + '\n resource-identifier: A key-value pair that identifies the target resource. i.e. {"ClusterName", "myCluster"}' + + '\n resource-type-prefix: A string that represents a type-name prefix. i.e. "AWS::DynamoDB::"' + + '\n tag-key: a string that matches resources with at least one tag with the provided key. i.e. "myTagKey"' + + '\n tag-value: a string that matches resources with at least one tag with the provided value. i.e. "myTagValue"', + }) .option('compress', { type: 'boolean', desc: 'Use this flag to zip the generated CDK app' }), ) .command('context', 'Manage cached context values', (yargs: Argv) => yargs @@ -679,6 +694,8 @@ export async function exec(args: string[], synthesizer?: Synthesizer): Promise { const resolvedOutputPath = path.join(outputPath ?? process.cwd(), stackName); const formattedStackName = decamelize(stackName); @@ -94,13 +96,17 @@ export function generateStack(template: string, stackName: string, language: str * @param inputPath The location of the template * @returns A string representation of the template if present, otherwise undefined */ -export function readFromPath(inputPath?: string): string | undefined { +export function readFromPath(inputPath: string): string { + let readFile: string; try { - return inputPath ? fs.readFileSync(inputPath, 'utf8') : undefined; + readFile = fs.readFileSync(inputPath, 'utf8'); } catch (e) { throw new Error(`'${inputPath}' is not a valid path.`); } - + if (readFile == '') { + throw new Error(`Cloudformation template filepath: '${inputPath}' is an empty file.`); + } + return readFile; } /** @@ -123,6 +129,148 @@ export async function readFromStack(stackName: string, sdkProvider: SdkProvider, return; } +/** + * Takes in a stack name and account and region and returns a generated cloudformation template using the cloudformation + * template generator. + * + * @param GenerateTemplateOptions An object containing the stack name, filters, sdkProvider, environment, and newScan flag + * @returns a generated cloudformation template + */ +export async function generateTemplate( + options: GenerateTemplateOptions, +): Promise { + const cfn = new CfnTemplateGeneratorProvider(await buildCfnClient(options.sdkProvider, options.environment)); + + const scanId = await findLastSuccessfulScan(cfn, options); + + // if a customer accidentally ctrl-c's out of the command and runs it again, this will continue the progress bar where it left off + const curScan = await cfn.describeResourceScan(scanId); + if ((curScan.Status == ScanStatus.IN_PROGRESS)) { + print('Resource scan in progress. Please wait, this can take 10 minutes or longer.'); + await scanProgressBar(scanId, cfn); + } + + displayTimeDiff(new Date(), new Date(curScan.StartTime!)); + + let resources: CloudFormation.ScannedResources = await cfn.listResourceScanResources(scanId!, options.filters); + + print('finding related resources.'); + let relatedResources = await cfn.getResourceScanRelatedResources(scanId!, resources); + + print(`Found ${relatedResources.length} resources.`); + + print('Generating CFN template from scanned resources.'); + const templateArn = (await cfn.createGeneratedTemplate(options.stackName, relatedResources)).GeneratedTemplateId!; + + let generatedTemplate = await cfn.describeGeneratedTemplate(templateArn); + + print('Please wait, template creation in progress. This may take a couple minutes.'); + while (generatedTemplate.Status !== ScanStatus.COMPLETE && generatedTemplate.Status !== ScanStatus.FAILED) { + await printDots(`[${generatedTemplate.Status}] Template Creation in Progress`, 400); + generatedTemplate = await cfn.describeGeneratedTemplate(templateArn); + } + print(''); + print('Template successfully generated!'); + return buildGenertedTemplateOutput(generatedTemplate, (await cfn.getGeneratedTemplate(templateArn)).TemplateBody!, templateArn); +} + +async function findLastSuccessfulScan(cfn: CfnTemplateGeneratorProvider, options: GenerateTemplateOptions): Promise { + let resourceScanSummaries: CloudFormation.ResourceScanSummaries | undefined = []; + const clientRequestToken = `cdk-migrate-${options.environment.account}-${options.environment.region}`; + if (options.fromScan === FromScan.NEW) { + print(`Starting new scan for account ${options.environment.account} in region ${options.environment.region}`); + try { + await cfn.startResourceScan(clientRequestToken); + resourceScanSummaries = (await cfn.listResourceScans()).ResourceScanSummaries; + } catch (e) { + // continuing here because if the scan fails on a new-scan it is very likely because there is either already a scan in progress + // or the customer hit a rate limit. In either case we want to continue with the most recent scan. + // If this happens to fail for a credential error then that will be caught immediately after anyway. + print(`Scan failed to start due to error '${(e as Error).message}', defaulting to latest scan.`); + } + } else { + resourceScanSummaries = (await cfn.listResourceScans()).ResourceScanSummaries; + await cfn.checkForResourceScan(resourceScanSummaries, options, clientRequestToken); + } + // get the latest scan, which we know will exist + resourceScanSummaries = (await cfn.listResourceScans()).ResourceScanSummaries; + let scanId: string | undefined = resourceScanSummaries![0].ResourceScanId; + + // find the most recent scan that isn't in a failed state in case we didn't start a new one + for (const summary of resourceScanSummaries!) { + if (summary.Status !== ScanStatus.FAILED) { + scanId = summary.ResourceScanId!; + break; + } + } + + return scanId!; +} + +/** + * Takes a string of filters in the format of key1=value1,key2=value2 and returns a map of the filters. + * + * @param filters a string of filters in the format of key1=value1,key2=value2 + * @returns a map of the filters + */ +function parseFilters(filters: string): {[key in FilterType]: string | undefined } { + if (!filters) { + return { + 'resource-identifier': undefined, + 'resource-type-prefix': undefined, + 'tag-key': undefined, + 'tag-value': undefined, + }; + }; + + const filterShorthands: { [key: string]: FilterType } = { + 'identifier': FilterType.RESOURCE_IDENTIFIER, + 'id': FilterType.RESOURCE_IDENTIFIER, + 'type': FilterType.RESOURCE_TYPE_PREFIX, + 'type-prefix': FilterType.RESOURCE_TYPE_PREFIX, + }; + + const filterList = filters.split(','); + + let filterMap: { [key in FilterType]: string | undefined} = { + [FilterType.RESOURCE_IDENTIFIER]: undefined, + [FilterType.RESOURCE_TYPE_PREFIX]: undefined, + [FilterType.TAG_KEY]: undefined, + [FilterType.TAG_VALUE]: undefined, + }; + + for (const fil of filterList) { + const filter = fil.split('='); + let filterKey = filter[0]; + const filterValue = filter[1]; + // if the key is a shorthand, replace it with the full name + if (filterKey in filterShorthands) { + filterKey = filterShorthands[filterKey]; + } + if (Object.values(FilterType).includes(filterKey as any)) { + filterMap[filterKey as keyof typeof filterMap] = filterValue; + } else { + throw new Error(`Invalid filter: ${filterKey}`); + } + } + return filterMap; +} + +/** + * Takes a list of any type and breaks it up into chunks of a specified size. + * + * @param list The list to break up + * @param chunkSize The size of each chunk + * @returns A list of lists of the specified size + */ +export function chunks(list: any[], chunkSize: number): any[][] { + const chunkedList: any[][] = []; + for (let i = 0; i < list.length; i += chunkSize) { + chunkedList.push(list.slice(i, i + chunkSize)); + } + return chunkedList; +} + /** * Sets the account and region for making CloudFormation calls. * @param account The account to use @@ -133,17 +281,642 @@ export function setEnvironment(account?: string, region?: string): Environment { return { account: account ?? UNKNOWN_ACCOUNT, region: region ?? UNKNOWN_REGION, name: 'cdk-migrate-env' }; } +/** + * Enum for the source options for the template + */ +export enum TemplateSourceOptions { + PATH = 'path', + STACK = 'stack', + SCAN = 'scan', +} + +/** + * An object representing the source of a template. + */ +type TemplateSource = + | { source: TemplateSourceOptions.SCAN } + | { source: TemplateSourceOptions.PATH, templatePath: string } + | { source: TemplateSourceOptions.STACK, stackName: string } + ; + +/** + * Enum for the status of a resource scan + */ +export enum ScanStatus { + IN_PROGRESS = 'IN_PROGRESS', + COMPLETE = 'COMPLETE', + FAILED = 'FAILED', +} + +export enum FilterType { + RESOURCE_IDENTIFIER = 'resource-identifier', + RESOURCE_TYPE_PREFIX = 'resource-type-prefix', + TAG_KEY = 'tag-key', + TAG_VALUE = 'tag-value', +} + /** * Validates that exactly one source option has been provided. * @param fromPath The content of the flag `--from-path` * @param fromStack the content of the flag `--from-stack` */ -export function validateSourceOptions(fromPath?: string, fromStack?: boolean) { +export function parseSourceOptions(fromPath?: string, fromStack?: boolean, stackName?: string): TemplateSource { if (fromPath && fromStack) { throw new Error('Only one of `--from-path` or `--from-stack` may be provided.'); } - + if (!stackName) { + throw new Error('`--stack-name` is a required field.'); + } if (!fromPath && !fromStack) { - throw new Error('Either `--from-path` or `--from-stack` must be used to provide the source of the CloudFormation template.'); + return { source: TemplateSourceOptions.SCAN }; + } + if (fromPath) { + return { source: TemplateSourceOptions.PATH, templatePath: fromPath }; } + return { source: TemplateSourceOptions.STACK, stackName: stackName! }; +} + +/** + * Takes a set of resources and removes any with the managedbystack flag set to true. + * + * @param resourceList the list of resources provided by the list scanned resources calls + * @returns a list of resources not managed by cfn stacks + */ +function excludeManaged(resourceList: CloudFormation.ScannedResources): CloudFormation.ScannedResourceIdentifiers { + return resourceList.filter((r) => !r.ManagedByStack).map((r) => ({ + ResourceType: r.ResourceType!, + ResourceIdentifier: r.ResourceIdentifier!, + })); +} + +/** + * Transforms a list of resources into a list of resource identifiers by removing the ManagedByStack flag. + * Setting the value of the field to undefined effectively removes it from the object. + * + * @param resourceList the list of resources provided by the list scanned resources calls + * @returns a list of ScannedResourceIdentifiers + */ +function resourceIdentifiers(resourceList: CloudFormation.ScannedResources): CloudFormation.ScannedResourceIdentifiers { + const identifiers: CloudFormation.ScannedResourceIdentifiers = []; + resourceList.forEach((r) => { + const identifier: CloudFormation.ScannedResourceIdentifier = { + ResourceType: r.ResourceType!, + ResourceIdentifier: r.ResourceIdentifier!, + }; + identifiers.push(identifier); + }); + return identifiers; +} + +/** + * Takes a scan id and maintains a progress bar to display the progress of a scan to the user. + * + * @param scanId A string representing the scan id + * @param cloudFormation The CloudFormation sdk client to use + */ +export async function scanProgressBar(scanId: string, cfn: CfnTemplateGeneratorProvider) { + let curProgress = 0.5; + // we know it's in progress initially since we wouldn't have gotten here if it wasn't + let curScan: CloudFormation.DescribeResourceScanOutput = { Status: ScanStatus.IN_PROGRESS }; + while (curScan.Status == ScanStatus.IN_PROGRESS) { + curScan = await cfn.describeResourceScan(scanId); + curProgress = curScan.PercentageCompleted ?? curProgress; + printBar(30, curProgress); + await new Promise(resolve => setTimeout(resolve, 2000)); + } + print(''); + print('✅ Scan Complete!'); +} + +/** + * Prints a progress bar to the console. To be used in a while loop to show progress of a long running task. + * The progress bar deletes the current line on the console and rewrites it with the progress amount. + * + * @param width The width of the progress bar + * @param progress The current progress to display as a percentage of 100 + */ +export function printBar(width: number, progress: number) { + if (!process.env.MIGRATE_INTEG_TEST) { + const FULL_BLOCK = '█'; + const PARTIAL_BLOCK = ['', '▏', '▎', '▍', '▌', '▋', '▊', '▉']; + const fraction = Math.min(progress / 100, 1); + const innerWidth = Math.max(1, width - 2); + const chars = innerWidth * fraction; + const remainder = chars - Math.floor(chars); + + const fullChars = FULL_BLOCK.repeat(Math.floor(chars)); + const partialChar = PARTIAL_BLOCK[Math.floor(remainder * PARTIAL_BLOCK.length)]; + const filler = '·'.repeat(innerWidth - Math.floor(chars) - (partialChar ? 1 : 0)); + + const color = chalk.green; + + rewriteLine('[' + color(fullChars + partialChar) + filler + `] (${progress}%)`); + } +} + +/** + * Prints a message to the console with a series periods appended to it. To be used in a while loop to show progress of a long running task. + * The message deletes the current line and rewrites it several times to display 1-3 periods to show the user that the task is still running. + * + * @param message The message to display + * @param timeoutx4 The amount of time to wait before printing the next period + */ +export async function printDots(message: string, timeoutx4: number) { + + if (!process.env.MIGRATE_INTEG_TEST) { + rewriteLine(message + ' .'); + await new Promise(resolve => setTimeout(resolve, timeoutx4)); + + rewriteLine(message + ' ..'); + await new Promise(resolve => setTimeout(resolve, timeoutx4)); + + rewriteLine(message + ' ...'); + await new Promise(resolve => setTimeout(resolve, timeoutx4)); + + rewriteLine(message); + await new Promise(resolve => setTimeout(resolve, timeoutx4)); + } + +} + +/** + * Rewrites the current line on the console and writes a new message to it. + * This is a helper funciton for printDots and printBar. + * + * @param message The message to display + */ +export function rewriteLine(message: string) { + process.stdout.clearLine(0); + process.stdout.cursorTo(0); + process.stdout.write(message); +} + +/** + * Prints the time difference between two dates in days, hours, and minutes. + * + * @param time1 The first date to compare + * @param time2 The second date to compare + */ +export function displayTimeDiff(time1: Date, time2: Date): void { + const diff = Math.abs(time1.getTime() - time2.getTime()); + + const days = Math.floor(diff / (1000 * 60 * 60 * 24)); + const hours = Math.floor((diff % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60)); + const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60)); + + print(`Using the latest successful scan which is ${days} days, ${hours} hours, and ${minutes} minutes old.`); +} + +/** + * Writes a migrate.json file to the output directory. + * + * @param outputPath The path to write the migrate.json file to + * @param stackName The name of the stack + * @param generatedOutput The output of the template generator + */ +export function writeMigrateJsonFile(outputPath: string | undefined, stackName: string, migrateJson: MigrateJsonFormat) { + const outputToJson = { + '//': 'This file is generated by cdk migrate. It will be automatically deleted after the first successful deployment of this app to the environment of the original resources.', + 'Source': migrateJson.source, + 'Resources': migrateJson.resources, + }; + fs.writeFileSync(`${path.join(outputPath ?? process.cwd(), stackName)}/migrate.json`, JSON.stringify(outputToJson, null, 2)); +} + +/** + * Takes a string representing the from-scan flag and returns a FromScan enum value. + * + * @param scanType A string representing the from-scan flag + * @returns A FromScan enum value + */ +export function getMigrateScanType(scanType: string) { + switch (scanType) { + case 'new': + return FromScan.NEW; + case 'most-recent': + return FromScan.MOST_RECENT; + case '': + return FromScan.DEFAULT; + case undefined: + return FromScan.DEFAULT; + default: + throw new Error(`Unknown scan type: ${scanType}`); + } +} + +/** + * Takes a generatedTemplateOutput objct and returns a boolean representing whether there are any warnings on any rescources. + * + * @param generatedTemplateOutput A GenerateTemplateOutput object + * @returns A boolean representing whether there are any warnings on any rescources + */ +export function isThereAWarning(generatedTemplateOutput: GenerateTemplateOutput) { + if (generatedTemplateOutput.resources) { + for (const resource of generatedTemplateOutput.resources) { + if (resource.Warnings && resource.Warnings.length > 0) { + return true; + } + } + } + return false; +} + +/** + * Builds the GenerateTemplateOutput object from the DescribeGeneratedTemplateOutput and the template body. + * + * @param generatedTemplateSummary The output of the describe generated template call + * @param templateBody The body of the generated template + * @returns A GenerateTemplateOutput object + */ +export function buildGenertedTemplateOutput(generatedTemplateSummary: CloudFormation.DescribeGeneratedTemplateOutput, + templateBody: string, source: string): GenerateTemplateOutput { + const resources: CloudFormation.ResourceDetails | undefined = generatedTemplateSummary.Resources; + const migrateJson: MigrateJsonFormat = { + templateBody: templateBody, + source: source, + resources: generatedTemplateSummary.Resources!.map((r) => ({ + ResourceType: r.ResourceType!, + LogicalResourceId: r.LogicalResourceId!, + ResourceIdentifier: r.ResourceIdentifier!, + })), + }; + const templateId = generatedTemplateSummary.GeneratedTemplateId!; + return { + migrateJson: migrateJson, + resources: resources, + templateId: templateId, + }; +} + +/** + * Builds a CloudFormation sdk client for making requests with the CFN template generator. + * + * @param sdkProvider The sdk provider for making CloudFormation calls + * @param environment The account and region where the stack is deployed + * @returns A CloudFormation sdk client + */ +export async function buildCfnClient(sdkProvider: SdkProvider, environment: Environment) { + const cloudFormationClient = (await sdkProvider.forEnvironment(environment, Mode.ForReading)).sdk.cloudFormation(); + + cloudFormationClient.config.customUserAgent = 'cdk-migrate'; + + return cloudFormationClient; +} + +/** + * Appends a list of warnings to a readme file. + * + * @param filepath The path to the readme file + * @param resources A list of resources to append warnings for + */ +export function appendWarningsToReadme(filepath: string, resources: CloudFormation.ResourceDetails) { + const readme = fs.readFileSync(filepath, 'utf8'); + const lines = readme.split('\n'); + const index = lines.findIndex((line) => line.trim() === 'Enjoy!'); + let linesToAdd = ['\n## Warnings']; + linesToAdd.push('### Write-only properties'); + linesToAdd.push("Write-only properties are resource property values that can be written to but can't be read by AWS CloudFormation or CDK Migrate. For more information, see [IaC generator and write-only properties](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/generate-IaC-write-only-properties.html)."); + linesToAdd.push('\n'); + linesToAdd.push('Write-only properties discovered during migration are organized here by resource ID and categorized by write-only property type. Resolve write-only properties by providing property values in your CDK app. For guidance, see [Resolve write-only properties](https://docs.aws.amazon.com/cdk/v2/guide/migrate.html#migrate-resources-writeonly).'); + for (const resource of resources) { + if (resource.Warnings && resource.Warnings.length > 0) { + linesToAdd.push(`### ${resource.LogicalResourceId}`); + for (const warning of resource.Warnings) { + linesToAdd.push(`- **${warning.Type}**: `); + for (const property of warning.Properties!) { + linesToAdd.push(` - ${property.PropertyPath}: ${property.Description}`); + } + } + } + } + lines.splice(index, 0, ...linesToAdd); + fs.writeFileSync(filepath, lines.join('\n')); +} + +/** + * takes a list of resources and returns a list of unique resources based on the resource type and logical resource id. + * + * @param resources A list of resources to deduplicate + * @returns A list of unique resources + */ +function deduplicateResources(resources: CloudFormation.ResourceDetails) { + let uniqueResources: {[key: string]: CloudFormation.ResourceDetail} = {}; + + for (const resource of resources) { + const key = Object.keys(resource.ResourceIdentifier!)[0]; + + // Creating our unique identifier using the resource type, the key, and the value of the resource identifier + // The resource identifier is a combination of a key value pair defined by a resource's schema, and the resource type of the resource. + const uniqueIdentifer = `${resource.ResourceType}:${key}:${resource.ResourceIdentifier![key]}`; + uniqueResources[uniqueIdentifer] = resource; + } + + return Object.values(uniqueResources); +}; + +/** + * Class for making CloudFormation template generator calls + */ +export class CfnTemplateGeneratorProvider { + private cfn: CloudFormation; + constructor(cfn: CloudFormation) { + this.cfn = cfn; + } + + async checkForResourceScan(resourceScanSummaries: CloudFormation.ResourceScanSummaries | undefined, + options: GenerateTemplateOptions, clientRequestToken: string) { + + if (!resourceScanSummaries || resourceScanSummaries.length === 0) { + if (options.fromScan === FromScan.MOST_RECENT) { + throw new Error('No scans found. Please either start a new scan with the `--from-scan` new or do not specify a `--from-scan` option.'); + } else { + print('No scans found. Initiating a new resource scan.'); + await this.startResourceScan(clientRequestToken); + } + } + } + + /** + * Retrieves a tokenized list of resources and their associated scan. If a token is present the function + * will loop through all pages and combine them into a single list of ScannedRelatedResources + * + * @param scanId scan id for the to list resources for + * @param resources A list of resources to find related resources for + */ + async getResourceScanRelatedResources(scanId: string, resources: CloudFormation.ScannedResources ) + : Promise { + let relatedResourceList = resources; + + // break the list of resources into chunks of 100 to avoid hitting the 100 resource limit + for (const chunk of chunks(resources, 100)) { + // get the first page of related resources + const res = await this.cfn.listResourceScanRelatedResources({ + ResourceScanId: scanId, + Resources: chunk, + }).promise(); + + // add the first page to the list + relatedResourceList.push(...(res.RelatedResources ?? [])); + let nextToken = res.NextToken; + + // if there are more pages, cycle through them and add them to the list before moving on to the next chunk + while (nextToken) { + const nextRelatedResources = await this.cfn.listResourceScanRelatedResources({ + ResourceScanId: scanId, + Resources: resourceIdentifiers(resources), + NextToken: nextToken, + }).promise(); + nextToken = nextRelatedResources.NextToken; + relatedResourceList.push(...(nextRelatedResources.RelatedResources ?? [])); + } + } + + relatedResourceList = deduplicateResources(relatedResourceList); + + // prune the managedbystack flag off of them again. + return process.env.MIGRATE_INTEG_TEST ? resourceIdentifiers(relatedResourceList) : resourceIdentifiers(excludeManaged(relatedResourceList)) ; + } + + /** + * Kicks off a scan of a customers account, returning the scan id. A scan can take + * 10 minutes or longer to complete. However this will return a scan id as soon as + * the scan has begun. + * + * @returns A string representing the scan id + */ + async startResourceScan(requestToken: string ) { + return (await this.cfn.startResourceScan({ + ClientRequestToken: requestToken, + }).promise()).ResourceScanId; + } + + /** + * Gets the most recent scans a customer has completed + * + * @returns a list of resource scan summaries + */ + async listResourceScans() { + + return this.cfn.listResourceScans().promise(); + } + + /** + * Retrieves a tokenized list of resources from a resource scan. If a token is present, this function + * will loop through all pages and combine them into a single list of ScannedResources. + * Additionally will apply any filters provided by the customer. + * + * @param scanId scan id for the to list resources for + * @param filters a string of filters in the format of key1=value1,key2=value2 + * @returns a combined list of all resources from the scan + */ + async listResourceScanResources(scanId: string, filters: string[] = [] ) + : Promise { + + let resourceList: CloudFormation.ScannedResources = []; + let resourceScanInputs: CloudFormation.ListResourceScanResourcesInput; + + if (filters.length > 0) { + print('Applying filters to resource scan.'); + for (const filter of filters) { + const filterList = parseFilters(filter); + resourceScanInputs = { + ResourceScanId: scanId, + ResourceIdentifier: filterList[FilterType.RESOURCE_IDENTIFIER], + ResourceTypePrefix: filterList[FilterType.RESOURCE_TYPE_PREFIX], + TagKey: filterList[FilterType.TAG_KEY], + TagValue: filterList[FilterType.TAG_VALUE], + }; + const resources = await this.cfn.listResourceScanResources(resourceScanInputs).promise(); + resourceList = resourceList.concat(resources.Resources ?? []); + let nextToken = resources.NextToken; + + // cycle through the pages adding all resources to the list until we run out of pages + while (nextToken) { + resourceScanInputs.NextToken = nextToken; + const nextResources = await this.cfn.listResourceScanResources(resourceScanInputs).promise(); + nextToken = nextResources.NextToken; + resourceList = resourceList!.concat(nextResources.Resources ?? []); + } + } + } else { + print('No filters provided. Retrieving all resources from scan.'); + resourceScanInputs = { + ResourceScanId: scanId, + }; + const resources = await this.cfn.listResourceScanResources(resourceScanInputs).promise(); + resourceList = resourceList!.concat(resources.Resources ?? []); + let nextToken = resources.NextToken; + + // cycle through the pages adding all resources to the list until we run out of pages + while (nextToken) { + resourceScanInputs.NextToken = nextToken; + const nextResources = await this.cfn.listResourceScanResources(resourceScanInputs).promise(); + nextToken = nextResources.NextToken; + resourceList = resourceList!.concat(nextResources.Resources ?? []); + } + } + if (resourceList.length === 0) { + throw new Error(`No resources found with filters ${filters.join(' ')}. Please try again with different filters.`); + } + resourceList = deduplicateResources(resourceList); + + return process.env.MIGRATE_INTEG_TEST ? resourceIdentifiers(resourceList) : resourceIdentifiers(excludeManaged(resourceList)); + } + + /** + * Retrieves information about a resource scan. + * + * @param scanId scan id for the to list resources for + * @returns information about the scan + */ + async describeResourceScan(scanId: string ): + Promise { + + return this.cfn.describeResourceScan({ + ResourceScanId: scanId, + }).promise(); + } + + /** + * Describes the current status of the template being generated. + * + * @param templateId A string representing the template id + * @returns DescribeGeneratedTemplateOutput an object containing the template status and results + */ + async describeGeneratedTemplate(templateId: string ): + Promise { + + const generatedTemplate = await this.cfn.describeGeneratedTemplate({ + GeneratedTemplateName: templateId, + }).promise(); + + if (generatedTemplate.Status == ScanStatus.FAILED) { + throw new Error(generatedTemplate.StatusReason); + } + + return generatedTemplate; + } + + /** + * Retrieves a completed generated cloudformation template from the template generator. + * + * @param templateId A string representing the template id + * @param cloudFormation The CloudFormation sdk client to use + * @returns DescribeGeneratedTemplateOutput an object containing the template status and body + */ + async getGeneratedTemplate(templateId: string ): + Promise { + + return this.cfn.getGeneratedTemplate({ + GeneratedTemplateName: templateId, + }).promise(); + } + + /** + * Kicks off a template generation for a set of resources. + * + * @param stackName The name of the stack + * @param resources A list of resources to generate the template from + * @returns CreateGeneratedTemplateOutput an object containing the template arn to query on later + */ + async createGeneratedTemplate(stackName: string, resources: CloudFormation.ResourceDefinitions ) { + + const createTemplateOutput = await this.cfn.createGeneratedTemplate({ + Resources: resources, + GeneratedTemplateName: stackName, + }).promise(); + + if (createTemplateOutput.GeneratedTemplateId === undefined) { + throw new Error('CreateGeneratedTemplate failed to retrun an Arn.'); + } + return createTemplateOutput; + } + + /** + * Deletes a generated template from the template generator. + * + * @param templateArn The arn of the template to delete + * @returns A promise that resolves when the template has been deleted + */ + async deleteGeneratedTemplate(templateArn: string): Promise { + await this.cfn.deleteGeneratedTemplate({ + GeneratedTemplateName: templateArn, + }).promise(); + } +} + +/** + * The possible ways to choose a scan to generate a CDK application from + */ +export enum FromScan { + /** + * Initiate a new resource scan to build the CDK application from. + */ + NEW, + + /** + * Use the last successful scan to build the CDK application from. Will fail if no scan is found. + */ + MOST_RECENT, + + /** + * Starts a scan if none exists, otherwise uses the most recent successful scan to build the CDK application from. + */ + DEFAULT, +} + +/** + * Interface for the options object passed to the generateTemplate function + * + * @param stackName The name of the stack + * @param filters A list of filters to apply to the scan + * @param fromScan An enum value specifying whether a new scan should be started or the most recent successful scan should be used + * @param sdkProvider The sdk provider for making CloudFormation calls + * @param environment The account and region where the stack is deployed + */ +export interface GenerateTemplateOptions { + stackName: string; + filters?: string[]; + fromScan?: FromScan; + sdkProvider: SdkProvider; + environment: Environment; +} + +/** + * Interface for the output of the generateTemplate function + * + * @param migrateJson The generated Migrate.json file + * @param resources The generated template + */ +export interface GenerateTemplateOutput { + migrateJson: MigrateJsonFormat; + resources?: CloudFormation.ResourceDetails; + templateId?: string; +} + +/** + * Interface defining the format of the generated Migrate.json file + * + * @param TemplateBody The generated template + * @param Source The source of the template + * @param Resources A list of resources that were used to generate the template + */ +export interface MigrateJsonFormat { + templateBody: string; + source: string; + resources?: GeneratedResourceImportIdentifier[]; +} + +/** + * Interface representing the format of a resource identifier required for resource import + * + * @param ResourceType The type of resource + * @param LogicalResourceId The logical id of the resource + * @param ResourceIdentifier The resource identifier of the resource + */ +export interface GeneratedResourceImportIdentifier { + // cdk deploy expects the migrate.json resource identifiers to be PascalCase, not camelCase. + ResourceType: string; + LogicalResourceId: string; + ResourceIdentifier: CloudFormation.ResourceIdentifierProperties; } diff --git a/packages/aws-cdk/test/cdk-toolkit.test.ts b/packages/aws-cdk/test/cdk-toolkit.test.ts index 48501295947a0..1a6f55ef63c52 100644 --- a/packages/aws-cdk/test/cdk-toolkit.test.ts +++ b/packages/aws-cdk/test/cdk-toolkit.test.ts @@ -867,12 +867,6 @@ describe('synth', () => { const autoscalingTemplatePath = path.join(...templatePath, 'autoscaling-template.yml'); const s3TemplatePath = path.join(...templatePath, 's3-template.json'); - test('migrate fails when neither --from-path or --from-stack are provided', async () => { - const toolkit = defaultToolkitSetup(); - await expect(() => toolkit.migrate({ stackName: 'no-source' })).rejects.toThrow('Either `--from-path` or `--from-stack` must be used to provide the source of the CloudFormation template.'); - expect(stderrMock.mock.calls[1][0]).toContain(' ❌ Migrate failed for `no-source`: Either `--from-path` or `--from-stack` must be used to provide the source of the CloudFormation template.'); - }); - test('migrate fails when both --from-path and --from-stack are provided', async () => { const toolkit = defaultToolkitSetup(); await expect(() => toolkit.migrate({ diff --git a/packages/aws-cdk/test/commands/migrate.test.ts b/packages/aws-cdk/test/commands/migrate.test.ts index 342f513fa46a4..ed0cef6bd1b70 100644 --- a/packages/aws-cdk/test/commands/migrate.test.ts +++ b/packages/aws-cdk/test/commands/migrate.test.ts @@ -2,8 +2,9 @@ import { exec as _exec } from 'child_process'; import * as os from 'os'; import * as path from 'path'; import { promisify } from 'util'; +import { CloudFormation } from 'aws-sdk'; import * as fs from 'fs-extra'; -import { generateCdkApp, generateStack, readFromPath, readFromStack, setEnvironment, validateSourceOptions } from '../../lib/commands/migrate'; +import { generateCdkApp, generateStack, readFromPath, readFromStack, setEnvironment, parseSourceOptions, generateTemplate, TemplateSourceOptions, GenerateTemplateOptions, FromScan } from '../../lib/commands/migrate'; import { MockSdkProvider, MockedObject, SyncHandlerSubsetOf } from '../util/mock-sdk'; const exec = promisify(_exec); @@ -19,6 +20,7 @@ describe('Migrate Function Tests', () => { const stackPath = [...testResourcePath, 'stacks']; const validTemplatePath = path.join(...templatePath, 's3-template.json'); + const emptyTemplatePath = path.join(...templatePath, 'empty-template.yml'); const validTemplate = readFromPath(validTemplatePath)!; beforeEach(async () => { @@ -29,28 +31,28 @@ describe('Migrate Function Tests', () => { sdkProvider.stubCloudFormation(cfnMocks as any); }); - test('validateSourceOptions throws if both --from-path and --from-stack is provided', () => { - expect(() => validateSourceOptions('any-value', true)).toThrowError('Only one of `--from-path` or `--from-stack` may be provided.'); + test('parseSourceOptions throws if both --from-path and --from-stack is provided', () => { + expect(() => parseSourceOptions('any-value', true, 'my-awesome-stack')).toThrowError('Only one of `--from-path` or `--from-stack` may be provided.'); }); - test('validateSourceOptions throws if neither --from-path or --from-stack is provided', () => { - expect(() => validateSourceOptions(undefined, undefined)).toThrowError('Either `--from-path` or `--from-stack` must be used to provide the source of the CloudFormation template.'); + test('parseSourceOptions returns from-scan when neither --from-path or --from-stack are provided', () => { + expect(parseSourceOptions(undefined, undefined, 'my-stack-name')).toStrictEqual({ source: TemplateSourceOptions.SCAN }); }); - test('validateSourceOptions does not throw when only --from-path is supplied', () => { - expect(() => validateSourceOptions('any-value', false)).not.toThrow(); + test('parseSourceOptions does not throw when only --from-path is supplied', () => { + expect(parseSourceOptions('my-file-path', undefined, 'my-stack-name')).toStrictEqual({ source: TemplateSourceOptions.PATH, templatePath: 'my-file-path' }); }); - test('validateSourceOptions does now throw when only --from-stack is provided', () => { - expect(() => validateSourceOptions(undefined, true)).not.toThrow(); + test('parseSourceOptions does now throw when only --from-stack is provided', () => { + expect(parseSourceOptions(undefined, true, 'my-stack-name')).toStrictEqual({ source: TemplateSourceOptions.STACK, stackName: 'my-stack-name' }); }); test('readFromPath produces a string representation of the template at a given path', () => { expect(readFromPath(validTemplatePath)).toEqual(fs.readFileSync(validTemplatePath, 'utf8')); }); - test('readFromPath returns undefined when template file is not provided', () => { - expect(readFromPath()).toEqual(undefined); + test('readFromPath throws error when template file is empty', () => { + expect(() => readFromPath(emptyTemplatePath)).toThrow(`\'${emptyTemplatePath}\' is an empty file.`); }); test('readFromPath throws error when template file does not exist at a given path', () => { @@ -264,4 +266,221 @@ async function withTempDir(cb: (dir: string) => void | Promise) { } finally { await fs.remove(tmpDir); } -} \ No newline at end of file +} + +describe('generateTemplate', () => { + let sdkProvider: MockSdkProvider; + let cloudFormationMocks: MockedObject>; + const sampleResource = { + ResourceType: 'AWS::S3::Bucket', + ManagedByStack: true, + ResourceIdentifier: { 'my-key': 'my-bucket' }, + LogicalResourceId: 'my-bucket', + }; + const sampleResource2 = { + ResourceType: 'AWS::EC2::Instance', + ResourceIdentifier: { + instanceId: 'i-1234567890abcdef0', + }, + LogicalResourceId: 'my-ec2-instance', + ManagedByStack: true, + }; + const stackName = 'my-stack'; + const environment = setEnvironment('123456789012', 'us-east-1'); + const scanId = 'fake-scan-id'; + const defaultExpectedResult = { + migrateJson: { + resources: [ + { + LogicalResourceId: 'my-bucket', + ResourceIdentifier: { 'my-key': 'my-bucket' }, + ResourceType: 'AWS::S3::Bucket', + }, + { + LogicalResourceId: 'my-ec2-instance', + ResourceIdentifier: { instanceId: 'i-1234567890abcdef0' }, + ResourceType: 'AWS::EC2::Instance', + }, + ], + source: 'template-arn', + templateBody: 'template-body', + }, + resources: [ + { + LogicalResourceId: 'my-bucket', + ManagedByStack: true, + ResourceIdentifier: { + 'my-key': 'my-bucket', + }, + ResourceType: 'AWS::S3::Bucket', + }, + { + LogicalResourceId: 'my-ec2-instance', + ManagedByStack: true, + ResourceIdentifier: { + instanceId: 'i-1234567890abcdef0', + }, + ResourceType: 'AWS::EC2::Instance', + }, + ], + }; + + beforeEach(() => { + sdkProvider = new MockSdkProvider(); + cloudFormationMocks = { + startResourceScan: jest.fn(), + listResourceScans: jest.fn(), + describeResourceScan: jest.fn(), + listResourceScanResources: jest.fn(), + createGeneratedTemplate: jest.fn(), + describeGeneratedTemplate: jest.fn(), + getGeneratedTemplate: jest.fn(), + listResourceScanRelatedResources: jest.fn(), + config: { + getCredentials: jest.fn(), + getToken: jest.fn(), + loadFromPath: jest.fn(), + update: jest.fn(), + getPromisesDependency: jest.fn(), + setPromisesDependency: jest.fn(), + customUserAgent: 'cdk-migrate', + }, + }; + + sdkProvider.stubCloudFormation(cloudFormationMocks as any); + }); + + test('generateTemplate successfully generates template with a new scan', async () => { + const resourceScanSummaries = [{ ResourceScanId: scanId, Status: 'COMPLETE', PercentageCompleted: 100 }]; + + cloudFormationMocks.startResourceScan!.mockReturnValue({ ResourceScanId: scanId }); + cloudFormationMocks.listResourceScans!.mockReturnValue({ ResourceScanSummaries: resourceScanSummaries }); + cloudFormationMocks.describeResourceScan!.mockReturnValue({ Status: 'COMPLETED' }); + cloudFormationMocks.listResourceScanResources!.mockReturnValue({ Resources: [sampleResource2] }); + cloudFormationMocks.createGeneratedTemplate!.mockReturnValue({ GeneratedTemplateId: 'template-arn' }); + cloudFormationMocks.describeGeneratedTemplate!.mockReturnValue({ Status: 'COMPLETE', Resources: [sampleResource, sampleResource2] }); + cloudFormationMocks.getGeneratedTemplate!.mockReturnValue({ TemplateBody: 'template-body' }); + cloudFormationMocks.listResourceScanRelatedResources!.mockReturnValue({ RelatedResources: [sampleResource] }); + + const opts: GenerateTemplateOptions = { + stackName: stackName, + filters: [], + fromScan: FromScan.NEW, + sdkProvider: sdkProvider, + environment: environment, + }; + + const template = await generateTemplate(opts); + expect(template).toEqual(defaultExpectedResult); + }); + + test('generateTemplate successfully defaults to latest scan instead of starting a new one', async () => { + const resourceScanSummaryComplete = [{ ResourceScanId: scanId, Status: 'COMPLETE', PercentageCompleted: 100 }]; + const resourceScanSummaryInProgress = [{ ResourceScanId: scanId, Status: 'IN_PROGRESS', PercentageCompleted: 50 }]; + + cloudFormationMocks.startResourceScan!.mockImplementation(() => { throw new Error('No >:('); }); + cloudFormationMocks.listResourceScans!.mockReturnValueOnce({ ResourceScanSummaries: resourceScanSummaryInProgress }); + cloudFormationMocks.listResourceScans!.mockReturnValueOnce({ ResourceScanSummaries: resourceScanSummaryComplete }); + cloudFormationMocks.describeResourceScan!.mockReturnValue({ Status: 'COMPLETED' }); + cloudFormationMocks.listResourceScanResources!.mockReturnValue({ Resources: [sampleResource2] }); + cloudFormationMocks.createGeneratedTemplate!.mockReturnValue({ GeneratedTemplateId: 'template-arn' }); + cloudFormationMocks.describeGeneratedTemplate!.mockReturnValue({ Status: 'COMPLETE', Resources: [sampleResource, sampleResource2] }); + cloudFormationMocks.getGeneratedTemplate!.mockReturnValue({ TemplateBody: 'template-body' }); + cloudFormationMocks.listResourceScanRelatedResources!.mockReturnValue({ RelatedResources: [sampleResource2] }); + + const opts = { + stackName: stackName, + filters: [], + newScan: true, + sdkProvider: sdkProvider, + environment: environment, + }; + const template = await generateTemplate(opts); + expect(template).toEqual(defaultExpectedResult); + }); + + test('generateTemplate throws an error when from-scan most-recent is passed but no scans are found.', async () => { + const resourceScanSummaries: CloudFormation.ResourceScanSummary[] = []; + + cloudFormationMocks.listResourceScans!.mockReturnValue({ ResourceScanSummaries: resourceScanSummaries }); + + const opts: GenerateTemplateOptions = { + stackName: stackName, + filters: [], + fromScan: FromScan.MOST_RECENT, + sdkProvider: sdkProvider, + environment: environment, + }; + await expect(generateTemplate(opts)).rejects.toThrow('No scans found. Please either start a new scan with the `--from-scan` new or do not specify a `--from-scan` option.'); + }); + + test('generateTemplate throws an error when an invalid key is passed in the filters', async () => { + const resourceScanSummaries = [{ ResourceScanId: scanId, Status: 'COMPLETE', PercentageCompleted: 100 }]; + + cloudFormationMocks.startResourceScan!.mockReturnValue({ ResourceScanId: scanId }); + cloudFormationMocks.listResourceScans!.mockReturnValue({ ResourceScanSummaries: resourceScanSummaries }); + cloudFormationMocks.describeResourceScan!.mockReturnValue({ Status: 'COMPLETED' }); + cloudFormationMocks.listResourceScanResources!.mockReturnValue({ Resources: [sampleResource2] }); + cloudFormationMocks.createGeneratedTemplate!.mockReturnValue({ GeneratedTemplateId: 'template-arn' }); + cloudFormationMocks.describeGeneratedTemplate!.mockReturnValue({ Status: 'COMPLETE' }); + cloudFormationMocks.getGeneratedTemplate!.mockReturnValue({ TemplateBody: 'template-body' }); + cloudFormationMocks.listResourceScanRelatedResources!.mockReturnValue({ RelatedResources: [sampleResource] }); + + const opts: GenerateTemplateOptions = { + stackName: stackName, + filters: ['invalid-key=invalid-value'], + fromScan: FromScan.MOST_RECENT, + sdkProvider: sdkProvider, + environment: environment, + }; + await expect(generateTemplate(opts)).rejects.toThrow('Invalid filter: invalid-key'); + }); + + test('generateTemplate defaults to starting a new scan when no options are provided', async () => { + const resourceScanSummaryComplete = [{ ResourceScanId: scanId, Status: 'COMPLETE', PercentageCompleted: 100 }]; + const resourceScanSummaryInProgress = [{ ResourceScanId: scanId, Status: 'IN_PROGRESS', PercentageCompleted: 50 }]; + + cloudFormationMocks.startResourceScan!.mockReturnValue({ ResourceScanId: scanId }); + cloudFormationMocks.listResourceScans!.mockReturnValueOnce({ ResourceScanSummaries: undefined }); + cloudFormationMocks.listResourceScans!.mockReturnValueOnce({ ResourceScanSummaries: resourceScanSummaryInProgress }); + cloudFormationMocks.listResourceScans!.mockReturnValueOnce({ ResourceScanSummaries: resourceScanSummaryComplete }); + cloudFormationMocks.describeResourceScan!.mockReturnValue({ Status: 'COMPLETED' }); + cloudFormationMocks.listResourceScanResources!.mockReturnValue({ Resources: [sampleResource2] }); + cloudFormationMocks.createGeneratedTemplate!.mockReturnValue({ GeneratedTemplateId: 'template-arn' }); + cloudFormationMocks.describeGeneratedTemplate!.mockReturnValue({ Status: 'COMPLETE', Resources: [sampleResource, sampleResource2] }); + cloudFormationMocks.getGeneratedTemplate!.mockReturnValue({ TemplateBody: 'template-body' }); + cloudFormationMocks.listResourceScanRelatedResources!.mockReturnValue({ RelatedResources: [sampleResource] }); + + const opts: GenerateTemplateOptions = { + stackName: stackName, + sdkProvider: sdkProvider, + environment: environment, + }; + const template = await generateTemplate(opts); + expect(template).toEqual(defaultExpectedResult); + expect(cloudFormationMocks.startResourceScan).toHaveBeenCalled(); + }); + + test('generateTemplate successfully generates templates with valid filter options', async () => { + const resourceScanSummaries = [{ ResourceScanId: scanId, Status: 'COMPLETE', PercentageCompleted: 100 }]; + + cloudFormationMocks.startResourceScan!.mockReturnValue({ ResourceScanId: scanId }); + cloudFormationMocks.listResourceScans!.mockReturnValue({ ResourceScanSummaries: resourceScanSummaries }); + cloudFormationMocks.describeResourceScan!.mockReturnValue({ Status: 'COMPLETED' }); + cloudFormationMocks.listResourceScanResources!.mockReturnValue({ Resources: [sampleResource2] }); + cloudFormationMocks.createGeneratedTemplate!.mockReturnValue({ GeneratedTemplateId: 'template-arn' }); + cloudFormationMocks.describeGeneratedTemplate!.mockReturnValue({ Status: 'COMPLETE', Resources: [sampleResource, sampleResource2] }); + cloudFormationMocks.getGeneratedTemplate!.mockReturnValue({ TemplateBody: 'template-body' }); + cloudFormationMocks.listResourceScanRelatedResources!.mockReturnValue({ RelatedResources: [sampleResource] }); + + const opts: GenerateTemplateOptions = { + stackName: stackName, + filters: ['type=AWS::S3::Bucket,identifier={"my-key":"my-bucket"}', 'type=AWS::EC2::Instance'], + sdkProvider: sdkProvider, + environment: environment, + }; + const template = await generateTemplate(opts); + expect(template).toEqual(defaultExpectedResult); + }); + +}); \ No newline at end of file diff --git a/packages/aws-cdk/test/commands/test-resources/templates/empty-template.yml b/packages/aws-cdk/test/commands/test-resources/templates/empty-template.yml new file mode 100644 index 0000000000000..e69de29bb2d1d