diff --git a/packages/amplify-cli-npm/index.ts b/packages/amplify-cli-npm/index.ts index 0481b41bb0e..9c8b6d78a61 100644 --- a/packages/amplify-cli-npm/index.ts +++ b/packages/amplify-cli-npm/index.ts @@ -16,4 +16,4 @@ export const install = async (): Promise => { return binary.install(); }; -// force version bump to 14.5.0 +// force version bump to 14.6.0 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 67a0c320430..7da711bbe8b 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 @@ -85,7 +85,7 @@ describe('AmplifyMigrationAssessor', () => { expect(assessment.resources[0].refactor.level).toBe('unsupported'); }); - it('marks function with non-JS runtime as unsupported for generate', () => { + it('marks function with non-JS runtime as supported for generate', () => { const gen1App = mockGen1App( [{ category: 'function', resourceName: 'myPythonFunc', service: 'Lambda', key: 'function:Lambda' }], ['function/myPythonFunc/myPythonFunc-cloudformation-template.json'], @@ -99,9 +99,9 @@ describe('AmplifyMigrationAssessor', () => { const assessment = assessor.assess(); expect(assessment.resources).toHaveLength(1); - expect(assessment.resources[0].generate.level).toBe('unsupported'); + expect(assessment.resources[0].generate.level).toBe('supported'); expect(assessment.resources[0].refactor.level).toBe('not-applicable'); - expect(assessment.validFor('generate')).toBe(false); + expect(assessment.validFor('generate')).toBe(true); expect(assessment.validFor('refactor')).toBe(true); }); }); 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 6000fa7c6be..08346566d6e 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 @@ -78,38 +78,17 @@ describe('FunctionAssessor', () => { expect(assessment.features).toHaveLength(0); }); - describe('non-JS runtime detection', () => { - it('marks resource as unsupported for generate when runtime is Python', () => { + it('marks generate as supported for every runtime', () => { + for (const runtime of ['nodejs18.x', 'python3.11', 'go1.x', 'java21', 'dotnet8', 'ruby3.3']) { const gen1App = mockGen1App([], { - [NODEJS_TEMPLATE_PATH]: { Resources: { LambdaFunction: { Properties: { Runtime: 'python3.11' } } } }, + [NODEJS_TEMPLATE_PATH]: { Resources: { LambdaFunction: { Properties: { Runtime: runtime } } } }, }); 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('requires adding code after generate'); - expect(entry!.refactor.level).toBe('not-applicable'); - }); - - 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); - }); + expect(assessment.validFor('generate')).toBe(true); + expect(assessment.validFor('refactor')).toBe(true); + } }); }); 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 92a52d3c2a8..2023fc2fdc9 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 @@ -157,13 +157,8 @@ describe('AmplifyMigrationGenerateStep', () => { 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; - }, + discover: () => [{ category: 'storage', resourceName: 'myImportedTable', service: 'DynamoDB', key: 'storage:DynamoDB' as const }], + categoryMeta: () => ({ myImportedTable: { serviceType: 'imported' } }), }); const logger = new SpinningLogger('generate', { debug: true }); @@ -172,7 +167,7 @@ describe('AmplifyMigrationGenerateStep', () => { // 3 validation ops (lock, working dir, assessment) // 6 infrastructure generators (backend, root package.json, backend package.json, tsconfig, amplify.yml, gitignore) // 1 post-generation op (replace folder) - // = 10 total — the unsupported function contributes zero operations. + // = 10 total — the unsupported (imported) resource 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(10); }); diff --git a/packages/amplify-cli/src/__tests__/commands/gen2-migration/generate/amplify/function/function.generator.test.ts b/packages/amplify-cli/src/__tests__/commands/gen2-migration/generate/amplify/function/function.generator.test.ts index 6b460b08b68..cff430a27a3 100644 --- a/packages/amplify-cli/src/__tests__/commands/gen2-migration/generate/amplify/function/function.generator.test.ts +++ b/packages/amplify-cli/src/__tests__/commands/gen2-migration/generate/amplify/function/function.generator.test.ts @@ -1237,7 +1237,7 @@ describe('FunctionGenerator', () => { expect(output).toContain('KinesisEventSource'); }); - it('throws for non-nodejs runtime', async () => { + it('generates a custom function resource.ts for non-nodejs runtime', async () => { const gen1App = await createGen1App({ providers: { awscloudformation: { StackName: 'amplify-test-main-123456', Region: 'us-east-1' } }, function: { @@ -1254,15 +1254,31 @@ describe('FunctionGenerator', () => { jest.spyOn(gen1App.aws, 'fetchFunctionConfig').mockResolvedValue({ FunctionName: 'myFunc-main-abc', Handler: 'handler.handler', - Timeout: 3, - MemorySize: 128, + Timeout: 15, + MemorySize: 256, Runtime: 'python3.9', Environment: { Variables: {} }, }); jest.spyOn(gen1App.aws, 'fetchFunctionSchedule').mockResolvedValue(undefined); const generator = createFunctionGenerator({ gen1App, backendGenerator, packageJsonGenerator, outputDir }); - await expect(generator.plan()).rejects.toThrow("unsupported runtime 'python3.9'"); + const ops = await generator.plan(); + await ops[0].execute(); + + const content = writtenFile('resource.ts'); + expect(content).toContain('export const myFunc = defineFunction('); + expect(content).toContain("handler: 'handler.handler'"); + expect(content).toContain('runtime: Runtime.PYTHON_3_9'); + expect(content).toContain('timeout: Duration.seconds(15)'); + expect(content).toContain('memorySize: 256'); + expect(content).toContain('python3 -m pip install'); + + // Non-JS functions are wired into backend.ts and retain their source (no throw). + expect(mockCp).toHaveBeenCalledWith( + expect.stringContaining('myFunc'), + expect.any(String), + expect.objectContaining({ recursive: true }), + ); }); it('uses original model name casing from schema for table grants and env vars', async () => { diff --git a/packages/amplify-cli/src/__tests__/commands/gen2-migration/generate/amplify/function/function.renderer.test.ts b/packages/amplify-cli/src/__tests__/commands/gen2-migration/generate/amplify/function/function.renderer.test.ts index bec0809f98c..5c86b157301 100644 --- a/packages/amplify-cli/src/__tests__/commands/gen2-migration/generate/amplify/function/function.renderer.test.ts +++ b/packages/amplify-cli/src/__tests__/commands/gen2-migration/generate/amplify/function/function.renderer.test.ts @@ -1,4 +1,8 @@ -import { extractTableName } from '../../../../../../commands/gen2-migration/generate/amplify/function/function.renderer'; +import { + extractTableName, + FunctionRenderer, + mapToCdkRuntime, +} from '../../../../../../commands/gen2-migration/generate/amplify/function/function.renderer'; describe('extractTableName', () => { it('recovers original casing via case-insensitive match against known model names', () => { @@ -33,3 +37,194 @@ describe('extractTableName', () => { expect(extractTableName('API_MYAPI_GRAPHQLAPIIDOUTPUT')).toBeUndefined(); }); }); + +describe('FunctionRenderer.renderCustomFunction', () => { + const renderer = new FunctionRenderer('d1abc2def3', 'main'); + + it('renders a Python function with CDK Function construct', () => { + const output = renderer.renderCustomFunction({ + resourceName: 'myPythonFunc', + handler: 'index.handler', + runtime: 'python3.11', + timeoutSeconds: 20, + memoryMB: 256, + }); + + expect(output).toContain("import { defineFunction } from '@aws-amplify/backend'"); + expect(output).toContain("import { Architecture, Code, Function, Runtime } from 'aws-cdk-lib/aws-lambda'"); + expect(output).toContain('export const myPythonFunc = defineFunction('); + expect(output).toContain("handler: 'index.handler'"); + expect(output).toContain('runtime: Runtime.PYTHON_3_11'); + expect(output).toContain('architecture: Architecture.X86_64'); + expect(output).toContain('timeout: Duration.seconds(20)'); + expect(output).toContain('memorySize: 256'); + expect(output).toContain('python3 -m pip install'); + expect(output).toContain('--platform manylinux2014_x86_64'); + expect(output).toContain('image: Runtime.PYTHON_3_11.bundlingImage'); + }); + + it('renders a Go function with CDK Function construct', () => { + const output = renderer.renderCustomFunction({ + resourceName: 'myGoFunc', + handler: 'bootstrap', + runtime: 'provided.al2023', + timeoutSeconds: 10, + }); + + expect(output).toContain('export const myGoFunc = defineFunction('); + expect(output).toContain("handler: 'bootstrap'"); + expect(output).toContain('runtime: Runtime.PROVIDED_AL2023'); + expect(output).toContain('GOARCH=amd64 GOOS=linux go build'); + }); + + it('renders a Java function with CDK Function construct', () => { + const output = renderer.renderCustomFunction({ + resourceName: 'myJavaFunc', + handler: 'com.example.Handler::handleRequest', + runtime: 'java21', + }); + + expect(output).toContain('export const myJavaFunc = defineFunction('); + expect(output).toContain("handler: 'com.example.Handler::handleRequest'"); + expect(output).toContain('runtime: Runtime.JAVA_21'); + expect(output).toContain('mvn package'); + }); + + it('renders a .NET function with CDK Function construct', () => { + const output = renderer.renderCustomFunction({ + resourceName: 'myDotnetFunc', + handler: 'MyAssembly::MyNamespace.Handler::FunctionHandler', + runtime: 'dotnet8', + }); + + expect(output).toContain('export const myDotnetFunc = defineFunction('); + expect(output).toContain('runtime: Runtime.DOTNET_8'); + expect(output).toContain('dotnet publish'); + }); + + it('uses default timeout and memory when not specified', () => { + const output = renderer.renderCustomFunction({ + resourceName: 'myFunc', + handler: 'index.handler', + runtime: 'python3.9', + }); + + expect(output).toContain('timeout: Duration.seconds(3)'); + expect(output).toContain('memorySize: 128'); + }); + + it('emits arm64 architecture and arm bundling flags when the function is arm64', () => { + const pythonOutput = renderer.renderCustomFunction({ + resourceName: 'myArmPythonFunc', + handler: 'index.handler', + runtime: 'python3.12', + architecture: 'arm64', + }); + + expect(pythonOutput).toContain('architecture: Architecture.ARM_64'); + expect(pythonOutput).toContain('--platform manylinux2014_aarch64'); + + const goOutput = renderer.renderCustomFunction({ + resourceName: 'myArmGoFunc', + handler: 'bootstrap', + runtime: 'provided.al2023', + architecture: 'arm64', + }); + + expect(goOutput).toContain('architecture: Architecture.ARM_64'); + expect(goOutput).toContain('GOARCH=arm64 GOOS=linux go build'); + }); + + it('falls back to PROVIDED_AL2023 with a note for a runtime CDK no longer exports', () => { + // python3.4 maps to PYTHON_3_4, which is not a member of CDK's Runtime enum. + const output = renderer.renderCustomFunction({ + resourceName: 'myEolFunc', + handler: 'index.handler', + runtime: 'python3.4', + }); + + expect(output).toContain('runtime: Runtime.PROVIDED_AL2023'); + expect(output).toContain("// NOTE: runtime 'python3.4' has no matching CDK Runtime member"); + }); + + it('emits literal environment variables on the function', () => { + const output = renderer.renderCustomFunction({ + resourceName: 'myEnvFunc', + handler: 'index.handler', + runtime: 'python3.12', + environment: { API_URL: 'https://example.com', STAGE: 'prod' }, + }); + + expect(output).toContain('environment: {'); + expect(output).toContain('"API_URL": "https://example.com"'); + expect(output).toContain('"STAGE": "prod"'); + }); + + it('emits a TODO block for configuration that was not migrated', () => { + const output = renderer.renderCustomFunction({ + resourceName: 'myTodoFunc', + handler: 'index.handler', + runtime: 'python3.12', + manualMigrationNotes: ['A schedule was not migrated.', 'DynamoDB access was not migrated.'], + }); + + expect(output).toContain('// TODO: The following Gen1 configuration was not migrated automatically'); + expect(output).toContain('// - A schedule was not migrated.'); + expect(output).toContain('// - DynamoDB access was not migrated.'); + }); + + it('forces the handler to bootstrap for go/provided runtimes regardless of the Gen1 handler', () => { + const output = renderer.renderCustomFunction({ + resourceName: 'myGoFunc', + handler: 'main', // non-bootstrap Gen1 handler + runtime: 'go1.x', + }); + + expect(output).toContain("handler: 'bootstrap'"); + expect(output).not.toContain("handler: 'main'"); + }); + + it('warns loudly for an unrecognized runtime family instead of silently generating broken code', () => { + const output = renderer.renderCustomFunction({ + resourceName: 'myUnknownFunc', + handler: 'index.handler', + runtime: 'cobol1.0', + }); + + expect(output).toContain('runtime: Runtime.PROVIDED_AL2023'); + expect(output).toContain("// WARNING: runtime 'cobol1.0' is not recognized"); + }); +}); + +describe('mapToCdkRuntime', () => { + it('maps Python runtimes', () => { + expect(mapToCdkRuntime('python3.9')).toBe('PYTHON_3_9'); + expect(mapToCdkRuntime('python3.11')).toBe('PYTHON_3_11'); + expect(mapToCdkRuntime('python3.12')).toBe('PYTHON_3_12'); + }); + + it('maps Java runtimes', () => { + expect(mapToCdkRuntime('java11')).toBe('JAVA_11'); + expect(mapToCdkRuntime('java17')).toBe('JAVA_17'); + expect(mapToCdkRuntime('java21')).toBe('JAVA_21'); + }); + + it('maps .NET runtimes', () => { + expect(mapToCdkRuntime('dotnet6')).toBe('DOTNET_6'); + expect(mapToCdkRuntime('dotnet8')).toBe('DOTNET_8'); + }); + + it('maps Go/custom runtimes to PROVIDED_AL2023', () => { + expect(mapToCdkRuntime('go1.x')).toBe('PROVIDED_AL2023'); + expect(mapToCdkRuntime('provided.al2023')).toBe('PROVIDED_AL2023'); + expect(mapToCdkRuntime('provided.al2')).toBe('PROVIDED_AL2023'); + }); + + it('maps Ruby runtimes', () => { + expect(mapToCdkRuntime('ruby3.3')).toBe('RUBY_3_3'); + }); + + it('falls back to PROVIDED_AL2023 for unknown runtimes', () => { + expect(mapToCdkRuntime('unknown')).toBe('PROVIDED_AL2023'); + }); +}); 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 29583014cfe..d99d2d0eb20 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,8 @@ import { Gen1App, DiscoveredResource, KNOWN_FEATURES } from '../../_common/gen1- /** * Assesses migration readiness for a single Lambda function resource. - * Detects non-JS runtimes and custom-policies.json usage. + * All runtimes (Node.js, Python, Go, Java, .NET) are supported for generation. + * Detects custom-policies.json usage as an unsupported feature. */ export class FunctionAssessor implements Assessor { public constructor(private readonly gen1App: Gen1App, private readonly resource: DiscoveredResource) {} @@ -13,13 +14,9 @@ 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: this.isNonJsRuntime(runtime) ? unsupported('requires adding code after generate') : supported(), + generate: supported(), refactor: notApplicable(), }); @@ -34,13 +31,6 @@ 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/amplify/function/function.generator.ts b/packages/amplify-cli/src/commands/gen2-migration/generate/amplify/function/function.generator.ts index c7cb9427a57..b60048a31c5 100644 --- a/packages/amplify-cli/src/commands/gen2-migration/generate/amplify/function/function.generator.ts +++ b/packages/amplify-cli/src/commands/gen2-migration/generate/amplify/function/function.generator.ts @@ -80,16 +80,13 @@ export class FunctionGenerator implements Planner { }); const runtime = config.Runtime; - if (runtime && !runtime.startsWith('nodejs')) { - throw new AmplifyError('UnsupportedRuntimeError', { - message: `Function '${deployedName}' uses unsupported runtime '${runtime}'. Gen 2 migration only supports Node.js functions.`, - resolution: 'Migrate the function to a Node.js runtime before running the Gen 2 migration.', - }); - } this.logger.debug(`Fetching Lambda function schedule '${deployedName}'`); const schedule = await this.gen1App.aws.fetchFunctionSchedule(deployedName); - const entry = TS.extractFilePathFromHandler(config.Handler ?? 'index.js'); + const entry = + runtime && !runtime.startsWith('nodejs') + ? config.Handler ?? 'index.handler' + : TS.extractFilePathFromHandler(config.Handler ?? 'index.js'); const { literalEnvVars, dynamicEnvVars } = classifyEnvVars(config.Environment?.Variables ?? {}, this.readModelNames()); const dynamoActions = this.extractDynamoActions(); @@ -141,21 +138,72 @@ export class FunctionGenerator implements Planner { describe: async () => [`Generate amplify/function/${resourceName}/resource.ts`], execute: async () => { const dirPath = path.join(this.outputDir, 'amplify', 'function', resourceName); - - this.logger.info(`Rendering function/${resourceName}/resource.ts`); - const nodes = this.renderer.render(renderOpts); - const content = TS.printNodes(nodes); - await fs.mkdir(dirPath, { recursive: true }); - await fs.writeFile(path.join(dirPath, 'resource.ts'), content, 'utf-8'); - await this.copyFunctionSource(resourceName, dirPath); const alias = resourceName; - this.backendGenerator.addNamespaceImport(alias, `./function/${resourceName}/resource`); - this.backendGenerator.addDefineBackendEntry(resourceName, alias, resourceName); - const escapeHatchArgs = this.deriveApplyEscapeHatchArguments(hasAnalytics, dynamicEnvVars); - this.backendGenerator.addApplyEscapeHatchesCall({ alias, extraArgs: escapeHatchArgs }); + if (runtime && !runtime.startsWith('nodejs')) { + // Non-JS runtime: emit the Gen2 custom function pattern + // (defineFunction((scope) => new Function(...))). These functions + // have no applyEscapeHatches() export, so no escape-hatch call is + // registered on the backend. + this.logger.info(`Rendering custom function/${resourceName}/resource.ts`); + const manualMigrationNotes: string[] = []; + if (dynamicEnvVars.length > 0) { + manualMigrationNotes.push( + `Dynamic environment variables (branch/secret/cross-resource references) were not migrated: ${dynamicEnvVars + .map((v) => v.name) + .join(', ')}. Wire them via backend.${resourceName}.resources.lambda.addEnvironment(...) in backend.ts.`, + ); + } + if (schedule) { + manualMigrationNotes.push( + `A schedule ('${schedule}') was configured on the Gen1 function but was not migrated. Add an EventBridge schedule in backend.ts.`, + ); + } + const ungrantedPermissions: string[] = []; + if (dynamoActions.length > 0) ungrantedPermissions.push('DynamoDB table access'); + if (appSyncPermissions.hasMutation || appSyncPermissions.hasQuery) ungrantedPermissions.push('AppSync (GraphQL API) access'); + if (hasAnalytics) ungrantedPermissions.push('Kinesis (analytics) access'); + if (unMappedAuthActions.length > 0) ungrantedPermissions.push('additional IAM policy actions'); + if (dataTriggerModels.length > 0) ungrantedPermissions.push('data (@model) trigger wiring'); + if (storageTriggerTables.length > 0) ungrantedPermissions.push('storage (DynamoDB) trigger wiring'); + if (ungrantedPermissions.length > 0) { + manualMigrationNotes.push( + `Permission grants / triggers were not migrated: ${ungrantedPermissions.join( + ', ', + )}. Re-add them on the function's CDK resource in backend.ts.`, + ); + } + const content = this.renderer.renderCustomFunction({ + resourceName, + handler: entry, + runtime, + architecture: config.Architectures?.[0], + timeoutSeconds: config.Timeout, + memoryMB: config.MemorySize, + environment: literalEnvVars, + manualMigrationNotes, + }); + await fs.writeFile(path.join(dirPath, 'resource.ts'), content, 'utf-8'); + await this.copyFunctionSource(resourceName, dirPath, runtime); + + this.backendGenerator.addNamespaceImport(alias, `./function/${resourceName}/resource`); + this.backendGenerator.addDefineBackendEntry(resourceName, alias, resourceName); + } else { + this.logger.info(`Rendering function/${resourceName}/resource.ts`); + const nodes = this.renderer.render(renderOpts); + const content = TS.printNodes(nodes); + + await fs.writeFile(path.join(dirPath, 'resource.ts'), content, 'utf-8'); + await this.copyFunctionSource(resourceName, dirPath); + + this.backendGenerator.addNamespaceImport(alias, `./function/${resourceName}/resource`); + this.backendGenerator.addDefineBackendEntry(resourceName, alias, resourceName); + + const escapeHatchArgs = this.deriveApplyEscapeHatchArguments(hasAnalytics, dynamicEnvVars); + this.backendGenerator.addApplyEscapeHatchesCall({ alias, extraArgs: escapeHatchArgs }); + } }, }, ]; @@ -220,20 +268,21 @@ export class FunctionGenerator implements Planner { } } - private async copyFunctionSource(resourceName: string, destDir: string): Promise { + private async copyFunctionSource(resourceName: string, destDir: string, runtime?: string): Promise { const srcDir = path.join('amplify', 'backend', 'function', resourceName, 'src'); + const isNonJs = runtime !== undefined && !runtime.startsWith('nodejs'); await fs.cp(srcDir, destDir, { recursive: true, filter: (src) => { const b = path.basename(src); - return ( - b !== 'node_modules' && - b !== '.yarn' && - b !== 'package.json' && - b !== 'package-lock.json' && - b !== 'yarn.lock' && - b !== 'pnpm-lock.yaml' - ); + if (b === 'node_modules' || b === '.yarn') return false; + // For Node.js functions, filter out JS dependency/lock files so they + // don't leak into the generated Gen2 project. Non-JS functions keep + // their dependency manifests (requirements.txt, go.mod, pom.xml, etc.). + if (!isNonJs) { + return b !== 'package.json' && b !== 'package-lock.json' && b !== 'yarn.lock' && b !== 'pnpm-lock.yaml'; + } + return true; }, }); } diff --git a/packages/amplify-cli/src/commands/gen2-migration/generate/amplify/function/function.renderer.ts b/packages/amplify-cli/src/commands/gen2-migration/generate/amplify/function/function.renderer.ts index 75ee4bcdd48..ac1180d3401 100644 --- a/packages/amplify-cli/src/commands/gen2-migration/generate/amplify/function/function.renderer.ts +++ b/packages/amplify-cli/src/commands/gen2-migration/generate/amplify/function/function.renderer.ts @@ -1,5 +1,6 @@ import ts, { ObjectLiteralElementLike } from 'typescript'; import { AmplifyError } from '@aws-amplify/amplify-cli-core'; +import { Runtime as CdkRuntime } from 'aws-cdk-lib/aws-lambda'; import { newLineIdentifier, TS } from '../../ts'; import { AnalyticsKinesisGenerator } from '../analytics/kinesis.generator'; @@ -42,6 +43,26 @@ export interface DynamicEnvVar { readonly expression: ts.Expression; } +/** + * Options for rendering a custom (non-JS) function resource file. + */ +export interface RenderCustomFunctionOptions { + readonly resourceName: string; + readonly handler: string; + readonly runtime: string; + readonly architecture?: string; + readonly timeoutSeconds?: number; + readonly memoryMB?: number; + /** Literal environment variables to emit on the function. */ + readonly environment?: Readonly>; + /** + * Human-readable notes about Gen1 configuration that could not be migrated + * automatically (dynamic env vars, schedule, permission grants). Emitted as a + * TODO block so the migrated function does not silently lose them. + */ + readonly manualMigrationNotes?: readonly string[]; +} + /** * Renders defineFunction() resource.ts files from Gen1 Lambda configuration. * Pure — no AWS calls, no side effects. @@ -55,6 +76,106 @@ export class FunctionRenderer { this.backendEnvironmentName = backendEnvironmentName; } + /** + * Produces the complete TypeScript source for a custom (non-JS) function's resource.ts. + * Uses the CDK Function construct pattern via defineFunction((scope) => new Function(...)). + * Literal environment variables are emitted; dynamic env vars, schedules, and permission + * grants (which the Node.js path wires via applyEscapeHatches) are surfaced as a TODO block + * so they are not silently lost. + */ + public renderCustomFunction(opts: RenderCustomFunctionOptions): string { + const architecture = opts.architecture === 'arm64' ? 'ARM_64' : 'X86_64'; + // Go/custom (provided.*) runtimes are always built to a `bootstrap` binary, so the + // handler MUST be 'bootstrap' regardless of the Gen1 handler string. + const isProvided = opts.runtime.startsWith('go') || opts.runtime.startsWith('provided'); + const handler = isProvided ? 'bootstrap' : opts.handler; + const mappedRuntime = mapToCdkRuntime(opts.runtime); + // Guard against Lambda runtimes that CDK's Runtime enum no longer exports + // (deprecated/EOL versions). Fall back to a custom runtime so the generated + // resource.ts still compiles; the user then sets the runtime/bootstrap. + const runtimeIsKnown = (CdkRuntime as unknown as Record)[mappedRuntime] !== undefined; + const cdkRuntime = runtimeIsKnown ? mappedRuntime : 'PROVIDED_AL2023'; + // An unrecognized runtime family maps to PROVIDED_AL2023 (a valid member) but only + // gets copy-only bundling, so the generated file compiles yet won't deploy a working + // artifact — warn loudly instead of silently emitting broken code. + const isUnknownFamily = mappedRuntime === 'PROVIDED_AL2023' && !isProvided; + const timeout = opts.timeoutSeconds ?? 3; + const memorySize = opts.memoryMB ?? 128; + + const lines: string[] = [ + `import { execSync } from 'node:child_process';`, + `import * as path from 'node:path';`, + `import { fileURLToPath } from 'node:url';`, + `import { defineFunction } from '@aws-amplify/backend';`, + `import { Duration } from 'aws-cdk-lib';`, + `import { Architecture, Code, Function, Runtime } from 'aws-cdk-lib/aws-lambda';`, + ``, + `const functionDir = path.dirname(fileURLToPath(import.meta.url));`, + ``, + ]; + + if (!runtimeIsKnown) { + lines.push( + `// NOTE: runtime '${opts.runtime}' has no matching CDK Runtime member (it may be deprecated/EOL).`, + `// Falling back to Runtime.PROVIDED_AL2023 — set the correct runtime and provide a bootstrap, or upgrade the function runtime.`, + ``, + ); + } else if (isUnknownFamily) { + lines.push( + `// WARNING: runtime '${opts.runtime}' is not recognized. The bundling below only copies source files,`, + `// so this function will NOT build or deploy a working artifact as-is. Set the correct runtime and`, + `// provide a build/bootstrap step, or migrate the function to a supported runtime.`, + ``, + ); + } + + // Local bundling runs the runtime toolchain (python3/pip, go, mvn, dotnet) on the host; + // the runtime's CDK bundling image is the Docker fallback when the toolchain is absent. + if (opts.manualMigrationNotes && opts.manualMigrationNotes.length > 0) { + lines.push(`// TODO: The following Gen1 configuration was not migrated automatically and must be re-added manually:`); + for (const note of opts.manualMigrationNotes) { + lines.push(`// - ${note}`); + } + lines.push(``); + } + + const bundlingBlock = renderBundlingBlock(opts.runtime, opts.architecture); + + lines.push(`export const ${opts.resourceName} = defineFunction(`); + lines.push(` (scope) =>`); + lines.push(` new Function(scope, '${opts.resourceName}', {`); + lines.push(` handler: '${handler}',`); + lines.push(` runtime: Runtime.${cdkRuntime},`); + lines.push(` architecture: Architecture.${architecture},`); + lines.push(` timeout: Duration.seconds(${timeout}),`); + lines.push(` memorySize: ${memorySize},`); + if (opts.environment && Object.keys(opts.environment).length > 0) { + lines.push(` environment: {`); + for (const [key, value] of Object.entries(opts.environment)) { + lines.push(` ${JSON.stringify(key)}: ${JSON.stringify(value)},`); + } + lines.push(` },`); + } + lines.push(` code: Code.fromAsset(functionDir, {`); + lines.push(` bundling: {`); + lines.push(` image: Runtime.${cdkRuntime}.bundlingImage,`); + lines.push(` local: {`); + lines.push(` tryBundle(outputDir: string) {`); + for (const cmd of bundlingBlock) { + lines.push(` ${cmd}`); + } + lines.push(` return true;`); + lines.push(` },`); + lines.push(` },`); + lines.push(` },`); + lines.push(` }),`); + lines.push(` }),`); + lines.push(`);`); + lines.push(``); + + return lines.join('\n'); + } + /** * Produces the TypeScript AST for the defineFunction() call. */ @@ -974,3 +1095,69 @@ function convertScheduleExpression(raw: string): string | undefined { return undefined; } + +/** + * Maps a Lambda runtime string to the CDK Runtime enum member name. + * 'python3.9' → 'PYTHON_3_9', 'go1.x' → 'PROVIDED_AL2023', etc. + */ +export function mapToCdkRuntime(runtime: string): string { + if (runtime.startsWith('python')) { + // 'python3.9' → 'PYTHON_3_9' + const version = runtime.replace('python', '').replace('.', '_'); + return `PYTHON_${version}`; + } + if (runtime.startsWith('java')) { + // 'java21' → 'JAVA_21' + const version = runtime.replace('java', ''); + return `JAVA_${version}`; + } + if (runtime.startsWith('dotnet')) { + // 'dotnet8' → 'DOTNET_8' + const version = runtime.replace('dotnet', ''); + return `DOTNET_${version}`; + } + if (runtime.startsWith('ruby')) { + // 'ruby3.3' → 'RUBY_3_3' + const version = runtime.replace('ruby', '').replace('.', '_'); + return `RUBY_${version}`; + } + // Go and custom runtimes use provided.al2023 + if (runtime.startsWith('go') || runtime.startsWith('provided')) { + return 'PROVIDED_AL2023'; + } + // Fallback for unknown runtimes + return 'PROVIDED_AL2023'; +} + +/** + * Returns the bundling commands for a non-JS runtime. + * Each string is a line of code inside the tryBundle function body. + */ +function renderBundlingBlock(runtime: string, architecture?: string): string[] { + const isArm = architecture === 'arm64'; + if (runtime.startsWith('python')) { + const platform = isArm ? 'manylinux2014_aarch64' : 'manylinux2014_x86_64'; + return [ + `execSync(\`python3 -m pip install -r \${path.join(functionDir, 'requirements.txt')} -t \${path.join(outputDir)} --platform ${platform} --only-binary=:all:\`);`, + `execSync(\`cp -r \${functionDir}/* \${path.join(outputDir)}\`);`, + ]; + } + if (runtime.startsWith('go') || runtime.startsWith('provided')) { + const goarch = isArm ? 'arm64' : 'amd64'; + return [ + `execSync(\`rsync -rLv \${functionDir}/* \${path.join(outputDir)}\`);`, + `execSync(\`cd \${path.join(outputDir)} && GOARCH=${goarch} GOOS=linux go build -tags lambda.norpc -o \${path.join(outputDir)}/bootstrap \${functionDir}/main.go\`);`, + ]; + } + if (runtime.startsWith('java')) { + return [ + `execSync(\`cp -r \${functionDir}/* \${path.join(outputDir)}\`);`, + `execSync(\`cd \${path.join(outputDir)} && mvn package -q\`);`, + ]; + } + if (runtime.startsWith('dotnet')) { + return [`execSync(\`dotnet publish \${functionDir} -c Release -o \${path.join(outputDir)}\`);`]; + } + // Default: just copy files + return [`execSync(\`cp -r \${functionDir}/* \${path.join(outputDir)}\`);`]; +}