diff --git a/packages/targets/deploy-lambda/src/index.test.ts b/packages/targets/deploy-lambda/src/index.test.ts new file mode 100644 index 00000000..1b904a13 --- /dev/null +++ b/packages/targets/deploy-lambda/src/index.test.ts @@ -0,0 +1,188 @@ +import { fakeBuildContext, fakeShipContext, smokeTest } from '@profullstack/sh1pt-core/testing'; +import { mkdtemp, readFile, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const { execMock } = vi.hoisted(() => ({ + execMock: vi.fn(), +})); + +vi.mock('@profullstack/sh1pt-core', async () => ({ + ...await vi.importActual('@profullstack/sh1pt-core'), + exec: execMock, +})); + +import adapter from './index.js'; + +smokeTest(adapter, { idPrefix: 'deploy', requireKind: true }); + +const tempDirs: string[] = []; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +afterEach(async () => { + await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))); +}); + +describe('AWS Lambda deployment target', () => { + it('writes a deploy plan with resolved AWS CLI commands', async () => { + const outDir = await mkdtemp(join(tmpdir(), 'sh1pt-lambda-')); + tempDirs.push(outDir); + + const result = await adapter.build(fakeBuildContext({ + outDir, + version: '1.2.3', + secret: (key: string) => key === 'AWS_REGION' ? 'eu-west-1' : undefined, + }) as any, { + functionName: 'my-function', + handler: 'dist/index.handler', + runtime: 'nodejs22.x', + zipFile: '/repo/dist/function.zip', + environment: { NODE_ENV: 'production' }, + layers: ['arn:aws:lambda:eu-west-1:123456789012:layer:deps:1'], + memorySize: 512, + timeout: 30, + publish: true, + }); + + expect(result.artifact).toBe(join(outDir, 'lambda-deploy.json')); + expect(execMock).not.toHaveBeenCalled(); + + const plan = JSON.parse(await readFile(result.artifact, 'utf-8')); + expect(plan).toMatchObject({ + provider: 'aws-lambda', + functionName: 'my-function', + region: 'eu-west-1', + handler: 'dist/index.handler', + runtime: 'nodejs22.x', + roleSecret: 'AWS_LAMBDA_ROLE', + artifact: '/repo/dist/function.zip', + environment: { NODE_ENV: 'production' }, + layers: ['arn:aws:lambda:eu-west-1:123456789012:layer:deps:1'], + memorySize: 512, + timeout: 30, + publish: true, + version: '1.2.3', + }); + expect(plan.commands.update).toEqual([ + 'aws', + 'lambda', + 'update-function-code', + '--function-name', + 'my-function', + '--zip-file', + 'fileb:///repo/dist/function.zip', + '--region', + 'eu-west-1', + '--publish', + ]); + expect(plan.commands.create).toEqual(expect.arrayContaining([ + '--role', + '', + '--handler', + 'dist/index.handler', + '--environment', + JSON.stringify({ Variables: { NODE_ENV: 'production' } }), + ])); + }); + + it('keeps dry-run shipping side-effect free', async () => { + await expect(adapter.ship(fakeShipContext({ + dryRun: true, + secret: (key: string) => key === 'AWS_REGION' ? 'ap-southeast-1' : undefined, + }) as any, { + functionName: 'my-function', + zipFile: '/repo/dist/function.zip', + publish: true, + })).resolves.toMatchObject({ + id: 'dry-run', + meta: { + functionName: 'my-function', + region: 'ap-southeast-1', + commands: { + update: expect.arrayContaining(['update-function-code', '--publish']), + create: expect.arrayContaining(['create-function', '']), + }, + }, + }); + expect(execMock).not.toHaveBeenCalled(); + }); + + it('updates an existing function in real ship mode', async () => { + execMock + .mockResolvedValueOnce({ exitCode: 0, stdout: '{}', stderr: '' }) + .mockResolvedValueOnce({ + exitCode: 0, + stdout: JSON.stringify({ + FunctionArn: 'arn:aws:lambda:us-east-2:123456789012:function:my-function', + Version: '7', + }), + stderr: '', + }); + + const ctx = fakeShipContext({ + dryRun: false, + env: { CI: 'true' }, + }); + const result = await adapter.ship(ctx as any, { + functionName: 'my-function', + zipFile: '/repo/dist/function.zip', + region: 'us-east-2', + }); + + expect(execMock).toHaveBeenNthCalledWith(1, 'aws', [ + 'lambda', + 'get-function', + '--function-name', + 'my-function', + '--region', + 'us-east-2', + ], { + env: { CI: 'true' }, + log: ctx.log, + throwOnNonZero: false, + }); + expect(execMock).toHaveBeenNthCalledWith(2, 'aws', [ + 'lambda', + 'update-function-code', + '--function-name', + 'my-function', + '--zip-file', + 'fileb:///repo/dist/function.zip', + '--region', + 'us-east-2', + ], { + env: { CI: 'true' }, + log: ctx.log, + throwOnNonZero: true, + }); + expect(result).toEqual({ + id: 'arn:aws:lambda:us-east-2:123456789012:function:my-function', + meta: { + functionName: 'my-function', + region: 'us-east-2', + version: '7', + }, + }); + }); + + it('requires a Lambda role before creating a new function', async () => { + execMock.mockResolvedValueOnce({ exitCode: 254, stdout: '', stderr: 'not found' }); + + await expect(adapter.ship(fakeShipContext({ + dryRun: false, + }) as any, { + functionName: 'my-function', + zipFile: '/repo/dist/function.zip', + })).rejects.toThrow('AWS_LAMBDA_ROLE not in vault'); + }); + + it('build validates functionName before writing a plan', async () => { + await expect(adapter.build(fakeBuildContext() as any, { + functionName: '', + })).rejects.toThrow('functionName is required'); + }); +}); diff --git a/packages/targets/deploy-lambda/src/index.ts b/packages/targets/deploy-lambda/src/index.ts index 244dbdb6..b30d7a7f 100644 --- a/packages/targets/deploy-lambda/src/index.ts +++ b/packages/targets/deploy-lambda/src/index.ts @@ -1,4 +1,6 @@ -import { defineTarget, setupGuide, exec } from '@profullstack/sh1pt-core'; +import { defineTarget, exec, manualSetup } from '@profullstack/sh1pt-core'; +import { mkdir, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; interface Config { functionName: string; @@ -7,6 +9,121 @@ interface Config { role?: string; zipFile?: string; invokePayload?: string; + region?: string; + description?: string; + environment?: Record; + layers?: string[]; + memorySize?: number; + timeout?: number; + publish?: boolean; +} + +function functionName(config: Config): string { + const fn = config.functionName?.trim(); + if (!fn) throw new Error('functionName is required'); + return fn; +} + +function region(ctx: { secret(key: string): string | undefined }, config: Config): string { + return config.region ?? ctx.secret('AWS_REGION') ?? 'us-east-1'; +} + +function zipFile(ctx: { outDir: string }, config: Config): string { + return config.zipFile ?? join(ctx.outDir, 'function.zip'); +} + +function applyOptionalCreateArgs(args: string[], config: Config): string[] { + if (config.description) args.push('--description', config.description); + if (config.timeout !== undefined) args.push('--timeout', String(config.timeout)); + if (config.memorySize !== undefined) args.push('--memory-size', String(config.memorySize)); + if (config.layers?.length) args.push('--layers', ...config.layers); + if (config.environment) { + args.push('--environment', JSON.stringify({ Variables: config.environment })); + } + if (config.publish) args.push('--publish'); + return args; +} + +function updateArgs(config: Config, artifact: string, awsRegion: string): string[] { + const args = [ + 'lambda', + 'update-function-code', + '--function-name', + functionName(config), + '--zip-file', + `fileb://${artifact}`, + '--region', + awsRegion, + ]; + if (config.publish) args.push('--publish'); + return args; +} + +function createArgs(config: Config, artifact: string, awsRegion: string, role: string): string[] { + return applyOptionalCreateArgs([ + 'lambda', + 'create-function', + '--function-name', + functionName(config), + '--runtime', + config.runtime ?? 'nodejs20.x', + '--role', + role, + '--handler', + config.handler ?? 'index.handler', + '--zip-file', + `fileb://${artifact}`, + '--region', + awsRegion, + ], config); +} + +function renderPlan( + ctx: { outDir: string; version: string; secret(key: string): string | undefined }, + config: Config +): string { + const artifact = zipFile(ctx, config); + const awsRegion = region(ctx, config); + const plannedRole = config.role ?? ''; + return `${JSON.stringify({ + provider: 'aws-lambda', + functionName: functionName(config), + region: awsRegion, + handler: config.handler ?? 'index.handler', + runtime: config.runtime ?? 'nodejs20.x', + role: config.role ?? null, + roleSecret: config.role ? null : 'AWS_LAMBDA_ROLE', + artifact, + environment: config.environment ?? {}, + layers: config.layers ?? [], + memorySize: config.memorySize ?? null, + timeout: config.timeout ?? null, + publish: config.publish ?? false, + version: ctx.version, + commands: { + update: ['aws', ...updateArgs(config, artifact, awsRegion)], + create: ['aws', ...createArgs(config, artifact, awsRegion, plannedRole)], + }, + }, null, 2)}\n`; +} + +function parseLambda(stdout: string): Record { + try { + return JSON.parse(stdout) as Record; + } catch { + return {}; + } +} + +function regionFromId(id: string, fallback: string): string { + const parts = id.split(':'); + return id.startsWith('arn:') && parts[3] ? parts[3] : fallback; +} + +function functionPathFromId(id: string): string { + const marker = ':function:'; + const index = id.indexOf(marker); + return index === -1 ? id : id.slice(index + marker.length); } export default defineTarget({ @@ -15,106 +132,89 @@ export default defineTarget({ label: 'AWS Lambda', async build(ctx, config) { - ctx.log('lambda: verifying AWS CLI availability'); - - try { - await exec('aws', ['--version'], { log: ctx.log, throwOnNonZero: false }); - } catch { - throw new Error( - 'AWS CLI not found. Install it from https://aws.amazon.com/cli/' - ); - } - - // Check credentials are configured - try { - await exec('aws', ['sts', 'get-caller-identity'], { - log: ctx.log, - throwOnNonZero: false, - }); - } catch { - throw new Error( - 'AWS credentials not configured. Run: aws configure' - ); - } - - const fn = config.functionName; - ctx.log(`lambda: preparing deployment for function "${fn}"`); - - return { artifact: config.zipFile ?? `${ctx.outDir}/function.zip` }; + const fn = functionName(config); + const planPath = join(ctx.outDir, 'lambda-deploy.json'); + ctx.log(`lambda plan - function=${fn} region=${region(ctx, config)}`); + await mkdir(ctx.outDir, { recursive: true }); + await writeFile(planPath, renderPlan(ctx, config), 'utf-8'); + return { artifact: planPath }; }, async ship(ctx, config) { - const fn = config.functionName; - const region = ctx.secret('AWS_REGION') ?? 'us-east-1'; + const fn = functionName(config); + const awsRegion = region(ctx, config); + const artifact = zipFile(ctx, config); + const updateCommand = ['aws', ...updateArgs(config, artifact, awsRegion)]; + const role = config.role ?? ctx.secret('AWS_LAMBDA_ROLE'); + const createCommand = ['aws', ...createArgs(config, artifact, awsRegion, role ?? '')]; - if (!fn) throw new Error('functionName is required'); + if (ctx.dryRun) { + ctx.log(`lambda: dry-run would deploy "${fn}"`); + return { + id: 'dry-run', + meta: { + functionName: fn, + region: awsRegion, + commands: { + update: updateCommand, + create: createCommand, + }, + }, + }; + } - // Check if function exists ctx.log(`lambda: checking if function "${fn}" exists`); const { exitCode } = await exec( 'aws', - ['lambda', 'get-function', '--function-name', fn, '--region', region], - { log: ctx.log, throwOnNonZero: false } + ['lambda', 'get-function', '--function-name', fn, '--region', awsRegion], + { env: ctx.env, log: ctx.log, throwOnNonZero: false } ); - if (ctx.dryRun) { - const action = exitCode === 0 ? 'update-function-code' : 'create-function'; - ctx.log(`lambda: dry-run — would ${action} "${fn}"`); - return { id: 'dry-run', meta: { functionName: fn, region, action } }; - } - if (exitCode === 0) { - // Update existing function code ctx.log(`lambda: updating code for "${fn}"`); const { stdout } = await exec( 'aws', - [ - 'lambda', 'update-function-code', - '--function-name', fn, - '--zip-file', `fileb://${ctx.artifact}`, - '--region', region, - ], - { log: ctx.log, throwOnNonZero: true } + updateArgs(config, artifact, awsRegion), + { env: ctx.env, log: ctx.log, throwOnNonZero: true } ); - const info = JSON.parse(stdout) as { FunctionArn?: string; Version?: string }; + const info = parseLambda(stdout); return { - id: info.FunctionArn ?? fn, - meta: { functionName: fn, region, version: info.Version }, + id: typeof info.FunctionArn === 'string' ? info.FunctionArn : fn, + meta: { + functionName: fn, + region: awsRegion, + version: typeof info.Version === 'string' ? info.Version : undefined, + }, }; - } else { - // Create new function - ctx.log(`lambda: creating function "${fn}"`); - const handler = config.handler ?? 'index.handler'; - const runtime = config.runtime ?? 'nodejs20.x'; - const role = config.role ?? ctx.secret('AWS_LAMBDA_ROLE'); - if (!role) throw new Error('role required. Set AWS_LAMBDA_ROLE secret or pass in config'); + } - const { stdout } = await exec( - 'aws', - [ - 'lambda', 'create-function', - '--function-name', fn, - '--runtime', runtime, - '--role', role, - '--handler', handler, - '--zip-file', `fileb://${ctx.artifact}`, - '--region', region, - ], - { log: ctx.log, throwOnNonZero: true } - ); - const info = JSON.parse(stdout) as { FunctionArn?: string }; - return { - id: info.FunctionArn ?? fn, - meta: { functionName: fn, region }, - }; + ctx.log(`lambda: creating function "${fn}"`); + if (!role) { + throw new Error('AWS_LAMBDA_ROLE not in vault - run: sh1pt secret set AWS_LAMBDA_ROLE '); } + + const { stdout } = await exec( + 'aws', + createArgs(config, artifact, awsRegion, role), + { env: ctx.env, log: ctx.log, throwOnNonZero: true } + ); + const info = parseLambda(stdout); + return { + id: typeof info.FunctionArn === 'string' ? info.FunctionArn : fn, + meta: { functionName: fn, region: awsRegion }, + }; }, - async status(id) { - return { state: 'live', url: `https://console.aws.amazon.com/lambda/home?region=us-east-1#/functions/${id}` }; + async status(id, config) { + const awsRegion = regionFromId(id, config.region ?? 'us-east-1'); + const fn = functionPathFromId(id); + return { + state: 'live', + url: `https://console.aws.amazon.com/lambda/home?region=${encodeURIComponent(awsRegion)}#/functions/${encodeURIComponent(fn)}`, + }; }, - setup: setupGuide({ + setup: manualSetup({ label: 'AWS Lambda', vendorDocUrl: 'https://aws.amazon.com/cli/', steps: [