Skip to content

Commit 64edeeb

Browse files
authored
feat(gen2-migration): allow generate to skip non-JS Lambda functions (#14744)
feat(cli-internal): allow generate to skip non-JS Lambda functions Surface non-JS Lambda runtimes as unsupported in the assessment rather than throwing at generation time. The FunctionAssessor now reads the runtime from the local CloudFormation template and marks the resource as unsupported for generate when it is not Node.js. The generate orchestrator checks assessment.of(resource, step) before entering the switch, skipping any resource that is not supported. This means --skip-validations allows generation to proceed while silently skipping non-JS functions. Also adds Assessment.of(resource, step) which returns the support level for a specific resource, usable by any step orchestrator. Tested with unit tests covering Python, Java, dotnet runtimes, Node.js as supported, and missing template as an error. --- Prompt: Fix issue 14723 — `generate` should allow skipping over non JS functions. These functions can later be hand coded by the user. Instead of throwing an error, surface the unsupported functions as part of the assessment validation — this would allow --skip-validations to skip over this as well.
1 parent 960b782 commit 64edeeb

File tree

8 files changed

+161
-25
lines changed

8 files changed

+161
-25
lines changed

docs/packages/amplify-cli/src/commands/gen2-migration/assess.md

Lines changed: 16 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ Standalone class (not a step). `assess()` returns an `Assessment` instance. `run
4545

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

48-
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`.
48+
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`.
4949

5050
### `Support`
5151

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

7171
## Supported Resources
7272

73-
| Category | Service | Generate | Refactor |
74-
| --------- | ----------------------- | -------- | ----------- |
75-
| auth | Cognito |||
76-
| auth | Cognito-UserPool-Groups |||
77-
| storage | S3 |||
78-
| storage | DynamoDB |||
79-
| api | AppSync || n/a |
80-
| api | API Gateway || n/a |
81-
| analytics | Kinesis |||
82-
| function | Lambda | ||
83-
| geo | Map |||
84-
| geo | PlaceIndex |||
85-
| geo | GeofenceCollection || unsupported |
73+
| Category | Service | Generate | Refactor |
74+
| --------- | ----------------------- | ---------------- | ----------- |
75+
| auth | Cognito | ||
76+
| auth | Cognito-UserPool-Groups | ||
77+
| storage | S3 | ||
78+
| storage | DynamoDB | ||
79+
| api | AppSync | | n/a |
80+
| api | API Gateway | | n/a |
81+
| analytics | Kinesis | ||
82+
| function | Lambda |(Node.js only) ||
83+
| geo | Map | ||
84+
| geo | PlaceIndex | ||
85+
| geo | GeofenceCollection | | unsupported |
8686

8787
## AI Development Notes
8888

8989
- 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.
90-
- 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.
90+
- 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.
9191
- Feature detection (overrides, custom policies) is assessor-specific. Each assessor checks for files in the cloud backend directory via `gen1App.fileExists()`.
92+
- `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.

docs/packages/amplify-cli/src/commands/gen2-migration/generate.md

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -69,10 +69,12 @@ The pipeline has two layers plus an orchestrator:
6969
- **Orchestrator** (`generate.ts`) — Uses `Gen1App.discover()` to iterate
7070
all resources from `amplify-meta.json`, dispatches by `resource.key`
7171
(a typed `ResourceKey`) via an exhaustive switch statement, and
72-
instantiates one generator per resource. Collects all operations and
73-
appends final operations for folder replacement + npm install. The same
74-
switch is used by the `assess()` method to record support into an
75-
`Assessment` collector.
72+
instantiates one generator per resource. Resources marked as unsupported
73+
by the assessment are skipped before the switch — no generator is
74+
instantiated for them. Collects all operations and appends final
75+
operations for folder replacement + npm install. The same switch is used
76+
by the `assess()` method to record support into an `Assessment`
77+
collector.
7678

7779
## Key Abstractions
7880

packages/amplify-cli/src/__tests__/commands/gen2-migration/assess.test.ts

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,10 @@ function mockGen1App(resources: DiscoveredResource[], existingFiles: string[] =
1414
} as unknown as Gen1App;
1515
}
1616

