From 645fce87956da02b96c86d60ebf6d6bec539bbf7 Mon Sep 17 00:00:00 2001 From: "Marko (ServerlessLife)" Date: Tue, 4 Mar 2025 10:58:55 +0100 Subject: [PATCH 1/6] feat: Add approval required option --- README.md | 1 + src/configuration/getConfigFromCliArgs.ts | 4 + src/configuration/getConfigFromWizard.ts | 16 ++ src/infraDeploy.ts | 270 ++++++++++++++-------- src/lldebugger.ts | 41 ++++ src/types/lldConfig.ts | 5 + 6 files changed, 245 insertions(+), 92 deletions(-) diff --git a/README.md b/README.md index fff2ef07..5c504fd3 100644 --- a/README.md +++ b/README.md @@ -110,6 +110,7 @@ The configuration is saved to `lldebugger.config.ts`. -m, --subfolder Monorepo subfolder -o, --observable Observability mode -i --interval Observability mode interval (default: "3000") + -a --approval User approval required for AWS infrastructure changes, like adding a Lambda layer --config-env SAM environment --sam-config-file SAM configuration file --sam-template-file SAM template file diff --git a/src/configuration/getConfigFromCliArgs.ts b/src/configuration/getConfigFromCliArgs.ts index b0e9bb29..9e1eda61 100644 --- a/src/configuration/getConfigFromCliArgs.ts +++ b/src/configuration/getConfigFromCliArgs.ts @@ -47,6 +47,10 @@ export async function getConfigFromCliArgs( 'Observability mode interval', defaultObservableInterval.toString(), ); + program.option( + '-a, --approval', + 'User approval required for AWS infrastructure changes, like adding a Lambda layer', + ); program.option('--config-env ', 'SAM environment'); program.option('--sam-config-file ', 'SAM configuration file'); program.option('--sam-template-file ', 'SAM template file'); diff --git a/src/configuration/getConfigFromWizard.ts b/src/configuration/getConfigFromWizard.ts index 22d7be53..80b7ea57 100644 --- a/src/configuration/getConfigFromWizard.ts +++ b/src/configuration/getConfigFromWizard.ts @@ -199,6 +199,19 @@ export async function getConfigFromWizard({ }; } + // do you want to manually approve AWS infrastructure changes? + const answersApproval = await inquirer.prompt([ + { + type: 'confirm', + name: 'approval', + message: + 'Before debugging, do you want to review and manually approve AWS infrastructure changes, like adding a Lambda layer?', + default: currentConfig?.approval === true, + }, + ]); + + answers = { ...answers, ...answersApproval }; + const answersAws = await inquirer.prompt([ { type: 'input', @@ -388,6 +401,8 @@ export default { observable: ${config.observable}, // Observable mode interval interval: ${config.interval === defaultObservableInterval ? undefined : config.interval}, + // Approval required for AWS infrastructure changes + approvalRequired: ${config.approval}, // Verbose logging verbose: ${config.verbose}, // Modify Lambda function list or support custom framework @@ -434,6 +449,7 @@ function getConfigFromAnswers(answers: any): LldConfigCliArgs { answers.interval !== undefined ? answers.interval : defaultObservableInterval, + approvalRequired: answers.approvalRequired, verbose: answers.verbose, interactive: answers.interactive, gitignore: answers.gitignore, diff --git a/src/infraDeploy.ts b/src/infraDeploy.ts index 4d7f98cf..ce0a3aa1 100755 --- a/src/infraDeploy.ts +++ b/src/infraDeploy.ts @@ -80,15 +80,11 @@ function getIAMClient(): IAMClient { /** * Find an existing layer - * @param layerName - * @param description * @returns */ -async function findExistingLayerVersion( - layerName: string, - description: string, -) { +async function findExistingLayerVersion() { let nextMarker: string | undefined; + const layerDescription = await getLayerDescription(); do { const listLayerVersionsCommand = new ListLayerVersionsCommand({ @@ -99,7 +95,7 @@ async function findExistingLayerVersion( const response = await getLambdaClient().send(listLayerVersionsCommand); if (response.LayerVersions && response.LayerVersions.length > 0) { const matchingLayer = response.LayerVersions.find( - (layer) => layer.Description === description, + (layer) => layer.Description === layerDescription, ); if (matchingLayer) { Logger.verbose( @@ -117,23 +113,24 @@ async function findExistingLayerVersion( return undefined; } +/** + * Get the description of the Lambda Layer that is set to the layer + * @returns + */ +async function getLayerDescription() { + return `Lambda Live Debugger Layer version ${await getVersion()}`; +} + /** * Deploy the Lambda Layer * @returns */ async function deployLayer() { - const layerDescription = `Lambda Live Debugger Layer version ${await getVersion()}`; + const layerDescription = await getLayerDescription(); // Check if the layer already exists - const existingLayer = await findExistingLayerVersion( - layerName, - layerDescription, - ); - if ( - existingLayer && - existingLayer.LayerVersionArn && - existingLayer.Description === layerDescription // check if the layer version is already deployed - ) { + const existingLayer = await findExistingLayerVersion(); + if (existingLayer && existingLayer.LayerVersionArn) { // delete existing layer when developing if ((await getVersion()) === '0.0.1') { Logger.verbose( @@ -434,23 +431,74 @@ async function getLambdaCongfiguration(functionName: string) { } /** - * Attach the layer to the Lambda function - * @param functionName - * @param functionId - * @param layerArn + * Attach the layer to the Lambda function and update the environment variables */ -async function attachLayerToLambda( - functionName: string, - functionId: string, - layerArn: string, -) { +async function updateLambda({ + functionName, + functionId, + layerVersionArn, +}: { + functionName: string; + functionId: string; + layerVersionArn: string; +}) { + 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: any) { + 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, +}: { + functionName: string; + functionId: string; + layerVersionArn: string; +}) { let needToUpdate: boolean = false; const { environmentVariables, ddlLayerArns, otherLayerArns, initialTimeout } = await getLambdaCongfiguration(functionName); // check if layer is already attached - if (!ddlLayerArns?.find((arn) => arn === layerArn)) { + if (!ddlLayerArns?.find((arn) => arn === layerVersionArn)) { needToUpdate = true; Logger.verbose( `[Function ${functionName}] Layer not attached to the function`, @@ -462,7 +510,7 @@ async function attachLayerToLambda( } // check if layers with the wrong version are attached - if (!needToUpdate && ddlLayerArns.find((arn) => arn !== layerArn)) { + if (!needToUpdate && ddlLayerArns.find((arn) => arn !== layerVersionArn)) { needToUpdate = true; Logger.verbose('Layer with the wrong version attached to the function'); } @@ -490,50 +538,46 @@ async function attachLayerToLambda( for (const [key, value] of Object.entries(ddlEnvironmentVariables)) { if (!environmentVariables || environmentVariables[key] !== value) { needToUpdate = true; - break; - } - } - - if (needToUpdate) { - try { - const updateFunctionConfigurationCommand = - new UpdateFunctionConfigurationCommand({ - FunctionName: functionName, - Layers: [layerArn, ...otherLayerArns], - Environment: { - Variables: { - ...environmentVariables, - ...ddlEnvironmentVariables, - }, - }, - //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: any) { - throw new Error( - `Failed to update Lambda ${functionName}: ${error.message}`, - { cause: error }, + `[Function ${functionName}] need to update environment variables`, ); + break; } - } else { - Logger.verbose( - `[Function ${functionName}] Lambda layer and environment already up to date`, - ); } + + return { + needToUpdate, + layers: [layerVersionArn, ...otherLayerArns], + environmentVariables: { + ...environmentVariables, + ...ddlEnvironmentVariables, + }, + initialTimeout, + }; } /** * Add the policy to the Lambda role + */ +async function lambdaRoleUpdate(roleName: string) { + // 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 addPolicyToLambdaRole(functionName: string) { - // Retrieve the Lambda function's execution role ARN +async function prepareLambdaRoleUpdate(functionName: string) { const getFunctionResponse = await getLambdaClient().send( new GetFunctionCommand({ FunctionName: functionName, @@ -555,7 +599,7 @@ async function addPolicyToLambdaRole(functionName: string) { ); } - const existingPolicy = getPolicyDocument(roleName); + const existingPolicy = await getPolicyDocument(roleName); let addPolicy: boolean = true; @@ -568,21 +612,7 @@ async function addPolicyToLambdaRole(functionName: string) { addPolicy = false; } } - - if (addPolicy) { - // add inline policy to the role using PutRolePolicyCommand - Logger.verbose( - `[Function ${functionName}] Attaching policy to the role ${roleName}`, - ); - - await getIAMClient().send( - new PutRolePolicyCommand({ - RoleName: roleName, - PolicyName: inlinePolicyName, - PolicyDocument: JSON.stringify(policyDocument), - }), - ); - } + return { addPolicy, roleName }; } /** @@ -721,11 +751,11 @@ async function deployInfrastructure() { const promises: Promise[] = []; for (const func of Configuration.getLambdas()) { - const p = attachLayerToLambda( - func.functionName, - func.functionId, - layerVersionArn, - ); + const p = updateLambda({ + functionName: func.functionName, + functionId: func.functionId, + layerVersionArn: layerVersionArn, + }); if (process.env.DISABLE_PARALLEL_DEPLOY === 'true') { await p; } else { @@ -733,21 +763,76 @@ async function deployInfrastructure() { } } - const p = (async () => { - // do not do it in parallel, because Lambdas could share the same role - for (const func of Configuration.getLambdas()) { - await addPolicyToLambdaRole(func.functionName); + 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)), + ] as string[]; + + for (const roleName of rolesToUpdateFiltered) { + const p = lambdaRoleUpdate(roleName); + if (process.env.DISABLE_PARALLEL_DEPLOY === 'true') { + await p; + } else { + promises.push(p); } - })(); // creates one promise - 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) as string[]; + + const rolesToUpdate = await rolesToUpdatePromise; + const rolesToUpdateFiltered = [ + ...new Set(rolesToUpdate.filter((r) => r)), + ] as string[]; + + return { + deployLayer: !existingLayer, + lambdasToUpdate: lambdasToUpdateFiltered, + rolesToUpdate: rolesToUpdateFiltered, + }; +} + /** * Remove the infrastructure */ @@ -780,6 +865,7 @@ async function removeInfrastructure() { } export const InfraDeploy = { + getPlanedInfrastructureChanges, deployInfrastructure, removeInfrastructure, deleteLayer, diff --git a/src/lldebugger.ts b/src/lldebugger.ts index fb19736c..c4f577b2 100755 --- a/src/lldebugger.ts +++ b/src/lldebugger.ts @@ -16,6 +16,7 @@ import fs from 'fs/promises'; import { Logger } from './logger.js'; import { getModuleDirname, getProjectDirname } from './getDirname.js'; import { LambdaConnection } from './lambdaConnection.js'; +import inquirer from 'inquirer'; /** * Start the Lambda Live Debugger @@ -92,6 +93,46 @@ async function run() { return; } + if (Configuration.config.approval === true) { + const changes = await InfraDeploy.getPlanedInfrastructureChanges(); + + if ( + !changes.deployLayer && + !changes.lambdasToUpdate.length && + !changes.rolesToUpdate.length + ) { + Logger.verbose('No infrastructure changes required.'); + } else { + // list all changes and ask for approval + const confirn = await inquirer.prompt([ + { + type: 'confirm', + name: 'approval', + message: `\nThe following changes will be applied to your AWS account:${ + (changes.deployLayer + ? '\n - Deploy Lambda Live Debugger layer' + : '') + + (changes.lambdasToUpdate.length + ? `\n - Attach the layer and add environment variables to the Lambdas:\n${changes.lambdasToUpdate + .map((l) => ` - ${l}`) + .join('\n')}` + : '') + + (changes.rolesToUpdate.length + ? `\n - Add IoT permissions to IAM Roles:\n${changes.rolesToUpdate + .map((r) => ` - ${r}`) + .join('\n')}` + : '') + }\n\nDo you want to continue?`, + }, + ]); + + if (!confirn.approval) { + Logger.log('Exiting...'); + return; + } + } + } + await InfraDeploy.deployInfrastructure(); const folders = [ diff --git a/src/types/lldConfig.ts b/src/types/lldConfig.ts index a63c52ef..b80f86d4 100644 --- a/src/types/lldConfig.ts +++ b/src/types/lldConfig.ts @@ -68,6 +68,11 @@ export type LldConfigBase = { * Monorepo subfolder */ subfolder?: string; + + /** + * Approval required for AWS infrastructure changes + */ + approval?: boolean; } & AwsConfiguration; export type LldConfigCliArgs = { From 5c8bf6b57ec12de99f52f3f1142b4781de5f928c Mon Sep 17 00:00:00 2001 From: "Marko (ServerlessLife)" Date: Tue, 4 Mar 2025 20:14:08 +0100 Subject: [PATCH 2/6] fix: approval setting in wizard --- src/configuration/getConfigFromWizard.ts | 4 ++-- test/opentofu-basic/.terraform/modules/test-js-commonjs_3 | 1 + 2 files changed, 3 insertions(+), 2 deletions(-) create mode 160000 test/opentofu-basic/.terraform/modules/test-js-commonjs_3 diff --git a/src/configuration/getConfigFromWizard.ts b/src/configuration/getConfigFromWizard.ts index 80b7ea57..22c51ef4 100644 --- a/src/configuration/getConfigFromWizard.ts +++ b/src/configuration/getConfigFromWizard.ts @@ -402,7 +402,7 @@ export default { // Observable mode interval interval: ${config.interval === defaultObservableInterval ? undefined : config.interval}, // Approval required for AWS infrastructure changes - approvalRequired: ${config.approval}, + approval: ${config.approval}, // Verbose logging verbose: ${config.verbose}, // Modify Lambda function list or support custom framework @@ -449,7 +449,7 @@ function getConfigFromAnswers(answers: any): LldConfigCliArgs { answers.interval !== undefined ? answers.interval : defaultObservableInterval, - approvalRequired: answers.approvalRequired, + approval: answers.approval, verbose: answers.verbose, interactive: answers.interactive, gitignore: answers.gitignore, diff --git a/test/opentofu-basic/.terraform/modules/test-js-commonjs_3 b/test/opentofu-basic/.terraform/modules/test-js-commonjs_3 new file mode 160000 index 00000000..84dfbfdd --- /dev/null +++ b/test/opentofu-basic/.terraform/modules/test-js-commonjs_3 @@ -0,0 +1 @@ +Subproject commit 84dfbfddf9483bc56afa0aff516177c03652f0c7 From 4f2fcb07fa03a3bcf7f77ca9c8fa4d631cf330b8 Mon Sep 17 00:00:00 2001 From: "Marko (ServerlessLife)" Date: Tue, 4 Mar 2025 20:27:53 +0100 Subject: [PATCH 3/6] fix: remove all parameter --- README.md | 2 +- src/configuration/getConfigFromCliArgs.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 5c504fd3..6c4c9475 100644 --- a/README.md +++ b/README.md @@ -101,7 +101,7 @@ The configuration is saved to `lldebugger.config.ts`. ``` -V, --version output the version number - -r, --remove [option] Remove Lambda Live Debugger infrastructure. Options: 'keep-layer' (default), 'remove-all'. The latest also removes the Lambda Layer + -r, --remove [option] Remove Lambda Live Debugger infrastructure. Options: 'keep-layer' (default), 'all'. The latest also removes the Lambda Layer -w, --wizard Program interactively asks for each parameter and saves it to lldebugger.config.ts -v, --verbose Verbose logging -c, --context AWS CDK context (default: []) diff --git a/src/configuration/getConfigFromCliArgs.ts b/src/configuration/getConfigFromCliArgs.ts index 9e1eda61..90c9a7b1 100644 --- a/src/configuration/getConfigFromCliArgs.ts +++ b/src/configuration/getConfigFromCliArgs.ts @@ -20,7 +20,7 @@ export async function getConfigFromCliArgs( program.name('lld').description('Lambda Live Debugger').version(version); program.option( '-r, --remove [option]', - "Remove Lambda Live Debugger infrastructure. Options: 'keep-layer' (default), 'remove-all'. The latest also removes the Lambda Layer", + "Remove Lambda Live Debugger infrastructure. Options: 'keep-layer' (default), 'all'. The latest also removes the Lambda Layer", //validateRemoveOption, //"keep-layer" ); From 6ecde4aeafce1c82af7c31376d7cb5fa03c2aefb Mon Sep 17 00:00:00 2001 From: "Marko (ServerlessLife)" Date: Wed, 5 Mar 2025 08:02:57 +0100 Subject: [PATCH 4/6] Add version --- src/lldebugger.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lldebugger.ts b/src/lldebugger.ts index c4f577b2..7710ef97 100755 --- a/src/lldebugger.ts +++ b/src/lldebugger.ts @@ -110,7 +110,7 @@ async function run() { name: 'approval', message: `\nThe following changes will be applied to your AWS account:${ (changes.deployLayer - ? '\n - Deploy Lambda Live Debugger layer' + ? `\n - Deploy Lambda Live Debugger layer version ${version}` : '') + (changes.lambdasToUpdate.length ? `\n - Attach the layer and add environment variables to the Lambdas:\n${changes.lambdasToUpdate From 70d0b7435be96c3f27e5622a29bc45ada8ee045a Mon Sep 17 00:00:00 2001 From: "Marko (ServerlessLife)" Date: Wed, 5 Mar 2025 08:10:27 +0100 Subject: [PATCH 5/6] Handle user canceling the input --- src/lldebugger.ts | 53 ++++++++++++++++++++++++++--------------------- 1 file changed, 29 insertions(+), 24 deletions(-) diff --git a/src/lldebugger.ts b/src/lldebugger.ts index 7710ef97..24cc674a 100755 --- a/src/lldebugger.ts +++ b/src/lldebugger.ts @@ -104,30 +104,35 @@ async function run() { Logger.verbose('No infrastructure changes required.'); } else { // list all changes and ask for approval - const confirn = await inquirer.prompt([ - { - type: 'confirm', - name: 'approval', - message: `\nThe following changes will be applied to your AWS account:${ - (changes.deployLayer - ? `\n - Deploy Lambda Live Debugger layer version ${version}` - : '') + - (changes.lambdasToUpdate.length - ? `\n - Attach the layer and add environment variables to the Lambdas:\n${changes.lambdasToUpdate - .map((l) => ` - ${l}`) - .join('\n')}` - : '') + - (changes.rolesToUpdate.length - ? `\n - Add IoT permissions to IAM Roles:\n${changes.rolesToUpdate - .map((r) => ` - ${r}`) - .join('\n')}` - : '') - }\n\nDo you want to continue?`, - }, - ]); - - if (!confirn.approval) { - Logger.log('Exiting...'); + try { + const confirn = await inquirer.prompt([ + { + type: 'confirm', + name: 'approval', + message: `\nThe following changes will be applied to your AWS account:${ + (changes.deployLayer + ? `\n - Deploy Lambda Live Debugger layer version ${version}` + : '') + + (changes.lambdasToUpdate.length + ? `\n - Attach the layer and add environment variables to the Lambdas:\n${changes.lambdasToUpdate + .map((l) => ` - ${l}`) + .join('\n')}` + : '') + + (changes.rolesToUpdate.length + ? `\n - Add IoT permissions to IAM Roles:\n${changes.rolesToUpdate + .map((r) => ` - ${r}`) + .join('\n')}` + : '') + }\n\nDo you want to continue?`, + }, + ]); + + if (!confirn.approval) { + Logger.log('Exiting...'); + return; + } + } catch { + // user canceled return; } } From 7929c0ee76c1e751ac0559dfcb596b5fb1ed97c1 Mon Sep 17 00:00:00 2001 From: "Marko (ServerlessLife)" Date: Wed, 5 Mar 2025 08:14:20 +0100 Subject: [PATCH 6/6] Fix exit --- src/lldebugger.ts | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/lldebugger.ts b/src/lldebugger.ts index 24cc674a..517b676b 100755 --- a/src/lldebugger.ts +++ b/src/lldebugger.ts @@ -131,9 +131,14 @@ async function run() { Logger.log('Exiting...'); return; } - } catch { - // user canceled - return; + } catch (error: any) { + if (error.name === 'ExitPromptError') { + // user canceled the prompt + Logger.log('Exiting...'); + return; + } else { + throw error; + } } } }