UNPKG

lambda-live-debugger

Version:

Debug Lambda functions locally like it is running in the cloud

550 lines (547 loc) 24.7 kB
import * as esbuild from 'esbuild'; import * as fs from 'fs/promises'; import * as path from 'path'; import { pathToFileURL } from 'url'; import { BundlingType } from '../types/resourcesDiscovery.mjs'; import { outputFolder } from '../constants.mjs'; import { findPackageJson } from '../utils/findPackageJson.mjs'; import { CloudFormation } from '../cloudFormation.mjs'; import { Logger } from '../logger.mjs'; import { Worker } from 'node:worker_threads'; import { getModuleDirname, getProjectDirname } from '../getDirname.mjs'; import { findNpmPath } from '../utils/findNpmPath.mjs'; import { loadSharedConfigFiles } from '@smithy/shared-ini-file-loader'; /** * Support for AWS CDK framework */ export class CdkFramework { /** * Framework name */ get name() { return 'cdk'; } /** * Can this class handle the current project * @returns */ async canHandle() { // check if there is cdk.json const cdkJsonPath = path.resolve('cdk.json'); try { await fs.access(cdkJsonPath, fs.constants.F_OK); return true; } catch { Logger.verbose(`[CDK] This is not a CDK project. ${cdkJsonPath} not found`); return false; } } /** * Get Lambda functions * @param config Configuration * @returns Lambda functions */ async getLambdas(config) { const awsConfiguration = { region: config.region, profile: config.profile, role: config.role, }; const cdkConfigPath = 'cdk.json'; // read cdk.json and extract the entry file const lambdasInCdk = await this.getLambdasDataFromCdkByCompilingAndRunning(cdkConfigPath, config); Logger.verbose(`[CDK] Found Lambda functions:`, JSON.stringify(lambdasInCdk, null, 2)); const cdkTokenRegex = /^\${Token\[TOKEN\.\d+\]}$/; const stackNamesDuplicated = lambdasInCdk.map((lambda) => { if (cdkTokenRegex.test(lambda.stackName)) { return lambda.rootStackName; } else { return lambda.stackName; } }); const stackNames = [...new Set(stackNamesDuplicated)]; Logger.verbose(`[CDK] Found the following stacks in CDK: ${stackNames.join(', ')}`); const lambdasDeployed = (await Promise.all(stackNames.map(async (stackName) => { const lambdasInStack = await CloudFormation.getLambdasInStack(stackName, awsConfiguration); const stackAndNestedStackNames = [ ...new Set(lambdasInStack.map((l) => l.stackName)), ]; const lambdasMetadata = (await Promise.all(stackAndNestedStackNames.map((stackOrNestedStackName) => this.getLambdaCdkPathFromTemplateMetadata(stackOrNestedStackName, awsConfiguration)))).flat(); Logger.verbose(`[CDK] Found Lambda functions in the stack ${stackName}:`, JSON.stringify(lambdasInStack, null, 2)); Logger.verbose(`[CDK] Found Lambda functions in the stack ${stackName} in the template metadata:`, JSON.stringify(lambdasMetadata, null, 2)); const lambdasPhysicalResourceIds = lambdasInStack.map((lambda) => { return { lambdaName: lambda.lambdaName, cdkPath: lambdasMetadata.find((lm) => lm.logicalId === lambda.logicalId && lm.stackName === lambda.stackName)?.cdkPath, }; }); return lambdasPhysicalResourceIds; }))).flat(); const lambdasDiscovered = []; // compare lambdas in CDK and Stack and get the code path of the Lambdas for (const lambdaInCdk of lambdasInCdk) { const functionName = lambdasDeployed.find((lambda) => lambda.cdkPath === lambdaInCdk.cdkPath)?.lambdaName; if (functionName) { const external = [ ...(lambdaInCdk.bundling?.externalModules ?? []), ...(lambdaInCdk.bundling?.nodeModules ?? []), ]; lambdasDiscovered.push({ functionName: functionName, codePath: lambdaInCdk.codePath, handler: lambdaInCdk.handler, packageJsonPath: lambdaInCdk.packageJsonPath, bundlingType: BundlingType.ESBUILD, esBuildOptions: { minify: lambdaInCdk.bundling?.minify, format: lambdaInCdk.bundling?.format, sourcesContent: lambdaInCdk.bundling?.sourcesContent, target: lambdaInCdk.bundling?.target, loader: lambdaInCdk.bundling?.loader, logLevel: lambdaInCdk.bundling?.logLevel, keepNames: lambdaInCdk.bundling?.keepNames, tsconfig: lambdaInCdk.bundling?.tsconfig, metafile: lambdaInCdk.bundling?.metafile, banner: lambdaInCdk.bundling?.banner ? { js: lambdaInCdk.bundling?.banner } : undefined, footer: lambdaInCdk.bundling?.footer ? { js: lambdaInCdk.bundling?.footer } : undefined, charset: lambdaInCdk.bundling?.charset, define: lambdaInCdk.bundling?.define, external: external.length > 0 ? external : undefined, }, metadata: { framework: 'cdk', stackName: lambdaInCdk.stackName, cdkPath: lambdaInCdk.cdkPath, }, }); } } return lambdasDiscovered; } /** * Getz Lambda functions from the CloudFormation template metadata * @param stackName * @param awsConfiguration * @returns */ async getLambdaCdkPathFromTemplateMetadata(stackName, awsConfiguration) { const cfTemplate = await CloudFormation.getCloudFormationStackTemplate(stackName, awsConfiguration); // get all Lambda functions in the template if (cfTemplate) { const lambdas = Object.entries(cfTemplate.Resources) .filter(([, resource]) => resource.Type === 'AWS::Lambda::Function') .map(([key, resource]) => { return { logicalId: key, cdkPath: resource.Metadata['aws:cdk:path'], stackName: stackName, }; }); return lambdas; } return []; } /** * Get Lambdas data from CDK by compiling and running the CDK code * @param cdkConfigPath * @param config * @returns */ async getLambdasDataFromCdkByCompilingAndRunning(cdkConfigPath, config) { const entryFile = await this.getCdkEntryFile(cdkConfigPath); let isESM = false; const packageJsonPath = await findPackageJson(entryFile); if (packageJsonPath) { try { const packageJson = JSON.parse(await fs.readFile(packageJsonPath, { encoding: 'utf-8' })); if (packageJson.type === 'module') { isESM = true; Logger.verbose(`[CDK] Using ESM format`); } } catch (err) { Logger.error(`Error reading CDK package.json (${packageJsonPath}): ${err.message}`, err); } } const rootDir = process.cwd(); // Plugin that: // - Fixes __dirname issues // - Injects code to get the file path of the Lambda function and CDK hierarchy const injectCodePlugin = { name: 'injectCode', setup(build) { build.onLoad({ filter: /.*/ }, async (args) => { // fix __dirname issues const isWindows = /^win/.test(process.platform); const esc = (p) => (isWindows ? p.replace(/\\/g, '/') : p); const variables = ` const __fileloc = { filename: "${esc(args.path)}", dirname: "${esc(path.dirname(args.path))}", relativefilename: "${esc(path.relative(rootDir, args.path))}", relativedirname: "${esc(path.relative(rootDir, path.dirname(args.path)))}", import: { meta: { url: "file://${esc(args.path)}" } } }; `; let fileContent = new TextDecoder().decode(await fs.readFile(args.path)); // remove shebang if (fileContent.startsWith('#!')) { const firstNewLine = fileContent.indexOf('\n'); fileContent = fileContent.slice(firstNewLine + 1); } let contents; if (args.path.endsWith('.ts') || args.path.endsWith('.js')) { // add the variables at the top of the file, that contains the file location contents = `${variables}\n${fileContent}`; } else { contents = fileContent; } // for .mjs files, use js loader const fileExtension = args.path.split('.').pop(); const loader = fileExtension === 'mjs' || fileExtension === 'cjs' ? 'js' : fileExtension; // Inject code to get the file path of the Lambda function and CDK hierarchy if (args.path.includes(path.join('aws-cdk-lib', 'aws-lambda', 'lib', 'function.'))) { const codeToFind = 'try{jsiiDeprecationWarnings().aws_cdk_lib_aws_lambda_FunctionProps(props)}'; if (!contents.includes(codeToFind)) { throw new Error(`Can not find code to inject in ${args.path}`); } // Inject code to get the file path of the Lambda function and CDK hierarchy // path to match it with the Lambda function. Store data in the global variable. contents = contents.replace(codeToFind, `; global.lambdas = global.lambdas ?? []; let rootStack = this.stack; while (rootStack.nestedStackParent) { rootStack = rootStack.nestedStackParent; } const lambdaInfo = { //cdkPath: this.node.defaultChild?.node.path ?? this.node.path, stackName: this.stack.stackName, rootStackName: rootStack.stackName, codePath: props.entry, code: props.code, node: this.node, handler: props.handler, bundling: props.bundling }; // console.log("CDK INFRA: ", { // stackName: lambdaInfo.stackName, // rootStackName: lambdaInfo.rootStackName, // codePath: lambdaInfo.codePath, // code: lambdaInfo.code, // handler: lambdaInfo.handler, // bundling: lambdaInfo.bundling // }); global.lambdas.push(lambdaInfo);` + codeToFind); } else if (args.path.includes(path.join('aws-cdk-lib', 'aws-s3-deployment', 'lib', 'bucket-deployment.'))) { let codeToFind = 'super(scope,id),this.requestDestinationArn=!1;'; if (!contents.includes(codeToFind)) { // newer CDK version codeToFind = 'super(scope,id);'; } if (!contents.includes(codeToFind)) { throw new Error(`Can not find code to inject in ${args.path}`); } // Inject code to prevent deploying the assets contents = contents.replace(codeToFind, codeToFind + `return;`); } else if (args.path.includes(path.join('aws-cdk-lib', 'aws-lambda-nodejs', 'lib', 'bundling.'))) { // prevent initializing Docker if esbuild is no installed // Docker is used for bundling if esbuild is not installed, but it is not needed at this point const origCode = 'const shouldBuildImage=props.forceDockerBundling||!Bundling.esbuildInstallation;'; const replaceCode = 'const shouldBuildImage=false;'; if (contents.includes(origCode)) { contents = contents.replace(origCode, replaceCode); } else { throw new Error(`Can not find code to inject in ${args.path} to prevent initializing Docker`); } } return { contents, loader, }; }); }, }; const compileOutput = path.join(getProjectDirname(), outputFolder, `compiledCdk.${isESM ? 'mjs' : 'cjs'}`); try { // Build CDK code await esbuild.build({ entryPoints: [entryFile], bundle: true, platform: 'node', keepNames: true, outfile: compileOutput, sourcemap: false, plugins: [injectCodePlugin], ...(isESM ? { format: 'esm', target: 'esnext', mainFields: ['module', 'main'], banner: { js: [ `import { createRequire as topLevelCreateRequire } from 'module';`, `global.require = global.require ?? topLevelCreateRequire(import.meta.url);`, `import { fileURLToPath as topLevelFileUrlToPath, URL as topLevelURL } from "url"`, `global.__dirname = global.__dirname ?? topLevelFileUrlToPath(new topLevelURL(".", import.meta.url))`, ].join('\n'), }, } : { format: 'cjs', target: 'node18', }), define: { // replace __dirname,... with the a variable that contains the file location __filename: '__fileloc.filename', __dirname: '__fileloc.dirname', __relativefilename: '__fileloc.relativefilename', __relativedirname: '__fileloc.relativedirname', 'import.meta.url': '__fileloc.import.meta.url', }, }); } catch (error) { throw new Error(`Error building CDK code: ${error.message}`, { cause: error, }); } const context = await this.getCdkContext(cdkConfigPath, config); const CDK_CONTEXT_JSON = { ...context, // prevent compiling assets 'aws:cdk:bundling-stacks': [], }; process.env.CDK_CONTEXT_JSON = JSON.stringify(CDK_CONTEXT_JSON); process.env.CDK_DEFAULT_REGION = config.region ?? (await this.getRegion(config.profile)); Logger.verbose(`[CDK] Context:`, JSON.stringify(CDK_CONTEXT_JSON, null, 2)); const awsCdkLibPath = await findNpmPath(path.join(getProjectDirname(), config.subfolder ?? '/'), 'aws-cdk-lib'); Logger.verbose(`[CDK] aws-cdk-lib path: ${awsCdkLibPath}`); const lambdas = await this.runCdkCodeAndReturnLambdas({ config, awsCdkLibPath, compileCodeFile: compileOutput, }); const list = await Promise.all(lambdas.map(async (lambda) => { // handler slit into file and file name const handlerSplit = lambda.handler.split('.'); const handler = handlerSplit.pop(); const filename = handlerSplit[0]; let codePath = lambda.codePath; if (!codePath) { const codePathJs = lambda.code?.path ? path.join(lambda.code.path, `${filename}.js`) : undefined; const codePathCjs = lambda.code?.path ? path.join(lambda.code.path, `${filename}.cjs`) : undefined; const codePathMjs = lambda.code?.path ? path.join(lambda.code.path, `${filename}.mjs`) : undefined; // get the first file that exists codePath = [codePathJs, codePathCjs, codePathMjs] .filter((c) => c) .find((file) => fs .access(file) .then(() => true) .catch(() => false)); } let packageJsonPath; if (codePath) { packageJsonPath = await findPackageJson(codePath); Logger.verbose(`[CDK] package.json path: ${packageJsonPath}`); } return { cdkPath: lambda.cdkPath, stackName: lambda.stackName, rootStackName: lambda.rootStackName, packageJsonPath, codePath: codePath, handler, bundling: lambda.bundling, }; })); const filteredList = list.filter((lambda) => lambda.codePath); const noCodeList = list.filter((lambda) => !lambda.codePath); if (noCodeList.length > 0) { Logger.warn(`[CDK] For the following Lambda functions the code file could not be determined and they will be ignored. Inline code is not supported.\n - ${noCodeList .map((l) => `${l.cdkPath}`) .join('\n - ')}`); } return filteredList.map((lambda) => ({ ...lambda, codePath: lambda.codePath, })); } /** * Run CDK code in a node thread worker and return the Lambda functions * @param param0 * @returns */ async runCdkCodeAndReturnLambdas({ config, awsCdkLibPath, compileCodeFile, }) { const lambdas = await new Promise((resolve, reject) => { const workerPath = pathToFileURL(path.resolve(path.join(getModuleDirname(), 'frameworks/cdkFrameworkWorker.mjs'))).href; const worker = new Worker(new URL(workerPath), { workerData: { verbose: config.verbose, awsCdkLibPath, projectDirname: getProjectDirname(), moduleDirname: getModuleDirname(), subfolder: config.subfolder, }, }); worker.on('message', async (message) => { resolve(message); await worker.terminate(); }); worker.on('error', (error) => { reject(new Error(`Error running CDK code in worker: ${error.message}`, { cause: error, })); }); worker.on('exit', (code) => { if (code !== 0) { reject(new Error(`CDK worker stopped with exit code ${code}`)); } }); // worker.stdout.on('data', (data: Buffer) => { // Logger.log(`[CDK]`, data.toString()); // }); // worker.stderr.on('data', (data: Buffer) => { // Logger.error(`[CDK]`, data.toString()); // }); worker.postMessage({ compileOutput: compileCodeFile, }); }); Logger.verbose(`[CDK] Found the following Lambda functions in the CDK code:`, JSON.stringify(lambdas, null, 2)); return lambdas; } /** * Get CDK context * @param cdkConfigPath * @param config * @returns */ async getCdkContext(cdkConfigPath, config) { // get CDK context from the command line // get all "-c" and "--context" arguments from the command line const contextFromLldConfig = config.context?.reduce((acc, arg) => { const [key, value] = arg.split('='); if (key && value) { acc[key.trim()] = value.trim(); } return acc; }, {}); // get all context from 'cdk.context.json' if it exists let contextFromJson = {}; try { const cdkContextJson = await fs.readFile('cdk.context.json', 'utf8'); contextFromJson = JSON.parse(cdkContextJson); } catch (err) { if (err.code !== 'ENOENT') { throw new Error(`Error reading cdk.context.json: ${err.message}`); } } // get context from cdk.json let cdkJson = {}; try { cdkJson = JSON.parse(await fs.readFile(cdkConfigPath, 'utf8')); } catch (err) { if (err.code !== 'ENOENT') { throw new Error(`Error reading cdk.json: ${err.message}`); } } return { ...contextFromJson, ...cdkJson.context, ...contextFromLldConfig }; } /** * Get CDK entry file * @param cdkConfigPath * @returns */ async getCdkEntryFile(cdkConfigPath) { const cdkJson = await fs.readFile(cdkConfigPath, 'utf8'); const cdkConfig = JSON.parse(cdkJson); const entry = cdkConfig.app; // just file that ends with .ts let entryFile = entry ?.split(' ') .find((file) => file.endsWith('.ts')) ?.trim(); if (!entryFile) { throw new Error(`Entry file not found in ${cdkConfigPath}`); } entryFile = path.resolve(entryFile); Logger.verbose(`[CDK] Entry file: ${entryFile}`); return entryFile; } /** * Attempts to get the region from a number of sources and falls back to us-east-1 if no region can be found, * as is done in the AWS CLI. * * The order of priority is the following: * * 1. Environment variables specifying region, with both an AWS prefix and AMAZON prefix * to maintain backwards compatibility, and without `DEFAULT` in the name because * Lambda and CodeBuild set the $AWS_REGION variable. * 2. Regions listed in the Shared Ini Files - First checking for the profile provided * and then checking for the default profile. * 3. xxx * 4. us-east-1 * * Code from aws-cdk-cli/packages/@aws-cdk/tmp-toolkit-helpers/src/api/aws-auth /awscli-compatible.ts */ async getRegion(maybeProfile) { const profile = maybeProfile || process.env.AWS_PROFILE || process.env.AWS_DEFAULT_PROFILE || 'default'; const region = process.env.AWS_REGION || process.env.AMAZON_REGION || process.env.AWS_DEFAULT_REGION || process.env.AMAZON_DEFAULT_REGION || (await this.getRegionFromIni(profile)); return region; } /** * Looks up the region of the provided profile. If no region is present, * it will attempt to lookup the default region. * @param profile The profile to use to lookup the region * @returns The region for the profile or default profile, if present. Otherwise returns undefined. * * Code from aws-cdk-cli/packages/@aws-cdk/tmp-toolkit-helpers/src/api/aws-auth */ async getRegionFromIni(profile) { const sharedFiles = await loadSharedConfigFiles({ ignoreCache: true }); return (this.getRegionFromIniFile(profile, sharedFiles.credentialsFile) ?? this.getRegionFromIniFile(profile, sharedFiles.configFile) ?? this.getRegionFromIniFile('default', sharedFiles.credentialsFile) ?? this.getRegionFromIniFile('default', sharedFiles.configFile)); } /** * Get region from ini file * @param profile * @param data * @returns */ getRegionFromIniFile(profile, data) { return data?.[profile]?.region; } } export const cdkFramework = new CdkFramework();