diff --git a/docs/packages/amplify-cli/src/commands/gen2-migration/assess.md b/docs/packages/amplify-cli/src/commands/gen2-migration/assess.md index f041fcb3d55..67b1fe9cb9f 100644 --- a/docs/packages/amplify-cli/src/commands/gen2-migration/assess.md +++ b/docs/packages/amplify-cli/src/commands/gen2-migration/assess.md @@ -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` @@ -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//-cloudformation-template.json`). Non-Node.js runtimes are marked as unsupported for generate, allowing `--skip-validations` to skip them. diff --git a/docs/packages/amplify-cli/src/commands/gen2-migration/generate.md b/docs/packages/amplify-cli/src/commands/gen2-migration/generate.md index 2c69d844c65..dc49cfe38da 100644 --- a/docs/packages/amplify-cli/src/commands/gen2-migration/generate.md +++ b/docs/packages/amplify-cli/src/commands/gen2-migration/generate.md @@ -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 diff --git a/packages/amplify-cli/src/__tests__/commands/gen2-migration/assess.test.ts b/packages/amplify-cli/src/__tests__/commands/gen2-migration/assess.test.ts index 39d91029b1b..8794a44656d 100644 --- a/packages/amplify-cli/src/__tests__/commands/gen2-migration/assess.test.ts +++ b/packages/amplify-cli/src/__tests__/commands/gen2-migration/assess.test.ts @@ -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', () => { @@ -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(); @@ -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(); @@ -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()', () => { diff --git a/packages/amplify-cli/src/__tests__/commands/gen2-migration/assess/function/function.assessor.test.ts b/packages/amplify-cli/src/__tests__/commands/gen2-migration/assess/function/function.assessor.test.ts index d05a090a931..54d1530e056 100644 --- a/packages/amplify-cli/src/__tests__/commands/gen2-migration/assess/function/function.assessor.test.ts +++ b/packages/amplify-cli/src/__tests__/commands/gen2-migration/assess/function/function.assessor.test.ts @@ -10,12 +10,18 @@ function mockGen1App(existingFiles: string[] = [], jsonFiles: Record { 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'); @@ -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'); @@ -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'); @@ -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); + }); + }); }); diff --git a/packages/amplify-cli/src/__tests__/commands/gen2-migration/generate.test.ts b/packages/amplify-cli/src/__tests__/commands/gen2-migration/generate.test.ts index 9aeb87322f6..9429de1287d 100644 --- a/packages/amplify-cli/src/__tests__/commands/gen2-migration/generate.test.ts +++ b/packages/amplify-cli/src/__tests__/commands/gen2-migration/generate.test.ts @@ -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); + }); }); }); diff --git a/packages/amplify-cli/src/commands/gen2-migration/assess/assessment.ts b/packages/amplify-cli/src/commands/gen2-migration/assess/assessment.ts index d805d088abe..4d494fed378 100644 --- a/packages/amplify-cli/src/commands/gen2-migration/assess/assessment.ts +++ b/packages/amplify-cli/src/commands/gen2-migration/assess/assessment.ts @@ -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. */ diff --git a/packages/amplify-cli/src/commands/gen2-migration/assess/function/function.assessor.ts b/packages/amplify-cli/src/commands/gen2-migration/assess/function/function.assessor.ts index 71287b1a6f9..a16add7add1 100644 --- a/packages/amplify-cli/src/commands/gen2-migration/assess/function/function.assessor.ts +++ b/packages/amplify-cli/src/commands/gen2-migration/assess/function/function.assessor.ts @@ -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) {} @@ -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(), refactor: supported(), }); @@ -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":[]}]`. diff --git a/packages/amplify-cli/src/commands/gen2-migration/generate.ts b/packages/amplify-cli/src/commands/gen2-migration/generate.ts index a44d23c46c7..eae72500411 100644 --- a/packages/amplify-cli/src/commands/gen2-migration/generate.ts +++ b/packages/amplify-cli/src/commands/gen2-migration/generate.ts @@ -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