17+
const NODEJS_TEMPLATE = {
18+
Resources: { LambdaFunction: { Properties: { Runtime: 'nodejs18.x' } } },
19+
};
20+
1721
describe('AmplifyMigrationAssessor', () => {
1822
describe('assess()', () => {
1923
it('returns empty assessment when no resources discovered', () => {
@@ -31,7 +35,10 @@ describe('AmplifyMigrationAssessor', () => {
3135
const gen1App = mockGen1App(
3236
[{ category: 'function', resourceName: 'myFunc', service: 'Lambda', key: 'function:Lambda' }],
3337
['function/myFunc/custom-policies.json'],
34-
{ 'function/myFunc/custom-policies.json': [{ Action: ['s3:GetObject'], Resource: ['arn:aws:s3:::my-bucket/*'] }] },
38+
{
39+
'function/myFunc/myFunc-cloudformation-template.json': NODEJS_TEMPLATE,
40+
'function/myFunc/custom-policies.json': [{ Action: ['s3:GetObject'], Resource: ['arn:aws:s3:::my-bucket/*'] }],
41+
},
3542
);
3643
const assessor = new AmplifyMigrationAssessor(gen1App);
3744
const assessment = assessor.assess();
@@ -56,7 +63,10 @@ describe('AmplifyMigrationAssessor', () => {
5663
const gen1App = mockGen1App(
5764
[{ category: 'function', resourceName: 'myFunc', service: 'Lambda', key: 'function:Lambda' }],
5865
['function/myFunc/custom-policies.json'],
59-
{ 'function/myFunc/custom-policies.json': [{ Action: [], Resource: [] }] },
66+
{
67+
'function/myFunc/myFunc-cloudformation-template.json': NODEJS_TEMPLATE,
68+
'function/myFunc/custom-policies.json': [{ Action: [], Resource: [] }],
69+
},
6070
);
6171
const assessor = new AmplifyMigrationAssessor(gen1App);
6272
const assessment = assessor.assess();
@@ -73,6 +83,26 @@ describe('AmplifyMigrationAssessor', () => {
7383
expect(assessment.resources[0].generate.level).toBe('unsupported');
7484
expect(assessment.resources[0].refactor.level).toBe('unsupported');
7585
});
86+
87+
it('marks function with non-JS runtime as unsupported for generate', () => {
88+
const gen1App = mockGen1App(
89+
[{ category: 'function', resourceName: 'myPythonFunc', service: 'Lambda', key: 'function:Lambda' }],
90+
['function/myPythonFunc/myPythonFunc-cloudformation-template.json'],
91+
{
92+
'function/myPythonFunc/myPythonFunc-cloudformation-template.json': {
93+
Resources: { LambdaFunction: { Properties: { Runtime: 'python3.11' } } },
94+
},
95+
},
96+
);
97+
const assessor = new AmplifyMigrationAssessor(gen1App);
98+
const assessment = assessor.assess();
99+
100+
expect(assessment.resources).toHaveLength(1);
101+
expect(assessment.resources[0].generate.level).toBe('unsupported');
102+
expect(assessment.resources[0].refactor.level).toBe('supported');
103+
expect(assessment.validFor('generate')).toBe(false);
104+
expect(assessment.validFor('refactor')).toBe(true);
105+
});
76106
});
77107

78108
describe('run()', () => {

packages/amplify-cli/src/__tests__/commands/gen2-migration/assess/function/function.assessor.test.ts

Lines changed: 46 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,18 @@ function mockGen1App(existingFiles: string[] = [], jsonFiles: Record<string, unk
1010
} as unknown as Gen1App;
1111
}
1212

13+
const NODEJS_TEMPLATE_PATH = 'function/myFunc/myFunc-cloudformation-template.json';
14+
const NODEJS_TEMPLATE = {
15+
Resources: { LambdaFunction: { Properties: { Runtime: 'nodejs18.x' } } },
16+
};
17+
1318
const RESOURCE: DiscoveredResource = { category: 'function', resourceName: 'myFunc', service: 'Lambda', key: 'function:Lambda' };
1419

1520
describe('FunctionAssessor', () => {
1621
it('records resource as supported', () => {
22+
const gen1App = mockGen1App([], { [NODEJS_TEMPLATE_PATH]: NODEJS_TEMPLATE });
1723
const assessment = new Assessment('app', 'dev');
18-
new FunctionAssessor(mockGen1App(), RESOURCE).record(assessment);
24+
new FunctionAssessor(gen1App, RESOURCE).record(assessment);
1925

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

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

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

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

5463
expect(assessment.features).toHaveLength(0);
5564
});
65+
66+
describe('non-JS runtime detection', () => {
67+
it('marks resource as unsupported for generate when runtime is Python', () => {
68+
const gen1App = mockGen1App([], {
69+
[NODEJS_TEMPLATE_PATH]: { Resources: { LambdaFunction: { Properties: { Runtime: 'python3.11' } } } },
70+
});
71+
const assessment = new Assessment('app', 'dev');
72+
new FunctionAssessor(gen1App, RESOURCE).record(assessment);
73+
74+
const entry = assessment.resources[0];
75+
expect(entry!.generate.level).toBe('unsupported');
76+
expect(entry!.generate.note).toContain('python3.11');
77+
expect(entry!.refactor.level).toBe('supported');
78+
});
79+
80+
it('fails assessment validFor generate when runtime is non-JS', () => {
81+
const gen1App = mockGen1App([], {
82+
[NODEJS_TEMPLATE_PATH]: { Resources: { LambdaFunction: { Properties: { Runtime: 'dotnet8' } } } },
83+
});
84+
const assessment = new Assessment('app', 'dev');
85+
new FunctionAssessor(gen1App, RESOURCE).record(assessment);
86+
87+
expect(assessment.validFor('generate')).toBe(false);
88+
expect(assessment.validFor('refactor')).toBe(true);
89+
});
90+
91+
it('treats nodejs runtimes as supported', () => {
92+
const gen1App = mockGen1App([], { [NODEJS_TEMPLATE_PATH]: NODEJS_TEMPLATE });
93+
const assessment = new Assessment('app', 'dev');
94+
new FunctionAssessor(gen1App, RESOURCE).record(assessment);
95+
96+
expect(assessment.resources[0]!.generate.level).toBe('supported');
97+
expect(assessment.features).toHaveLength(0);
98+
});
99+
});
56100
});

packages/amplify-cli/src/__tests__/commands/gen2-migration/generate.test.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,5 +152,28 @@ describe('AmplifyMigrationGenerateStep', () => {
152152
lockSpy.mockRestore();
153153
wdSpy.mockRestore();
154154
});
155+
156+
it('skips unsupported resources without instantiating generators', async () => {
157+
const gen1 = mockGen1App({
158+
discover: () => [{ category: 'function', resourceName: 'myPythonFunc', service: 'Lambda', key: 'function:Lambda' as const }],
159+
json: (p: string) => {
160+
if (p.endsWith('-cloudformation-template.json')) {
161+
return { Resources: { LambdaFunction: { Properties: { Runtime: 'python3.11' } } } };
162+
}
163+
return undefined;
164+
},
165+
});
166+
const logger = new SpinningLogger('generate', { debug: true });
167+
168+
const plan = await new AmplifyMigrationGenerateStep(logger, gen1, {} as $TSContext, {} as AmplifyGen2MigrationValidations).forward();
169+
170+
// 3 validation ops (lock, working dir, assessment)
171+
// 1 delete amplify dir
172+
// 6 infrastructure generators (backend, root package.json, backend package.json, tsconfig, amplify.yml, gitignore)
173+
// 2 post-generation ops (replace folder, install deps)
174+
// = 12 total — the unsupported function contributes zero operations.
175+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- accessing private field to assert operation count
176+
expect((plan as any).operations).toHaveLength(12);
177+
});
155178
});
156179
});

packages/amplify-cli/src/commands/gen2-migration/assess/assessment.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,25 @@ export class Assessment {
9898
return this._features;
9999
}
100100

101+
/**
102+
* Returns the support level for a specific resource in the given step.
103+
*/
104+
// eslint-disable-next-line consistent-return -- exhaustive switch; compiler enforces all cases
105+
public of(resource: DiscoveredResource, step: 'generate' | 'refactor'): Support {
106+
const entry = this._resources.find(
107+
(ra) => ra.resource.category === resource.category && ra.resource.resourceName === resource.resourceName,
108+
);
109+
if (!entry) {
110+
throw new Error(`No assessment recorded for resource '${resource.category}/${resource.resourceName}'`);
111+
}
112+
switch (step) {
113+
case 'generate':
114+
return entry.generate;
115+
case 'refactor':
116+
return entry.refactor;
117+
}
118+
}
119+
101120
/**
102121
* Returns true if all resources and features are supported for the given step.
103122
*/

packages/amplify-cli/src/commands/gen2-migration/assess/function/function.assessor.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { Gen1App, DiscoveredResource, KNOWN_FEATURES } from '../../generate/_inf
44

55
/**
66
* Assesses migration readiness for a single Lambda function resource.
7-
* Detects custom-policies.json usage.
7+
* Detects non-JS runtimes and custom-policies.json usage.
88
*/
99
export class FunctionAssessor implements Assessor {
1010
public constructor(private readonly gen1App: Gen1App, private readonly resource: DiscoveredResource) {}
@@ -13,9 +13,13 @@ export class FunctionAssessor implements Assessor {
1313
* Records resource-level and feature-level support for this function.
1414
*/
1515
public record(assessment: Assessment): void {
16+
const templatePath = `function/${this.resource.resourceName}/${this.resource.resourceName}-cloudformation-template.json`;
17+
const template = this.gen1App.json(templatePath);
18+
const runtime = template.Resources.LambdaFunction.Properties.Runtime;
19+
1620
assessment.recordResource({
1721
resource: this.resource,
18-
generate: supported(),
22+
generate: this.isNonJsRuntime(runtime) ? unsupported(`uses non-JS runtime '${runtime}'`) : supported(),
1923
refactor: supported(),
2024
});
2125

@@ -30,6 +34,13 @@ export class FunctionAssessor implements Assessor {
3034
}
3135
}
3236

37+
/**
38+
* Returns true if the runtime is present and is not a Node.js variant.
39+
*/
40+
private isNonJsRuntime(runtime: string): boolean {
41+
return !runtime.startsWith('nodejs');
42+
}
43+
3344
/**
3445
* Returns true if the function has non-empty custom policies.
3546
* The file always exists but defaults to `[{"Action":[],"Resource":[]}]`.

packages/amplify-cli/src/commands/gen2-migration/generate.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,12 @@ export class AmplifyMigrationGenerateStep extends AmplifyMigrationStep {
7171
const discovered = this.gen1App.discover();
7272

7373
for (const resource of discovered) {
74+
// skip resources the assessment did not mark as supported.
75+
// these will show up as validation errors the user has to acknowledge.
76+
if (assessment.of(resource, 'generate').level !== 'supported') {
77+
continue;
78+
}
79+
7480
switch (resource.key) {
7581
case 'auth:Cognito': {
7682
const isReferenceAuth = discovered

0 commit comments

Comments
 (0)