UNPKG

lambda-live-debugger

Version:

Debug Lambda functions locally like it is running in the cloud

643 lines (642 loc) 24 kB
import { DeleteLayerVersionCommand, LambdaClient, ListLayerVersionsCommand, PublishLayerVersionCommand, UpdateFunctionConfigurationCommand, GetFunctionCommand, ListLayersCommand, } from '@aws-sdk/client-lambda'; import { IAMClient, GetRolePolicyCommand, PutRolePolicyCommand, DeleteRolePolicyCommand, } from '@aws-sdk/client-iam'; import { getVersion } from './version.mjs'; import fs from 'fs/promises'; import * as path from 'path'; import { Configuration } from './configuration.mjs'; import { AwsCredentials } from './awsCredentials.mjs'; import { getModuleDirname } from './getDirname.mjs'; import { Logger } from './logger.mjs'; import * as crypto from 'crypto'; let lambdaClient; let iamClient; const inlinePolicyName = 'LambdaLiveDebuggerPolicy'; const layerName = 'LambdaLiveDebugger'; const lldWrapperPath = '/opt/lld-wrapper'; let layerDescription; /** * Policy document to attach to the Lambda role */ const policyDocument = { Version: '2012-10-17', Statement: [ { Effect: 'Allow', Action: [ 'iot:DescribeEndpoint', 'iot:Connect', 'iot:Publish', 'iot:Subscribe', 'iot:Receive', ], Resource: '*', }, ], }; /** * Get the Lambda client * @returns */ function getLambdaClient() { if (!lambdaClient) { lambdaClient = new LambdaClient({ region: Configuration.config.region, credentials: AwsCredentials.getCredentialsProvider({ region: Configuration.config.region, profile: Configuration.config.profile, role: Configuration.config.role, }), }); } return lambdaClient; } /** * Get the IAM client * @returns */ function getIAMClient() { if (!iamClient) { iamClient = new IAMClient({ region: Configuration.config.region, credentials: AwsCredentials.getCredentialsProvider({ region: Configuration.config.region, profile: Configuration.config.profile, role: Configuration.config.role, }), }); } return iamClient; } /** * Find an existing layer * @returns */ async function findExistingLayerVersion() { let nextMarker; const layerDescription = await getLayerDescription(); do { const listLayerVersionsCommand = new ListLayerVersionsCommand({ LayerName: layerName, Marker: nextMarker, }); const response = await getLambdaClient().send(listLayerVersionsCommand); if (response.LayerVersions && response.LayerVersions.length > 0) { const matchingLayer = response.LayerVersions.find((layer) => layer.Description === layerDescription); if (matchingLayer) { Logger.verbose(`Matching layer version: ${matchingLayer.Version}, description: ${matchingLayer.Description}`); return matchingLayer; } } nextMarker = response.NextMarker; } while (nextMarker); Logger.verbose('No existing layer found.'); return undefined; } /** * Get the description of the Lambda Layer that is set to the layer * @returns */ async function getLayerDescription() { if (!layerDescription) { layerDescription = `Lambda Live Debugger Layer version ${await getVersion()}`; } if ((await getVersion()) === '0.0.1') { // add a random string to the description to make it unique layerDescription = `Lambda Live Debugger Layer - development ${crypto.randomUUID()}`; } return layerDescription; } /** * Deploy the Lambda Layer * @returns */ async function deployLayer() { const layerDescription = await getLayerDescription(); // Check if the layer already exists const existingLayer = await findExistingLayerVersion(); if (existingLayer && existingLayer.LayerVersionArn) { Logger.verbose(`${layerDescription} already deployed.`); return existingLayer.LayerVersionArn; } // check the ZIP let layerZipPathFullPath = path.resolve(path.join(getModuleDirname(), './extension/extension.zip')); // get the full path to the ZIP file try { await fs.access(layerZipPathFullPath); } catch { // if I am debugging const layerZipPathFullPath2 = path.join(getModuleDirname(), '../dist/extension/extension.zip'); try { await fs.access(layerZipPathFullPath2); layerZipPathFullPath = layerZipPathFullPath2; } catch { throw new Error(`File for the layer not found: ${layerZipPathFullPath}`); } } Logger.verbose(`Layer ZIP path: ${layerZipPathFullPath}`); // Read the ZIP file containing your layer code const layerContent = await fs.readFile(layerZipPathFullPath); Logger.verbose(`Deploying ${layerDescription}`); // Create the command for publishing a new layer version const publishLayerVersionCommand = new PublishLayerVersionCommand({ LayerName: layerName, Description: layerDescription, Content: { ZipFile: layerContent, }, CompatibleArchitectures: ['x86_64', 'arm64'], CompatibleRuntimes: ['nodejs18.x', 'nodejs20.x'], }); const response = await getLambdaClient().send(publishLayerVersionCommand); if (!response.LayerVersionArn) { throw new Error('Failed to retrieve the layer version ARN'); } Logger.verbose(`Deployed ${response.Description} ARN: ${response.LayerVersionArn}`); return response.LayerVersionArn; } /** * Delete the Lambda Layer */ async function deleteLayer() { let nextMarker; do { const layers = await getLambdaClient().send(new ListLayersCommand({ Marker: nextMarker, MaxItems: 10, })); // Filter layers by name const targetLayers = layers.Layers?.filter((layer) => layer.LayerName === layerName) || []; for (const layer of targetLayers) { await deleteAllVersionsOfLayer(layer.LayerArn); } nextMarker = layers.NextMarker; } while (nextMarker); } /** * Delete all versions of a layer * @param layerArn */ async function deleteAllVersionsOfLayer(layerArn) { let nextMarker; do { const versions = await getLambdaClient().send(new ListLayerVersionsCommand({ LayerName: layerArn, Marker: nextMarker, //MaxItems: 5, })); for (const version of versions.LayerVersions || []) { await deleteLayerVersion(layerArn, version.Version); } nextMarker = versions.NextMarker; } while (nextMarker); } /** * Delete a specific version of a layer * @param layerArn * @param versionNumber */ async function deleteLayerVersion(layerArn, versionNumber) { try { Logger.verbose(`Deleting version ${versionNumber} of layer ${layerArn}`); await getLambdaClient().send(new DeleteLayerVersionCommand({ LayerName: layerArn, VersionNumber: versionNumber, })); } catch (error) { Logger.error(`Error deleting version ${versionNumber} of layer ${layerArn}:`, error); throw error; } } /** * Remove the layer from the Lambda function * @param functionName */ async function removeLayerFromLambda(functionName) { try { let needToUpdate = false; const { environmentVariables, ddlLayerArns, otherLayerArns, initialTimeout, } = await getLambdaCongfiguration(functionName); if (ddlLayerArns.length > 0) { needToUpdate = true; Logger.verbose(`Detaching layer from the function ${functionName}`); } else { Logger.verbose(`Skipping detaching layer from the function ${functionName}, no layer attached`); } const initalExecWraper = environmentVariables.LLD_INITIAL_AWS_LAMBDA_EXEC_WRAPPER; const ddlEnvironmentVariables = getEnvironmentVarablesForDebugger({ // set dummy data, so we just get the list of environment variables functionId: 'xxx', timeout: 0, verbose: true, initalExecWraper: 'test', }); // check if environment variables are set for each property for (const [key] of Object.entries(ddlEnvironmentVariables)) { if (environmentVariables && environmentVariables[key]) { needToUpdate = true; break; } } if (needToUpdate) { Logger.verbose(`Updating function configuration for ${functionName} to remove layer and reset environment variables`); Logger.verbose('Existing environment variables', JSON.stringify(environmentVariables, null, 2)); //remove environment variables for (const [key] of Object.entries(ddlEnvironmentVariables)) { if (environmentVariables && environmentVariables[key]) { if (key === 'AWS_LAMBDA_EXEC_WRAPPER') { if (environmentVariables[key] === lldWrapperPath) { delete environmentVariables[key]; } else { // do not remove the original AWS_LAMBDA_EXEC_WRAPPER that was set before LLD } } else { delete environmentVariables[key]; } } } if (initalExecWraper) { environmentVariables.AWS_LAMBDA_EXEC_WRAPPER = initalExecWraper; } Logger.verbose('New environment variables', JSON.stringify(environmentVariables, null, 2)); const updateFunctionConfigurationCommand = new UpdateFunctionConfigurationCommand({ FunctionName: functionName, Layers: otherLayerArns, Environment: { Variables: { ...environmentVariables, }, }, Timeout: initialTimeout, }); await getLambdaClient().send(updateFunctionConfigurationCommand); Logger.verbose(`Function configuration cleared ${functionName}`); } else { Logger.verbose(`Function ${functionName} configuration already cleared.`); } } catch (error) { throw new Error(`Failed to remove layer from lambda ${functionName}: ${error.message}`, { cause: error }); } } /** * Get the Lambda configuration * @param functionName * @returns */ async function getLambdaCongfiguration(functionName) { try { const getFunctionResponse = await getLambdaClient().send(new GetFunctionCommand({ FunctionName: functionName, })); const timeout = getFunctionResponse.Configuration?.Timeout; // get all layers this fuction has by name const layers = getFunctionResponse.Configuration?.Layers || []; const layerArns = layers.map((l) => l.Arn).filter((arn) => arn); const ddlLayerArns = layerArns.filter((arn) => arn?.includes(`:layer:${layerName}:`)); const otherLayerArns = layerArns.filter((arn) => !arn?.includes(`:layer:${layerName}:`)); const environmentVariables = getFunctionResponse.Configuration?.Environment?.Variables ?? {}; let initialTimeout; const initialTimeoutStr = environmentVariables?.LLD_INITIAL_TIMEOUT; if (!initialTimeoutStr || isNaN(Number(initialTimeoutStr))) { initialTimeout = timeout; } else { initialTimeout = Number(initialTimeoutStr); } return { environmentVariables, ddlLayerArns, otherLayerArns, initialTimeout, }; } catch (error) { throw new Error(`Failed to get lambda configuration ${functionName}: ${error.message}`, { cause: error }); } } /** * Attach the layer to the Lambda function and update the environment variables */ async function updateLambda({ functionName, functionId, layerVersionArn, }) { const { needToUpdate, layers, environmentVariables, initialTimeout } = await prepareLambdaUpdate({ functionName, functionId, layerVersionArn, }); if (needToUpdate) { try { const updateFunctionConfigurationCommand = new UpdateFunctionConfigurationCommand({ FunctionName: functionName, Layers: layers, Environment: { Variables: environmentVariables, }, //Timeout: LlDebugger.argOptions.observable ? undefined : 300, // Increase the timeout to 5 minutes Timeout: Math.max(initialTimeout, 300), // Increase the timeout to min. 5 minutes }); await getLambdaClient().send(updateFunctionConfigurationCommand); Logger.verbose(`[Function ${functionName}] Lambda layer and environment variables updated`); } catch (error) { throw new Error(`Failed to update Lambda ${functionName}: ${error.message}`, { cause: error }); } } else { Logger.verbose(`[Function ${functionName}] Lambda layer and environment already up to date`); } } /** * Prepare the Lambda function for the update */ async function prepareLambdaUpdate({ functionName, functionId, layerVersionArn, }) { let needToUpdate = false; const { environmentVariables, ddlLayerArns, otherLayerArns, initialTimeout } = await getLambdaCongfiguration(functionName); // check if layer is already attached if (!ddlLayerArns?.find((arn) => arn === layerVersionArn)) { needToUpdate = true; Logger.verbose(`[Function ${functionName}] Layer not attached to the function`); } else { Logger.verbose(`[Function ${functionName}] Layer already attached to the function`); } // check if layers with the wrong version are attached if (!needToUpdate && ddlLayerArns.find((arn) => arn !== layerVersionArn)) { needToUpdate = true; Logger.verbose('Layer with the wrong version attached to the function'); } // support for multiple internal Lambda extensions const initalExecWraper = environmentVariables.AWS_LAMBDA_EXEC_WRAPPER !== lldWrapperPath ? environmentVariables.AWS_LAMBDA_EXEC_WRAPPER : undefined; if (initalExecWraper) { Logger.warn(`[Function ${functionName}] Another internal Lambda extension is already attached to the function, which might cause unpredictable behavior.`); } const ddlEnvironmentVariables = getEnvironmentVarablesForDebugger({ functionId, timeout: initialTimeout, verbose: Configuration.config.verbose, initalExecWraper, }); // check if environment variables are already set for each property for (const [key, value] of Object.entries(ddlEnvironmentVariables)) { if (!environmentVariables || environmentVariables[key] !== value) { needToUpdate = true; Logger.verbose(`[Function ${functionName}] need to update environment variables`); break; } } return { needToUpdate, layers: [layerVersionArn, ...otherLayerArns], environmentVariables: { ...environmentVariables, ...ddlEnvironmentVariables, }, initialTimeout, }; } /** * Add the policy to the Lambda role */ async function lambdaRoleUpdate(roleName) { // add inline policy to the role using PutRolePolicyCommand Logger.verbose(`[Role ${roleName}] Attaching policy to the role ${roleName}`); await getIAMClient().send(new PutRolePolicyCommand({ RoleName: roleName, PolicyName: inlinePolicyName, PolicyDocument: JSON.stringify(policyDocument), })); } /** * Prepare the Lambda role for the update * @param functionName * @returns */ async function prepareLambdaRoleUpdate(functionName) { const getFunctionResponse = await getLambdaClient().send(new GetFunctionCommand({ FunctionName: functionName, })); const roleArn = getFunctionResponse.Configuration?.Role; if (!roleArn) { throw new Error(`Failed to retrieve the role ARN for Lambda ${functionName}`); } // Extract the role name from the role ARN const roleName = roleArn.split('/').pop(); if (!roleName) { throw new Error(`Failed to extract role name from role ARN: ${roleArn} for lambda ${functionName}`); } const existingPolicy = await getPolicyDocument(roleName); let addPolicy = true; // compare existing policy with the new one if (existingPolicy) { if (JSON.stringify(existingPolicy) === JSON.stringify(policyDocument)) { Logger.verbose(`[Function ${functionName}] Policy already attached to the role ${roleName}`); addPolicy = false; } } return { addPolicy, roleName }; } /** * Get the environment variables for the Lambda function */ function getEnvironmentVarablesForDebugger({ functionId, timeout, verbose, initalExecWraper, }) { const env = { LLD_FUNCTION_ID: functionId, AWS_LAMBDA_EXEC_WRAPPER: lldWrapperPath, LLD_DEBUGGER_ID: Configuration.config.debuggerId, LLD_INITIAL_TIMEOUT: timeout ? timeout.toString() : '-1', // should never be negative LLD_OBSERVABLE_MODE: Configuration.config.observable ? 'true' : 'false', LLD_OBSERVABLE_INTERVAL: Configuration.config.interval.toString(), }; if (initalExecWraper) { env.LLD_INITIAL_AWS_LAMBDA_EXEC_WRAPPER = initalExecWraper; } if (verbose) { env.LLD_VERBOSE = 'true'; } return env; } /** * Remove the policy from the Lambda role * @param functionName * @returns */ async function removePolicyFromLambdaRole(functionName) { try { // Retrieve the Lambda function's execution role ARN const getFunctionResponse = await getLambdaClient().send(new GetFunctionCommand({ FunctionName: functionName, })); const roleArn = getFunctionResponse.Configuration?.Role; if (!roleArn) { throw new Error(`Failed to retrieve the role ARN for lambda ${functionName}`); } // Extract the role name from the role ARN const roleName = roleArn.split('/').pop(); if (!roleName) { Logger.error(`Failed to extract role name from role ARN: ${roleArn} for Lambda ${functionName}`); return; } const existingPolicy = await getPolicyDocument(roleName); if (existingPolicy) { try { Logger.verbose(`[Function ${functionName}] Removing policy from the role ${roleName}`); await getIAMClient().send(new DeleteRolePolicyCommand({ RoleName: roleName, PolicyName: inlinePolicyName, })); } catch (error) { Logger.error(`Failed to delete inline policy ${inlinePolicyName} from role ${roleName} for Lambda ${functionName}:`, error); } } else { Logger.verbose(`[Function ${functionName}] No need to remove policy from the role ${roleName}, policy not found`); } } catch (error) { throw new Error(`Failed to remove policy from the role for Lambda ${functionName}: ${error.message}`, { cause: error }); } } /** * Get the policy document needed to attach to the Lambda role needed for the Lambda Live Debugger * @param roleName * @returns */ async function getPolicyDocument(roleName) { try { const policy = await getIAMClient().send(new GetRolePolicyCommand({ RoleName: roleName, PolicyName: inlinePolicyName, })); if (policy.PolicyDocument) { const policyDocument = JSON.parse(decodeURIComponent(policy.PolicyDocument)); return policyDocument; } else { return undefined; } } catch (error) { if (error.name === 'NoSuchEntityException') { return undefined; } else { throw error; } } } /** * Deploy the infrastructure */ async function deployInfrastructure() { const layerVersionArn = await deployLayer(); const promises = []; for (const func of Configuration.getLambdas()) { const p = updateLambda({ functionName: func.functionName, functionId: func.functionId, layerVersionArn: layerVersionArn, }); if (process.env.DISABLE_PARALLEL_DEPLOY === 'true') { await p; } else { promises.push(p); } } const rolesToUpdatePromise = Promise.all(Configuration.getLambdas().map(async (func) => { const roleUpdate = await prepareLambdaRoleUpdate(func.functionName); return roleUpdate.addPolicy ? roleUpdate.roleName : undefined; })); const rolesToUpdate = await rolesToUpdatePromise; const rolesToUpdateFiltered = [ // unique roles ...new Set(rolesToUpdate.filter((r) => r)), ]; for (const roleName of rolesToUpdateFiltered) { const p = lambdaRoleUpdate(roleName); if (process.env.DISABLE_PARALLEL_DEPLOY === 'true') { await p; } else { promises.push(p); } } await Promise.all(promises); } /** * Get the planed infrastructure changes */ async function getPlanedInfrastructureChanges() { const existingLayer = await findExistingLayerVersion(); const lambdasToUpdatePromise = Promise.all(Configuration.getLambdas().map(async (func) => { if (!existingLayer?.LayerVersionArn) { return func.functionName; } else { const lambdaUpdate = await prepareLambdaUpdate({ functionName: func.functionName, functionId: func.functionId, layerVersionArn: existingLayer.LayerVersionArn, }); return lambdaUpdate.needToUpdate ? func.functionName : undefined; } })); const rolesToUpdatePromise = Promise.all(Configuration.getLambdas().map(async (func) => { const roleUpdate = await prepareLambdaRoleUpdate(func.functionName); return roleUpdate.addPolicy ? roleUpdate.roleName : undefined; })); const lambdasToUpdate = await lambdasToUpdatePromise; const lambdasToUpdateFiltered = lambdasToUpdate.filter((l) => l); const rolesToUpdate = await rolesToUpdatePromise; const rolesToUpdateFiltered = [ ...new Set(rolesToUpdate.filter((r) => r)), ]; return { deployLayer: !existingLayer, lambdasToUpdate: lambdasToUpdateFiltered, rolesToUpdate: rolesToUpdateFiltered, }; } /** * Remove the infrastructure */ async function removeInfrastructure() { Logger.verbose('Removing Lambda Live Debugger infrastructure.'); const promises = []; for (const func of Configuration.getLambdas()) { const p = removeLayerFromLambda(func.functionName); if (process.env.DISABLE_PARALLEL_DEPLOY === 'true') { await p; } else { promises.push(p); } } const p = (async () => { // do not do it in parallel, because Lambdas could share the same role for (const func of Configuration.getLambdas()) { await removePolicyFromLambdaRole(func.functionName); } })(); // creates one promise if (process.env.DISABLE_PARALLEL_DEPLOY === 'true') { await p; } else { promises.push(p); } await Promise.all(promises); } export const InfraDeploy = { getPlanedInfrastructureChanges, deployInfrastructure, removeInfrastructure, deleteLayer, };