Skip to content

Commit 3d6d042

Browse files
chore: add --compress flag to cdk migrate (#27184)
This also updates some of the aliases used because they clashed with other flags. We'll just remove these. The archive file is shamelessly ripped off from the cdk-assets package. ---- *By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license*
1 parent e15d0c0 commit 3d6d042

File tree

7 files changed

+249
-42
lines changed

7 files changed

+249
-42
lines changed

packages/aws-cdk/lib/cdk-toolkit.ts

+7-1
Original file line numberDiff line numberDiff line change
@@ -713,7 +713,7 @@ export class CdkToolkit {
713713
await readFromStack(options.stackName, this.props.sdkProvider, setEnvironment(options.account, options.region));
714714
const stack = generateStack(template!, options.stackName, language);
715715
success(' ⏳ Generating CDK app for %s...', chalk.blue(options.stackName));
716-
await generateCdkApp(options.stackName, stack!, language, options.outputPath);
716+
await generateCdkApp(options.stackName, stack!, language, options.outputPath, options.compress);
717717
} catch (e) {
718718
error(' ❌ Migrate failed for `%s`: %s', chalk.blue(options.stackName), (e as Error).message);
719719
throw e;
@@ -1244,6 +1244,12 @@ export interface MigrateOptions {
12441244
*/
12451245
readonly region?: string;
12461246

1247+
/**
1248+
* Whether to zip the generated cdk app folder.
1249+
*
1250+
* @default false
1251+
*/
1252+
readonly compress?: boolean;
12471253
}
12481254

12491255
/**

packages/aws-cdk/lib/cli.ts

+13-6
Original file line numberDiff line numberDiff line change
@@ -274,11 +274,12 @@ async function parseCommandLineArguments(args: string[]) {
274274
.command('migrate', false /* hidden from "cdk --help" */, (yargs: Argv) => yargs
275275
.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 })
276276
.option('language', { type: 'string', default: 'typescript', alias: 'l', desc: 'The language to be used for the new project', choices: MIGRATE_SUPPORTED_LANGUAGES })
277-
.option('account', { type: 'string', alias: 'a' })
278-
.option('region', { type: 'string' })
279-
.option('from-path', { type: 'string', alias: 'p', desc: 'The path to the CloudFormation template to migrate. Use this for locally stored templates' })
280-
.option('from-stack', { type: 'boolean', alias: 's', desc: 'USe this flag to retrieve the template for an existing CloudFormation stack' })
281-
.option('output-path', { type: 'string', alias: 'o', desc: 'The output path for the migrated cdk app' }),
277+
.option('account', { type: 'string', desc: 'The account to retrieve the CloudFormation stack template from' })
278+
.option('region', { type: 'string', desc: 'The region to retrieve the CloudFormation stack template from' })
279+
.option('from-path', { type: 'string', desc: 'The path to the CloudFormation template to migrate. Use this for locally stored templates' })
280+
.option('from-stack', { type: 'boolean', desc: 'Use this flag to retrieve the template for an existing CloudFormation stack' })
281+
.option('output-path', { type: 'string', desc: 'The output path for the migrated CDK app' })
282+
.option('compress', { type: 'boolean', desc: 'Use this flag to zip the generated CDK app' }),
282283
)
283284
.command('context', 'Manage cached context values', (yargs: Argv) => yargs
284285
.option('reset', { alias: 'e', desc: 'The context key (or its index) to reset', type: 'string', requiresArg: true })
@@ -659,7 +660,12 @@ export async function exec(args: string[], synthesizer?: Synthesizer): Promise<n
659660
if (args.list) {
660661
return printAvailableTemplates(language);
661662
} else {
662-
return cliInit(args.TEMPLATE, language, undefined, args.generateOnly);
663+
return cliInit({
664+
type: args.TEMPLATE,
665+
language,
666+
canUseNetwork: undefined,
667+
generateOnly: args.generateOnly,
668+
});
663669
}
664670
case 'migrate':
665671
return cli.migrate({
@@ -670,6 +676,7 @@ export async function exec(args: string[], synthesizer?: Synthesizer): Promise<n
670676
outputPath: args['output-path'],
671677
account: args.account,
672678
region: args.region,
679+
compress: args.compress,
673680
});
674681
case 'version':
675682
return data(version.DISPLAY_VERSION);

packages/aws-cdk/lib/commands/migrate.ts

+17-5
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,14 @@
1+
/* eslint-disable @typescript-eslint/no-require-imports */
2+
/* eslint-disable @typescript-eslint/no-var-requires */
13
import * as fs from 'fs';
24
import * as path from 'path';
35
import { Environment, UNKNOWN_ACCOUNT, UNKNOWN_REGION } from '@aws-cdk/cx-api';
46
import * as cdk_from_cfn from 'cdk-from-cfn';
57
import { cliInit } from '../../lib/init';
68
import { Mode, SdkProvider } from '../api';
9+
import { zipDirectory } from '../util/archive';
710

8-
/* eslint-disable @typescript-eslint/no-var-requires */ // Packages don't have @types module
9-
// eslint-disable-next-line @typescript-eslint/no-require-imports
1011
const camelCase = require('camelcase');
11-
// eslint-disable-next-line @typescript-eslint/no-require-imports
1212
const decamelize = require('decamelize');
1313

1414
/** The list of languages supported by the built-in noctilucent binary. */
@@ -22,14 +22,22 @@ export const MIGRATE_SUPPORTED_LANGUAGES: readonly string[] = cdk_from_cfn.suppo
2222
* @param language The language to generate the CDK app in
2323
* @param outputPath The path at which to generate the CDK app
2424
*/
25-
export async function generateCdkApp(stackName: string, stack: string, language: string, outputPath?: string) {
25+
export async function generateCdkApp(stackName: string, stack: string, language: string, outputPath?: string, compress?: boolean) {
2626
const resolvedOutputPath = path.join(outputPath ?? process.cwd(), stackName);
2727
const formattedStackName = decamelize(stackName);
2828

2929
try {
3030
fs.rmSync(resolvedOutputPath, { recursive: true, force: true });
3131
fs.mkdirSync(resolvedOutputPath, { recursive: true });
32-
await cliInit('app', language, true, false, resolvedOutputPath, stackName);
32+
const generateOnly = compress;
33+
await cliInit({
34+
type: 'app',
35+
language,
36+
canUseNetwork: true,
37+
generateOnly,
38+
workDir: resolvedOutputPath,
39+
stackName,
40+
});
3341

3442
let stackFileName: string;
3543
switch (language) {
@@ -50,6 +58,10 @@ export async function generateCdkApp(stackName: string, stack: string, language:
5058
throw new Error(`${language} is not supported by CDK Migrate. Please choose from: ${MIGRATE_SUPPORTED_LANGUAGES.join(', ')}`);
5159
}
5260
fs.writeFileSync(stackFileName, stack);
61+
if (compress) {
62+
await zipDirectory(resolvedOutputPath, `${resolvedOutputPath}.zip`);
63+
fs.rmSync(resolvedOutputPath, { recursive: true, force: true });
64+
}
5365
} catch (error) {
5466
fs.rmSync(resolvedOutputPath, { recursive: true, force: true });
5567
throw error;

packages/aws-cdk/lib/init.ts

+20-15
Original file line numberDiff line numberDiff line change
@@ -14,39 +14,44 @@ const camelCase = require('camelcase');
1414
// eslint-disable-next-line @typescript-eslint/no-require-imports
1515
const decamelize = require('decamelize');
1616

17+
export interface CliInitOptions {
18+
readonly type?: string;
19+
readonly language?: string;
20+
readonly canUseNetwork?: boolean;
21+
readonly generateOnly?: boolean;
22+
readonly workDir?: string;
23+
readonly stackName?: string;
24+
}
25+
1726
/**
1827
* Initialize a CDK package in the current directory
1928
*/
20-
export async function cliInit(
21-
type?: string,
22-
language?: string,
23-
canUseNetwork = true,
24-
generateOnly = false,
25-
workDir = process.cwd(),
26-
stackName?: string,
27-
) {
28-
if (!type && !language) {
29+
export async function cliInit(options: CliInitOptions) {
30+
const canUseNetwork = options.canUseNetwork ?? true;
31+
const generateOnly = options.generateOnly ?? false;
32+
const workDir = options.workDir ?? process.cwd();
33+
if (!options.type && !options.language) {
2934
await printAvailableTemplates();
3035
return;
3136
}
3237

33-
type = type || 'default'; // "default" is the default type (and maps to "app")
38+
const type = options.type || 'default'; // "default" is the default type (and maps to "app")
3439

3540
const template = (await availableInitTemplates()).find(t => t.hasName(type!));
3641
if (!template) {
37-
await printAvailableTemplates(language);
42+
await printAvailableTemplates(options.language);
3843
throw new Error(`Unknown init template: ${type}`);
3944
}
40-
if (!language && template.languages.length === 1) {
41-
language = template.languages[0];
45+
if (!options.language && template.languages.length === 1) {
46+
const language = template.languages[0];
4247
warning(`No --language was provided, but '${type}' supports only '${language}', so defaulting to --language=${language}`);
4348
}
44-
if (!language) {
49+
if (!options.language) {
4550
print(`Available languages for ${chalk.green(type)}: ${template.languages.map(l => chalk.blue(l)).join(', ')}`);
4651
throw new Error('No language was selected');
4752
}
4853

49-
await initializeProject(template, language, canUseNetwork, generateOnly, workDir, stackName);
54+
await initializeProject(template, options.language, canUseNetwork, generateOnly, workDir, options.stackName);
5055
}
5156

5257
/**

packages/aws-cdk/lib/util/archive.ts

+90
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
import { error } from 'console';
2+
import { createWriteStream, promises as fs } from 'fs';
3+
import * as path from 'path';
4+
import * as glob from 'glob';
5+
6+
// eslint-disable-next-line @typescript-eslint/no-require-imports
7+
const archiver = require('archiver');
8+
9+
// Adapted from cdk-assets
10+
export async function zipDirectory(directory: string, outputFile: string): Promise<void> {
11+
// We write to a temporary file and rename at the last moment. This is so that if we are
12+
// interrupted during this process, we don't leave a half-finished file in the target location.
13+
const temporaryOutputFile = `${outputFile}.${randomString()}._tmp`;
14+
await writeZipFile(directory, temporaryOutputFile);
15+
await moveIntoPlace(temporaryOutputFile, outputFile);
16+
}
17+
18+
function writeZipFile(directory: string, outputFile: string): Promise<void> {
19+
return new Promise(async (ok, fail) => {
20+
// The below options are needed to support following symlinks when building zip files:
21+
// - nodir: This will prevent symlinks themselves from being copied into the zip.
22+
// - follow: This will follow symlinks and copy the files within.
23+
const globOptions = {
24+
dot: true,
25+
nodir: true,
26+
follow: true,
27+
cwd: directory,
28+
};
29+
const files = glob.sync('**', globOptions); // The output here is already sorted
30+
31+
const output = createWriteStream(outputFile);
32+
33+
const archive = archiver('zip');
34+
archive.on('warning', fail);
35+
archive.on('error', fail);
36+
37+
// archive has been finalized and the output file descriptor has closed, resolve promise
38+
// this has to be done before calling `finalize` since the events may fire immediately after.
39+
// see https://www.npmjs.com/package/archiver
40+
output.once('close', ok);
41+
42+
archive.pipe(output);
43+
44+
// Append files serially to ensure file order
45+
for (const file of files) {
46+
const fullPath = path.resolve(directory, file);
47+
const [data, stat] = await Promise.all([fs.readFile(fullPath), fs.stat(fullPath)]);
48+
archive.append(data, {
49+
name: file,
50+
mode: stat.mode,
51+
});
52+
}
53+
54+
await archive.finalize();
55+
});
56+
}
57+
58+
/**
59+
* Rename the file to the target location, taking into account:
60+
*
61+
* - That we may see EPERM on Windows while an Antivirus scanner still has the
62+
* file open, so retry a couple of times.
63+
* - This same function may be called in parallel and be interrupted at any point.
64+
*/
65+
async function moveIntoPlace(source: string, target: string) {
66+
let delay = 100;
67+
let attempts = 5;
68+
while (true) {
69+
try {
70+
// 'rename' is guaranteed to overwrite an existing target, as long as it is a file (not a directory)
71+
await fs.rename(source, target);
72+
return;
73+
} catch (e: any) {
74+
if (e.code !== 'EPERM' || attempts-- <= 0) {
75+
throw e;
76+
}
77+
error(e.message);
78+
await sleep(Math.floor(Math.random() * delay));
79+
delay *= 2;
80+
}
81+
}
82+
}
83+
84+
function sleep(ms: number) {
85+
return new Promise(ok => setTimeout(ok, ms));
86+
}
87+
88+
function randomString() {
89+
return Math.random().toString(36).replace(/[^a-z0-9]+/g, '');
90+
}

packages/aws-cdk/test/commands/migrate.test.ts

+25
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,13 @@
1+
import { exec as _exec } from 'child_process';
12
import * as os from 'os';
23
import * as path from 'path';
4+
import { promisify } from 'util';
35
import * as fs from 'fs-extra';
46
import { generateCdkApp, generateStack, readFromPath, readFromStack, setEnvironment, validateSourceOptions } from '../../lib/commands/migrate';
57
import { MockSdkProvider, MockedObject, SyncHandlerSubsetOf } from '../util/mock-sdk';
68

9+
const exec = promisify(_exec);
10+
711
describe('Migrate Function Tests', () => {
812
let sdkProvider: MockSdkProvider;
913
let getTemplateMock: jest.Mock;
@@ -173,6 +177,27 @@ describe('Migrate Function Tests', () => {
173177
const replacedStack = fs.readFileSync(path.join(workDir, 'GoodCSharp', 'src', 'GoodCSharp', 'GoodCSharpStack.cs'));
174178
expect(replacedStack).toEqual(fs.readFileSync(path.join(...stackPath, 'S3Stack.cs')));
175179
});
180+
181+
cliTest('generatedCdkApp generates a zip file when --compress is used', async (workDir) => {
182+
const stack = generateStack(validTemplate, 'GoodTypeScript', 'typescript');
183+
await generateCdkApp('GoodTypeScript', stack, 'typescript', workDir, true);
184+
185+
// Packages not in outDir
186+
expect(fs.pathExistsSync(path.join(workDir, 'GoodTypeScript', 'package.json'))).toBeFalsy();
187+
expect(fs.pathExistsSync(path.join(workDir, 'GoodTypeScript', 'bin', 'good_type_script.ts'))).toBeFalsy();
188+
expect(fs.pathExistsSync(path.join(workDir, 'GoodTypeScript', 'lib', 'good_type_script-stack.ts'))).toBeFalsy();
189+
190+
// Zip file exists
191+
expect(fs.pathExistsSync(path.join(workDir, 'GoodTypeScript.zip'))).toBeTruthy();
192+
193+
// Unzip it
194+
await exec(`unzip ${path.join(workDir, 'GoodTypeScript.zip')}`, { cwd: workDir });
195+
196+
// Now the files should be there
197+
expect(fs.pathExistsSync(path.join(workDir, 'package.json'))).toBeTruthy();
198+
expect(fs.pathExistsSync(path.join(workDir, 'bin', 'good_type_script.ts'))).toBeTruthy();
199+
expect(fs.pathExistsSync(path.join(workDir, 'lib', 'good_type_script-stack.ts'))).toBeTruthy();
200+
});
176201
});
177202

178203
function cliTest(name: string, handler: (dir: string) => void | Promise<any>): void {

0 commit comments

Comments
 (0)