Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 6 additions & 4 deletions docs/packages/amplify-cli/src/commands/gen2-migration.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,10 @@ const rollingBack = (context.input.options ?? {})['rollback'] ?? false;

### Common Gen1 Configuration Extraction

Creates a `Gen1App` facade that encapsulates all Gen1 app state — AWS clients, environment config, and the cloud backend snapshot. `Gen1App.create(context)` reads `team-provider-info.json`, fetches the app from the Amplify service, downloads the cloud backend from S3, and reads `amplify-meta.json`. The resulting instance is passed to all step constructors.
Creates a `Gen1App` facade that encapsulates all Gen1 app state — AWS clients, environment config, and the cloud backend snapshot. `Gen1App.create(context, stepName)` reads `team-provider-info.json`, fetches the app from the Amplify service, downloads the cloud backend from S3 (with spinner progress), and reads `amplify-meta.json`. The resulting instance is passed to all step constructors.

```ts
const gen1App = await Gen1App.create(context);
const gen1App = await Gen1App.create(context, stepName);
const implementation: AmplifyMigrationStep = new step.class(logger, gen1App, context, validations);
```

Expand Down Expand Up @@ -127,6 +127,8 @@ Atomic operation with `describe()`, `validate()`, and `execute()` methods. The `

Logger that manages a spinner in normal mode and falls back to plain text output in debug mode. Consumers use `info`/`debug`/`warn` for messages and `push`/`pop` to manage hierarchical spinner context. Used by `Plan` to show progress during validation and execution.

In non-debug mode, `debug()` calls are written to a log file at `$TMPDIR/amplify-gen2-migration/logs/gen2-migration-<timestamp>.log`. On failure, the error message includes the path to this log file so users can share it for troubleshooting without needing to re-run with `--debug`.

## CLI Interface

```bash
Expand Down Expand Up @@ -163,9 +165,9 @@ amplify gen2-migration <step> [options]
- Steps now return a `Plan` from `forward()` and `rollback()`. The `Plan` drives the full validate/describe/execute lifecycle — the dispatcher doesn't manage operations directly.
- Validations are embedded in operations via `validate()`. When a validation fails, its `report` field is displayed in a "Failed Validations Report" section before the summary table.
- `SpinningLogger` is the only logger class — the deprecated `Logger` subclass has been removed. Import directly from `_common/spinning-logger.ts`.
- Automatic rollback is enabled by default but can be disabled with `--no-rollback`.
- Automatic rollback is enabled by default but can be disabled with `--no-rollback`. If rollback itself fails, the error is printed but the original execution error is still thrown (rollback failure doesn't mask the root cause).
- The `--rollback` flag explicitly executes rollback operations for a step.
- `Gen1App` is the single facade for all Gen1 app state. It is created once in the dispatcher via `Gen1App.create(context)` and passed to all steps. Steps access `gen1App.appId`, `gen1App.region`, `gen1App.envName`, etc. instead of individual constructor params.
- `Gen1App` is the single facade for all Gen1 app state. It is created once in the dispatcher via `Gen1App.create(context, stepName)` and passed to all steps. Steps access `gen1App.appId`, `gen1App.region`, `gen1App.envName`, etc. instead of individual constructor params.
- `AwsClients` has a private constructor — use `AwsClients.create(context)` in production. Tests bypass this with `new (AwsClients as any)(...)`.
- Assessment uses a `Support` type with `level` and `note` fields. Each assessor provides its own note for unsupported entries. Use the `supported()`, `unsupported(note)`, `notApplicable()` helpers. The standard unsupported note is `'requires adding code after generate'`.
- `KNOWN_RESOURCE_KEYS` (in `gen1-app.ts`) defines all supported category:service pairs. Unknown resources get the `'UNKNOWN'` key.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,5 +36,5 @@ export async function createGen1App(meta: Record<string, unknown>): Promise<Gen1
} as unknown as AwsClients);

