Skip to content
Merged
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
31 changes: 16 additions & 15 deletions docs/packages/amplify-cli/src/commands/gen2-migration/assess.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ Standalone class (not a step). `assess()` returns an `Assessment` instance. `run

[`src/commands/gen2-migration/assess/assessment.ts`](../../../../packages/amplify-cli/src/commands/gen2-migration/assess/assessment.ts)

Collector that assessors contribute to. Exposes `validFor('generate' | 'refactor')` for step validation, and `render()` for terminal output. Each entry uses the `Support` type with `level` and optional `note`.
Collector that assessors contribute to. Exposes `validFor('generate' | 'refactor')` for step validation, `of(resource, step)` for per-resource support lookup, and `render()` for terminal output. Each entry uses the `Support` type with `level` and optional `note`.

### `Support`

Expand All @@ -70,22 +70,23 @@ Produced by `Gen1App.discover()`. The `key` field is a typed `category:service`

## Supported Resources

| Category | Service | Generate | Refactor |
| --------- | ----------------------- | -------- | ----------- |
| auth | Cognito | ✔ | ✔ |
| auth | Cognito-UserPool-Groups | ✔ | ✔ |
| storage | S3 | ✔ | ✔ |
| storage | DynamoDB | ✔ | ✔ |
| api | AppSync | ✔ | n/a |
| api | API Gateway | ✔ | n/a |
| analytics | Kinesis | ✔ | ✔ |
| function | Lambda | ✔ | ✔ |
| geo | Map | ✔ | ✔ |
| geo | PlaceIndex | ✔ | ✔ |
| geo | GeofenceCollection | ✔ | unsupported |
| Category | Service | Generate | Refactor |
| --------- | ----------------------- | ---------------- | ----------- |
| auth | Cognito | ✔ | ✔ |
| auth | Cognito-UserPool-Groups | ✔ | ✔ |
| storage | S3 | ✔ | ✔ |
| storage | DynamoDB | ✔ | ✔ |
| api | AppSync | ✔ | n/a |
| api | API Gateway | ✔ | n/a |
| analytics | Kinesis | ✔ | ✔ |
| function | Lambda | ✔ (Node.js only) | ✔ |
| geo | Map | ✔ | ✔ |
| geo | PlaceIndex | ✔ | ✔ |
| geo | GeofenceCollection | ✔ | unsupported |

## AI Development Notes

- Adding a new resource type: add the pair to `KNOWN_RESOURCE_KEYS` in `gen1-app.ts`, create an assessor, handle the case in `assess.ts`, and in the generate/refactor steps. The compiler enforces exhaustiveness.
- The `Assessment` is also used by generate and refactor steps for validation — `validFor(step)` returns false if any resource or feature is unsupported for that step.
- The `Assessment` is also used by generate and refactor steps for validation — `validFor(step)` returns false if any resource or feature is unsupported for that step. The `of(resource, step)` method returns the `Support` for a specific resource, used by the generate orchestrator to skip unsupported resources before instantiating generators.
- Feature detection (overrides, custom policies) is assessor-specific. Each assessor checks for files in the cloud backend directory via `gen1App.fileExists()`.
- `FunctionAssessor` reads the Lambda runtime from the local CloudFormation template (`function/<name>/<name>-cloudformation-template.json`). Non-Node.js runtimes are marked as unsupported for generate, allowing `--skip-validations` to skip them.
Original file line number Diff line number Diff line change
Expand Up @@ -69,10 +69,12 @@ The pipeline has two layers plus an orchestrator:
- **Orchestrator** (`generate.ts`) — Uses `Gen1App.discover()` to iterate
all resources from `amplify-meta.json`, dispatches by `resource.key`
(a typed `ResourceKey`) via an exhaustive switch statement, and
instantiates one generator per resource. Collects all operations and
appends final operations for folder replacement + npm install. The same
switch is used by the `assess()` method to record support into an
`Assessment` collector.
instantiates one generator per resource. Resources marked as unsupported
by the assessment are skipped before the switch — no generator is
instantiated for them. Collects all operations and appends final
operations for folder replacement + npm install. The same switch is used
by the `assess()` method to record support into an `Assessment`
collector.

## Key Abstractions

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@ function mockGen1App(resources: DiscoveredResource[], existingFiles: string[] =
} as unknown as Gen1App;
}

const NODEJS_TEMPLATE = {
Resources: { LambdaFunction: { Properties: { Runtime: 'nodejs18.x' } } },
};

describe('AmplifyMigrationAssessor', () => {
describe('assess()', () => {
it('returns empty assessment when no resources discovered', () => {
Expand All @@ -31,7 +35,10 @@ describe('AmplifyMigrationAssessor', () => {
const gen1App = mockGen1App(
[{ category: 'function', resourceName: 'myFunc', service: 'Lambda', key: 'function:Lambda' }],
['function/myFunc/custom-policies.json'],
{ 'function/myFunc/custom-policies.json': [{ Action: ['s3:GetObject'], Resource: ['arn:aws:s3:::my-bucket/*'] }] },
{
'function/myFunc/myFunc-cloudformation-template.json': NODEJS_TEMPLATE,
'function/myFunc/custom-policies.json': [{ Action: ['s3:GetObject'], Resource: ['arn:aws:s3:::my-bucket/*'] }],
},
);
const assessor = new AmplifyMigrationAssessor(gen1App);
const assessment = assessor.assess();
Expand All @@ -56,7 +63,10 @@ describe('AmplifyMigrationAssessor', () => {
const gen1App = mockGen1App(
[{ category: 'function', resourceName: 'myFunc', service: 'Lambda', key: 'function:Lambda' }],
['function/myFunc/custom-policies.json'],
{ 'function/myFunc/custom-policies.json': [{ Action: [], Resource: [] }] },
{
'function/myFunc/myFunc-cloudformation-template.json': NODEJS_TEMPLATE,
'function/myFunc/custom-policies.json': [{ Action: [], Resource: [] }],
},
);
const assessor = new AmplifyMigrationAssessor(gen1App);
const assessment = assessor.assess();
Expand All @@ -73,6 +83,26 @@ describe('AmplifyMigrationAssessor', () => {
expect(assessment.resources[0].generate.level).toBe('unsupported');
expect(assessment.resources[0].refactor.level).toBe('unsupported');
});

it('marks function with non-JS runtime as unsupported for generate', () => {
const gen1App = mockGen1App(
[{ category: 'function', resourceName: 'myPythonFunc', service: 'Lambda', key: 'function:Lambda' }],
['function/myPythonFunc/myPythonFunc-cloudformation-template.json'],
{
'function/myPythonFunc/myPythonFunc-cloudformation-template.json': {
Resources: { LambdaFunction: { Properties: { Runtime: 'python3.11' } } },
},
},
);
const assessor = new AmplifyMigrationAssessor(gen1App);
const assessment = assessor.assess();

expect(assessment.resources).toHaveLength(1);
expect(assessment.resources[0].generate.level).toBe('unsupported');
expect(assessment.resources[0].refactor.level).toBe('supported');
expect(assessment.validFor('generate')).toBe(false);
expect(assessment.validFor('refactor')).toBe(true);
});
});

describe('run()', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,18 @@ function mockGen1App(existingFiles: string[] = [], jsonFiles: Record<string, unk
} as unknown as Gen1App;
}

const NODEJS_TEMPLATE_PATH = 'function/myFunc/myFunc-cloudformation-template.json';
const NODEJS_TEMPLATE = {
Resources: { LambdaFunction: { Properties: { Runtime: 'nodejs18.x' } } },
};

const RESOURCE: DiscoveredResource = { category: 'function', resourceName: 'myFunc', service: 'Lambda', key: 'function:Lambda' };

describe('FunctionAssessor', () => {
it('records resource as supported', () => {
const gen1App = mockGen1App([], { [NODEJS_TEMPLATE_PATH]: NODEJS_TEMPLATE });
const assessment = new Assessment('app', 'dev');
new FunctionAssessor(mockGen1App(), RESOURCE).record(assessment);
new FunctionAssessor(gen1App, RESOURCE).record(assessment);

const entry = assessment.resources[0];
expect(entry!.generate.level).toBe('supported');
Expand All @@ -24,6 +30,7 @@ describe('FunctionAssessor', () => {

it('detects non-empty custom-policies.json', () => {
const gen1App = mockGen1App(['function/myFunc/custom-policies.json'], {
[NODEJS_TEMPLATE_PATH]: NODEJS_TEMPLATE,
'function/myFunc/custom-policies.json': [{ Action: ['s3:GetObject'], Resource: ['arn:aws:s3:::bucket/*'] }],
});
const assessment = new Assessment('app', 'dev');
Expand All @@ -39,6 +46,7 @@ describe('FunctionAssessor', () => {

it('ignores empty custom-policies.json', () => {
const gen1App = mockGen1App(['function/myFunc/custom-policies.json'], {
[NODEJS_TEMPLATE_PATH]: NODEJS_TEMPLATE,
'function/myFunc/custom-policies.json': [{ Action: [], Resource: [] }],
});
const assessment = new Assessment('app', 'dev');
Expand All @@ -48,9 +56,45 @@ describe('FunctionAssessor', () => {
});

it('records no features when custom-policies.json is absent', () => {
const gen1App = mockGen1App([], { [NODEJS_TEMPLATE_PATH]: NODEJS_TEMPLATE });
const assessment = new Assessment('app', 'dev');
new FunctionAssessor(mockGen1App(), RESOURCE).record(assessment);
new FunctionAssessor(gen1App, RESOURCE).record(assessment);

expect(assessment.features).toHaveLength(0);
});

describe('non-JS runtime detection', () => {
it('marks resource as unsupported for generate when runtime is Python', () => {
const gen1App = mockGen1App([], {
[NODEJS_TEMPLATE_PATH]: { Resources: { LambdaFunction: { Properties: { Runtime: 'python3.11' } } } },
});
const assessment = new Assessment('app', 'dev');
new FunctionAssessor(gen1App, RESOURCE).record(assessment);

const entry = assessment.resources[0];
expect(entry!.generate.level).toBe('unsupported');
expect(entry!.generate.note).toContain('python3.11');
expect(entry!.refactor.level).toBe('supported');
});

it('fails assessment validFor generate when runtime is non-JS', () => {
const gen1App = mockGen1App([], {
[NODEJS_TEMPLATE_PATH]: { Resources: { LambdaFunction: { Properties: { Runtime: 'dotnet8' } } } },
});
const assessment = new Assessment('app', 'dev');
new FunctionAssessor(gen1App, RESOURCE).record(assessment);

expect(assessment.validFor('generate')).toBe(false);
expect(assessment.validFor('refactor')).toBe(true);
});

it('treats nodejs runtimes as supported', () => {
const gen1App = mockGen1App([], { [NODEJS_TEMPLATE_PATH]: NODEJS_TEMPLATE });
const assessment = new Assessment('app', 'dev');
new FunctionAssessor(gen1App, RESOURCE).record(assessment);

expect(assessment.resources[0]!.generate.level).toBe('supported');
expect(assessment.features).toHaveLength(0);
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -152,5 +152,28 @@ describe('AmplifyMigrationGenerateStep', () => {
lockSpy.mockRestore();
wdSpy.mockRestore();
});

it('skips unsupported resources without instantiating generators', async () => {
const gen1 = mockGen1App({
discover: () => [{ category: 'function', resourceName: 'myPythonFunc', service: 'Lambda', key: 'function:Lambda' as const }],
json: (p: string) => {
if (p.endsWith('-cloudformation-template.json')) {
return { Resources: { LambdaFunction: { Properties: { Runtime: 'python3.11' } } } };
}
return undefined;
},
});
const logger = new SpinningLogger('generate', { debug: true });

const plan = await new AmplifyMigrationGenerateStep(logger, gen1, {} as $TSContext, {} as AmplifyGen2MigrationValidations).forward();

// 3 validation ops (lock, working dir, assessment)
// 1 delete amplify dir
// 6 infrastructure generators (backend, root package.json, backend package.json, tsconfig, amplify.yml, gitignore)
// 2 post-generation ops (replace folder, install deps)
// = 12 total — the unsupported function contributes zero operations.
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- accessing private field to assert operation count
expect((plan as any).operations).toHaveLength(12);
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,25 @@ export class Assessment {
return this._features;
}

/**
* Returns the support level for a specific resource in the given step.
*/
// eslint-disable-next-line consistent-return -- exhaustive switch; compiler enforces all cases
public of(resource: DiscoveredResource, step: 'generate' | 'refactor'): Support {
const entry = this._resources.find(
(ra) => ra.resource.category === resource.category && ra.resource.resourceName === resource.resourceName,
);
if (!entry) {
throw new Error(`No assessment recorded for resource '${resource.category}/${resource.resourceName}'`);
}
switch (step) {
case 'generate':
return entry.generate;
case 'refactor':
return entry.refactor;
}
}

/**
* Returns true if all resources and features are supported for the given step.
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { Gen1App, DiscoveredResource, KNOWN_FEATURES } from '../../generate/_inf

/**
* Assesses migration readiness for a single Lambda function resource.
* Detects custom-policies.json usage.
* Detects non-JS runtimes and custom-policies.json usage.
*/
export class FunctionAssessor implements Assessor {
public constructor(private readonly gen1App: Gen1App, private readonly resource: DiscoveredResource) {}
Expand All @@ -13,9 +13,13 @@ export class FunctionAssessor implements Assessor {
* Records resource-level and feature-level support for this function.
*/
public record(assessment: Assessment): void {
const templatePath = `function/${this.resource.resourceName}/${this.resource.resourceName}-cloudformation-template.json`;
const template = this.gen1App.json(templatePath);
const runtime = template.Resources.LambdaFunction.Properties.Runtime;

assessment.recordResource({
resource: this.resource,
generate: supported(),
generate: this.isNonJsRuntime(runtime) ? unsupported(`uses non-JS runtime '${runtime}'`) : supported(),
Comment thread
iliapolo marked this conversation as resolved.
refactor: supported(),
});

Expand All @@ -30,6 +34,13 @@ export class FunctionAssessor implements Assessor {
}
}

/**
* Returns true if the runtime is present and is not a Node.js variant.
*/
private isNonJsRuntime(runtime: string): boolean {
return !runtime.startsWith('nodejs');
}

/**
* Returns true if the function has non-empty custom policies.
* The file always exists but defaults to `[{"Action":[],"Resource":[]}]`.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,12 @@ export class AmplifyMigrationGenerateStep extends AmplifyMigrationStep {
const discovered = this.gen1App.discover();

for (const resource of discovered) {
// skip resources the assessment did not mark as supported.
// these will show up as validation errors the user has to acknowledge.
if (assessment.of(resource, 'generate').level !== 'supported') {
continue;
}

switch (resource.key) {
case 'auth:Cognito': {
const isReferenceAuth = discovered
Expand Down
Loading