Skip to content

Commit

Permalink
chore: add --from-stack to cdk migrate command (#27155)
Browse files Browse the repository at this point in the history
This change includes a refactor to migrate to align more closely with how the other cli commands are implemented.

`--from-stack` also allows users to set the account and region to get the stack template from.

Not included in this PR:
1. Integ tests - this PR has been manually tested and automated tests will be added in a subsequent PR.
2. Go support - there are still some bugs to work out with go (in cdk-from-cfn) so implementation for it will be added after that's been worked out.
3. The option to compress the app to a zip file

----

*By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license*
  • Loading branch information
TheRealAmazonKendra authored Sep 15, 2023
1 parent 0634c68 commit 452b868
Show file tree
Hide file tree
Showing 12 changed files with 1,505 additions and 39 deletions.
74 changes: 74 additions & 0 deletions packages/aws-cdk/lib/cdk-toolkit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { HotswapMode } from './api/hotswap/common';
import { findCloudWatchLogGroups } from './api/logs/find-cloudwatch-logs';
import { CloudWatchLogEventMonitor } from './api/logs/logs-monitor';
import { StackActivityProgress } from './api/util/cloudformation/stack-activity-monitor';
import { generateCdkApp, generateStack, readFromPath, readFromStack, setEnvironment, validateSourceOptions } from './commands/migrate';
import { printSecurityDiff, printStackDiff, RequireApproval } from './diff';
import { ResourceImporter } from './import';
import { data, debug, error, highlight, print, success, warning, withCorkedLogging } from './logging';
Expand Down Expand Up @@ -698,6 +699,28 @@ export class CdkToolkit {
}));
}

/**
* Migrates a CloudFormation stack/template to a CDK app
* @param options Options for CDK app creation
*/
public async migrate(options: MigrateOptions): Promise<void> {
warning('This is an experimental feature. We make no guarantees about the outcome or stability of the functionality.');
const language = options.language ?? 'typescript';

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);
success(' ⏳ Generating CDK app for %s...', chalk.blue(options.stackName));
await generateCdkApp(options.stackName, stack!, language, options.outputPath);
} catch (e) {
error(' ❌ Migrate failed for `%s`: %s', chalk.blue(options.stackName), (e as Error).message);
throw e;
}

}

