lambda-live-debugger
Version:
Debug Lambda functions locally like it is running in the cloud
261 lines (260 loc) • 10.4 kB
JavaScript
import * as fs from 'fs/promises';
import * as path from 'path';
import { constants } from 'fs';
import { findPackageJson } from '../utils/findPackageJson.mjs';
import { exec } from 'child_process';
import { promisify } from 'util';
import ts from 'typescript';
import { Logger } from '../logger.mjs';
export const execAsync = promisify(exec);
/**
* Support for Terraform framework
*/
export class TerraformFramework {
/**
* Framework name
*/
get name() {
return 'terraform';
}
/**
* Name of the framework in logs
*/
get logName() {
return 'Terrform';
}
/**
* Get Terraform state CI command
*/
get stateCommand() {
return 'terraform show --json';
}
/**
*
* @returns Get command to check if Terraform is installed
*/
get checkInstalledCommand() {
return 'terraform --version';
}
/**
* Can this class handle the current project
* @returns
*/
async canHandle() {
// is there any filey with *.tf extension
const files = await fs.readdir(process.cwd());
const r = files.some((f) => f.endsWith('.tf'));
if (!r) {
Logger.verbose(`[${this.logName}] This is not a ${this.logName} project. There are no *.tf files in ${path.resolve('.')} folder.`);
return false;
}
else {
// check if Terraform or OpenTofu is installed
try {
await execAsync(this.checkInstalledCommand);
return true;
}
catch {
Logger.verbose(`[${this.logName}] This is not a ${this.logName} project. ${this.logName} is not installed.`);
return false;
}
}
}
/**
* Get Lambda functions
* @param _config Configuration
* @returns Lambda functions
*/
// eslint-disable-next-line @typescript-eslint/no-unused-vars
async getLambdas(config) {
const state = await this.readTerraformState();
const lambdas = this.extractLambdaInfo(state);
Logger.verbose(`[${this.logName}] Found Lambdas:`, JSON.stringify(lambdas, null, 2));
const lambdasDiscovered = [];
const tsOutDir = await this.getTsConfigOutDir();
if (tsOutDir) {
Logger.verbose(`[${this.logName}] tsOutDir:`, tsOutDir);
}
for (const func of lambdas) {
const functionName = func.functionName;
const handlerParts = func.handler.split('.');
// get last part of the handler
const handler = handlerParts[handlerParts.length - 1];
const filename = func.sourceFilename;
let pathWithourExtension;
if (filename) {
// remove extension
pathWithourExtension = filename.replace(/\.[^/.]+$/, '');
}
else {
pathWithourExtension = path.join(func.sourceDir, handlerParts[0]);
}
let possibleCodePaths = [
`${pathWithourExtension}.ts`,
`${pathWithourExtension}.js`,
`${pathWithourExtension}.cjs`,
`${pathWithourExtension}.mjs`,
];
if (tsOutDir) {
// remove outDir from path
const pathWithourExtensionTypeScript = pathWithourExtension
.replace(tsOutDir, '')
.replace(/\/\//g, '/');
possibleCodePaths = [
`${pathWithourExtensionTypeScript}.ts`,
`${pathWithourExtensionTypeScript}.js`,
`${pathWithourExtensionTypeScript}.cjs`,
`${pathWithourExtensionTypeScript}.mjs`,
...possibleCodePaths,
];
}
let codePath;
for (const cp of possibleCodePaths) {
try {
await fs.access(cp, constants.F_OK);
codePath = cp;
break;
}
catch {
// ignore, file not found
}
}
if (!codePath) {
throw new Error(`Code path not found for handler: ${functionName}`);
}
const packageJsonPath = await findPackageJson(codePath);
const lambdaResource = {
functionName,
codePath,
handler,
packageJsonPath,
esBuildOptions: undefined,
metadata: {
framework: this.name,
},
};
lambdasDiscovered.push(lambdaResource);
}
return lambdasDiscovered;
}
extractLambdaInfo(resources) {
const lambdas = [];
for (const resource of resources) {
if (resource.type === 'aws_lambda_function') {
Logger.verbose(`[${this.logName}] Found Lambda:`, JSON.stringify(resource, null, 2));
let sourceDir;
let sourceFilename;
const functionName = resource.values.function_name;
const handler = resource.values.handler;
if (!functionName) {
Logger.error('Failed to find function name for Lambda');
continue;
}
// get dependency "data.archive_file"
const dependencies = resource.depends_on;
const archiveFileResourceName = dependencies.find((dep) => dep.startsWith('data.archive_file.'));
if (archiveFileResourceName) {
// get the resource
const name = archiveFileResourceName.split('.')[2];
const archiveFileResource = resources.find((r) => r.name === name);
// get source_dir or source_filename
if (archiveFileResource) {
sourceDir = archiveFileResource.values.source_dir;
sourceFilename = archiveFileResource.values.source_file;
}
}
// get dependency "archive_prepare" = serverless.tf support
const archivePrepareResourceName = dependencies.find((dep) => dep.includes('.archive_prepare'));
if (archivePrepareResourceName) {
// get the resource
const name = archivePrepareResourceName;
const archivePrepareResource = resources.find((r) => r.address?.startsWith(name));
// get source_dir or source_filename
if (archivePrepareResource) {
sourceDir =
archivePrepareResource.values.query?.source_path?.replaceAll('"', '');
}
}
if (!sourceDir && !sourceFilename) {
Logger.error(`Failed to find source code for Lambda ${functionName}`);
}
else {
lambdas.push({
functionName,
sourceDir,
sourceFilename,
handler: handler ?? 'handler',
});
}
}
}
return lambdas;
}
async readTerraformState() {
// Is there a better way to get the Terraform state???
let output;
// get state by running "terraform show --json" command
try {
Logger.verbose(`[${this.logName}] Getting state with '${this.stateCommand}' command`);
output = await execAsync(this.stateCommand);
}
catch (error) {
throw new Error(`[${this.logName}] Failed to getstate from '${this.stateCommand}' command: ${error.message}`, { cause: error });
}
if (output.stderr) {
throw new Error(`[${this.logName}] Failed to get state from '${this.stateCommand}' command: ${output.stderr}`);
}
if (!output.stdout) {
throw new Error(`[${this.logName}] Failed to get state from '${this.stateCommand}' command`);
}
let jsonString = output.stdout;
Logger.verbose(`[${this.logName}] State:`, jsonString);
jsonString = jsonString?.split('\n').find((line) => line.startsWith('{'));
if (!jsonString) {
throw new Error(`[${this.logName}] Failed to get state. JSON string not found in the output.`);
}
try {
const state = JSON.parse(jsonString);
const rootResources = state.values?.root_module?.resources ?? [];
const childResources = state.values?.root_module?.child_modules
?.map((m) => m.resources)
.flat() ?? [];
return [...rootResources, ...childResources];
}
catch (error) {
//save state to file
await fs.writeFile(`${this.name}-state.json`, jsonString);
Logger.error(`[${this.logName}] Failed to parse state JSON:`, error);
throw new Error(`Failed to parse ${this.logName} state JSON: ${error.message}`, { cause: error });
}
}
/**
* Get the outDir from tsconfig.json
*/
async getTsConfigOutDir() {
let currentDir = process.cwd();
let tsConfigPath;
while (currentDir !== '/') {
tsConfigPath = path.resolve(path.join(currentDir, 'tsconfig.json'));
try {
await fs.access(tsConfigPath, constants.F_OK);
break;
}
catch {
// tsconfig.json not found, move up one directory
currentDir = path.dirname(currentDir);
}
}
if (!tsConfigPath) {
Logger.verbose(`[${this.logName}] tsconfig.json not found`);
return undefined;
}
Logger.verbose(`[${this.logName}] tsconfig.json found:`, tsConfigPath);
const configFile = ts.readConfigFile(tsConfigPath, ts.sys.readFile);
const compilerOptions = ts.parseJsonConfigFileContent(configFile.config, ts.sys, './');
return compilerOptions.options.outDir
? path.resolve(compilerOptions.options.outDir)
: undefined;
}
}
export const terraformFramework = new TerraformFramework();