UNPKG

scandium

Version:

> Easily deploy any Node.js web server to AWS Lambda.

345 lines (294 loc) 12.3 kB
import fs from 'node:fs' import path from 'node:path' import util from 'node:util' import { parse as parseArn } from '@sandfox/arn' import amendObject from 'amend-object' import awsHasRegion, { errorText as awsHasRegionErrorText } from 'aws-has-region' import dotenv from 'dotenv' import { loadJsonFile } from 'load-json-file' import parseKeyValuePair from 'parse-key-value-pair' import bytes from 'bytes' import formatDuration from 'format-duration' import * as amazon from './amazon.js' import * as builder from './builder.js' import * as swagger from './swagger.js' import UserError from './user-error.js' import { parseVpcConfig } from './vpc-config.js' const readFile = util.promisify(fs.readFile) const writeFile = util.promisify(fs.writeFile) const DEFAULT_ENVIRONMENT = { NODE_ENV: 'production' } export const validateAwsRegion = { title: 'Validating AWS region', task: async (ctx, task) => { if (!(await awsHasRegion())) { throw new UserError(awsHasRegionErrorText) } } } export const parseOptions = { title: 'Parse options', task: async (ctx, task) => { if (ctx.args['--name']) { ctx.name = ctx.args['--name'] } else { try { ctx.name = (await loadJsonFile('package.json')).name } catch (err) { if (err.code !== 'ENOENT') throw err } if (!ctx.name) { throw new UserError('Please specify a name using the --name flag') } } ctx.dryRun = ctx.args['--dry-run'] || false ctx.skipNodeModules = ctx.args['--skip-node-modules'] || false if (ctx.args['--name-postfix']) { ctx.name = `${ctx.name}${ctx.args['--name-postfix']}` } ctx.environment = {} if (ctx.args['--docker-build-context']) { ctx.dockerBuildContext = path.resolve(ctx.args['--docker-build-context']) if (!process.cwd().startsWith(ctx.dockerBuildContext + '/')) { throw new UserError('The --docker-build-context path must be a parent of the current working directory') } ctx.dockerBuildDirectory = process.cwd().slice(ctx.dockerBuildContext.length + 1) } else { ctx.dockerBuildContext = process.cwd() ctx.dockerBuildDirectory = null } if (ctx.args['--env-from-file']) { Object.assign(ctx.environment, dotenv.parse(await readFile(ctx.args['--env-from-file'], 'utf-8'))) } if (ctx.args['--env']) { amendObject(ctx.environment, ctx.args['--env'].map(str => parseKeyValuePair(str))) } if (ctx.args['--output']) { ctx.outputPath = path.resolve(ctx.args['--output']) } if (ctx.args['--bucket']) { ctx.bucket = ctx.args['--bucket'] } switch (ctx.args['--arch']) { case 'arm64': ctx.arch = 'arm64' ctx.platform = 'linux/arm64' break case 'x86_64': ctx.arch = 'x86_64' ctx.platform = 'linux/amd64' break default: throw new UserError('Invalid --arch value, should be either "arm64" or "x86_64"') } if (ctx.args['--ssh-key']) { ctx.sshKey = ctx.args['--ssh-key'] } if (ctx.args['--entrypoint']) { ctx.customEntrypoint = true ctx.entrypointHandler = ctx.args['--entrypoint'] } else { ctx.customEntrypoint = false ctx.entrypointHandler = 'scandium-entrypoint.handler' } if (ctx.args['--vpc-config']) { ctx.vpcConfig = parseVpcConfig(ctx.args['--vpc-config']) } } } export const packageApp = { title: 'Packaging app for Lambda', task: async (ctx, task) => { const startedAt = Date.now() let ticker if (!ctx.isVerbose) { ticker = setInterval(() => { task.title = `Packaging app for Lambda (${formatDuration(Date.now() - startedAt)})` }, 100) } try { ctx.code = { ZipFile: await builder.createZipFile(ctx.dockerBuildContext, { customEntrypoint: ctx.customEntrypoint, directory: ctx.dockerBuildDirectory, platform: ctx.platform, skipNodeModules: ctx.skipNodeModules, sshKey: ctx.sshKey }) } } finally { if (ticker) clearInterval(ticker) } task.title = `App packaged successfully in ${formatDuration(Date.now() - startedAt)}! Final size: ${bytes.format(ctx.code.ZipFile.byteLength)}` } } export const saveApp = { title: 'Saving packaged app', enabled: (ctx) => Boolean(ctx.outputPath) && !ctx.dryRun, task: async (ctx, task) => { if (!ctx.code) throw new Error('Missing ctx.code') if (!ctx.code.ZipFile) throw new Error('Missing ctx.code.ZipFile') await writeFile(ctx.outputPath, ctx.code.ZipFile) task.title = `Packaged app saved to ${ctx.outputPath}` } } export const verifyCodeSize = { title: 'Verify code size', task: async (ctx, task) => { if (!ctx.code) throw new Error('Missing ctx.code') if (!ctx.code.ZipFile) throw new Error('Missing ctx.code.ZipFile') amazon.verifyCodeSize(ctx.code.ZipFile, { usingS3: Boolean(ctx.bucket) }) } } export const uploadToS3 = { title: 'Uploading zip to S3', enabled: (ctx) => Boolean(ctx.bucket) && !ctx.dryRun, task: async (ctx, task) => { if (!ctx.code) throw new Error('Missing ctx.code') if (!ctx.code.ZipFile) throw new Error('Missing ctx.code.ZipFile') const key = await amazon.uploadToS3({ zipFile: ctx.code.ZipFile, functionName: ctx.name, bucketName: ctx.bucket }) ctx.code = { S3Key: key, S3Bucket: ctx.bucket } } } export const createLambdaRole = { title: 'Creating Lambda role', enabled: (ctx) => !ctx.dryRun, task: async (ctx, task) => { if (ctx.args['--role']) { ctx.roleArn = ctx.args['--role'] task.skip('Using provided role') } else { const vpcAccess = (ctx.vpcConfig != null) && (ctx.vpcConfig.SubnetIds.length > 0 || ctx.vpcConfig.SecurityGroupIds.length > 0) ctx.roleArn = await amazon.createLambdaRole(ctx.name, vpcAccess) task.title = `Created new Lambda role with ARN: ${ctx.roleArn}` } } } export const createLambdaFunction = { title: 'Creating Lambda function', enabled: (ctx) => !ctx.dryRun, task: async (ctx, task) => { ctx.environment = Object.assign({}, DEFAULT_ENVIRONMENT, ctx.environment) const onFailedAttempt = (error) => { task.title = `Attempt ${error.attemptNumber} failed. There are ${error.retriesLeft} attempts left.` } ctx.lambdaArn = await amazon.createFunction({ arch: ctx.arch, code: ctx.code, functionName: ctx.name, handler: ctx.entrypointHandler, role: ctx.roleArn, environment: ctx.environment, onFailedAttempt, vpcConfig: ctx.vpcConfig }) task.title = `Created new Lambda function with ARN: ${ctx.lambdaArn}` } } export const updateLambdaFunction = { title: 'Updating Lambda function', enabled: (ctx) => !ctx.dryRun, task: async (ctx, task) => { const onFailedAttempt = (error) => { task.title = `Attempt ${error.attemptNumber} failed. There are ${error.retriesLeft} attempts left.` } try { ctx.lambdaArn = await amazon.updateFunction({ arch: ctx.arch, code: ctx.code, functionName: ctx.name, handler: ctx.entrypointHandler, onFailedAttempt, vpcConfig: ctx.vpcConfig }) task.title = `Updated existing Lambda function with ARN: ${ctx.lambdaArn}` } catch (err) { if (err.code === 'ResourceNotFoundException') { throw new UserError(`Unable to find a Lambda function named "${ctx.name}", did you mean to run the create command?`) } throw err } } } export const updateLambdaEnvironment = { title: 'Update Lambda environment', enabled: (ctx) => Boolean(ctx.args['--env-from-file'] || ctx.args['--env']) && !ctx.dryRun, task: async (ctx, task) => { if (!ctx.currentEnvironment) throw new Error('Missing ctx.currentEnvironment') ctx.environment = Object.assign({}, ctx.currentEnvironment, ctx.environment) await amazon.updateFunctionEnvironment({ functionName: ctx.name, environment: ctx.environment }) task.title = 'Updated existing Lambda environment' } } export const invokeHooks = { title: 'Invoke hooks', enabled: (ctx) => Boolean(ctx.args['--hooks']) && !ctx.dryRun, task: async (ctx, task) => { const onFailedAttempt = (error) => { task.title = `Attempt ${error.attemptNumber} failed. There are ${error.retriesLeft} attempts left.` } const payload = { scandiumInvokeHook: { file: ctx.args['--hooks'], hook: 'deploy' } } const { log } = await amazon.invokeLambda({ functionName: ctx.lambdaArn, onFailedAttempt, payload }) task.title = `Invoked hooks:\n\n${log}` } } export const loadSwaggerDefinition = { title: 'Load Swagger definition', enabled: (ctx) => !ctx.args['--no-api-gateway'] && Boolean(ctx.args['--swagger']) && !ctx.dryRun, task: async (ctx, task) => { if (ctx.apiGatewayType === 'REST') { ctx.definition = await swagger.loadSwaggerFileV1(ctx.args['--swagger'], ctx.name, ctx.lambdaArn) } else { ctx.definition = await swagger.loadSwaggerFileV2(ctx.args['--swagger'], ctx.name, ctx.lambdaArn) } task.title = 'Loaded Swagger definition from file' } } export const generateSwaggerDefinition = { title: 'Generate Swagger definition', enabled: (ctx) => !ctx.args['--no-api-gateway'] && !ctx.args['--swagger'] && !ctx.dryRun, task: async (ctx, task) => { if (ctx.apiGatewayType === 'REST') { ctx.definition = await swagger.forwardAllDefinitionV1(ctx.name, ctx.lambdaArn) } else { ctx.definition = await swagger.forwardAllDefinitionV2(ctx.name, ctx.lambdaArn) } task.title = 'Generated "forward all" Swagger definition' } } export const createApiGateway = { title: 'Creating new API Gateway', enabled: (ctx) => !ctx.args['--no-api-gateway'] && !ctx.dryRun, task: async (ctx, task) => { if (ctx.args['--rest-api-id']) { ctx.id = ctx.args['--rest-api-id'] task.skip('Using provided API Gateway') } else { ctx.id = await amazon.createApiGateway({ definition: ctx.definition, lambdaArn: ctx.lambdaArn }) task.title = `Created new API Gateway with id: ${ctx.id}` } } } export const updateApiGateway = { title: 'Updating existing API Gateway', enabled: (ctx) => !ctx.args['--no-api-gateway'] && !ctx.dryRun, task: async (ctx, task) => { if (ctx.args['--http-api-id']) { ctx.id = ctx.args['--http-api-id'] ctx.apiGatewayType = 'HTTP' } else if (ctx.args['--rest-api-id']) { ctx.id = ctx.args['--rest-api-id'] ctx.apiGatewayType = 'REST' } else { ctx.id = await amazon.findApiGateway(ctx.name) ctx.apiGatewayType = 'HTTP' } task.title = `Updating existing ${ctx.apiGatewayType} API Gateway with id: ${ctx.id}` if (ctx.apiGatewayType === 'REST') { await amazon.updateApiGatewayV1({ id: ctx.id, definition: ctx.definition, lambdaArn: ctx.lambdaArn }) } else { await amazon.updateApiGatewayV2({ id: ctx.id, definition: ctx.definition, lambdaArn: ctx.lambdaArn }) } task.title = `Updated existing ${ctx.apiGatewayType} API Gateway with id: ${ctx.id}` } } export const deployApi = { title: 'Deploying the API to a live address', enabled: (ctx) => !ctx.args['--no-api-gateway'] && !ctx.dryRun, task: async (ctx, task) => { const { region } = parseArn(ctx.lambdaArn) if (ctx.apiGatewayType === 'REST') { const stage = ctx.args['--api-gateway-stage'] || 'default' task.title = `Deploying the API to API Gateway stage: ${stage}, region: ${region}` await amazon.deployApiGatewayV1({ id: ctx.id, stage }) task.title = `Now serving live requests at: https://${ctx.id}.execute-api.${region}.amazonaws.com/${stage}` } else { const stage = ctx.args['--api-gateway-stage'] || '$default' task.title = `Deploying the API to API Gateway stage: ${stage}, region: ${region}` await amazon.deployApiGatewayV2({ id: ctx.id, stage }) task.title = `Now serving live requests at: https://${ctx.id}.execute-api.${region}.amazonaws.com${stage === '$default' ? '' : `/${stage}`}` } } } export const getCurrentEnvironment = { title: 'Fetching current environment', enabled: (ctx) => Boolean(ctx.args['--env-from-file'] || ctx.args['--env'] || ctx.args.environment), task: async (ctx, task) => { ctx.currentEnvironment = await amazon.getFunctionEnvironment({ functionName: ctx.name }) } }