private async selectStacksForList(patterns: string[]) {
const assembly = await this.assembly();
const stacks = await assembly.selectStacks({ patterns }, { defaultBehavior: DefaultSelection.AllStacks });
Expand Down Expand Up @@ -1172,6 +1195,57 @@ export interface DestroyOptions {
readonly ci?: boolean;
}

export interface MigrateOptions {
/**
* The name assigned to the generated stack. This is also used to get
* the stack from the user's account if `--from-stack` is used.
*/
readonly stackName: string;

/**
* The target language for the generated the CDK app.
*
* @default typescript
*/
readonly language?: string;

/**
* The local path of the template used to generate the CDK app.
*
* @default - Local path is not used for the template source.
*/
readonly fromPath?: string;

/**
* Whether to get the template from an existing CloudFormation stack.
*
* @default false
*/
readonly fromStack?: boolean;

/**
* The output path at which to create the CDK app.
*
* @default - The current directory
*/
readonly outputPath?: string;

/**
* The account from which to retrieve the template of the CloudFormation stack.
*
* @default - Uses the account for the credentials in use by the user.
*/
readonly account?: string;

/**
* The region from which to retrieve the template of the CloudFormation stack.
*
* @default - Uses the default region for the credentials in use by the user.
*/
readonly region?: string;

}

/**
* @returns an array with the tags available in the stack metadata.
*/
Expand Down
10 changes: 8 additions & 2 deletions packages/aws-cdk/lib/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, cliMigrate } from '../lib/commands/migrate';
import { MIGRATE_SUPPORTED_LANGUAGES } 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';
Expand Down Expand Up @@ -274,7 +274,10 @@ async function parseCommandLineArguments(args: string[]) {
.command('migrate', false /* hidden from "cdk --help" */, (yargs: Argv) => yargs
.option('stack-name', { type: 'string', alias: 'n', desc: 'The name assigned to the stack created in the new project. The name of the app will be based off this name as well.', requiresArg: true })
.option('language', { type: 'string', default: 'typescript', alias: 'l', desc: 'The language to be used for the new project', choices: MIGRATE_SUPPORTED_LANGUAGES })
.option('account', { type: 'string', alias: 'a' })
.option('region', { type: 'string' })
.option('from-path', { type: 'string', alias: 'p', desc: 'The path to the CloudFormation template to migrate. Use this for locally stored templates' })
.option('from-stack', { type: 'boolean', alias: 's', desc: 'USe this flag to retrieve the template for an existing CloudFormation stack' })
.option('output-path', { type: 'string', alias: 'o', desc: 'The output path for the migrated cdk app' }),
)
.command('context', 'Manage cached context values', (yargs: Argv) => yargs
Expand Down Expand Up @@ -659,11 +662,14 @@ export async function exec(args: string[], synthesizer?: Synthesizer): Promise<n
return cliInit(args.TEMPLATE, language, undefined, args.generateOnly);
}
case 'migrate':
return cliMigrate({
return cli.migrate({
stackName: args['stack-name'],
fromPath: args['from-path'],
fromStack: args['from-stack'],
language: args.language,
outputPath: args['output-path'],
account: args.account,
region: args.region,
});
case 'version':
return data(version.DISPLAY_VERSION);
Expand Down
125 changes: 88 additions & 37 deletions packages/aws-cdk/lib/commands/migrate.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import * as fs from 'fs';
import * as path from 'path';
import { Environment, UNKNOWN_ACCOUNT, UNKNOWN_REGION } from '@aws-cdk/cx-api';
import * as cdk_from_cfn from 'cdk-from-cfn';
import { cliInit } from '../../lib/init';
import { warning } from '../logging';
import { Mode, SdkProvider } from '../api';

/* eslint-disable @typescript-eslint/no-var-requires */ // Packages don't have @types module
// eslint-disable-next-line @typescript-eslint/no-require-imports
Expand All @@ -13,66 +14,116 @@ const decamelize = require('decamelize');
/** The list of languages supported by the built-in noctilucent binary. */
export const MIGRATE_SUPPORTED_LANGUAGES: readonly string[] = cdk_from_cfn.supported_languages();

export interface CliMigrateOptions {
readonly stackName: string;
readonly language?: string;
readonly fromPath?: string;
readonly outputPath?: string;
}

export async function cliMigrate(options: CliMigrateOptions) {
warning('This is an experimental feature. We make no guarantees about the outcome or stability of the functionality.');

// TODO: Validate stack name

const language = options.language ?? 'typescript';
const outputPath = path.join(options.outputPath ?? process.cwd(), options.stackName);

const generatedStack = generateStack(options, language);
const stackName = decamelize(options.stackName);
/**
* Generates a CDK app from a yaml or json template.
*
* @param stackName The name to assign to the stack in the generated app
* @param stack The yaml or json template for the stack
* @param language The language to generate the CDK app in
* @param outputPath The path at which to generate the CDK app
*/
export async function generateCdkApp(stackName: string, stack: string, language: string, outputPath?: string) {
const resolvedOutputPath = path.join(outputPath ?? process.cwd(), stackName);
const formattedStackName = decamelize(stackName);

try {
fs.rmSync(outputPath, { recursive: true, force: true });
fs.mkdirSync(outputPath, { recursive: true });
await cliInit('app', language, true, false, outputPath, options.stackName);
fs.rmSync(resolvedOutputPath, { recursive: true, force: true });
fs.mkdirSync(resolvedOutputPath, { recursive: true });
await cliInit('app', language, true, false, resolvedOutputPath, stackName);

let stackFileName: string;
switch (language) {
case 'typescript':
stackFileName = `${outputPath}/lib/${stackName}-stack.ts`;
stackFileName = `${resolvedOutputPath}/lib/${formattedStackName}-stack.ts`;
break;
case 'java':
stackFileName = `${outputPath}/src/main/java/com/myorg/${camelCase(stackName, { pascalCase: true })}Stack.java`;
stackFileName = `${resolvedOutputPath}/src/main/java/com/myorg/${camelCase(formattedStackName, { pascalCase: true })}Stack.java`;
break;
case 'python':
stackFileName = `${outputPath}/${stackName.replace(/-/g, '_')}/${stackName.replace(/-/g, '_')}_stack.py`;
stackFileName = `${resolvedOutputPath}/${formattedStackName.replace(/-/g, '_')}/${formattedStackName.replace(/-/g, '_')}_stack.py`;
break;
case 'csharp':
stackFileName = `${outputPath}/src/${camelCase(stackName, { pascalCase: true })}/${camelCase(stackName, { pascalCase: true })}Stack.cs`;
stackFileName = `${resolvedOutputPath}/src/${camelCase(formattedStackName, { pascalCase: true })}/${camelCase(formattedStackName, { pascalCase: true })}Stack.cs`;
break;
// TODO: Add Go support
default:
throw new Error(`${language} is not supported by CDK Migrate. Please choose from: ${MIGRATE_SUPPORTED_LANGUAGES.join(', ')}`);
}
fs.writeFileSync(stackFileName!, generatedStack);
fs.writeFileSync(stackFileName, stack);
} catch (error) {
fs.rmSync(outputPath, { recursive: true, force: true });
fs.rmSync(resolvedOutputPath, { recursive: true, force: true });
throw error;
}
}

/**
* Generates a CDK stack file.
* @param template The template to translate into a CDK stack
* @param stackName The name to assign to the stack
* @param language The language to generate the stack in
* @returns A string representation of a CDK stack file
*/
export function generateStack(template: string, stackName: string, language: string) {
try {
const formattedStackName = `${camelCase(decamelize(stackName), { pascalCase: true })}Stack`;
return cdk_from_cfn.transmute(template, language, formattedStackName);
} catch (e) {
throw new Error(`stack generation failed due to error '${(e as Error).message}'`);
}
}

function generateStack(options: CliMigrateOptions, language: string) {
const stackName = `${camelCase(decamelize(options.stackName), { pascalCase: true })}Stack`;
// We will add other options here in a future change.
if (options.fromPath) {
return fromPath(stackName, options.fromPath, language);
/**
* Reads and returns a stack template from a local path.
*
* @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 {
try {
return inputPath ? fs.readFileSync(inputPath, 'utf8') : undefined;
} catch (e) {
throw new Error(`'${inputPath}' is not a valid path.`);
}
// TODO: replace with actual output for other options.
return '';

}

function fromPath(stackName: string, inputPath: string, language: string): string {
const templateFile = fs.readFileSync(inputPath, 'utf8');
return cdk_from_cfn.transmute(templateFile, language, stackName);
/**
* Reads and returns a stack template from a deployed CloudFormation stack.
*
* @param stackName The name of the stack
* @param sdkProvider The sdk provider for making CloudFormation calls
* @param environment The account and region where the stack is deployed
* @returns A string representation of the template if present, otherwise undefined
*/
export async function readFromStack(stackName: string, sdkProvider: SdkProvider, environment: Environment): Promise<string | undefined> {
const cloudFormation = (await sdkProvider.forEnvironment(environment, Mode.ForReading)).sdk.cloudFormation();

return (await cloudFormation.getTemplate({
StackName: stackName,
}).promise()).TemplateBody;
}

/**
* Sets the account and region for making CloudFormation calls.
* @param account The account to use
* @param region The region to use
* @returns The environment object
*/
export function setEnvironment(account?: string, region?: string): Environment {
return { account: account ?? UNKNOWN_ACCOUNT, region: region ?? UNKNOWN_REGION, name: 'cdk-migrate-env' };
}

/**
* 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) {
if (fromPath && fromStack) {
throw new Error('Only one of `--from-path` or `--from-stack` may be provided.');
}

if (!fromPath && !fromStack) {
throw new Error('Either `--from-path` or `--from-stack` must be used to provide the source of the CloudFormation template.');
}
}
Loading

0 comments on commit 452b868

Please sign in to comment.