UNPKG

@adobe/helix-deploy

Version:

Library and Commandline Tools to build and deploy OpenWhisk Actions

1,106 lines (1,012 loc) 39.5 kB
/* * Copyright 2020 Adobe. All rights reserved. * This file is licensed to you under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. You may obtain a copy * of the License at http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software distributed under * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS * OF ANY KIND, either express or implied. See the License for the specific language * governing permissions and limitations under the License. */ /* eslint-disable no-await-in-loop,no-restricted-syntax */ import chalk from 'chalk-template'; import processQueue from '@adobe/helix-shared-process-queue'; import { promisify } from 'util'; import { DeleteObjectCommand, PutObjectCommand, S3Client, } from '@aws-sdk/client-s3'; import { GetCallerIdentityCommand, STSClient, } from '@aws-sdk/client-sts'; import { AddPermissionCommand, CreateAliasCommand, CreateFunctionCommand, DeleteAliasCommand, DeleteFunctionCommand, GetAliasCommand, GetFunctionCommand, LambdaClient, ListAliasesCommand, ListTagsCommand, ListVersionsByFunctionCommand, PublishVersionCommand, TagResourceCommand, UntagResourceCommand, UpdateAliasCommand, UpdateFunctionCodeCommand, UpdateFunctionConfigurationCommand, } from '@aws-sdk/client-lambda'; import { ApiGatewayV2Client, CreateApiCommand, CreateAuthorizerCommand, CreateIntegrationCommand, CreateRouteCommand, CreateStageCommand, DeleteIntegrationCommand, GetApiCommand, GetApisCommand, GetAuthorizersCommand, GetIntegrationsCommand, GetRoutesCommand, GetStagesCommand, UpdateAuthorizerCommand, UpdateRouteCommand, } from '@aws-sdk/client-apigatewayv2'; import { PutParameterCommand, SSMClient } from '@aws-sdk/client-ssm'; import { PutSecretValueCommand, SecretsManagerClient } from '@aws-sdk/client-secrets-manager'; import path from 'path'; import fse from 'fs-extra'; import crypto from 'crypto'; import BaseDeployer from './BaseDeployer.js'; import ActionBuilder from '../ActionBuilder.js'; import AWSConfig from './AWSConfig.js'; const sleep = promisify(setTimeout); const API_GW_NAME_DEFAULT = 'API Managed by Helix Deploy'; export default class AWSDeployer extends BaseDeployer { /** * @param {BaseConfig} baseConfig * @param {AWSConfig} config */ constructor(baseConfig, config) { super(baseConfig); Object.assign(this, { id: 'aws', name: 'AmazonWebServices', /** @type AWSConfig */ _cfg: config, _functionARN: '', _aliasARN: '', _accountId: '', }); } ready() { return !!this._cfg.region && !!this._cfg.apiId && !!this._cfg.role && !!this._s3 && !!this._lambda && !!this._ssm; } get host() { return `${this._cfg.apiId}.execute-api.${this._cfg.region}.amazonaws.com`; } // eslint-disable-next-line class-methods-use-this get urlVCL() { return '"/" + var.package + "/" + var.action + var.slashversion + var.rest'; } get functionPath() { if (!this._functionPath) { const { cfg } = this; this._functionPath = ActionBuilder.substitute(cfg.format.aws, { ...cfg, ...cfg.properties }); } return this._functionPath; } get functionName() { if (!this._functionName) { const { cfg } = this; this._functionName = ActionBuilder.substitute(this._cfg.lambdaFormat, { ...cfg, ...cfg.properties }).replace(/\./g, '_'); } return this._functionName; } get basePath() { return `${this.functionPath}`; } // eslint-disable-next-line class-methods-use-this get customVCL() { // https://docs.aws.amazon.com/elasticloadbalancing/latest/application/load-balancer-request-tracing.html // set X-Amzn-Trace-Id (tracing from x-cdn-request-id) return `if (req.http.x-cdn-request-id != "") { # aws trace id: root/self + version + timestamp + id (stripped of dashes) set req.http.X-Amzn-Trace-Id = "Root=1" + now.sec + req.http.x-cdn-request-id; }`; } get additionalTags() { return (this._cfg.tags || []).map((tag) => tag.split('=')).reduce((acc, tagSplit) => { if (tagSplit.length >= 2) { const [key, ...value] = tagSplit; acc[key] = value.join('='); } return acc; }, {}); } validate() { const req = []; if (!this._cfg.role) { req.push('--aws-role'); } if (!this._cfg.region) { req.push('--aws-region'); } if (req.length) { throw Error(`AWS target needs ${req.join(' and ')}`); } } async init() { if (this._cfg.region) { this._s3 = new S3Client({ region: this._cfg.region, }); this._lambda = new LambdaClient({ region: this._cfg.region, }); this._api = new ApiGatewayV2Client({ region: this._cfg.region, }); this._ssm = new SSMClient({ region: this._cfg.region, }); this._sm = new SecretsManagerClient({ region: this._cfg.region, }); } } async initAccountId() { let sts; try { sts = new STSClient({ region: this._cfg.region, }); const ret = await sts.send(new GetCallerIdentityCommand()); this._accountId = ret.Account; this.log.info(chalk`{green ok:} initialized AWS deployer for account {yellow ${ret.Account}}`); this._bucket = this._cfg.deployBucket || `helix-deploy-bucket-${this._accountId}${this._cfg.region !== 'us-east-1' ? `-${this._cfg.region}` : ''}`; } finally { sts.destroy(); } } async uploadZIP() { const { cfg } = this; const relZip = path.relative(process.cwd(), cfg.zipFile); // ensure upload key is unique this._key = `${path.basename(relZip)}-${crypto.randomBytes(16).toString('hex')}`; const uploadParams = { Bucket: this._bucket, Key: this._key, Body: await fse.readFile(cfg.zipFile), }; this.log.info(chalk`--: uploading ${relZip} to S3 bucket {blueBright s3://${this._bucket}}...`); try { await this._s3.send(new PutObjectCommand(uploadParams)); this.log.info(chalk`{green ok:} uploaded deploy package {blueBright s3://${this._bucket}/${this._key}}`); } catch (e) { this.log.error(chalk`{red error:} failed to update package to {blueBright s3://${this._bucket}/${this._key}}: ${e.message}`); throw new Error('upload failed'); } } async deleteZIP() { await this._s3.send(new DeleteObjectCommand({ Bucket: this._bucket, Key: this._key, })); this.log.info(chalk`{green ok:} deleted deploy package {blueBright s3://${this._bucket}/${this._key}}.`); } get functionConfig() { const { cfg, functionName, additionalTags } = this; const functionConfig = { Code: { S3Bucket: this._bucket, S3Key: this._key, }, // todo: package name FunctionName: functionName, Role: this._cfg.role, Runtime: `nodejs${cfg.nodeVersion}.x`, // todo: cram annotations into description? Tags: { pkgVersion: cfg.version, // AWS tags have a size limit of 256. currently disabling // dependencies: cfg.dependencies.main // .map((dep) => `${dep.name}:${dep.version}`).join(','), repository: encodeURIComponent(cfg.gitUrl).replace(/%/g, '@'), git: encodeURIComponent(`${cfg.gitOrigin}#${cfg.gitRef}`).replace(/%/g, '@'), updated: `${cfg.updatedAt}`, }, Description: cfg.pkgJson.description, MemorySize: cfg.memory, Timeout: Math.floor(cfg.timeout / 1000), Environment: { Variables: cfg.params, }, Handler: this._cfg.handler || (cfg.esm ? 'esm-adapter/index.handler' : 'index.lambda'), Architectures: [ this._cfg.arch, ], LoggingConfig: this._cfg.logFormat ? { Format: this._cfg.logFormat } : undefined, Layers: this._cfg.layers || [], TracingConfig: this._cfg.tracingMode ? { Mode: this._cfg.tracingMode } : undefined, }; // add additional tags which are not empty Object.entries(additionalTags).forEach(([key, value]) => { if (value !== '') { functionConfig.Tags[key] = value; } }); return functionConfig; } async createLambda() { const { cfg, functionName, additionalTags, functionConfig, } = this; const functionVersion = cfg.version.replace(/\./g, '_'); this.log.info(`--: using lambda role "${this._cfg.role}"`); // check if function already exists let baseARN; try { this.log.info(chalk`--: checking existing Lambda function {yellow ${functionName}}`); const { Configuration: { FunctionArn } } = await this._lambda.send(new GetFunctionCommand({ FunctionName: functionName, })); baseARN = FunctionArn; this.log.info(chalk`{green ok}: exist {yellow ${FunctionArn}}`); } catch (e) { if (e.name === 'ResourceNotFoundException') { this.log.info(chalk`{green ok}: does not exist yet.`); this.log.info(chalk`--: creating new Lambda function {yellow ${functionName}}`); const res = await this._lambda.send(new CreateFunctionCommand(functionConfig)); baseARN = res.FunctionArn; } else { this.log.error(chalk`Unable to verify existence of Lambda function {yellow ${functionName}}`); throw e; } } // update existing function if (baseARN) { await this.checkFunctionReady(baseARN); this.log.info(chalk`--: updating existing Lambda function configuration {yellow ${functionName}}`); await this._lambda.send(new UpdateFunctionConfigurationCommand(functionConfig)); await this.checkFunctionReady(baseARN); this.log.info(chalk`--: updating existing Lambda function tags {yellow ${functionName}}`); // set all the tags in the current configuration await this._lambda.send(new TagResourceCommand({ Resource: baseARN, Tags: functionConfig.Tags, })); // then remove any tags with a blank value in the configuration (and are currently set), // leaving other tags alone const tagsToPotentiallyRemove = Object.entries(additionalTags).filter(([_, value]) => value === '').map(([key]) => key); if (tagsToPotentiallyRemove.length) { const { Tags: currentTags } = await this._lambda.send(new ListTagsCommand({ Resource: baseARN, })); const tagsToRemove = tagsToPotentiallyRemove.filter((key) => currentTags[key]); if (tagsToRemove.length) { await this._lambda.send(new UntagResourceCommand({ Resource: baseARN, TagKeys: tagsToRemove, })); } } await this.checkFunctionReady(baseARN); this.log.info('--: updating Lambda function code...'); await this._lambda.send(new UpdateFunctionCodeCommand({ FunctionName: functionName, ...functionConfig.Code, })); } await this.checkFunctionReady(baseARN); this.log.info('--: publishing new version'); const versiondata = await this._lambda.send(new PublishVersionCommand({ FunctionName: functionName, })); this._functionARN = versiondata.FunctionArn; // eslint-disable-next-line prefer-destructuring this._accountId = this._functionARN.split(':')[4]; const versionNum = versiondata.Version; this.log.info(chalk`{green ok}: version {yellow ${versionNum}} published.`); try { await this._lambda.send(new GetAliasCommand({ FunctionName: functionName, Name: functionVersion, })); this.log.info(chalk`--: updating existing alias {yellow ${functionName}:${functionVersion}} to version {yellow ${versionNum}}`); const updatedata = await this._lambda.send(new UpdateAliasCommand({ FunctionName: functionName, Name: functionVersion, FunctionVersion: versionNum, })); this._aliasARN = updatedata.AliasArn; this.log.info(chalk`{green ok}: alias {yellow ${this._aliasARN}} updated.`); } catch (e) { if (e.name === 'ResourceNotFoundException') { this.log.info(`--: creating new alias ${functionName}:${functionVersion} at v${versionNum}`); const createdata = await this._lambda.send(new CreateAliasCommand({ FunctionName: functionName, Name: functionVersion, FunctionVersion: versionNum, })); this._aliasARN = createdata.AliasArn; this.log.info(chalk`{green ok}: alias {yellow ${this._aliasARN}} created.`); } else { this.log.error(`Unable to verify existence of Lambda alias ${functionName}:${functionVersion}`); throw e; } } } async initApiId() { let res; if (!this._cfg.apiId) { throw new Error('--aws-api is required'); } else if (this._cfg.apiId === 'create') { this.log.info('--: creating API from scratch'); res = await this._api.send(new CreateApiCommand({ Name: API_GW_NAME_DEFAULT, ProtocolType: 'HTTP', })); this.log.info(`Using new API "${res.ApiId}"`); } else if (this._cfg.apiId === 'auto') { res = await this._api.send(new GetApisCommand({ })); // todo: find API with appropriate tag. eg `helix-deploy:<namespace`. res = res.Items.find((a) => a.Name === API_GW_NAME_DEFAULT); if (!res) { throw Error('--aws-api=auto didn\'t find an appropriate api.'); } this.log.info(`--: using existing API "${res.ApiId}"`); } else { res = await this._api.send(new GetApiCommand({ ApiId: this._cfg.apiId, })); this.log.info(`--: using existing API "${res.ApiId}"`); } const { ApiId, ApiEndpoint } = res; this._cfg.apiId = ApiId; this._apiEndpoint = ApiEndpoint; return { ApiId, ApiEndpoint }; } async findIntegration(ApiId, IntegrationUri) { let nextToken; do { const res = await this._api.send(new GetIntegrationsCommand({ ApiId, NextToken: nextToken, })); const integration = res.Items.find((i) => i.IntegrationUri === IntegrationUri); if (integration) { return integration; } nextToken = res.NextToken; } while (nextToken); return null; } async fetchIntegration(ApiId) { let nextToken; const integrations = []; do { const res = await this._api.send(new GetIntegrationsCommand({ ApiId, NextToken: nextToken, })); integrations.push(...res.Items); nextToken = res.NextToken; } while (nextToken); return integrations; } async fetchRoutes(ApiId) { let nextToken; const routes = []; do { const res = await this._api.send(new GetRoutesCommand({ ApiId, NextToken: nextToken, })); routes.push(...res.Items); nextToken = res.NextToken; } while (nextToken); return routes; } async fetchAuthorizers(ApiId) { let nextToken; const authorizers = []; do { const res = await this._api.send(new GetAuthorizersCommand({ ApiId, NextToken: nextToken, })); authorizers.push(...res.Items); nextToken = res.NextToken; } while (nextToken); return authorizers; } async listAliases(functionName) { let nextMarker; const aliases = []; do { const res = await this._lambda.send(new ListAliasesCommand({ FunctionName: functionName, Marker: nextMarker, })); aliases.push(...res.Aliases); nextMarker = res.NextMarker; } while (nextMarker); return aliases; } async listVersions(functionName) { let nextMarker; const versions = []; do { const res = await this._lambda.send(new ListVersionsByFunctionCommand({ FunctionName: functionName, Marker: nextMarker, })); versions.push(...res.Versions); nextMarker = res.NextMarker; } while (nextMarker); return versions; } async createAPI() { const { cfg } = this; const { ApiId, ApiEndpoint } = await this.initApiId(); this._functionURL = `${ApiEndpoint}${this.functionPath}`; // check for stage const res = await this._api.send(new GetStagesCommand({ ApiId: this._cfg.apiId, })); const stage = res.Items.find((s) => s.StageName === '$default'); if (!stage) { await this._api.send(new CreateStageCommand({ StageName: '$default', AutoDeploy: true, ApiId, })); } if (this._cfg.createRoutes) { // find integration let integration = await this.findIntegration(ApiId, this._aliasARN); if (integration) { this.log.info(`--: using existing integration "${integration.IntegrationId}" for "${this._aliasARN}"`); } else { integration = await this._api.send(new CreateIntegrationCommand({ ApiId, IntegrationMethod: 'POST', IntegrationType: 'AWS_PROXY', IntegrationUri: this._aliasARN, PayloadFormatVersion: '2.0', TimeoutInMillis: Math.min(cfg.timeout, 30000), })); this.log.info(chalk`{green ok:} created new integration "${integration.IntegrationId}" for "${this._aliasARN}"`); } // need to create 2 routes. one for the exact path, and one with suffix const { IntegrationId } = integration; this.log.info('--: fetching existing routes...'); const routes = await this.fetchRoutes(ApiId); const routeParams = { ApiId, Target: `integrations/${IntegrationId}`, }; await this.createOrUpdateRoute(routes, routeParams, `ANY ${this.functionPath}/{path+}`); await this.createOrUpdateRoute(routes, routeParams, `ANY ${this.functionPath}`); } // setup permissions for entire package. // this way we don't need to setup more permissions for link routes // eslint-disable-next-line no-underscore-dangle const sourceArn = `arn:aws:execute-api:${this._cfg.region}:${this._accountId}:${ApiId}/*/*/${cfg.packageName}/*`; try { // eslint-disable-next-line no-await-in-loop await this._lambda.send(new AddPermissionCommand({ FunctionName: this._aliasARN, Action: 'lambda:InvokeFunction', SourceArn: sourceArn, Principal: 'apigateway.amazonaws.com', StatementId: crypto.createHash('md5').update(this._aliasARN + sourceArn).digest('hex'), })); this.log.info(chalk`{green ok:} added invoke permissions for ${sourceArn}`); } catch (e) { // ignore, most likely the permission already exists } this.log.info(chalk`{green ok:} function deployed: {blueBright ${this._functionURL}}`); } async test() { let url = this._functionURL; if (!url) { url = `https://${this._cfg.apiId}.execute-api.${this._cfg.region}.amazonaws.com${this.functionPath}`; } return this.testRequest({ url, idHeader: 'apigw-requestid', retry404: 5, }); } get fullFunctionName() { return this._functionURL; } async updateSecrets() { const { cfg } = this; const SecretId = cfg.updateSecrets || `/helix-deploy/${cfg.packageName}/${cfg.baseName}`; this.log.info(`--: updating app parameters in secrets manager at '${SecretId}'...`); try { await this._sm.send(new PutSecretValueCommand({ SecretId, SecretString: JSON.stringify(cfg.params), })); } catch (e) { this.log.error(chalk`{red error:} unable to update value of '${SecretId}'`); throw e; } } async updatePackage() { const { cfg } = this; let found = false; if (this._cfg.parameterMgr.includes('secret')) { found = true; this.log.info('--: updating app (package) parameters (secrets mananger)...'); const SecretId = `/helix-deploy/${cfg.packageName}/all`; try { await this._sm.send(new PutSecretValueCommand({ SecretId, SecretString: JSON.stringify(cfg.packageParams), })); } catch (e) { this.log.error(chalk`{red error:} unable to update value of '${SecretId}'`); throw e; } } if (this._cfg.parameterMgr.includes('system')) { found = true; this.log.info('--: updating app (package) parameters (param store)...'); const commands = Object .entries(cfg.packageParams) .map(([key, value]) => this._ssm.send(new PutParameterCommand({ Name: `/helix-deploy/${cfg.packageName}/${key}`, Value: value, Type: 'SecureString', DataType: 'text', Overwrite: true, }))); await Promise.all(commands); } if (!found) { throw Error(`Unable to update package parameters. invalid manager specified: ${this._cfg.parameterMgr}`); } this.log.info(chalk`{green ok}: parameters updated.`); } async cleanup() { const { cfg, functionName } = this; const cleanupMatchers = [{ name: 'CI', property: 'cleanupCiAge', match: (alias) => alias.match(/^ci(\d+)$/), }, { name: 'patch', property: 'cleanupPatchAge', match: (alias) => alias.match(/^(\d+)_(\d+)_(\d+)(-test)?$/), }]; try { this.log.info('Clean up old aliases and versions'); this.log.info(chalk`--: fetching aliases...`); const aliases = await this.listAliases(functionName); this.log.info(chalk`--: fetching versions...`); const versions = (await this.listVersions(functionName)) .map((version) => ({ ...version, Aliases: aliases .filter(({ FunctionVersion }) => version.Version === FunctionVersion) .map(({ Name }) => Name), })); for (const { name, property, match } of cleanupMatchers) { if (cfg[property]) { const cleanupBefore = Date.now() - (cfg[property] * 1000); const oldVersions = versions .filter(({ Aliases }) => Aliases.length > 0 && Aliases.every((alias) => match(alias))) .filter(({ LastModified }) => Date.parse(LastModified) < cleanupBefore); this.log.info(`Found ${oldVersions.length} old ${name} versions`); if (oldVersions.length) { this.log.info(chalk`--: deleting their aliases and versions...`); const deleted = await processQueue(oldVersions, async ({ Aliases, Version }) => { await Promise.all(Aliases.map(async (Name) => { await this._lambda.send(new DeleteAliasCommand({ FunctionName: functionName, Name, })); })); await this._lambda.send(new DeleteFunctionCommand({ FunctionName: functionName, Qualifier: Version, })); return Version; }, 2); this.log.info(chalk`{green ok}: deleted ${deleted.length} old ${name} versions.`); } } } } catch (e) { this.log.error(chalk`{red error:} Cleanup failed, proceeding anyway.`, e); } } async cleanUpVersions() { const { functionName } = this; this.log.info('Clean up unused versions'); this.log.info(chalk`--: fetching aliases...`); const aliases = await this.listAliases(functionName); this.log.info(chalk`--: fetching versions...`); const versions = (await this.listVersions(functionName)) .map((version) => ({ ...version, Aliases: aliases .filter(({ FunctionVersion }) => version.Version === FunctionVersion) .map(({ Name }) => Name), })); const unusedVersions = versions .filter(({ Version }) => Version !== '$LATEST') .filter(({ Aliases }) => Aliases.length === 0); this.log.info(`Found ${unusedVersions.length} unused versions`); if (unusedVersions.length) { this.log.info(chalk`--: deleting unused versions...`); const deleted = await processQueue(unusedVersions, async ({ Version }) => { await this._lambda.send(new DeleteFunctionCommand({ FunctionName: functionName, Qualifier: Version, })); return Version; }, 2); this.log.info(chalk`{green ok}: deleted ${deleted.length} unused versions.`); } } async cleanUpIntegrations(filter) { this.log.info('Clean up Integrations'); const { ApiId } = await this.initApiId(); this.log.info(chalk`--: fetching routes...`); const routes = await this.fetchRoutes(ApiId); this.log.info(chalk`{green ok}: ${routes.length} routes.`); this.log.info(chalk`--: fetching integrations...`); const ints = await this.fetchIntegration(ApiId); this.log.info(chalk`{green ok}: ${ints.length} integrations.`); const routesByTarget = new Map(); routes.forEach((route) => { const rts = routesByTarget.get(route.Target) || []; routesByTarget.set(route.Target, rts); rts.push(route); }); const unused = []; if (filter) { this.log.info(chalk`Integrations / Routes for {grey ${filter}}`); } else { this.log.info('Integrations / Routes'); } ints.sort((i0, i1) => (i0.IntegrationUri.localeCompare(i1.IntegrationUri))); ints.forEach((int, idx) => { if (filter && int.IntegrationUri.indexOf(filter) < 0) { return; } const key = `integrations/${int.IntegrationId}`; let pfx = idx === ints.length - 1 ? '└──' : '├──'; const fnc = int.IntegrationUri.split(':').splice(-2, 2).join('@'); this.log.info(chalk`${pfx} {yellow ${fnc}} {grey (${int.IntegrationId}})`); const rts = routesByTarget.get(key) || []; if (!rts.length) { unused.push(int); } pfx = idx === ints.length - 1 ? ' ' : '│ '; rts.forEach((route, idx1) => { const pfx1 = idx1 === rts.length - 1 ? '└──' : '├──'; this.log.info(chalk`${pfx}${pfx1} {blue ${route.RouteKey}}`); }); }); if (!unused.length) { this.log.info(chalk`{green ok}: No unused integrations.`); return; } this.log.info(chalk`--: deleting ${unused.length} unused integrations...`); // don't execute parallel to avoid flooding the API for (const int of unused) { const fnc = int.IntegrationUri.split(':') .splice(-2, 2) .join('@'); try { await this._api.send(new DeleteIntegrationCommand({ ApiId, IntegrationId: int.IntegrationId, })); this.log.info(chalk`{green ok}: {yellow ${int.IntegrationId}} {grey ${fnc}}`); // const delay = res.$metadata.totalRetryDelay; } catch (e) { this.log.info(chalk`{red error}: {yellow ${int.IntegrationId}} {grey ${fnc}}: ${e.message}`); } } this.log.info(chalk`{green ok}: deleted ${unused.length} unused integrations.`); } async createOrUpdateRoute(routes, routeParams, RouteKey) { const existing = routes.find((r) => r.RouteKey === RouteKey); const auth = routeParams.AuthorizerId ? chalk` {yellow (${routeParams.AuthorizerId})}` : ''; if (existing) { this.log.info(chalk`--: updating route for: {blue ${existing.RouteKey}}...`); const res = await this._api.send(new UpdateRouteCommand({ ...routeParams, RouteKey, RouteId: existing.RouteId, })); this.log.info(chalk`{green ok}: updated route for: {blue ${res.RouteKey}}${auth}`); } else { this.log.info(chalk`--: creating route for: {blue ${RouteKey}}...`); const res = await this._api.send(new CreateRouteCommand({ ...routeParams, RouteKey, })); this.log.info(chalk`{green ok}: created route for: {blue ${res.RouteKey}}${auth}`); } } async createOrUpdateAlias(name, functionName, functionVersion) { try { await this._lambda.send(new GetAliasCommand({ FunctionName: functionName, Name: name, })); this.log.info(chalk`--: updating alias {blue ${name}}...`); const res = await this._lambda.send(new UpdateAliasCommand({ FunctionName: functionName, Name: name, FunctionVersion: functionVersion, })); this.log.info(chalk`{green ok:} updated alias {blue ${name}} to version {yellow ${functionVersion}}.`); return res.AliasArn; } catch (e) { if (e.name === 'ResourceNotFoundException') { this.log.info(chalk`--: creating alias {blue ${name}}...`); const res = await this._lambda.send(new CreateAliasCommand({ FunctionName: functionName, Name: name, FunctionVersion: functionVersion, })); this.log.info(chalk`{green ok:} created alias {blue ${name}} for version {yellow ${functionVersion}}.`); return res.AliasArn; } else { this.log.error(`Unable to verify existence of Lambda alias ${name}`); throw e; } } } async updateLinks() { const { cfg, functionName } = this; const { ApiId } = await this.initApiId(); const functionVersion = cfg.version.replace(/\./g, '_'); let res; let incrementalVersion; try { this.log.info(chalk`--: fetching alias ...`); res = await this._lambda.send(new GetAliasCommand({ FunctionName: functionName, Name: functionVersion, })); incrementalVersion = res.FunctionVersion; // eslint-disable-next-line prefer-destructuring this._accountId = res.AliasArn.split(':')[4]; this.log.info(chalk`{green ok}: ${functionName}@${functionVersion} => ${incrementalVersion}`); } catch (e) { this.log.error(chalk`{red error}: Unable to create link to function ${functionName}`); throw e; } // get all the routes this.log.info(chalk`--: fetching routes ...`); const routes = await this.fetchRoutes(ApiId); // create routes for each symlink const sfx = this.getLinkVersions(); for (const suffix of sfx) { // create or update alias const aliasArn = await this.createOrUpdateAlias(suffix.replace('.', '_'), functionName, incrementalVersion); // find or create integration let integration = await this.findIntegration(ApiId, aliasArn); if (integration) { this.log.info(`--: using existing integration "${integration.IntegrationId}" for "${aliasArn}"`); } else { integration = await this._api.send(new CreateIntegrationCommand({ ApiId, IntegrationMethod: 'POST', IntegrationType: 'AWS_PROXY', IntegrationUri: aliasArn, PayloadFormatVersion: '2.0', TimeoutInMillis: Math.min(cfg.timeout, 30000), })); this.log.info(chalk`{green ok:} created new integration "${integration.IntegrationId}" for "${aliasArn}"`); } const { IntegrationId } = integration; const routeParams = { ApiId, Target: `integrations/${IntegrationId}`, AuthorizerId: undefined, AuthorizationType: 'NONE', }; if (this._cfg.attachAuthorizer) { this.log.info(chalk`--: fetching authorizers...`); const authorizers = await this.fetchAuthorizers(ApiId); const authorizer = authorizers.find((info) => info.Name === this._cfg.attachAuthorizer); if (!authorizer) { throw Error(`Specified authorizer ${this._cfg.attachAuthorizer} does not exist in api ${ApiId}.`); } routeParams.AuthorizerId = authorizer.AuthorizerId; routeParams.AuthorizationType = 'CUSTOM'; this.log.info(chalk`{green ok:} configuring routes with authorizer {blue ${this._cfg.attachAuthorizer}} {yellow ${authorizer.AuthorizerId}}`); } // create or update routes await this.createOrUpdateRoute(routes, routeParams, `ANY /${cfg.packageName}/${cfg.baseName}/${suffix}`); await this.createOrUpdateRoute(routes, routeParams, `ANY /${cfg.packageName}/${cfg.baseName}/${suffix}/{path+}`); await this.updateAuthorizers(ApiId, functionName, aliasArn); // add permissions to invoke function (with and without path) let sourceArn = `arn:aws:execute-api:${this._cfg.region}:${this._accountId}:${ApiId}/*/*/${cfg.packageName}/${cfg.baseName}/${suffix}`; try { // eslint-disable-next-line no-await-in-loop await this._lambda.send(new AddPermissionCommand({ FunctionName: aliasArn, Action: 'lambda:InvokeFunction', SourceArn: sourceArn, Principal: 'apigateway.amazonaws.com', StatementId: crypto.createHash('md5').update(aliasArn + sourceArn).digest('hex'), })); this.log.info(chalk`{green ok:} added invoke permissions for ${sourceArn}`); } catch (e) { // ignore, most likely the permission already exists } sourceArn = `arn:aws:execute-api:${this._cfg.region}:${this._accountId}:${ApiId}/*/*/${cfg.packageName}/${cfg.baseName}/${suffix}/{path+}`; try { // eslint-disable-next-line no-await-in-loop await this._lambda.send(new AddPermissionCommand({ FunctionName: aliasArn, Action: 'lambda:InvokeFunction', SourceArn: sourceArn, Principal: 'apigateway.amazonaws.com', StatementId: crypto.createHash('md5').update(aliasArn + sourceArn).digest('hex'), })); this.log.info(chalk`{green ok:} added invoke permissions for ${sourceArn}`); } catch (e) { // ignore, most likely the permission already exists } } } async updateAuthorizers(ApiId, functionName, aliasArn) { const cfg = this._cfg; if (!cfg.createAuthorizer) { return; } const AUTH_URI_PREFIX = `arn:aws:apigateway:${cfg.region}:lambda:path/2015-03-31/functions/`; const accountId = aliasArn.split(':')[4]; this.log.info(chalk`--: patching authorizers...`); const authorizers = await this.fetchAuthorizers(ApiId); const versions = this.getLinkVersions(); for (const version of versions) { const props = { ...this.cfg, ...this.cfg.properties, // overwrite version with link name version, }; const authorizerName = ActionBuilder.substitute(cfg.createAuthorizer, props).replace(/\./g, '_'); const existing = authorizers.find((info) => info.Name === authorizerName) || {}; let { AuthorizerId } = existing; if (AuthorizerId) { const res = await this._api.send(new UpdateAuthorizerCommand({ ApiId, AuthorizerId, AuthorizerUri: `${AUTH_URI_PREFIX}${aliasArn}/invocations`, IdentitySource: this._cfg.identitySources, })); this.log.info(chalk`{green ok}: updated authorizer: {blue ${res.Name}}`); } else { const res = await this._api.send(new CreateAuthorizerCommand({ ApiId, AuthorizerPayloadFormatVersion: '2.0', AuthorizerType: 'REQUEST', AuthorizerUri: `${AUTH_URI_PREFIX}${aliasArn}/invocations`, AuthorizerResultTtlInSeconds: 0, EnableSimpleResponses: true, IdentitySource: this._cfg.identitySources, Name: authorizerName, })); AuthorizerId = res.AuthorizerId; this.log.info(chalk`{green ok}: created authorizer: {blue ${res.Name}}`); } // add permission to alias for the API Gateway is allowed to invoke the authorized function try { const sourceArn = `arn:aws:execute-api:${this._cfg.region}:${accountId}:${ApiId}/authorizers/${AuthorizerId}`; await this._lambda.send(new AddPermissionCommand({ FunctionName: aliasArn, Action: 'lambda:InvokeFunction', SourceArn: sourceArn, Principal: 'apigateway.amazonaws.com', StatementId: crypto.createHash('sha256').update(aliasArn + sourceArn).digest('hex'), })); this.log.info(chalk`{green ok:} added invoke permissions for ${sourceArn}`); } catch (e) { // ignore, most likely the permission already exists } } } async checkFunctionReady(arn) { let tries = 6; let wait = 1500; while (tries > 0) { try { tries -= 1; this.log.info(chalk`--: checking function state ...`); const { Configuration } = await this._lambda.send(new GetFunctionCommand({ FunctionName: arn ?? this._functionARN, })); if (Configuration.State !== 'Active' || Configuration.LastUpdateStatus === 'InProgress') { this.log.info(chalk`{yellow !!:} function is {blue ${Configuration.State}} and last update was {blue ${Configuration.LastUpdateStatus}} (retry...)`); } else { this.log.info(chalk`{green ok:} function is {blue ${Configuration.State}} and last update was {blue ${Configuration.LastUpdateStatus}}.`); return; } // eslint-disable-next-line no-await-in-loop await sleep(wait); wait *= 2; } catch (e) { this.log.error(chalk`{red error}: error checking function state`); throw e; } } this.log.warn(chalk`{yellow warn:} function is not active yet, which might lead to failed requests.`); } async validateAdditionalTasks() { if (this._cfg.cleanUpIntegrations || this._cfg.updateSecrets !== undefined) { // disable auto build if no deploy if (!this.cfg.deploy) { this.cfg.build = false; } await this.validate(); } } async runAdditionalTasks() { if (this._cfg.cleanUpIntegrations) { await this.cleanUpIntegrations(); } if (this._cfg.cleanUpVersions) { await this.cleanUpVersions(); } if (this._cfg.updateSecrets !== undefined) { await this.updateSecrets(); } } async createExtraPermissions() { const { functionName } = this; if (this._cfg.extraPermissions) { await Promise.allSettled(this._cfg.extraPermissions.map(async (extraPermission) => { const [sourceArn, principalAndOptionalAlias] = extraPermission.split('@', 2); const [principal, alias] = principalAndOptionalAlias.split(':', 2); const functionNameForPermission = alias ? `${functionName}:${alias}` : functionName; try { await this._lambda.send(new AddPermissionCommand({ FunctionName: functionNameForPermission, Action: 'lambda:InvokeFunction', SourceArn: sourceArn, Principal: principal, StatementId: crypto.createHash('sha256').update(functionName + sourceArn).digest('hex'), })); this.log.info(chalk`{green ok:} added invoke permissions for ${sourceArn} on ${functionNameForPermission}`); } catch (e) { // ignore, most likely the permission already exists } })); } } async deploy() { try { this.log.info(`--: using aws region "${this._cfg.region}"`); await this.initAccountId(); await this.uploadZIP(); await this.createLambda(); await this.deleteZIP(); await this.createAPI(); await this.createExtraPermissions(); await this.checkFunctionReady(); } catch (err) { this.log.error(`Unable to deploy Lambda function: ${err.message}`, err); throw err; } } async close() { this._s3?.destroy(); this._lambda?.destroy(); this._api?.destroy(); this._ssm?.destroy(); this._sm?.destroy(); await super.close(); } } AWSDeployer.Config = AWSConfig;