// eslint-disable-next-line @typescript-eslint/no-explicit-any -- no real $TSContext needed
return Gen1App.create({} as any);
return Gen1App.create({} as any, 'test');
}
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,7 @@ describe('Gen1App', () => {
it('throws InputValidationError when file contains a JSON object instead of array', async () => {
(fs.readFile as jest.Mock).mockResolvedValue(JSON.stringify({ key: 'value' }));

await expect(Gen1App.create({} as any, '/path/to/stateful-types.json')).rejects.toMatchObject({
await expect(Gen1App.create({} as any, 'test', '/path/to/stateful-types.json')).rejects.toMatchObject({
name: 'InputValidationError',
message: 'Invalid file structure: /path/to/stateful-types.json. Must be a JSON array.',
});
Expand All @@ -161,7 +161,7 @@ describe('Gen1App', () => {
it('throws InputValidationError when file contains a JSON string instead of array', async () => {
(fs.readFile as jest.Mock).mockResolvedValue(JSON.stringify('just a string'));

await expect(Gen1App.create({} as any, '/path/to/stateful-types.json')).rejects.toMatchObject({
await expect(Gen1App.create({} as any, 'test', '/path/to/stateful-types.json')).rejects.toMatchObject({
name: 'InputValidationError',
message: 'Invalid file structure: /path/to/stateful-types.json. Must be a JSON array.',
});
Expand All @@ -170,7 +170,7 @@ describe('Gen1App', () => {
it('throws InputValidationError when file contains a number instead of array', async () => {
(fs.readFile as jest.Mock).mockResolvedValue(JSON.stringify(42));

await expect(Gen1App.create({} as any, '/path/to/stateful-types.json')).rejects.toMatchObject({
await expect(Gen1App.create({} as any, 'test', '/path/to/stateful-types.json')).rejects.toMatchObject({
name: 'InputValidationError',
message: 'Invalid file structure: /path/to/stateful-types.json. Must be a JSON array.',
});
Expand All @@ -187,7 +187,7 @@ describe('Gen1App', () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
jest.spyOn(Gen1App as any, 'downloadCloudBackend').mockResolvedValue(ccbDir);

const app = await Gen1App.create({} as any, '/path/to/stateful-types.json');
const app = await Gen1App.create({} as any, 'test', '/path/to/stateful-types.json');

expect(app.statefulResourceTypes).toEqual([...DEFAULT_STATEFUL_RESOURCES, 'AWS::Custom::MyResource']);
});
Expand Down
60 changes: 48 additions & 12 deletions packages/amplify-cli/src/commands/gen2-migration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,10 +60,38 @@ export const run = async (context: $TSContext) => {
});
}

const gen1App = await Gen1App.create(context, additionalStatefulResources);
const gen1App = await Gen1App.create(context, stepName, additionalStatefulResources);

const logger = new SpinningLogger(`${stepName}] [${gen1App.appName}/${gen1App.envName}`, { debug: isDebug });

try {
await runStep(context, stepName, gen1App, logger, {
skipValidations,
validationsOnly,
rollingBack,
disableAutoRollback,
});
} catch (e: unknown) {
if (e instanceof Error && !logger.debugMode) {
// record stacktrace and let the user know that a debug log is available even though
// they didn't run the command with --debug. note if the command is executed
// with --debug, the stacktrace is already recorded by the generic amplify exception handler.
logger.debug(e.stack ?? 'Stacktrace: N/A');
(e as Error).message = `${(e as Error).message}\n\nDebug log written to: ${logger.logFilePath}`;
}
throw e;
}
};

interface RunStepOptions {
readonly skipValidations: boolean;
readonly validationsOnly: boolean;
readonly rollingBack: boolean;
readonly disableAutoRollback: boolean;
}

async function runStep(context: $TSContext, stepName: string, gen1App: Gen1App, logger: SpinningLogger, options: RunStepOptions) {
const step = STEPS[stepName];
// Assess is not a migration step — handle it separately.
if (stepName === 'assess') {
const assessor = new AmplifyMigrationAssessor(gen1App, logger);
Expand All @@ -79,16 +107,16 @@ export const run = async (context: $TSContext) => {
logger.start('Planning');
let plan: Plan;
try {
plan = rollingBack ? await implementation.rollback() : await implementation.forward();
logger.succeed('Planning complete');
plan = options.rollingBack ? await implementation.rollback() : await implementation.forward();
logger.succeed('Planning complete');
} catch (error: unknown) {
logger.failed('Planning failed');
logger.failed('Planning failed');
printer.blankLine();
throw error;
}

// Validate
if (!skipValidations) {
if (!options.skipValidations) {
const passed = await plan.validate();
if (!passed) {
const skipCommand = `amplify ${context.input.argv.join(' ').trim()} --skip-validations`;
Expand All @@ -100,19 +128,21 @@ export const run = async (context: $TSContext) => {
}
}

if (validationsOnly) return;
if (options.validationsOnly) return;

printer.blankLine();
printer.info(
chalk.yellow(
`You are about to ${rollingBack ? 'rollback' : 'execute'} '${stepName}' on environment '${gen1App.appName}/${gen1App.envName}'.`,
`You are about to ${options.rollingBack ? 'rollback' : 'execute'} '${stepName}' on environment '${gen1App.appName}/${
gen1App.envName
}'.`,
),
);
printer.blankLine();

await plan.describe();

if (!rollingBack) {
if (!options.rollingBack) {
printer.info(chalk.grey(`(You can rollback this command by running: 'amplify gen2-migration ${stepName} --rollback')`));
printer.blankLine();
}
Expand All @@ -134,17 +164,23 @@ export const run = async (context: $TSContext) => {
await plan.execute();
return;
} catch (error: unknown) {
if (!rollingBack && !disableAutoRollback) {
if (!options.rollingBack && !options.disableAutoRollback) {
printer.blankLine();
printer.error(`Failed: ${error}`);
printer.blankLine();
const rollbackPlan = await implementation.rollback();
await rollbackPlan.execute();
try {
const rollbackPlan = await implementation.rollback();
await rollbackPlan.execute();
} catch (e: unknown) {
printer.blankLine();
printer.error(`Rollback failed: ${error}`);
printer.blankLine();
}
}

throw error;
}
};
}

function shiftParams(context) {
delete context.parameters.first;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ import { AwsFetcher } from './aws-fetcher';
import { stateManager, pathManager } from '@aws-amplify/amplify-cli-core';
import { App, GetAppCommand } from '@aws-sdk/client-amplify';
import { DEFAULT_STATEFUL_RESOURCES } from './resource-types';
import { SpinningLogger } from './spinning-logger';
import { isDebug } from '@aws-amplify/amplify-prompts';

interface Gen1AppProps {
readonly ccbDir: string;
Expand Down Expand Up @@ -104,7 +106,8 @@ export class Gen1App {
}
}

public static async create(context: $TSContext, additionalStatefulResourceTypesPath?: string): Promise<Gen1App> {
public static async create(context: $TSContext, stepName: string, additionalStatefulResourceTypesPath?: string): Promise<Gen1App> {
const logger = new SpinningLogger(stepName, { debug: isDebug });
const clients = await AwsClients.create(context);

const tpiRelPath = `./${path.relative(process.cwd(), pathManager.getTeamProviderInfoFilePath())}`;
Expand Down Expand Up @@ -146,7 +149,10 @@ export class Gen1App {
});
}

logger.start(`Downloading backend from ${cfnProvider.DeploymentBucketName}`);
const ccbDir = await Gen1App.downloadCloudBackend(clients.s3, cfnProvider.DeploymentBucketName);
logger.stop();
logger.debug(`Finished downloading backend`);
return new Gen1App({ ccbDir, clients, envName, app: app.app!, additionalStatefulResourceTypes });
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ export class Plan {
this.logger.pop();
entries.push({ description: validation.description, valid: result.valid, report: result.report });
}
this.logger.succeed('Validating complete');
this.logger.succeed('Validating complete');
this.renderValidationResults(entries);
return entries.every((e) => e.valid);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import { printer, AmplifySpinner, isDebug as globalIsDebug } from '@aws-amplify/amplify-prompts';
import chalk from 'chalk';

const LOG_DIR = path.join(os.tmpdir(), 'amplify-gen2-migration', 'logs');

/**
* Logger that manages a spinner in normal mode and falls back to
* plain text output in debug mode. Consumers use info/debug/warn
Expand All @@ -10,14 +15,16 @@ export class SpinningLogger {
private static readonly SEPARATOR = ' → ';
private readonly segments: string[] = [];
private readonly spinner: AmplifySpinner;
private readonly debugMode: boolean;
public readonly debugMode: boolean;
public readonly logFilePath: string;
private spinnerActive = false;

constructor(private readonly prefix: string, options?: { readonly debug?: boolean }) {
this.debugMode = options?.debug ?? globalIsDebug;
this.spinner = new AmplifySpinner();
this.logFilePath = path.join(LOG_DIR, `gen2-migration-${Date.now()}.log`);

// Restore the cursor if the process is interrupted while the spinner is active
// Restore the cursor if the process is interrupted while the spinner is active.
process.on('SIGINT', () => {
if (this.spinnerActive) {
this.spinner.stop();
Expand All @@ -34,7 +41,7 @@ export class SpinningLogger {
this.segments.length = 0;
this.segments.push(text);
if (this.debugMode) {
this.printLine(text, '→');
this.debug(text);
return;
}
this.spinnerActive = true;
Expand All @@ -58,7 +65,7 @@ export class SpinningLogger {
public succeed(text: string): void {
this.segments.length = 0;
if (this.debugMode) {
this.printLine(text, '');
this.printLine(text, '');
return;
}
if (this.spinnerActive) {
Expand All @@ -73,7 +80,7 @@ export class SpinningLogger {
public failed(text: string): void {
this.segments.length = 0;
if (this.debugMode) {
this.printLine(text, '');
this.printLine(text, '');
return;
}
if (this.spinnerActive) {
Expand All @@ -88,7 +95,8 @@ export class SpinningLogger {
public push(text: string): void {
this.segments.push(text);
if (this.debugMode) {
this.printLine(text, '→');
// in debug this will just show without the previous segment context
// and make it hard to read. our debug logs should suffice.
return;
}
if (this.spinnerActive) {
Expand All @@ -110,27 +118,34 @@ export class SpinningLogger {
* Logs an informational message. Pauses the spinner if active.
*/
public info(message: string): void {
this.withSpinnerPaused(() => this.printLine(message, '•'));
this.withSpinnerPaused(() => this.printLine(message, this.debugMode ? '[INFO]' : '•'));
}

/**
* Logs a debug message (only visible in debug mode).
*/
public debug(message: string): void {
const line = this.formatLine(message, '[DEBUG]');
if (this.debugMode) {
printer.debug(this.formatLine(message, '·'));
printer.debug(line);
} else {
// log debug statements to a file regardless of mode for easier
// bug reporting.
this.appendToFile(line);
}
}

/**
* Logs a warning message. Pauses the spinner if active.
*/
public warn(message: string): void {
const line = this.formatLine(message, '[WARN]');
if (this.debugMode) {
printer.warn(this.formatLine(message, '·'));
printer.warn(line);
return;
} else {
this.withSpinnerPaused(() => printer.warn(line));
}
this.withSpinnerPaused(() => printer.warn(this.formatLine(message, '·')));
}

/**
Expand Down Expand Up @@ -180,6 +195,14 @@ export class SpinningLogger {
return `[${new Date().toISOString()}] [${chalk.bold(this.prefix)}] ${bullet} ${message}`;
}

private appendToFile(line: string) {
const dir = path.dirname(this.logFilePath);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
fs.appendFileSync(this.logFilePath, `${line}\n`, { encoding: 'utf-8' });
}

private printLine(message: string, bullet: string): void {
printer.info(this.formatLine(message, bullet));
}
Expand Down
6 changes: 6 additions & 0 deletions packages/amplify-cli/src/commands/gen2-migration/lock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -335,19 +335,23 @@ export class AmplifyMigrationLockStep extends AmplifyMigrationStep {
const cfn = new Cfn(this.gen1App.clients.cloudFormation, this.logger);

const stackName = extractStackNameFromId(stackId);
this.logger.debug(`Fetching template for stack: ${stackName}`);
const template = await cfn.fetchTemplate(stackId);

for (const [logicalId, resource] of Object.entries(template.Resources)) {
if (this.gen1App.statefulResourceTypes.includes(resource.Type)) {
this.logger.debug(`Modifying removal policies for resource ${logicalId} (${resource.Type}) to Retain`);
resource.DeletionPolicy = 'Retain';
resource.UpdateReplacePolicy = 'Retain';
}

if (AUTH_HOSTED_UI_LOGICAL_IDS_TO_RETAIN.includes(logicalId)) {
this.logger.debug(`Modifying removal policies for resource ${logicalId} (${resource.Type}) to Retain`);
resource.DeletionPolicy = 'Retain';
resource.UpdateReplacePolicy = 'Retain';
}
if (resource.Type === DYNAMO_RESOURCE_TYPE) {
this.logger.debug(`Setting ${DYNAMO_DELETION_PROTECTION_PROPERTY} property of resource ${logicalId} (${resource.Type}) to true`);
// https://docs.aws.amazon.com/AWSCloudFormation/latest/TemplateReference/aws-resource-dynamodb-table.html#cfn-dynamodb-table-deletionprotectionenabled
resource.Properties[DYNAMO_DELETION_PROTECTION_PROPERTY] = true;
}
Expand All @@ -360,6 +364,7 @@ export class AmplifyMigrationLockStep extends AmplifyMigrationStep {
}));

this.logger.push(`${stackName} (Create ChangeSet)`);
this.logger.debug(`Creating changeset for stack: ${stackName}`);
const changeSet = await cfn.createChangeSet({
stackName: stackId,
templateBody: template,
Expand All @@ -372,6 +377,7 @@ export class AmplifyMigrationLockStep extends AmplifyMigrationStep {
}

const report = cfn.renderChangeSet(changeSet);
this.logger.debug(`Validating changeset for stack ${stackName} contains expected changes`);
const valid = this.validateRetainChangeset(changeSet);

operations.push({
Expand Down
Loading
Loading