diff --git a/package-lock.json b/package-lock.json index 01f0168efab..206d3c6d66a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,7 +21,7 @@ "vscode-nls-dev": "^4.0.4" }, "devDependencies": { - "@aws-toolkits/telemetry": "^1.0.324", + "@aws-toolkits/telemetry": "^1.0.326", "@playwright/browser-chromium": "^1.43.1", "@stylistic/eslint-plugin": "^2.11.0", "@types/he": "^1.2.3", @@ -11405,11 +11405,10 @@ } }, "node_modules/@aws-toolkits/telemetry": { - "version": "1.0.324", - "resolved": "https://registry.npmjs.org/@aws-toolkits/telemetry/-/telemetry-1.0.324.tgz", - "integrity": "sha512-O4K9Ip3ge+EdTITOhMNcVxp+DPxK/1JHm9XcDwg/5N3q9SbwQ7/WeVtTHkvgq+IiQGKjnJ/4Vuyw3/3h29K7ww==", + "version": "1.0.326", + "resolved": "https://registry.npmjs.org/@aws-toolkits/telemetry/-/telemetry-1.0.326.tgz", + "integrity": "sha512-4PpnljGgERDJpdAJdBHKb9eaDhq8ktYiWoYS/mCG2ojplGvEP/ymzfzPJ6apUErT3iu74+md1x5JL8h7N7/ZFA==", "dev": true, - "license": "Apache-2.0", "dependencies": { "ajv": "^6.12.6", "cross-spawn": "^7.0.6", diff --git a/package.json b/package.json index 8dab28aba35..53e03dc56ce 100644 --- a/package.json +++ b/package.json @@ -41,7 +41,7 @@ "skippedTestReport": "ts-node ./scripts/skippedTestReport.ts ./packages/amazonq/test/e2e/" }, "devDependencies": { - "@aws-toolkits/telemetry": "^1.0.324", + "@aws-toolkits/telemetry": "^1.0.326", "@playwright/browser-chromium": "^1.43.1", "@stylistic/eslint-plugin": "^2.11.0", "@types/he": "^1.2.3", diff --git a/packages/amazonq/package.json b/packages/amazonq/package.json index 48525fa8121..1ae1c68c298 100644 --- a/packages/amazonq/package.json +++ b/packages/amazonq/package.json @@ -1227,110 +1227,124 @@ "fontCharacter": "\\f1d0" } }, - "aws-lambda-function": { + "aws-lambda-create-stack": { "description": "AWS Contributed Icon", "default": { "fontPath": "./resources/fonts/aws-toolkit-icons.woff", "fontCharacter": "\\f1d1" } }, - "aws-mynah-MynahIconBlack": { + "aws-lambda-create-stack-light": { "description": "AWS Contributed Icon", "default": { "fontPath": "./resources/fonts/aws-toolkit-icons.woff", "fontCharacter": "\\f1d2" } }, - "aws-mynah-MynahIconWhite": { + "aws-lambda-function": { "description": "AWS Contributed Icon", "default": { "fontPath": "./resources/fonts/aws-toolkit-icons.woff", "fontCharacter": "\\f1d3" } }, - "aws-mynah-logo": { + "aws-mynah-MynahIconBlack": { "description": "AWS Contributed Icon", "default": { "fontPath": "./resources/fonts/aws-toolkit-icons.woff", "fontCharacter": "\\f1d4" } }, - "aws-redshift-cluster": { + "aws-mynah-MynahIconWhite": { "description": "AWS Contributed Icon", "default": { "fontPath": "./resources/fonts/aws-toolkit-icons.woff", "fontCharacter": "\\f1d5" } }, - "aws-redshift-cluster-connected": { + "aws-mynah-logo": { "description": "AWS Contributed Icon", "default": { "fontPath": "./resources/fonts/aws-toolkit-icons.woff", "fontCharacter": "\\f1d6" } }, - "aws-redshift-database": { + "aws-redshift-cluster": { "description": "AWS Contributed Icon", "default": { "fontPath": "./resources/fonts/aws-toolkit-icons.woff", "fontCharacter": "\\f1d7" } }, - "aws-redshift-redshift-cluster-connected": { + "aws-redshift-cluster-connected": { "description": "AWS Contributed Icon", "default": { "fontPath": "./resources/fonts/aws-toolkit-icons.woff", "fontCharacter": "\\f1d8" } }, - "aws-redshift-schema": { + "aws-redshift-database": { "description": "AWS Contributed Icon", "default": { "fontPath": "./resources/fonts/aws-toolkit-icons.woff", "fontCharacter": "\\f1d9" } }, - "aws-redshift-table": { + "aws-redshift-redshift-cluster-connected": { "description": "AWS Contributed Icon", "default": { "fontPath": "./resources/fonts/aws-toolkit-icons.woff", "fontCharacter": "\\f1da" } }, - "aws-s3-bucket": { + "aws-redshift-schema": { "description": "AWS Contributed Icon", "default": { "fontPath": "./resources/fonts/aws-toolkit-icons.woff", "fontCharacter": "\\f1db" } }, - "aws-s3-create-bucket": { + "aws-redshift-table": { "description": "AWS Contributed Icon", "default": { "fontPath": "./resources/fonts/aws-toolkit-icons.woff", "fontCharacter": "\\f1dc" } }, - "aws-schemas-registry": { + "aws-s3-bucket": { "description": "AWS Contributed Icon", "default": { "fontPath": "./resources/fonts/aws-toolkit-icons.woff", "fontCharacter": "\\f1dd" } }, - "aws-schemas-schema": { + "aws-s3-create-bucket": { "description": "AWS Contributed Icon", "default": { "fontPath": "./resources/fonts/aws-toolkit-icons.woff", "fontCharacter": "\\f1de" } }, - "aws-stepfunctions-preview": { + "aws-schemas-registry": { "description": "AWS Contributed Icon", "default": { "fontPath": "./resources/fonts/aws-toolkit-icons.woff", "fontCharacter": "\\f1df" } + }, + "aws-schemas-schema": { + "description": "AWS Contributed Icon", + "default": { + "fontPath": "./resources/fonts/aws-toolkit-icons.woff", + "fontCharacter": "\\f1e0" + } + }, + "aws-stepfunctions-preview": { + "description": "AWS Contributed Icon", + "default": { + "fontPath": "./resources/fonts/aws-toolkit-icons.woff", + "fontCharacter": "\\f1e1" + } } }, "walkthroughs": [ diff --git a/packages/core/package.json b/packages/core/package.json index 05b73b2acd7..6f0226684c4 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -309,110 +309,124 @@ "fontCharacter": "\\f1d0" } }, - "aws-lambda-function": { + "aws-lambda-create-stack": { "description": "AWS Contributed Icon", "default": { "fontPath": "./resources/fonts/aws-toolkit-icons.woff", "fontCharacter": "\\f1d1" } }, - "aws-mynah-MynahIconBlack": { + "aws-lambda-create-stack-light": { "description": "AWS Contributed Icon", "default": { "fontPath": "./resources/fonts/aws-toolkit-icons.woff", "fontCharacter": "\\f1d2" } }, - "aws-mynah-MynahIconWhite": { + "aws-lambda-function": { "description": "AWS Contributed Icon", "default": { "fontPath": "./resources/fonts/aws-toolkit-icons.woff", "fontCharacter": "\\f1d3" } }, - "aws-mynah-logo": { + "aws-mynah-MynahIconBlack": { "description": "AWS Contributed Icon", "default": { "fontPath": "./resources/fonts/aws-toolkit-icons.woff", "fontCharacter": "\\f1d4" } }, - "aws-redshift-cluster": { + "aws-mynah-MynahIconWhite": { "description": "AWS Contributed Icon", "default": { "fontPath": "./resources/fonts/aws-toolkit-icons.woff", "fontCharacter": "\\f1d5" } }, - "aws-redshift-cluster-connected": { + "aws-mynah-logo": { "description": "AWS Contributed Icon", "default": { "fontPath": "./resources/fonts/aws-toolkit-icons.woff", "fontCharacter": "\\f1d6" } }, - "aws-redshift-database": { + "aws-redshift-cluster": { "description": "AWS Contributed Icon", "default": { "fontPath": "./resources/fonts/aws-toolkit-icons.woff", "fontCharacter": "\\f1d7" } }, - "aws-redshift-redshift-cluster-connected": { + "aws-redshift-cluster-connected": { "description": "AWS Contributed Icon", "default": { "fontPath": "./resources/fonts/aws-toolkit-icons.woff", "fontCharacter": "\\f1d8" } }, - "aws-redshift-schema": { + "aws-redshift-database": { "description": "AWS Contributed Icon", "default": { "fontPath": "./resources/fonts/aws-toolkit-icons.woff", "fontCharacter": "\\f1d9" } }, - "aws-redshift-table": { + "aws-redshift-redshift-cluster-connected": { "description": "AWS Contributed Icon", "default": { "fontPath": "./resources/fonts/aws-toolkit-icons.woff", "fontCharacter": "\\f1da" } }, - "aws-s3-bucket": { + "aws-redshift-schema": { "description": "AWS Contributed Icon", "default": { "fontPath": "./resources/fonts/aws-toolkit-icons.woff", "fontCharacter": "\\f1db" } }, - "aws-s3-create-bucket": { + "aws-redshift-table": { "description": "AWS Contributed Icon", "default": { "fontPath": "./resources/fonts/aws-toolkit-icons.woff", "fontCharacter": "\\f1dc" } }, - "aws-schemas-registry": { + "aws-s3-bucket": { "description": "AWS Contributed Icon", "default": { "fontPath": "./resources/fonts/aws-toolkit-icons.woff", "fontCharacter": "\\f1dd" } }, - "aws-schemas-schema": { + "aws-s3-create-bucket": { "description": "AWS Contributed Icon", "default": { "fontPath": "./resources/fonts/aws-toolkit-icons.woff", "fontCharacter": "\\f1de" } }, - "aws-stepfunctions-preview": { + "aws-schemas-registry": { "description": "AWS Contributed Icon", "default": { "fontPath": "./resources/fonts/aws-toolkit-icons.woff", "fontCharacter": "\\f1df" } + }, + "aws-schemas-schema": { + "description": "AWS Contributed Icon", + "default": { + "fontPath": "./resources/fonts/aws-toolkit-icons.woff", + "fontCharacter": "\\f1e0" + } + }, + "aws-stepfunctions-preview": { + "description": "AWS Contributed Icon", + "default": { + "fontPath": "./resources/fonts/aws-toolkit-icons.woff", + "fontCharacter": "\\f1e1" + } } } }, diff --git a/packages/core/package.nls.json b/packages/core/package.nls.json index aa1ac167917..ea37b36e9b4 100644 --- a/packages/core/package.nls.json +++ b/packages/core/package.nls.json @@ -160,7 +160,11 @@ "AWS.command.downloadLambda": "Download...", "AWS.command.uploadLambda": "Upload Lambda...", "AWS.command.invokeLambda": "Invoke in the cloud", + "AWS.command.openLambdaFile": "Open your Lambda code", + "AWS.command.quickDeployLambda": "Save and deploy your code", + "AWS.command.openLambdaWorkspace": "Open in a workspace", "AWS.command.invokeLambda.cn": "Invoke on Amazon", + "AWS.command.lambda.convertToSam": "Convert to SAM Application", "AWS.command.refreshAwsExplorer": "Refresh Explorer", "AWS.command.refreshCdkExplorer": "Refresh CDK Explorer", "AWS.command.cdk.help": "View CDK Documentation", diff --git a/packages/core/resources/icons/aws/lambda/create-stack-light.svg b/packages/core/resources/icons/aws/lambda/create-stack-light.svg new file mode 100644 index 00000000000..3dd66689af0 --- /dev/null +++ b/packages/core/resources/icons/aws/lambda/create-stack-light.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/packages/core/resources/icons/aws/lambda/create-stack.svg b/packages/core/resources/icons/aws/lambda/create-stack.svg new file mode 100644 index 00000000000..b8a08164556 --- /dev/null +++ b/packages/core/resources/icons/aws/lambda/create-stack.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/packages/core/resources/markdown/lambda2sam.md b/packages/core/resources/markdown/lambda2sam.md new file mode 100644 index 00000000000..ab43a89729a --- /dev/null +++ b/packages/core/resources/markdown/lambda2sam.md @@ -0,0 +1,42 @@ +# Welcome to Lambda Development with AWS SAM + +This project was generated from existing ${sourceType} to ${stackName} stack using the AWS Toolkit for VS Code. Your Lambda functions are now a project in AWS Serverless Application Model (SAM). Here, you can manage your functions as infrastructure as code using the AWS SAM template. This eliminates the need for manual changes in the AWS Console, provides better version control, and allows automated deployments of your serverless resources. + +${warning} + +## Prerequisites + +Confirm you have installed the following tools: + +- **The AWS CLI**: Needed to interact with AWS services from the command line. +- **The AWS SAM CLI:** Needed to locally build, invoke, and deploy your functions. Version 1.98+ is required. +- **Docker**: Optional, but required if you want to invoke locally, Docker is required. + +**Note:** For help on installing these tools, choose the **Application Builder** panel in **EXPLORER** or the AWS Toolkit Extension, and select **Walkthrough of Application Builder**. + +## What you can do with AWS SAM + +Your functions are ready for local development. You can either use the **AWS Application Builder** or the **SAM CLI** to edit and manage your functions. + +To get started using Application Builder, choose the **Application Builder** panel in **EXPLORER** or the AWS Toolkit Extension, and select **Walkthrough of Application Builder**. + +Use the following SAM CLI commands to manage your functions: + +- **Build Your Code:** Run [`sam build`](https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/sam-cli-command-reference-sam-build.html) in the terminal to compile your code and install dependencies. +- **Test Locally:** Run the [`sam local invoke`](https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/sam-cli-command-reference-sam-local-invoke.html) and [`sam local start-api`](https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/sam-cli-command-reference-sam-local-start-api.html)commands in the terminal. +- **Deploy Your Changes:** Run [`sam deploy --guided`](https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/sam-cli-command-reference-sam-deploy.html) in the terminal to deploy your updated function to AWS. +- **Verify Deployment:** Run [`sam remote invoke`](https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/sam-cli-command-reference-remote-invoke.html) or go to the Lambda Console + +## Quick Reference + +- **SAM Template**: [template.yaml](./template.yaml) - Contains your infrastructure as code +- **SAM Configuration**: [samconfig.toml](./samconfig.toml) - Contains deployment configuration + +## Advanced features + +You can also debug your functions locally with breakpoints, manage environment variables, work with layers and dependencies, and configure function triggers and permissions through the AWS Toolkit interface. For more details, refer to the following resources + +- [AWS toolkit for Visual Studio Code User Guide](https://docs.aws.amazon.com/toolkit-for-vscode/latest/userguide/welcome.html) +- [Working with Application Builder](https://docs.aws.amazon.com/toolkit-for-vscode/latest/userguide/appbuilder-overview-overview.html) +- [AWS SAM Developer Guide](https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/what-is-sam.html) +- [AWS SAM command line reference](http://https//docs.aws.amazon.com/serverless-application-model/latest/developerguide/serverless-sam-cli-command-reference.html) diff --git a/packages/core/resources/markdown/lambdaEdit.md b/packages/core/resources/markdown/lambdaEdit.md new file mode 100644 index 00000000000..c8842cf19ec --- /dev/null +++ b/packages/core/resources/markdown/lambdaEdit.md @@ -0,0 +1,19 @@ +# Welcome to Lambda Local Development + +Learn how to view your Lambda Function locally, iterate, and deploy changes to the AWS Cloud. + +## Edit your Lambda function + +- Make changes to your function code and save it. You will be prompted to deploy if you're done editing. You can come back later if you have more changes to make. +- Using the terminal and your favorite package manager, add dependencies for your project. + +## Manage your Lambda functions + +- Select the AWS icon in the left sidebar and select **EXPLORER** +- In your desired region, select the Lambda dropdown menu: + - To save and deploy a previously edited Lambda function, select the cloud deploy icon next to your Lambda function. + - To remotely invoke a function, select the play icon next to your Lambda function. + +## Advanced Features + +- To convert to a Lambda function to an AWS SAM application, select the ![createStack](./create-stack.svg) icon next to your Lambda function. For details on what AWS SAM is and how it can help you, see the [AWS Serverless Application Model Developer Guide](https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/what-is-sam.html). diff --git a/packages/core/src/auth/utils.ts b/packages/core/src/auth/utils.ts index 9da35fa06e5..910c5cee949 100644 --- a/packages/core/src/auth/utils.ts +++ b/packages/core/src/auth/utils.ts @@ -71,7 +71,7 @@ export async function promptForConnection(auth: Auth, type?: 'iam' | 'iam-only' if (resp === 'addNewConnection') { // We could call this command directly, but it either lives in packages/toolkit or will at some point. const source: AuthSource = 'addConnectionQuickPick' // enforcing type sanity check - await vscode.commands.executeCommand('aws.toolkit.auth.manageConnections', placeholder, source) + await vscode.commands.executeCommand('aws.toolkit.auth.manageConnections', placeholder, source, undefined, true) return undefined } @@ -89,8 +89,9 @@ export async function promptForConnection(auth: Auth, type?: 'iam' | 'iam-only' export async function promptAndUseConnection(...[auth, type]: Parameters) { return telemetry.aws_setCredentials.run(async (span) => { let conn = await promptForConnection(auth, type) + // Returning because either conn is a valid connection, or the customer selected 'addNewConnection' or 'editCredentials' if (!conn) { - throw new CancellationError('user') + return } // HACK: We assume that if we are toolkit we want AWS account scopes. @@ -113,7 +114,11 @@ export async function promptAndUseConnection(...[auth, type]: Parameters async function registerAppBuilderCommands(context: ExtContext): Promise { const source = 'AppBuilderWalkthrough' context.extensionContext.subscriptions.push( + Commands.register({ id: 'aws.toolkit.lambda.convertToSam', autoconnect: true }, async (lambdaNode) => { + await telemetry.appbuilder_lambda2sam.run(async () => { + telemetry.record({ source: 'explorer' }) + await lambdaToSam(lambdaNode) + }) + }), Commands.register('aws.toolkit.installSAMCLI', async () => { await getOrInstallCliWrapper('sam-cli', source) }), diff --git a/packages/core/src/awsService/appBuilder/lambda2sam/lambda2sam.ts b/packages/core/src/awsService/appBuilder/lambda2sam/lambda2sam.ts new file mode 100644 index 00000000000..20f7c372583 --- /dev/null +++ b/packages/core/src/awsService/appBuilder/lambda2sam/lambda2sam.ts @@ -0,0 +1,1006 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as vscode from 'vscode' +import { LambdaFunctionNode } from '../../../lambda/explorer/lambdaFunctionNode' +import fs from '../../../shared/fs/fs' +import { getLogger } from '../../../shared/logger/logger' +import * as os from 'os' +import { + LAMBDA_FUNCTION_TYPE, + LAMBDA_LAYER_TYPE, + LAMBDA_URL_TYPE, + SERVERLESS_FUNCTION_TYPE, + SERVERLESS_LAYER_TYPE, + Template, + TemplateResources, + loadByContents, + save, + tryLoad, + ZipResourceProperties, + Resource, +} from '../../../shared/cloudformation/cloudformation' + +import { downloadUnzip, getLambdaClient, getCFNClient, isPermissionError } from '../utils' +import { openProjectInWorkspace } from '../walkthrough' +import { ToolkitError } from '../../../shared/errors' +import { ResourcesToImport, StackResource } from 'aws-sdk/clients/cloudformation' +import { SignatureV4 } from '@smithy/signature-v4' +import { Sha256 } from '@aws-crypto/sha256-js' +import { getIAMConnection } from '../../../auth/utils' +import globals from '../../../shared/extensionGlobals' +import { Runtime, telemetry } from '../../../shared/telemetry/telemetry' + +/** + * Information about a CloudFormation stack + */ +export interface StackInfo { + stackId: string + stackName: string + isSamTemplate: boolean + template: Template +} + +/** + * Main entry point for converting a Lambda function to a SAM project + */ +export async function lambdaToSam(lambdaNode: LambdaFunctionNode): Promise { + try { + // Show progress notification for the overall process + await vscode.window.withProgress( + { + location: vscode.ProgressLocation.Notification, + title: `Converting ${lambdaNode.name} to SAM project`, + cancellable: false, + }, + async (progress) => { + // 0. Prompt user for project location + const saveUri = await promptForProjectLocation() + if (!saveUri) { + getLogger().info('User canceled project location selection') + return + } + progress.report({ increment: 0, message: 'Checking stack association...' }) + + // 1. Determine which scenario applies to this Lambda function + telemetry.record({ runtime: lambdaNode?.configuration?.Runtime as Runtime | undefined }) + let stackInfo = await determineStackAssociation(lambdaNode) + + // 2. Handle the appropriate scenario + let samTemplate: Template + let sourceType: 'LambdaFunction' | 'SAMStack' | 'CFNStack' + let stackName: string | undefined + if (!stackInfo) { + telemetry.record({ action: 'deployStack' }) + // Scenario 1: Lambda doesn't belong to any stack + sourceType = 'LambdaFunction' + progress.report({ increment: 30, message: 'Generating template...' }) + // 2.1 call api to get CFN + let cfnTemplate: Template + let resourcesToImport: ResourcesToImport + try { + ;[cfnTemplate, resourcesToImport] = await callExternalApiForCfnTemplate(lambdaNode) + } catch (error) { + throw new ToolkitError(`Failed to generate template: ${error}`) + } + + // 2.2. Deploy the CFN template to create a stack + progress.report({ increment: 20, message: 'Deploying template...' }) + stackName = await promptForStackName(lambdaNode.name.replaceAll('_', '-')) + if (!stackName) { + throw new ToolkitError('Stack name not provided') + } + + stackInfo = await deployCfnTemplate( + cfnTemplate, + resourcesToImport, + stackName, + lambdaNode.regionCode + ) + samTemplate = { + AWSTemplateFormatVersion: stackInfo.template.AWSTemplateFormatVersion, + Transform: 'AWS::Serverless-2016-10-31', + Parameters: stackInfo.template.Parameters, + Globals: stackInfo.template.Globals, + Resources: stackInfo.template.Resources, + } + } else if (stackInfo.isSamTemplate) { + // Scenario 3: Lambda belongs to a stack deployed by SAM + sourceType = 'SAMStack' + progress.report({ increment: 50, message: 'Processing SAM template...' }) + samTemplate = stackInfo.template + stackName = stackInfo.stackName + } else { + // Scenario 2: Lambda belongs to a CFN stack + sourceType = 'CFNStack' + progress.report({ increment: 50, message: 'Creating SAM project from CFN...' }) + samTemplate = { + AWSTemplateFormatVersion: stackInfo.template.AWSTemplateFormatVersion, + Transform: 'AWS::Serverless-2016-10-31', + Parameters: stackInfo.template.Parameters, + Globals: stackInfo.template.Globals, + Resources: stackInfo.template.Resources, + } + stackName = stackInfo.stackName + } + + const projectUri = vscode.Uri.joinPath(saveUri[0], stackName) + + telemetry.record({ iac: sourceType }) + + // 3. Process Lambda functions in the template + if (!samTemplate.Resources) { + throw new ToolkitError('Template does not contain any resource, please retry') + } + + progress.report({ message: 'Downloading Lambda function code...' }) + await cfn2sam(samTemplate.Resources, projectUri, stackInfo, lambdaNode.regionCode) + + // 4. Save the SAM template + progress.report({ message: 'Saving SAM template...' }) + await save(samTemplate, vscode.Uri.joinPath(projectUri, 'template.yaml').fsPath) + + // 5. Create a basic README.md + // Use stack name from stackInfo if available, otherwise use the Lambda function name + progress.report({ message: 'Creating Readme...' }) + await createReadme(stackName, sourceType, projectUri) + + // 6. Create samconfig.toml + progress.report({ message: 'Creating SAM configuration...' }) + await createSAMConfig(stackName, lambdaNode.regionCode, projectUri) + + // 7. Open the project in VS Code + await openProjectInWorkspace(projectUri) + + // 8. Show success message + void vscode.window.showInformationMessage(`SAM project created successfully at ${projectUri.fsPath}`) + progress.report({ increment: 100, message: 'Done!' }) + } + ) + } catch (err) { + throw new ToolkitError(`Failed to convert Lambda to SAM: ${err instanceof Error ? err.message : String(err)}`) + } +} + +export async function createReadme( + stackName: string, + sourceType: 'LambdaFunction' | 'SAMStack' | 'CFNStack', + projectUri: vscode.Uri +) { + const warningSection = + sourceType !== 'LambdaFunction' + ? '' + : `**[Warning**: Currently only a subset of resource support converting to SAM, For any missing resources, please check the Lambda Console and add them manually to your SAM template. ]` + const lambda2SAMReadmeSource = 'resources/markdown/lambda2sam.md' + const readme = (await fs.readFileText(globals.context.asAbsolutePath(lambda2SAMReadmeSource))) + .replace(/\$\{sourceType\}/g, sourceType) + .replace(/\$\{stackName\}/g, stackName) + .replace(/\$\{warning\}/g, warningSection) + + await fs.writeFile(vscode.Uri.joinPath(projectUri, 'README.md'), readme) +} + +export async function createSAMConfig(stackName: string, region: string, projectUri: vscode.Uri) { + const samConfigContent = `# More information about the configuration file can be found here: +# https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/serverless-sam-cli-config.html +version = 0.1 + +[default] +[default.global.parameters] +stack_name = "${stackName}" +region = "${region}"` + await fs.writeFile(vscode.Uri.joinPath(projectUri, 'samconfig.toml'), samConfigContent) +} + +/** + * Determines if the Lambda function is associated with a CloudFormation stack + * and if that stack was deployed using SAM + */ +export async function determineStackAssociation(lambdaNode: LambdaFunctionNode): Promise { + try { + // Get Lambda function details including tags + const lambdaClient = getLambdaClient(lambdaNode.regionCode) + const functionDetails = await lambdaClient.getFunction(lambdaNode.name) + + // Check if the Lambda function has CloudFormation stack tags + if (!functionDetails.Tags) { + // Lambda doesn't have any tags, so it's not part of a stack + return undefined + } + + // Look for the CloudFormation stack ID tag + const stackIdTag = functionDetails.Tags['aws:cloudformation:stack-id'] + if (!stackIdTag) { + // Lambda doesn't have a CloudFormation stack ID tag + return undefined + } + + // Get the stack name tag if available, otherwise extract from stack ID + let stackName = functionDetails.Tags['aws:cloudformation:stack-name'] + if (!stackName) { + // Extract stack name from stack ID + const stackIdParts = stackIdTag.split('/') + stackName = stackIdParts.length > 1 ? stackIdParts[1] : '' + } + + // Create CloudFormation client + const cfn = await getCFNClient(lambdaNode.regionCode) + + // stack could be in DELETE_COMPLETE status or doesn't exist + const describeStacksResult = await cfn.describeStacks({ StackName: stackIdTag }) + if (!describeStacksResult.Stacks || describeStacksResult.Stacks.length === 0) { + return undefined + } + if (describeStacksResult.Stacks![0].StackStatus === 'DELETE_COMPLETE') { + return undefined + } + // Get the original stack template + const templateResponse = await cfn.getTemplate({ + StackName: stackIdTag, + TemplateStage: 'Original', // Critical to get the original SAM template + }) + + const templateBody = templateResponse.TemplateBody || '{}' + const template = await loadByContents(templateBody, false) + + // Determine if it's a SAM template by checking for the transform + const isSamTemplate = ifSamTemplate(template) + + return { + stackId: stackIdTag, + stackName, + isSamTemplate, + template, + } + } catch (err) { + throw new ToolkitError(`Error determining stack association: ${err}, please try again`) + } +} + +/** + * Checks if a template is a SAM template by looking for the SAM transform + */ +export function ifSamTemplate(template: Template): boolean { + // Check for SAM transform + if (template.Transform) { + if (typeof template.Transform === 'string') { + return template.Transform.startsWith('AWS::Serverless') + } else if (typeof template.Transform === 'object' && Array.isArray(template.Transform)) { + // Handle case where Transform might be an array + return template.Transform.some((t: string) => typeof t === 'string' && t.startsWith('AWS::Serverless')) + } + } + + return false +} + +/** + * Calls the external API to generate a CloudFormation template for a Lambda function + * Note: This is a placeholder for the actual API call + */ +export async function callExternalApiForCfnTemplate( + lambdaNode: LambdaFunctionNode +): Promise<[Template, ResourcesToImport]> { + const conn = await getIAMConnection() + if (!conn || conn.type !== 'iam') { + return [{}, []] + } + + const cred = await conn.getCredentials() + const signer = new SignatureV4({ + credentials: cred, + region: lambdaNode.regionCode, + service: 'lambdaconsole', + sha256: Sha256, + }) + + // TODO: govcloud URL is in a slightly different format + const url = new URL( + `https://${lambdaNode.regionCode}.prod.topology.console.lambda.aws.a2z.com/lambda-api/topology/topology?lambdaArn=${lambdaNode.arn}` + ) + + const signedRequest = await signer.sign({ + method: 'GET', + headers: { + host: url.hostname, + }, + hostname: url.hostname, + path: url.pathname, + query: Object.fromEntries(url.searchParams), + protocol: url.protocol, + }) + + const response = await fetch(url.toString(), { + method: 'GET', + headers: { + Accept: 'application/xml', + 'Content-Type': 'application/json', + ...signedRequest.headers, + }, + }) + + if (!response.ok) { + getLogger().error('Failed to retrieve generated CloudFormation template: %O', await response.json()) + throw new ToolkitError(`Failed to retrieve generated CloudFormation template ID: ${response.statusText}`) + } + + const data = await response.json() + if (!data.cloudFormationTemplateId) { + throw new ToolkitError('No template ID returned') + } + + let status: string | undefined = 'CREATE_IN_PROGRESS' + let getGeneratedTemplateResponse + let resourcesToImport: ResourcesToImport = [] + const cfn = await getCFNClient(lambdaNode.regionCode) + + // Wait for template generation to complete + while (status !== 'COMPLETE') { + getGeneratedTemplateResponse = await cfn.getGeneratedTemplate({ + Format: 'YAML', + GeneratedTemplateName: data.cloudFormationTemplateId, + }) + + status = getGeneratedTemplateResponse.Status + if (status === 'FAILED') { + throw new ToolkitError('CloudFormation template create status FAILED') + } + + // Add a small delay to avoid hitting API rate limits + if (status !== 'COMPLETE') { + await new Promise((resolve) => setTimeout(resolve, 1000)) + } + } + + // Get the generated template details to extract resource information + const describeGeneratedTemplateResponse = await cfn.describeGeneratedTemplate({ + GeneratedTemplateName: data.cloudFormationTemplateId, + }) + + if (describeGeneratedTemplateResponse.Status === 'FAILED') { + throw new ToolkitError('CloudFormation template describe request failed') + } + + // Build resourcesToImport from the generated template resources + if (describeGeneratedTemplateResponse.Resources) { + resourcesToImport = describeGeneratedTemplateResponse.Resources.filter( + (resource) => resource.LogicalResourceId && resource.ResourceType && resource.ResourceIdentifier + ).map((resource) => { + const resourceIdentifier = { ...resource.ResourceIdentifier! } + + // Fix Lambda function identifiers - extract function name from ARN + if (resource.ResourceType === 'AWS::Lambda::Function' && resourceIdentifier.FunctionName) { + // FunctionName might be returned as 'arn:aws:lambda:region:account:function:name' + // We need to extract just the function name + const functionNameOrArn = resourceIdentifier.FunctionName + if (functionNameOrArn.startsWith('arn:')) { + const arnParts = functionNameOrArn.split(':') + // ARN format: arn:aws:lambda:region:account:function:function-name + if (arnParts.length >= 7 && arnParts[5] === 'function') { + resourceIdentifier.FunctionName = arnParts[6] + } + } + } + + return { + ResourceType: resource.ResourceType!, + LogicalResourceId: resource.LogicalResourceId!, + ResourceIdentifier: resourceIdentifier, + } + }) + } + + const cfnTemplate = getGeneratedTemplateResponse!.TemplateBody + + const load = await tryLoad(vscode.Uri.from({ scheme: 'untitled' }), cfnTemplate) + if (!load.template || !load.template.Resources) { + throw new ToolkitError('Failed to load CloudFormation template') + } + + return [load.template, resourcesToImport] +} + +/** + * Prompts the user for a stack name + */ +export async function promptForStackName(defaultName: string): Promise { + return vscode.window.showInputBox({ + title: 'Enter Stack Name', + prompt: 'Enter a name for the CloudFormation stack', + value: `${defaultName}-stack`, + validateInput: (value) => { + if (!value) { + return 'Stack name is required' + } + if (!/^[a-zA-Z][a-zA-Z0-9-]*$/.test(value)) { + return 'Stack name must start with a letter and contain only letters, numbers, and hyphens' + } + return undefined + }, + }) +} + +async function promptForProjectLocation(): Promise { + return vscode.window.showOpenDialog({ + canSelectFolders: true, + canSelectFiles: false, + canSelectMany: false, + openLabel: 'Select SAM project location', + // if not workspace, use home dir + defaultUri: vscode.workspace.workspaceFolders?.[0]?.uri ?? vscode.Uri.file(os.homedir()), + }) +} + +/** + * Deploys a CloudFormation template to create a stack and imports the existing Lambda function + */ +export async function deployCfnTemplate( + template: Template, + resourcesToImport: ResourcesToImport, + stackName: string, + region: string +): Promise { + const cfn = await getCFNClient(region) + + removeUnwantedCodeParameters(template) + + // Convert template object to JSON string + const templateBody = JSON.stringify(template) + + // Create a change set to import the existing resources + const changeSetName = `ImportLambda-${Date.now()}` + const changeSetResponse = await cfn.createChangeSet({ + StackName: stackName, + ChangeSetName: changeSetName, + ChangeSetType: 'IMPORT', + TemplateBody: templateBody, + Capabilities: ['CAPABILITY_IAM', 'CAPABILITY_NAMED_IAM', 'CAPABILITY_AUTO_EXPAND'], + ResourcesToImport: resourcesToImport, + }) + + if (!changeSetResponse.Id) { + throw new ToolkitError('Failed to create change set') + } + + // Wait for change set creation to complete + await cfn + .waitFor('changeSetCreateComplete', { + StackName: stackName, + ChangeSetName: changeSetName, + $waiter: { + delay: 2, + }, + }) + .catch(async (err: any) => { + // If the change set failed to create, get the status reason + const describeResponse = await cfn.describeChangeSet({ + StackName: stackName, + ChangeSetName: changeSetName, + }) + + throw new ToolkitError(`Change set creation failed: ${describeResponse.StatusReason || err.message}`) + }) + + // Execute the change set + await cfn.executeChangeSet({ + StackName: stackName, + ChangeSetName: changeSetName, + }) + + // Wait for stack import to complete + await cfn + .waitFor('stackImportComplete', { + StackName: stackName, + $waiter: { + delay: 2, + }, + }) + .catch(async () => { + // If the stack import failed, wait for stack update complete instead + // (AWS SDK might not have stackImportComplete waiter) + await cfn.waitFor('stackUpdateComplete', { + StackName: stackName, + $waiter: { + delay: 2, + }, + }) + }) + + // Get the stack ID + const describeStackResponse = await cfn.describeStacks({ + StackName: stackName, + }) + + if (!describeStackResponse.Stacks || !describeStackResponse.Stacks[0].StackId) { + throw new ToolkitError('Failed to get stack information') + } + + // Return information about the deployed stack + return { + stackId: describeStackResponse.Stacks[0].StackId, + stackName, + template, + isSamTemplate: false, + } +} + +export function removeUnwantedCodeParameters(template: Template) { + if (!template.Resources) { + throw new Error('No Resources found in template') + } + + const lambdaKey = Object.keys(template.Resources).find( + (key) => template.Resources![key]?.Type === 'AWS::Lambda::Function' + ) + + if (!lambdaKey) { + throw new Error('No Lambda function found in template') + } + + template.Resources[lambdaKey]!.Properties!.Code = { + ZipFile: '', + } + + template.Parameters = {} +} + +/** + * Extracts the logical ID from an intrinsic function like !Ref or !GetAtt + * Returns undefined if the value is not an intrinsic function + */ +export function extractLogicalIdFromIntrinsic(value: any): string | undefined { + // Check for Ref: { "Ref": "logicalId" } + if (typeof value === 'object' && value !== null && Object.keys(value).length === 1 && value.Ref) { + return value.Ref + } + + // Check for GetAtt: { "Fn::GetAtt": ["logicalId", "Arn"] } + if ( + typeof value === 'object' && + value !== null && + Object.keys(value).length === 1 && + value['Fn::GetAtt'] && + Array.isArray(value['Fn::GetAtt']) && + value['Fn::GetAtt'].length === 2 && + value['Fn::GetAtt'][1] === 'Arn' + ) { + return value['Fn::GetAtt'][0] + } + + return undefined +} + +/** + * the main tansform to convert a CFN template to a sam project + * @param resources the parsed CFN template + * @param projectDir selected local location for project + * @param stackInfo + * @param region + */ +export async function cfn2sam( + resources: TemplateResources, + projectDir: vscode.Uri, + stackInfo: StackInfo, + region: string +): Promise { + const lambdaProcess = processLambdaResources(resources, projectDir, stackInfo, region) + const lambdaLayerProcess = processLambdaLayerResources(resources, projectDir, stackInfo, region) + await Promise.all([lambdaProcess, lambdaLayerProcess]) + await processLambdaUrlResources(resources) +} + +/** + * Processes Lambda resources in a template, transforming AWS::Lambda::Function to AWS::Serverless::Function + */ +export function Lambda2Serverless(resourceProp: ZipResourceProperties, key: string): Resource { + const dlqConfig = resourceProp.DeadLetterConfig + return { + Type: SERVERLESS_FUNCTION_TYPE, + Metadata: resourceProp.Metadata, + Properties: { + ...resourceProp, + // Transform Tags from array to object format + Tags: resourceProp.Tags + ? Object.fromEntries( + resourceProp.Tags.filter((item: { Key: any; Value: any }) => item.Key !== 'lambda:createdBy').map( + (item: { Key: any; Value: any }) => [item.Key, item.Value] + ) + ) + : undefined, + // Remove Code property (S3 reference) + Code: undefined, + // Map TracingConfig.Mode to Tracing property + Tracing: resourceProp.TracingConfig?.Mode, + TracingConfig: undefined, + // Transform DeadLetterConfig to DeadLetterQueue + DeadLetterQueue: dlqConfig + ? { + Type: dlqConfig.TargetArn.split(':')[2] === 'sqs' ? 'SQS' : 'SNS', + TargetArn: dlqConfig.TargetArn, + } + : undefined, + // Set CodeUri to the local path + CodeUri: key, + }, + } +} + +/** + * Processes Lambda URL resources in a template, transforming AWS::Lambda::Url to AWS::Serverless::Function.FunctionUrlConfig + */ +export async function processLambdaResources( + resources: TemplateResources, + projectDir: vscode.Uri, + stackInfo: StackInfo, + region: string +): Promise { + await Promise.all( + Object.entries(resources).map(async ([key, resource]) => { + if (!resource) { + return + } + + const resourceProp = resource.Properties + if (!resourceProp || resourceProp.PackageType === 'Image') { + return + } + + if (resource.Type === LAMBDA_FUNCTION_TYPE) { + // Transform AWS::Lambda::Function to AWS::Serverless::Function + try { + await downloadLambdaFunctionCode(key, stackInfo, projectDir, region, resourceProp.FunctionName) + + // Transform to Serverless Function + resources[key] = Lambda2Serverless(resourceProp, key) + } catch (err) { + throw new ToolkitError( + `Failed to process Lambda function ${key}: ${err instanceof Error ? err.message : String(err)}` + ) + } + } else if (resource.Type === SERVERLESS_FUNCTION_TYPE) { + // Update CodeUri for AWS::Serverless::Function + try { + await downloadLambdaFunctionCode(key, stackInfo, projectDir, region, resourceProp.FunctionName) + // Update the CodeUri to point to the local directory + resourceProp.CodeUri = key + } catch (err) { + throw new ToolkitError( + `Failed to process Serverless function ${key}: ${err instanceof Error ? err.message : String(err)}` + ) + } + } + }) + ) +} + +/** + * Processes Lambda Layer resources in a template, transforming AWS::Lambda::LayerVersion to AWS::Serverless::LayerVersion + */ +export async function processLambdaLayerResources( + resources: TemplateResources, + projectDir: vscode.Uri, + stackInfo: StackInfo, + region: string +): Promise { + // Process each resource + await Promise.all( + Object.entries(resources).map(async ([key, resource]) => { + if (!resource || (resource.Type !== LAMBDA_LAYER_TYPE && resource.Type !== SERVERLESS_LAYER_TYPE)) { + return + } + + const resourceProp = resource.Properties + if (!resourceProp) { + return + } + + try { + // Download the layer code + await downloadLayerVersionResourceByName(key, stackInfo, projectDir, region) + + // Transform to Serverless LayerVersion + resources[key] = { + Type: SERVERLESS_LAYER_TYPE, + Properties: { + ...resourceProp, + // Remove Content property (S3 reference) + Content: undefined, + // Set ContentUri to the local path + ContentUri: key, + }, + } + + getLogger().info(`Successfully transformed Lambda Layer ${key} to Serverless LayerVersion`) + } catch (err) { + throw new ToolkitError( + `Failed to process Lambda Layer ${key}: ${err instanceof Error ? err.message : String(err)}` + ) + } + }) + ) +} + +/** + * Processes Lambda URL resources in a template, transforming AWS::Lambda::Url to AWS::Serverless::Function.FunctionUrlConfig + */ +export async function processLambdaUrlResources(resources: TemplateResources): Promise { + for (const [key, resource] of Object.entries(resources)) { + if (resource && resource.Type === LAMBDA_URL_TYPE) { + try { + const resourceProp = resource.Properties + if (!resourceProp) { + continue + } + + // Skip if Qualifier is present (not supported in FunctionUrlConfig) + if (resourceProp.Qualifier) { + getLogger().info( + `Skipping Lambda URL ${key} because Qualifier is not supported in FunctionUrlConfig` + ) + continue + } + + // Find the target function using TargetFunctionArn + const targetFunctionArn = resourceProp.TargetFunctionArn + if (!targetFunctionArn) { + getLogger().warn(`Lambda URL ${key} does not have a TargetFunctionArn`) + continue + } + + const targetFunctionKey = extractLogicalIdFromIntrinsic(targetFunctionArn) + if (!targetFunctionKey) { + getLogger().debug(`Could not extract logical ID from TargetFunctionArn in Lambda URL ${key}`) + continue + } + + const targetFunction = resources[targetFunctionKey] + // if MyLambdaFunction 's url is not formated as MyLambdaFunctionUrl, then we shouldn't transform it + if ( + !targetFunction || + targetFunction.Type !== SERVERLESS_FUNCTION_TYPE || + targetFunctionKey + 'Url' !== key + ) { + getLogger().debug(`Target function ${targetFunctionKey} not found or not a Serverless Function`) + continue + } + + // Add FunctionUrlConfig to the Serverless Function + if (!targetFunction.Properties) { + // skip if target function is not correctly setup + continue + } + + // Now we can safely add FunctionUrlConfig + if (targetFunction.Properties) { + targetFunction.Properties.FunctionUrlConfig = { + AuthType: resourceProp.AuthType, + Cors: resourceProp.Cors, + InvokeMode: resourceProp.InvokeMode, + } + } + + // Remove the original Lambda URL resource + delete resources[key] + + getLogger().info( + `Successfully transformed Lambda URL ${key} to FunctionUrlConfig in ${targetFunctionKey}` + ) + } catch (err) { + throw new ToolkitError( + `Failed to process Lambda URL ${key}: ${err instanceof Error ? err.message : String(err)}` + ) + } + } + } +} + +/** + * Download lambda function code based on logical resource ID or physical resrouce ID + * If logical id is given, it will try to find the physical id first and then download the code + * If physical id is given, it will download the code directly + * @param resourceName logical name of Lambda function in CFN template + * @param stackInfo + * @param targetDir Local location to store the code + * @param region + * @param physicalResourceId Physical name of Lambda function + */ +export async function downloadLambdaFunctionCode( + resourceName: string, // This is the logical name from CFN + stackInfo: StackInfo, + targetDir: vscode.Uri, + region: string, + physicalResourceId?: string +) { + try { + if (!physicalResourceId || typeof physicalResourceId !== 'string') { + physicalResourceId = await getPhysicalIdfromCFNResourceName( + resourceName, + region, + stackInfo.stackId, + LAMBDA_FUNCTION_TYPE + ) + if (!physicalResourceId) { + throw new ToolkitError(`Could not find physical resource ID for ${resourceName}`) + } + } + + const lambdaClient = getLambdaClient(region) + const functionDetails = await lambdaClient.getFunction(physicalResourceId) + + if (!functionDetails.Code || !functionDetails.Code.Location) { + throw new ToolkitError(`Could not determine code location for function: ${physicalResourceId}`) + } + + const outputPath = vscode.Uri.joinPath(targetDir, resourceName) + await downloadUnzip(functionDetails.Code.Location, outputPath) + + getLogger().info(`Successfully downloaded and extracted: ${resourceName}`) + } catch (err) { + throw new ToolkitError( + `Failed to download resource ${resourceName}: ${err instanceof Error ? err.message : String(err)}` + ) + } +} + +/** + * Get physical resource ID from CFN resource name + * @param name CFN resource name + * @param region + * @param stackId + * @returns Physical resrouce ID + */ +export async function getPhysicalIdfromCFNResourceName( + name: string, + region: string, + stackId: string, + resourceType: string +): Promise { + // Create CloudFormation client + const cfn = await getCFNClient(region) + + try { + // First try the exact match approach + let describeResult + try { + describeResult = await cfn.describeStackResource({ + StackName: stackId, + LogicalResourceId: name, + }) + } catch (error) { + // If it's a permission error, re-throw it immediately + if (isPermissionError(error)) { + throw error + } + // For other errors (like ResourceNotFound), continue to fuzzy matching + describeResult = undefined + } + + if (describeResult?.StackResourceDetail?.PhysicalResourceId) { + const physicalResourceId = describeResult.StackResourceDetail.PhysicalResourceId + getLogger().debug(`Resource ${name} found with exact match, physical ID: ${physicalResourceId}`) + return physicalResourceId + } + + // only do fuzzy matching for layer, function doesn't have random suffix + if (resourceType === LAMBDA_FUNCTION_TYPE || resourceType === SERVERLESS_FUNCTION_TYPE) { + throw new ToolkitError(`Could not find physical resource ID for ${name}`) + } + + // If exact match fails, get all resources and try fuzzy matching + getLogger().debug(`Resource ${name} not found with exact match, trying fuzzy match...`) + const resources = await cfn.describeStackResources({ + StackName: stackId, + }) + + if (!resources.StackResources || resources.StackResources.length === 0) { + getLogger().debug(`No resources found in stack ${stackId}`) + return undefined + } + + // Find resources that start with the given name (SAM transform often adds suffixes) + const matchingResources = resources.StackResources.filter((resource: StackResource) => + resource.LogicalResourceId.startsWith(name) + ) + + if (matchingResources.length === 0) { + // Try a more flexible approach - check if the resource name is a substring + const substringMatches = resources.StackResources.filter((resource: StackResource) => + resource.LogicalResourceId.includes(name) + ) + + if (substringMatches.length === 0) { + getLogger().debug(`No fuzzy matches found for resource ${name}`) + return undefined + } + + // Use the first substring match + const match = substringMatches[0] + getLogger().debug( + `Resource ${name} matched with ${match.LogicalResourceId} using substring match, physical ID: ${match.PhysicalResourceId}` + ) + return match.PhysicalResourceId + } + + // If we have multiple matches, prefer exact prefix match + // Sort by length to get the closest match (shortest additional suffix) + matchingResources.sort( + (a: StackResource, b: StackResource) => a.LogicalResourceId.length - b.LogicalResourceId.length + ) + + const bestMatch = matchingResources[0] + getLogger().debug( + `Resource ${name} matched with ${bestMatch.LogicalResourceId} using prefix match, physical ID: ${bestMatch.PhysicalResourceId}` + ) + return bestMatch.PhysicalResourceId + } catch (err) { + throw ToolkitError.chain(err, `Error finding physical ID for resource ${name}, please retry`) + } +} + +/** + * Download a Lambda Layer resource by name and stack info + * @param resourceName Layer's Logical name from CFN + * @param stackInfo + * @param targetDir local location to store + * @param region + */ +export async function downloadLayerVersionResourceByName( + resourceName: string, // This is the logical name from CFN + stackInfo: StackInfo, + targetDir: vscode.Uri, + region: string +) { + try { + const physicalResourceId = await getPhysicalIdfromCFNResourceName( + resourceName, + region, + stackInfo.stackId, + LAMBDA_LAYER_TYPE + ) + if (!physicalResourceId) { + throw new ToolkitError(`Could not find physical resource ID for ${resourceName}`) + } + + getLogger().debug(`Resource ${resourceName} has physical ID ${physicalResourceId} and type LayerVersion`) + + // Parse the ARN to extract layer name and version + // Format: arn:aws:lambda:region:account-id:layer:layer-name:version + const arnParts = physicalResourceId.split(':') + if (arnParts.length < 8) { + throw new ToolkitError(`Invalid layer ARN format: ${physicalResourceId}`) + } + + const layerName = arnParts[6] + const version = parseInt(arnParts[7], 10) + + if (isNaN(version)) { + throw new ToolkitError(`Invalid version number in layer ARN: ${physicalResourceId}`) + } + + getLogger().debug(`Extracted layer name: ${layerName}, version: ${version} from ARN`) + + const lambdaClient = getLambdaClient(region) + + // Get the layer version details directly using the extracted name and version + const layerDetails = await lambdaClient.getLayerVersion(layerName, version) + + if (!layerDetails.Content || !layerDetails.Content.Location) { + throw new ToolkitError(`Could not determine code location for layer: ${layerName}:${version}`) + } + + // Download Lambda layer code using the presigned URL + const presignedUrl = layerDetails.Content.Location + + // Use node-fetch to download from the presigned URL + const outputPath = vscode.Uri.joinPath(targetDir, resourceName) + await downloadUnzip(presignedUrl, outputPath) + + getLogger().info(`Successfully downloaded and extracted layer ${layerName}:${version} to: ${resourceName}`) + } catch (err) { + throw new ToolkitError( + `Failed to download resource ${resourceName}: ${err instanceof Error ? err.message : String(err)}, please retry` + ) + } +} diff --git a/packages/core/src/awsService/appBuilder/utils.ts b/packages/core/src/awsService/appBuilder/utils.ts index 63b116b20eb..ba5b3baf7f8 100644 --- a/packages/core/src/awsService/appBuilder/utils.ts +++ b/packages/core/src/awsService/appBuilder/utils.ts @@ -19,8 +19,479 @@ import fs from '../../shared/fs/fs' import { getLogger } from '../../shared/logger/logger' import { RuntimeFamily, getFamily } from '../../lambda/models/samLambdaRuntime' import { showMessage } from '../../shared/utilities/messages' +import { DefaultLambdaClient } from '../../shared/clients/lambdaClient' +import AdmZip from 'adm-zip' +import { CloudFormation, Lambda } from 'aws-sdk' +import { isAwsError, UnknownError } from '../../shared/errors' const localize = nls.loadMessageBundle() +/** + * Interface for mapping AWS service actions to their required permissions + */ +interface PermissionMapping { + service: 'cloudformation' | 'lambda' + action: string + requiredPermissions: string[] + documentation?: string +} + +/** + * Comprehensive mapping of AWS service actions to their required permissions + */ +const PermissionMappings: PermissionMapping[] = [ + // CloudFormation permissions + { + service: 'cloudformation', + action: 'describeStacks', + requiredPermissions: ['cloudformation:DescribeStacks'], + documentation: 'https://docs.aws.amazon.com/AWSCloudFormation/latest/APIReference/API_DescribeStacks.html', + }, + { + service: 'cloudformation', + action: 'getTemplate', + requiredPermissions: ['cloudformation:GetTemplate'], + documentation: 'https://docs.aws.amazon.com/AWSCloudFormation/latest/APIReference/API_GetTemplate.html', + }, + { + service: 'cloudformation', + action: 'createChangeSet', + requiredPermissions: ['cloudformation:CreateChangeSet'], + documentation: 'https://docs.aws.amazon.com/AWSCloudFormation/latest/APIReference/API_CreateChangeSet.html', + }, + { + service: 'cloudformation', + action: 'executeChangeSet', + requiredPermissions: ['cloudformation:ExecuteChangeSet'], + documentation: 'https://docs.aws.amazon.com/AWSCloudFormation/latest/APIReference/API_ExecuteChangeSet.html', + }, + { + service: 'cloudformation', + action: 'describeChangeSet', + requiredPermissions: ['cloudformation:DescribeChangeSet'], + documentation: 'https://docs.aws.amazon.com/AWSCloudFormation/latest/APIReference/API_DescribeChangeSet.html', + }, + { + service: 'cloudformation', + action: 'describeStackResources', + requiredPermissions: ['cloudformation:DescribeStackResources'], + documentation: + 'https://docs.aws.amazon.com/AWSCloudFormation/latest/APIReference/API_DescribeStackResources.html', + }, + { + service: 'cloudformation', + action: 'describeStackResource', + requiredPermissions: ['cloudformation:DescribeStackResource'], + documentation: + 'https://docs.aws.amazon.com/AWSCloudFormation/latest/APIReference/API_DescribeStackResource.html', + }, + { + service: 'cloudformation', + action: 'getGeneratedTemplate', + requiredPermissions: ['cloudformation:GetGeneratedTemplate'], + documentation: + 'https://docs.aws.amazon.com/AWSCloudFormation/latest/APIReference/API_GetGeneratedTemplate.html', + }, + { + service: 'cloudformation', + action: 'describeGeneratedTemplate', + requiredPermissions: ['cloudformation:DescribeGeneratedTemplate'], + documentation: + 'https://docs.aws.amazon.com/AWSCloudFormation/latest/APIReference/API_DescribeGeneratedTemplate.html', + }, + // Lambda permissions + { + service: 'lambda', + action: 'getFunction', + requiredPermissions: ['lambda:GetFunction'], + documentation: 'https://docs.aws.amazon.com/lambda/latest/api/API_GetFunction.html', + }, + { + service: 'lambda', + action: 'listFunctions', + requiredPermissions: ['lambda:ListFunctions'], + documentation: 'https://docs.aws.amazon.com/lambda/latest/api/API_ListFunctions.html', + }, + { + service: 'lambda', + action: 'getLayerVersion', + requiredPermissions: ['lambda:GetLayerVersion'], + documentation: 'https://docs.aws.amazon.com/lambda/latest/api/API_GetLayerVersion.html', + }, + { + service: 'lambda', + action: 'listLayerVersions', + requiredPermissions: ['lambda:ListLayerVersions'], + documentation: 'https://docs.aws.amazon.com/lambda/latest/api/API_ListLayerVersions.html', + }, + { + service: 'lambda', + action: 'listFunctionUrlConfigs', + requiredPermissions: ['lambda:GetFunctionUrlConfig'], + documentation: 'https://docs.aws.amazon.com/lambda/latest/api/API_GetFunctionUrlConfig.html', + }, + { + service: 'lambda', + action: 'updateFunctionCode', + requiredPermissions: ['lambda:UpdateFunctionCode'], + documentation: 'https://docs.aws.amazon.com/lambda/latest/api/API_UpdateFunctionCode.html', + }, + { + service: 'lambda', + action: 'deleteFunction', + requiredPermissions: ['lambda:DeleteFunction'], + documentation: 'https://docs.aws.amazon.com/lambda/latest/api/API_DeleteFunction.html', + }, + { + service: 'lambda', + action: 'invoke', + requiredPermissions: ['lambda:InvokeFunction'], + documentation: 'https://docs.aws.amazon.com/lambda/latest/api/API_Invoke.html', + }, +] + +/** + * Creates an enhanced error message for permission-related failures + */ +function createEnhancedPermissionError( + originalError: unknown, + service: 'cloudformation' | 'lambda', + action: string, + resourceArn?: string +): ToolkitError { + const mapping = PermissionMappings.find((m) => m.service === service && m.action === action) + + if (!mapping) { + return ToolkitError.chain(originalError, `Permission denied for ${service}:${action}`) + } + + const permissionsList = mapping.requiredPermissions.map((p) => ` - ${p}`).join('\n') + const resourceInfo = resourceArn ? `\nResource: ${resourceArn}` : '' + + const message = `Permission denied: Missing required permissions for ${service}:${action} + +Required permissions: +${permissionsList}${resourceInfo} + +To fix this issue: +1. Contact your AWS administrator to add the missing permissions +2. Add these permissions to your IAM user/role policy +3. If using IAM roles, ensure the role has these permissions attached + +${mapping.documentation ? `Documentation: ${mapping.documentation}` : ''}` + + return new ToolkitError(message, { + code: 'InsufficientPermissions', + cause: UnknownError.cast(originalError), + details: { + service, + action, + requiredPermissions: mapping.requiredPermissions, + resourceArn, + }, + }) +} + +/** + * Checks if an error is a permission-related error + */ +export function isPermissionError(error: unknown): boolean { + return ( + isAwsError(error) && + (error.code === 'AccessDeniedException' || + error.code === 'UnauthorizedOperation' || + error.code === 'Forbidden' || + error.code === 'AccessDenied' || + (error as any).statusCode === 403) + ) +} + +/** + * Enhanced Lambda client wrapper that provides better error messages for permission issues + */ +export class EnhancedLambdaClient { + constructor( + private readonly client: DefaultLambdaClient, + private readonly regionCode: string + ) {} + + async deleteFunction(name: string): Promise { + try { + return await this.client.deleteFunction(name) + } catch (error) { + if (isPermissionError(error)) { + throw createEnhancedPermissionError( + error, + 'lambda', + 'deleteFunction', + `arn:aws:lambda:${this.regionCode}:*:function:${name}` + ) + } + throw error + } + } + + async invoke(name: string, payload?: Lambda.InvocationRequest['Payload']): Promise { + try { + return await this.client.invoke(name, payload) + } catch (error) { + if (isPermissionError(error)) { + throw createEnhancedPermissionError( + error, + 'lambda', + 'invoke', + `arn:aws:lambda:${this.regionCode}:*:function:${name}` + ) + } + throw error + } + } + + async *listFunctions(): AsyncIterableIterator { + try { + yield* this.client.listFunctions() + } catch (error) { + if (isPermissionError(error)) { + throw createEnhancedPermissionError(error, 'lambda', 'listFunctions') + } + throw error + } + } + + async getFunction(name: string): Promise { + try { + return await this.client.getFunction(name) + } catch (error) { + if (isPermissionError(error)) { + throw createEnhancedPermissionError( + error, + 'lambda', + 'getFunction', + `arn:aws:lambda:${this.regionCode}:*:function:${name}` + ) + } + throw error + } + } + + async getLayerVersion(name: string, version: number): Promise { + try { + return await this.client.getLayerVersion(name, version) + } catch (error) { + if (isPermissionError(error)) { + throw createEnhancedPermissionError( + error, + 'lambda', + 'getLayerVersion', + `arn:aws:lambda:${this.regionCode}:*:layer:${name}:${version}` + ) + } + throw error + } + } + + async *listLayerVersions(name: string): AsyncIterableIterator { + try { + yield* this.client.listLayerVersions(name) + } catch (error) { + if (isPermissionError(error)) { + throw createEnhancedPermissionError( + error, + 'lambda', + 'listLayerVersions', + `arn:aws:lambda:${this.regionCode}:*:layer:${name}` + ) + } + throw error + } + } + + async getFunctionUrlConfigs(name: string): Promise { + try { + return await this.client.getFunctionUrlConfigs(name) + } catch (error) { + if (isPermissionError(error)) { + throw createEnhancedPermissionError( + error, + 'lambda', + 'listFunctionUrlConfigs', + `arn:aws:lambda:${this.regionCode}:*:function:${name}` + ) + } + throw error + } + } + + async updateFunctionCode(name: string, zipFile: Uint8Array): Promise { + try { + return await this.client.updateFunctionCode(name, zipFile) + } catch (error) { + if (isPermissionError(error)) { + throw createEnhancedPermissionError( + error, + 'lambda', + 'updateFunctionCode', + `arn:aws:lambda:${this.regionCode}:*:function:${name}` + ) + } + throw error + } + } +} + +/** + * Enhanced CloudFormation client wrapper that provides better error messages for permission issues + */ +export class EnhancedCloudFormationClient { + constructor( + private readonly client: CloudFormation, + private readonly regionCode: string + ) {} + + async describeStacks(params: CloudFormation.DescribeStacksInput): Promise { + try { + return await this.client.describeStacks(params).promise() + } catch (error) { + if (isPermissionError(error)) { + const stackArn = params.StackName + ? `arn:aws:cloudformation:${this.regionCode}:*:stack/${params.StackName}/*` + : undefined + throw createEnhancedPermissionError(error, 'cloudformation', 'describeStacks', stackArn) + } + throw error + } + } + + async getTemplate(params: CloudFormation.GetTemplateInput): Promise { + try { + return await this.client.getTemplate(params).promise() + } catch (error) { + if (isPermissionError(error)) { + const stackArn = params.StackName + ? `arn:aws:cloudformation:${this.regionCode}:*:stack/${params.StackName}/*` + : undefined + throw createEnhancedPermissionError(error, 'cloudformation', 'getTemplate', stackArn) + } + throw error + } + } + + async createChangeSet(params: CloudFormation.CreateChangeSetInput): Promise { + try { + return await this.client.createChangeSet(params).promise() + } catch (error) { + if (isPermissionError(error)) { + const stackArn = params.StackName + ? `arn:aws:cloudformation:${this.regionCode}:*:stack/${params.StackName}/*` + : undefined + throw createEnhancedPermissionError(error, 'cloudformation', 'createChangeSet', stackArn) + } + throw error + } + } + + async executeChangeSet( + params: CloudFormation.ExecuteChangeSetInput + ): Promise { + try { + return await this.client.executeChangeSet(params).promise() + } catch (error) { + if (isPermissionError(error)) { + const stackArn = params.StackName + ? `arn:aws:cloudformation:${this.regionCode}:*:stack/${params.StackName}/*` + : undefined + throw createEnhancedPermissionError(error, 'cloudformation', 'executeChangeSet', stackArn) + } + throw error + } + } + + async describeChangeSet( + params: CloudFormation.DescribeChangeSetInput + ): Promise { + try { + return await this.client.describeChangeSet(params).promise() + } catch (error) { + if (isPermissionError(error)) { + const stackArn = params.StackName + ? `arn:aws:cloudformation:${this.regionCode}:*:stack/${params.StackName}/*` + : undefined + throw createEnhancedPermissionError(error, 'cloudformation', 'describeChangeSet', stackArn) + } + throw error + } + } + + async describeStackResources( + params: CloudFormation.DescribeStackResourcesInput + ): Promise { + try { + return await this.client.describeStackResources(params).promise() + } catch (error) { + if (isPermissionError(error)) { + const stackArn = params.StackName + ? `arn:aws:cloudformation:${this.regionCode}:*:stack/${params.StackName}/*` + : undefined + throw createEnhancedPermissionError(error, 'cloudformation', 'describeStackResources', stackArn) + } + throw error + } + } + + async describeStackResource( + params: CloudFormation.DescribeStackResourceInput + ): Promise { + try { + return await this.client.describeStackResource(params).promise() + } catch (error) { + if (isPermissionError(error)) { + const stackArn = params.StackName + ? `arn:aws:cloudformation:${this.regionCode}:*:stack/${params.StackName}/*` + : undefined + throw createEnhancedPermissionError(error, 'cloudformation', 'describeStackResource', stackArn) + } + throw error + } + } + + async getGeneratedTemplate( + params: CloudFormation.GetGeneratedTemplateInput + ): Promise { + try { + return await this.client.getGeneratedTemplate(params).promise() + } catch (error) { + if (isPermissionError(error)) { + throw createEnhancedPermissionError(error, 'cloudformation', 'getGeneratedTemplate') + } + throw error + } + } + + async describeGeneratedTemplate( + params: CloudFormation.DescribeGeneratedTemplateInput + ): Promise { + try { + return await this.client.describeGeneratedTemplate(params).promise() + } catch (error) { + if (isPermissionError(error)) { + throw createEnhancedPermissionError(error, 'cloudformation', 'describeGeneratedTemplate') + } + throw error + } + } + + async waitFor(state: string, params: any): Promise { + try { + return await this.client.waitFor(state as any, params).promise() + } catch (error) { + if (isPermissionError(error)) { + // For waitFor operations, we'll provide a generic permission error since the specific action varies + throw createEnhancedPermissionError(error, 'cloudformation', 'describeStacks') + } + throw error + } + } +} + export async function runOpenTemplate(arg?: TreeNode) { const templateUri = arg ? (arg.resource as SamAppLocation).samTemplateUri : await promptUserForTemplate() if (!templateUri || !(await fs.exists(templateUri))) { @@ -199,3 +670,35 @@ export async function deployTypePrompt() { } return selected } + +export async function downloadUnzip(url: string, destination: vscode.Uri) { + const response = await fetch(url) + if (!response.ok) { + throw new Error(`Failed to download Lambda layer code: ${response.statusText}`) + } + + // Get the response as an ArrayBuffer + const arrayBuffer = await response.arrayBuffer() + const zipBuffer = Buffer.from(arrayBuffer) + + // Create AdmZip instance with the buffer + const zip = new AdmZip(zipBuffer) + + // Create output directory if it doesn't exist + if (!(await fs.exists(destination))) { + await fs.mkdir(destination) + } + + // Extract zip contents to output path + zip.extractAllTo(destination.fsPath, true) +} + +export function getLambdaClient(region: string): EnhancedLambdaClient { + const originalClient = new DefaultLambdaClient(region) + return new EnhancedLambdaClient(originalClient, region) +} + +export async function getCFNClient(regionCode: string): Promise { + const originalClient = await globals.sdkClientBuilder.createAwsService(CloudFormation, {}, regionCode) + return new EnhancedCloudFormationClient(originalClient, regionCode) +} diff --git a/packages/core/src/awsexplorer/activation.ts b/packages/core/src/awsexplorer/activation.ts index f904658fcaa..ec4c23ccd79 100644 --- a/packages/core/src/awsexplorer/activation.ts +++ b/packages/core/src/awsexplorer/activation.ts @@ -36,6 +36,7 @@ import { TreeNode } from '../shared/treeview/resourceTreeDataProvider' import { getSourceNode } from '../shared/utilities/treeNodeUtils' import { openAwsCFNConsoleCommand, openAwsConsoleCommand } from '../shared/awsConsole' import { StackNameNode } from '../awsService/appBuilder/explorer/nodes/deployedStack' +import { LambdaFunctionNodeDecorationProvider } from '../lambda/explorer/lambdaFunctionNodeDecorationProvider' /** * Activates the AWS Explorer UI and related functionality. @@ -65,7 +66,10 @@ export async function activate(args: { telemetry.aws_expandExplorerNode.emit({ serviceType: element.element.serviceId, result: 'Succeeded' }) } }) - globals.context.subscriptions.push(view) + globals.context.subscriptions.push( + view, + vscode.window.registerFileDecorationProvider(LambdaFunctionNodeDecorationProvider.getInstance()) + ) await registerAwsExplorerCommands(args.context, awsExplorer, args.toolkitOutputChannel) diff --git a/packages/core/src/commands.ts b/packages/core/src/commands.ts index db038a72bbd..f69a8dd173c 100644 --- a/packages/core/src/commands.ts +++ b/packages/core/src/commands.ts @@ -46,7 +46,7 @@ import { Commands, VsCodeCommandArg, placeholder, vscodeComponent } from './shar import { isValidResponse } from './shared/wizards/wizard' import { CancellationError } from './shared/utilities/timeoutUtils' import { ToolkitError } from './shared/errors' -import { setContext } from './shared/vscode/setContext' +import { getContext, setContext } from './shared/vscode/setContext' function switchConnections(auth: Auth | TreeNode | unknown) { if (!(auth instanceof Auth)) { @@ -103,7 +103,7 @@ export function registerCommands(context: vscode.ExtensionContext) { const manageConnections = Commands.register( { id: 'aws.toolkit.auth.manageConnections', compositeKey: { 1: 'source' } }, - async (_: VsCodeCommandArg, source: AuthSource, serviceToShow?: ServiceItemId) => { + async (_: VsCodeCommandArg, source: AuthSource, serviceToShow?: ServiceItemId, blocking?: boolean) => { if (_ !== placeholder) { source = AuthSources.vscodeComponent } @@ -124,7 +124,23 @@ export function registerCommands(context: vscode.ExtensionContext) { CommonAuthWebview.authSource = source await vscode.commands.executeCommand('aws.explorer.setLoginService', serviceToShow) await setContext('aws.explorer.showAuthView', true) + + // While the auth view is open, we want to be blocking (if the command has been specified to be blocking) + const authWindowPromise = new Promise((resolve) => { + if (!blocking) { + resolve() + } + + const check = globals.clock.setInterval(() => { + if (getContext('aws.explorer.showAuthView') === false) { + clearInterval(check) + resolve() + } + }, 500) + }) + await vscode.commands.executeCommand('aws.toolkit.AmazonCommonAuth.focus') + await authWindowPromise } ) diff --git a/packages/core/src/lambda/activation.ts b/packages/core/src/lambda/activation.ts index d799173697e..1bb91a737b3 100644 --- a/packages/core/src/lambda/activation.ts +++ b/packages/core/src/lambda/activation.ts @@ -3,18 +3,21 @@ * SPDX-License-Identifier: Apache-2.0 */ +import * as path from 'path' import * as vscode from 'vscode' +import * as nls from 'vscode-nls' + import { Lambda } from 'aws-sdk' import { deleteLambda } from './commands/deleteLambda' import { uploadLambdaCommand } from './commands/uploadLambda' import { LambdaFunctionNode } from './explorer/lambdaFunctionNode' -import { downloadLambdaCommand } from './commands/downloadLambda' +import { downloadLambdaCommand, openLambdaFile } from './commands/downloadLambda' import { tryRemoveFolder } from '../shared/filesystemUtilities' import { ExtContext } from '../shared/extensions' import { invokeRemoteLambda } from './vue/remoteInvoke/invokeLambda' import { registerSamDebugInvokeVueCommand, registerSamInvokeVueCommand } from './vue/configEditor/samInvokeBackend' import { Commands } from '../shared/vscode/commands2' -import { DefaultLambdaClient } from '../shared/clients/lambdaClient' +import { DefaultLambdaClient, getFunctionWithCredentials } from '../shared/clients/lambdaClient' import { copyLambdaUrl } from './commands/copyLambdaUrl' import { ResourceNode } from '../awsService/appBuilder/explorer/nodes/resourceNode' import { isTreeNode, TreeNode } from '../shared/treeview/resourceTreeDataProvider' @@ -24,11 +27,50 @@ import { liveTailRegistry, liveTailCodeLensProvider } from '../awsService/cloudW import { getFunctionLogGroupName } from '../awsService/cloudWatchLogs/activation' import { ToolkitError, isError } from '../shared/errors' import { LogStreamFilterResponse } from '../awsService/cloudWatchLogs/wizard/liveTailLogStreamSubmenu' +import { tempDirPath } from '../shared/filesystemUtilities' +import fs from '../shared/fs/fs' +import { deployFromTemp, editLambda, getReadme, openLambdaFolderForEdit } from './commands/editLambda' +import { getTempLocation } from './utils' +import { registerLambdaUriHandler } from './uriHandlers' + +const localize = nls.loadMessageBundle() /** * Activates Lambda components. */ export async function activate(context: ExtContext): Promise { + try { + if (vscode.workspace.workspaceFolders) { + for (const workspaceFolder of vscode.workspace.workspaceFolders) { + // Making the comparison case insensitive because Windows can have `C\` or `c\` + const workspacePath = workspaceFolder.uri.fsPath.toLowerCase() + const tempPath = path.join(tempDirPath, 'lambda').toLowerCase() + if (workspacePath.startsWith(tempPath)) { + const name = path.basename(workspaceFolder.uri.fsPath) + const region = path.basename(path.dirname(workspaceFolder.uri.fsPath)) + const getFunctionOutput = await getFunctionWithCredentials(region, name) + const configuration = getFunctionOutput.Configuration + await editLambda( + { + name, + region, + // Configuration as any due to the difference in types between sdkV2 and sdkV3 + configuration: configuration as any, + }, + true + ) + + const readmeUri = vscode.Uri.file(await getReadme()) + await vscode.commands.executeCommand('markdown.showPreview', readmeUri, vscode.ViewColumn.Two) + } + } + } + } catch (e) { + void vscode.window.showWarningMessage( + localize('AWS.lambda.open.failure', `Unable to edit Lambda Function locally: ${e}`) + ) + } + context.extensionContext.subscriptions.push( Commands.register('aws.deleteLambda', async (node: LambdaFunctionNode | TreeNode) => { const sourceNode = getSourceNode(node) @@ -47,6 +89,7 @@ export async function activate(context: ExtContext): Promise { source: source, }) }), + // Capture debug finished events, and delete the temporary directory if it exists vscode.debug.onDidTerminateDebugSession(async (session) => { if ( @@ -56,10 +99,12 @@ export async function activate(context: ExtContext): Promise { await tryRemoveFolder(session.configuration.baseBuildDir) } }), + Commands.register('aws.downloadLambda', async (node: LambdaFunctionNode | TreeNode) => { const sourceNode = getSourceNode(node) await downloadLambdaCommand(sourceNode) }), + Commands.register({ id: 'aws.uploadLambda', autoconnect: true }, async (arg?: unknown) => { if (arg instanceof LambdaFunctionNode) { await uploadLambdaCommand({ @@ -73,6 +118,26 @@ export async function activate(context: ExtContext): Promise { await uploadLambdaCommand() } }), + + Commands.register({ id: 'aws.quickDeployLambda' }, async (node: LambdaFunctionNode) => { + const functionName = node.configuration.FunctionName! + const region = node.regionCode + const lambda = { name: functionName, region, configuration: node.configuration } + const tempLocation = getTempLocation(functionName, region) + + if (await fs.existsDir(tempLocation)) { + await deployFromTemp(lambda, vscode.Uri.file(tempLocation)) + } + }), + + Commands.register('aws.openLambdaFile', async (path: string) => { + await openLambdaFile(path) + }), + + Commands.register('aws.lambda.openWorkspace', async (node: LambdaFunctionNode) => { + await openLambdaFolderForEdit(node.functionName, node.regionCode) + }), + Commands.register('aws.copyLambdaUrl', async (node: LambdaFunctionNode | TreeNode) => { const sourceNode = getSourceNode(node) await copyLambdaUrl(sourceNode, new DefaultLambdaClient(sourceNode.regionCode)) @@ -116,6 +181,8 @@ export async function activate(context: ExtContext): Promise { throw err } } - }) + }), + + registerLambdaUriHandler() ) } diff --git a/packages/core/src/lambda/commands/downloadLambda.ts b/packages/core/src/lambda/commands/downloadLambda.ts index e2e1dc2be91..80932a34a76 100644 --- a/packages/core/src/lambda/commands/downloadLambda.ts +++ b/packages/core/src/lambda/commands/downloadLambda.ts @@ -11,7 +11,7 @@ import { LambdaFunctionNode } from '../explorer/lambdaFunctionNode' import { showConfirmationMessage } from '../../shared/utilities/messages' import { LaunchConfiguration, getReferencedHandlerPaths } from '../../shared/debug/launchConfiguration' -import { makeTemporaryToolkitFolder, fileExists, tryRemoveFolder } from '../../shared/filesystemUtilities' +import { makeTemporaryToolkitFolder, tryRemoveFolder } from '../../shared/filesystemUtilities' import * as localizedText from '../../shared/localizedText' import { getLogger } from '../../shared/logger/logger' import { HttpResourceFetcher } from '../../shared/resourcefetcher/node/httpResourceFetcher' @@ -26,6 +26,7 @@ import { DefaultLambdaClient } from '../../shared/clients/lambdaClient' import { telemetry } from '../../shared/telemetry/telemetry' import { Result, Runtime } from '../../shared/telemetry/telemetry' import { fs } from '../../shared/fs/fs' +import { LambdaFunction } from './uploadLambda' export async function downloadLambdaCommand(functionNode: LambdaFunctionNode) { const result = await runDownloadLambda(functionNode) @@ -75,6 +76,22 @@ async function runDownloadLambda(functionNode: LambdaFunctionNode): Promise { return await vscode.window.withProgress( { location: vscode.ProgressLocation.Notification, @@ -82,7 +99,7 @@ async function runDownloadLambda(functionNode: LambdaFunctionNode): Promise { - return selectedUri === val.uri - }).length === 0 - ) { - await addFolderToWorkspace({ uri: selectedUri! }, true) + if (workspaceFolders) { + if ( + workspaceFolders.filter((val) => { + return selectedUri === val.uri + }).length === 0 + ) { + await addFolderToWorkspace({ uri: selectedUri! }, true) + } + const workspaceFolder = vscode.workspace.getWorkspaceFolder(vscode.Uri.file(downloadLocation))! + + await addLaunchConfigEntry(lambdaLocation, lambda, workspaceFolder) } - const workspaceFolder = vscode.workspace.getWorkspaceFolder(vscode.Uri.file(downloadLocation))! - - await addLaunchConfigEntry(lambdaLocation, functionNode, workspaceFolder) - return 'Succeeded' } catch (e) { // failed to open handler file or add launch config. @@ -137,17 +155,17 @@ async function downloadAndUnzipLambda( message?: string | undefined increment?: number | undefined }>, - functionNode: LambdaFunctionNode, + lambda: LambdaFunction, extractLocation: string, - lambda = new DefaultLambdaClient(functionNode.regionCode) + lambdaClient = new DefaultLambdaClient(lambda.region) ): Promise { - const functionArn = functionNode.configuration.FunctionArn! + const functionArn = lambda.configuration!.FunctionArn! let tempDir: string | undefined try { tempDir = await makeTemporaryToolkitFolder() const downloadLocation = path.join(tempDir, 'function.zip') - const response = await lambda.getFunction(functionArn) + const response = await lambdaClient.getFunction(functionArn) const codeLocation = response.Code!.Location! // arbitrary increments since there's no "busy" state for progress bars @@ -177,7 +195,7 @@ async function downloadAndUnzipLambda( } export async function openLambdaFile(lambdaLocation: string): Promise { - if (!(await fileExists(lambdaLocation))) { + if (!(await fs.exists(lambdaLocation))) { const warning = localize( 'AWS.lambda.download.fileNotFound', 'Handler file {0} not found in downloaded function.', @@ -193,16 +211,16 @@ export async function openLambdaFile(lambdaLocation: string): Promise { async function addLaunchConfigEntry( lambdaLocation: string, - functionNode: LambdaFunctionNode, + lambda: LambdaFunction, workspaceFolder: vscode.WorkspaceFolder ): Promise { - const handler = functionNode.configuration.Handler! + const handler = lambda.configuration!.Handler! const samDebugConfig = createCodeAwsSamDebugConfig( workspaceFolder, handler, - computeLambdaRoot(lambdaLocation, functionNode), - functionNode.configuration.Runtime! + computeLambdaRoot(lambdaLocation, lambda), + lambda.configuration!.Runtime! ) const launchConfig = new LaunchConfiguration(vscode.Uri.file(lambdaLocation)) @@ -218,8 +236,8 @@ async function addLaunchConfigEntry( * @param lambdaLocation Lambda handler file location * @param functionNode Function node */ -function computeLambdaRoot(lambdaLocation: string, functionNode: LambdaFunctionNode): string { - const lambdaDetails = getLambdaDetails(functionNode.configuration) +function computeLambdaRoot(lambdaLocation: string, lambda: LambdaFunction): string { + const lambdaDetails = getLambdaDetails(lambda.configuration!) const normalizedLocation = pathutils.normalize(lambdaLocation) const lambdaIndex = normalizedLocation.indexOf(`/${lambdaDetails.fileName}`) diff --git a/packages/core/src/lambda/commands/editLambda.ts b/packages/core/src/lambda/commands/editLambda.ts new file mode 100644 index 00000000000..05476a8c765 --- /dev/null +++ b/packages/core/src/lambda/commands/editLambda.ts @@ -0,0 +1,244 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +import * as vscode from 'vscode' +import * as nls from 'vscode-nls' +import { LambdaFunctionNode } from '../explorer/lambdaFunctionNode' +import { downloadLambdaInLocation, openLambdaFile } from './downloadLambda' +import { LambdaFunction, runUploadDirectory } from './uploadLambda' +import { + compareCodeSha, + getFunctionInfo, + getLambdaDetails, + getTempLocation, + lambdaEdits, + lambdaTempPath, + setFunctionInfo, +} from '../utils' +import { showConfirmationMessage } from '../../shared/utilities/messages' +import fs from '../../shared/fs/fs' +import globals from '../../shared/extensionGlobals' +import { LambdaFunctionNodeDecorationProvider } from '../explorer/lambdaFunctionNodeDecorationProvider' +import path from 'path' +import { telemetry } from '../../shared/telemetry/telemetry' +import { ToolkitError } from '../../shared/errors' + +const localize = nls.loadMessageBundle() + +let lastPromptTime = Date.now() - 5000 + +export function watchForUpdates(lambda: LambdaFunction, projectUri: vscode.Uri): void { + const watcher = vscode.workspace.createFileSystemWatcher(new vscode.RelativePattern(projectUri, '*')) + const startTime = globals.clock.Date.now() + + watcher.onDidChange(async (fileUri) => { + await promptForSync(lambda, projectUri, fileUri) + }) + + watcher.onDidCreate(async (fileUri) => { + // When the code is downloaded and the watcher is set, this will immediately trigger the onDidCreate + // To avoid this, we must check that the file was actually created AFTER the watcher was created + if ((await fs.stat(fileUri.fsPath)).ctime < startTime) { + return + } + await promptForSync(lambda, projectUri, fileUri) + }) + + watcher.onDidDelete(async (fileUri) => { + // We don't want to sync if the whole directory has been deleted + if (fileUri.fsPath !== projectUri.fsPath) { + await promptForSync(lambda, projectUri, fileUri) + } + }) +} + +// Creating this function for testing, can't mock the vscode.window in the tests +export async function promptDeploy() { + const confirmItem = localize('AWS.lambda.upload.sync', 'Deploy') + const cancelItem = localize('AWS.lambda.upload.noSync', 'No, thanks') + const response = await vscode.window.showInformationMessage( + localize('AWS.lambda.upload.confirmSync', 'Would you like to deploy these changes to the cloud?'), + confirmItem, + cancelItem + ) + return response === confirmItem +} + +export async function promptForSync(lambda: LambdaFunction, projectUri: vscode.Uri, fileUri: vscode.Uri) { + if (!(await fs.existsDir(projectUri.fsPath)) || globals.clock.Date.now() - lastPromptTime < 5000) { + return + } + + await setFunctionInfo(lambda, { + undeployed: true, + }) + + await LambdaFunctionNodeDecorationProvider.getInstance().addBadge( + fileUri, + vscode.Uri.from({ scheme: 'lambda', path: `${lambda.region}/${lambda.name}` }) + ) + + lastPromptTime = globals.clock.Date.now() + if (await promptDeploy()) { + await deployFromTemp(lambda, projectUri) + } +} + +async function confirmOutdatedChanges(prompt: string): Promise { + return await showConfirmationMessage({ + prompt, + confirm: localize('AWS.lambda.upload.overwrite', 'Overwrite'), + cancel: localize('AWS.lambda.upload.noOverwrite', 'Cancel'), + }) +} + +export async function deployFromTemp(lambda: LambdaFunction, projectUri: vscode.Uri) { + return telemetry.lambda_quickDeploy.run(async () => { + const prompt = localize( + 'AWS.lambda.upload.confirmOutdatedSync', + 'There are changes to your Function in the cloud after you created this local copy, overwrite anyway?' + ) + + const isShaDifferent = !(await compareCodeSha(lambda)) + const overwriteChanges = isShaDifferent ? await confirmOutdatedChanges(prompt) : true + + if (overwriteChanges) { + // Reset the lastPrompt time because we don't want to retrigger the watcher flow + lastPromptTime = globals.clock.Date.now() + await vscode.workspace.saveAll() + try { + await runUploadDirectory(lambda, 'zip', projectUri) + } catch { + throw new ToolkitError('Failed to deploy Lambda function', { code: 'deployFailure' }) + } + await setFunctionInfo(lambda, { + lastDeployed: globals.clock.Date.now(), + undeployed: false, + }) + await LambdaFunctionNodeDecorationProvider.getInstance().removeBadge( + projectUri, + vscode.Uri.from({ scheme: 'lambda', path: `${lambda.region}/${lambda.name}` }) + ) + if (isShaDifferent) { + telemetry.record({ action: 'overwriteChanges' }) + } + } else { + telemetry.record({ action: 'cancelOverwrite' }) + } + }) +} + +export async function editLambdaCommand(functionNode: LambdaFunctionNode) { + const region = functionNode.regionCode + const functionName = functionNode.configuration.FunctionName! + return await editLambda({ name: functionName, region, configuration: functionNode.configuration }) +} + +export async function editLambda(lambda: LambdaFunction, onActivation?: boolean) { + return await telemetry.lambda_quickEditFunction.run(async () => { + telemetry.record({ source: onActivation ? 'workspace' : 'explorer' }) + const { name, region, configuration } = lambda + const downloadLocation = getTempLocation(lambda.name, lambda.region) + const downloadLocationName = vscode.workspace.asRelativePath(downloadLocation, true) + + // We don't want to do anything if the folder already exists as a workspace folder, it means it's already being edited + if ( + vscode.workspace.workspaceFolders?.some((folder) => folder.uri.fsPath === downloadLocation && !onActivation) + ) { + return downloadLocation + } + + const prompt = localize( + 'AWS.lambda.download.confirmOutdatedSync', + 'There are changes to your Function in the cloud since you last edited locally, do you want to overwrite your local changes?' + ) + + // We want to overwrite changes in the following cases: + // 1. There is no code sha locally (getCodeShaLocal returns falsy) + // 2. There is a code sha locally, it does not match the one remotely, and the user confirms they want to overwrite it + const localExists = !!(await getFunctionInfo(lambda, 'sha')) + // This record tells us if they're attempting to edit a function they've edited before + telemetry.record({ action: localExists ? 'existingEdit' : 'newEdit' }) + + const overwriteChanges = + !localExists || (!(await compareCodeSha(lambda)) ? await confirmOutdatedChanges(prompt) : false) + + if (overwriteChanges) { + try { + // Clear directory contents instead of deleting to avoid Windows EBUSY errors + if (await fs.existsDir(downloadLocation)) { + const entries = await fs.readdir(downloadLocation) + await Promise.all( + entries.map((entry) => + fs.delete(path.join(downloadLocation, entry[0]), { recursive: true, force: true }) + ) + ) + } else { + await fs.mkdir(downloadLocation) + } + + await downloadLambdaInLocation(lambda, downloadLocationName, downloadLocation) + + // Watching for updates, then setting info, then removing the badges must be done in this order + // This is because the files creating can throw the watcher, which sometimes leads to changes being marked as undeployed + watchForUpdates(lambda, vscode.Uri.file(downloadLocation)) + await setFunctionInfo(lambda, { + lastDeployed: globals.clock.Date.now(), + undeployed: false, + sha: lambda.configuration!.CodeSha256, + }) + await LambdaFunctionNodeDecorationProvider.getInstance().removeBadge( + vscode.Uri.file(downloadLocation), + vscode.Uri.from({ scheme: 'lambda', path: `${lambda.region}/${lambda.name}` }) + ) + } catch { + throw new ToolkitError('Failed to download Lambda function', { code: 'failedDownload' }) + } + } else { + const lambdaLocation = path.join(downloadLocation, getLambdaDetails(lambda.configuration!).fileName) + await openLambdaFile(lambdaLocation) + watchForUpdates(lambda, vscode.Uri.file(downloadLocation)) + } + + const newEdit = { location: downloadLocationName, region, functionName: name, configuration } + lambdaEdits.push(newEdit) + + return downloadLocation + }) +} + +export async function openLambdaFolderForEdit(name: string, region: string) { + const downloadLocation = getTempLocation(name, region) + + if ( + vscode.workspace.workspaceFolders?.some((workspaceFolder) => + workspaceFolder.uri.fsPath.toLowerCase().startsWith(downloadLocation.toLowerCase()) + ) + ) { + // If the folder already exists in the workspace, show that folder + await vscode.commands.executeCommand('workbench.action.focusSideBar') + await vscode.commands.executeCommand('workbench.view.explorer') + } else { + await fs.mkdir(downloadLocation) + + await vscode.commands.executeCommand('vscode.openFolder', vscode.Uri.file(downloadLocation), { + newWindow: true, + noRecentEntry: true, + }) + } +} + +export async function getReadme(): Promise { + const readmeSource = 'resources/markdown/lambdaEdit.md' + const readmeDestination = path.join(lambdaTempPath, 'README.md') + const readmeContent = await fs.readFileText(globals.context.asAbsolutePath(readmeSource)) + await fs.writeFile(readmeDestination, readmeContent) + + // Put cloud deploy icon in the readme + const createStackIconSource = 'resources/icons/aws/lambda/create-stack-light.svg' + const createStackIconDestination = path.join(lambdaTempPath, 'create-stack.svg') + await fs.copy(globals.context.asAbsolutePath(createStackIconSource), createStackIconDestination) + + return readmeDestination +} diff --git a/packages/core/src/lambda/commands/uploadLambda.ts b/packages/core/src/lambda/commands/uploadLambda.ts index 692d07409a2..8105506ee45 100644 --- a/packages/core/src/lambda/commands/uploadLambda.ts +++ b/packages/core/src/lambda/commands/uploadLambda.ts @@ -19,13 +19,14 @@ import { SamCliBuildInvocation } from '../../shared/sam/cli/samCliBuild' import { getSamCliContext } from '../../shared/sam/cli/samCliContext' import { SamTemplateGenerator } from '../../shared/templates/sam/samTemplateGenerator' import { addCodiconToString } from '../../shared/utilities/textUtilities' -import { getLambdaDetails, listLambdaFunctions } from '../utils' +import { getLambdaEditFromNameRegion, getLambdaDetails, listLambdaFunctions } from '../utils' import { getIdeProperties } from '../../shared/extensionUtilities' import { createQuickPick, DataQuickPickItem } from '../../shared/ui/pickerPrompter' import { createCommonButtons } from '../../shared/ui/buttons' import { StepEstimator, Wizard, WIZARD_BACK } from '../../shared/wizards/wizard' import { createSingleFileDialog } from '../../shared/ui/common/openDialog' import { Prompter, PromptResult } from '../../shared/ui/prompter' +import { SkipPrompter } from '../../shared/ui/common/skipPrompter' import { ToolkitError } from '../../shared/errors' import { FunctionConfiguration } from 'aws-sdk/clients/lambda' import globals from '../../shared/extensionGlobals' @@ -103,6 +104,13 @@ export async function uploadLambdaCommand(lambdaArg?: LambdaFunction, path?: vsc } else if (response.uploadType === 'directory' && response.directoryBuildType) { result = (await runUploadDirectory(lambda, response.directoryBuildType, response.targetUri)) ?? result result = 'Succeeded' + } else if (response.uploadType === 'edit') { + const functionPath = getLambdaEditFromNameRegion(lambda.name, lambda.region)?.location + if (!functionPath) { + throw new ToolkitError('Function had a local copy before, but not anymore') + } else { + await runUploadDirectory(lambda, 'zip', vscode.Uri.file(functionPath)) + } } // TODO(sijaden): potentially allow the wizard to easily support tagged-union states } catch (err) { @@ -131,8 +139,8 @@ export async function uploadLambdaCommand(lambdaArg?: LambdaFunction, path?: vsc /** * Selects the type of file to upload (zip/dir) and proceeds with the rest of the workflow. */ -function createUploadTypePrompter() { - const items: DataQuickPickItem<'zip' | 'directory'>[] = [ +function createUploadTypePrompter(lambda?: LambdaFunction) { + const items: DataQuickPickItem<'edit' | 'zip' | 'directory'>[] = [ { label: addCodiconToString('file-zip', localize('AWS.generic.filetype.zipfile', 'ZIP Archive')), data: 'zip', @@ -143,6 +151,17 @@ function createUploadTypePrompter() { }, ] + if (lambda !== undefined) { + const { region, name: functionName } = lambda + const lambdaEdit = getLambdaEditFromNameRegion(functionName, region) + if (lambdaEdit) { + items.unshift({ + label: addCodiconToString('edit', localize('AWS.generic.filetype.edit', 'Local edit')), + data: 'edit', + }) + } + } + return createQuickPick(items, { title: localize('AWS.lambda.upload.title', 'Select Upload Type'), buttons: createCommonButtons(), @@ -196,7 +215,7 @@ function createConfirmDeploymentPrompter(lambda: LambdaFunction) { } export interface UploadLambdaWizardState { - readonly uploadType: 'zip' | 'directory' + readonly uploadType: 'edit' | 'zip' | 'directory' readonly targetUri: vscode.Uri readonly directoryBuildType: 'zip' | 'sam' readonly confirmedDeploy: boolean @@ -215,23 +234,23 @@ export class UploadLambdaWizard extends Wizard { this.form.targetUri.setDefault(this.invokePath) } } else { - this.form.uploadType.bindPrompter(() => createUploadTypePrompter()) - this.form.targetUri.bindPrompter(({ uploadType }) => { - if (uploadType === 'directory') { - return createSingleFileDialog({ - canSelectFolders: true, - canSelectFiles: false, - }) - } else { - return createSingleFileDialog({ - canSelectFolders: false, - canSelectFiles: true, - filters: { - 'ZIP archive': ['zip'], - }, - }) - } - }) + this.form.uploadType.bindPrompter(() => createUploadTypePrompter(this.lambda)) + this.form.targetUri.bindPrompter( + ({ uploadType }) => { + if (uploadType === 'directory') { + return createSingleFileDialog({ + canSelectFolders: false, + canSelectFiles: true, + filters: { + 'ZIP archive': ['zip'], + }, + }) + } else { + return new SkipPrompter() + } + }, + { showWhen: ({ uploadType }) => uploadType !== 'edit' } + ) } this.form.lambda.name.bindPrompter((state) => { @@ -258,7 +277,12 @@ export class UploadLambdaWizard extends Wizard { this.form.directoryBuildType.setDefault('zip') } - this.form.confirmedDeploy.bindPrompter((state) => createConfirmDeploymentPrompter(state.lambda!)) + this.form.confirmedDeploy.bindPrompter( + (state) => { + return createConfirmDeploymentPrompter(state.lambda!) + }, + { showWhen: ({ uploadType }) => uploadType !== 'edit' } + ) return this } @@ -277,7 +301,7 @@ export class UploadLambdaWizard extends Wizard { * @param type Whether to zip or sam build the directory * @param window Wrapper around vscode.window functionality for testing */ -async function runUploadDirectory(lambda: LambdaFunction, type: 'zip' | 'sam', parentDir: vscode.Uri) { +export async function runUploadDirectory(lambda: LambdaFunction, type: 'zip' | 'sam', parentDir: vscode.Uri) { if (type === 'sam' && lambda.configuration) { return await runUploadLambdaWithSamBuild({ ...lambda, configuration: lambda.configuration }, parentDir) } else { diff --git a/packages/core/src/lambda/explorer/cloudFormationNodes.ts b/packages/core/src/lambda/explorer/cloudFormationNodes.ts index 6fd8c4d0e96..e2d9e9aae27 100644 --- a/packages/core/src/lambda/explorer/cloudFormationNodes.ts +++ b/packages/core/src/lambda/explorer/cloudFormationNodes.ts @@ -153,8 +153,7 @@ function makeCloudFormationLambdaFunctionNode( regionCode: string, configuration: Lambda.FunctionConfiguration ): LambdaFunctionNode { - const node = new LambdaFunctionNode(parent, regionCode, configuration) - node.contextValue = contextValueCloudformationLambdaFunction + const node = new LambdaFunctionNode(parent, regionCode, configuration, contextValueCloudformationLambdaFunction) return node } diff --git a/packages/core/src/lambda/explorer/lambdaFunctionFileNode.ts b/packages/core/src/lambda/explorer/lambdaFunctionFileNode.ts new file mode 100644 index 00000000000..422de99b31f --- /dev/null +++ b/packages/core/src/lambda/explorer/lambdaFunctionFileNode.ts @@ -0,0 +1,38 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { AWSResourceNode } from '../../shared/treeview/nodes/awsResourceNode' +import { AWSTreeNodeBase } from '../../shared/treeview/nodes/awsTreeNodeBase' +import { LambdaFunctionNode } from './lambdaFunctionNode' +import { getIcon } from '../../shared/icons' +import { isCloud9 } from '../../shared/extensionUtilities' +import { LambdaFunctionFolderNode } from './lambdaFunctionFolderNode' + +export class LambdaFunctionFileNode extends AWSTreeNodeBase implements AWSResourceNode { + public constructor( + public readonly parent: LambdaFunctionNode | LambdaFunctionFolderNode, + public readonly filename: string, + public readonly path: string + ) { + super(filename) + this.iconPath = getIcon('vscode-file') + this.contextValue = 'lambdaFunctionFileNode' + this.command = !isCloud9() + ? { + command: 'aws.openLambdaFile', + title: 'Open file', + arguments: [path], + } + : undefined + } + + public get arn(): string { + return '' + } + + public get name(): string { + return '' + } +} diff --git a/packages/core/src/lambda/explorer/lambdaFunctionFolderNode.ts b/packages/core/src/lambda/explorer/lambdaFunctionFolderNode.ts new file mode 100644 index 00000000000..7c67d482666 --- /dev/null +++ b/packages/core/src/lambda/explorer/lambdaFunctionFolderNode.ts @@ -0,0 +1,60 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as vscode from 'vscode' +import { AWSResourceNode } from '../../shared/treeview/nodes/awsResourceNode' +import { AWSTreeNodeBase } from '../../shared/treeview/nodes/awsTreeNodeBase' +import { LambdaFunctionNode } from './lambdaFunctionNode' +import { fs } from '../../shared/fs/fs' +import { getIcon } from '../../shared/icons' +import { makeChildrenNodes } from '../../shared/treeview/utils' +import { PlaceholderNode } from '../../shared/treeview/nodes/placeholderNode' +import { localize } from 'vscode-nls' +import path from 'path' +import { LambdaFunctionFileNode } from './lambdaFunctionFileNode' + +export class LambdaFunctionFolderNode extends AWSTreeNodeBase implements AWSResourceNode { + public constructor( + public readonly parent: LambdaFunctionNode | LambdaFunctionFolderNode, + public readonly filename: string, + public readonly path: string + ) { + super(filename, vscode.TreeItemCollapsibleState.Collapsed) + this.iconPath = getIcon('vscode-folder') + this.contextValue = 'lambdaFunctionFolderNode' + } + + public get arn(): string { + return '' + } + + public get name(): string { + return '' + } + + public override async getChildren(): Promise { + return await makeChildrenNodes({ + getChildNodes: async () => this.loadFunctionFiles(), + getNoChildrenPlaceholderNode: async () => + new PlaceholderNode(this, localize('AWS.explorerNode.s3.noObjects', '[No Objects found]')), + }) + } + + public async loadFunctionFiles(): Promise { + const nodes: AWSTreeNodeBase[] = [] + const files = await fs.readdir(this.path) + for (const file of files) { + const [fileName, type] = file + const filePath = path.join(this.path, fileName) + if (type === vscode.FileType.Directory) { + nodes.push(new LambdaFunctionFolderNode(this, fileName, filePath)) + } else { + nodes.push(new LambdaFunctionFileNode(this, fileName, filePath)) + } + } + + return nodes + } +} diff --git a/packages/core/src/lambda/explorer/lambdaFunctionNode.ts b/packages/core/src/lambda/explorer/lambdaFunctionNode.ts index 02fa8439d5c..2093a9585d4 100644 --- a/packages/core/src/lambda/explorer/lambdaFunctionNode.ts +++ b/packages/core/src/lambda/explorer/lambdaFunctionNode.ts @@ -5,26 +5,49 @@ import { Lambda } from 'aws-sdk' import * as os from 'os' +import * as vscode from 'vscode' import { getIcon } from '../../shared/icons' import { AWSResourceNode } from '../../shared/treeview/nodes/awsResourceNode' import { AWSTreeNodeBase } from '../../shared/treeview/nodes/awsTreeNodeBase' +import { editLambdaCommand } from '../commands/editLambda' +import { fs } from '../../shared/fs/fs' +import { makeChildrenNodes } from '../../shared/treeview/utils' +import { PlaceholderNode } from '../../shared/treeview/nodes/placeholderNode' +import path from 'path' +import { localize } from 'vscode-nls' +import { LambdaFunctionFolderNode } from './lambdaFunctionFolderNode' +import { LambdaFunctionFileNode } from './lambdaFunctionFileNode' + +export const contextValueLambdaFunction = 'awsRegionFunctionNode' +export const contextValueLambdaFunctionImportable = 'awsRegionFunctionNodeDownloadable' export class LambdaFunctionNode extends AWSTreeNodeBase implements AWSResourceNode { public constructor( public readonly parent: AWSTreeNodeBase, public override readonly regionCode: string, - public configuration: Lambda.FunctionConfiguration + public configuration: Lambda.FunctionConfiguration, + public override readonly contextValue?: string ) { - super('') + super( + `${configuration.FunctionArn}`, + contextValue === contextValueLambdaFunctionImportable + ? vscode.TreeItemCollapsibleState.Collapsed + : vscode.TreeItemCollapsibleState.None + ) this.update(configuration) + this.resourceUri = vscode.Uri.from({ scheme: 'lambda', path: `${regionCode}/${configuration.FunctionName}` }) this.iconPath = getIcon('aws-lambda-function') + this.contextValue = contextValue } public update(configuration: Lambda.FunctionConfiguration): void { this.configuration = configuration this.label = this.configuration.FunctionName || '' this.tooltip = `${this.configuration.FunctionName}${os.EOL}${this.configuration.FunctionArn}` + if (this.contextValue === contextValueLambdaFunction) { + this.tooltip += `${os.EOL} This function is not downloadable` + } } public get functionName(): string { @@ -46,4 +69,35 @@ export class LambdaFunctionNode extends AWSTreeNodeBase implements AWSResourceNo return this.configuration.FunctionName } + + public override async getChildren(): Promise { + if (!(this.contextValue === contextValueLambdaFunctionImportable)) { + return [] + } + + return await makeChildrenNodes({ + getChildNodes: async () => { + const path = await editLambdaCommand(this) + return path ? this.loadFunctionFiles(path) : [] + }, + getNoChildrenPlaceholderNode: async () => + new PlaceholderNode(this, localize('AWS.explorerNode.lambda.noFiles', '[No files found]')), + }) + } + + public async loadFunctionFiles(tmpPath: string): Promise { + const nodes: AWSTreeNodeBase[] = [] + const files = await fs.readdir(tmpPath) + for (const file of files) { + const [fileName, type] = file + const filePath = path.join(tmpPath, fileName) + if (type === vscode.FileType.Directory) { + nodes.push(new LambdaFunctionFolderNode(this, fileName, filePath)) + } else { + nodes.push(new LambdaFunctionFileNode(this, fileName, filePath)) + } + } + + return nodes + } } diff --git a/packages/core/src/lambda/explorer/lambdaFunctionNodeDecorationProvider.ts b/packages/core/src/lambda/explorer/lambdaFunctionNodeDecorationProvider.ts new file mode 100644 index 00000000000..b31de940991 --- /dev/null +++ b/packages/core/src/lambda/explorer/lambdaFunctionNodeDecorationProvider.ts @@ -0,0 +1,107 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +import * as vscode from 'vscode' +import { fs } from '../../shared/fs/fs' +import path from 'path' +import { getFunctionInfo } from '../utils' +import { LambdaFunction } from '../commands/uploadLambda' + +export class LambdaFunctionNodeDecorationProvider implements vscode.FileDecorationProvider { + // Make it a singleton so that it's easier to access + private static instance: LambdaFunctionNodeDecorationProvider + private readonly _onDidChangeFileDecorations = new vscode.EventEmitter() + readonly onDidChangeFileDecorations = this._onDidChangeFileDecorations.event + + private constructor() {} + + public static getInstance(): LambdaFunctionNodeDecorationProvider { + if (!LambdaFunctionNodeDecorationProvider.instance) { + LambdaFunctionNodeDecorationProvider.instance = new LambdaFunctionNodeDecorationProvider() + } + return LambdaFunctionNodeDecorationProvider.instance + } + + async provideFileDecoration(uri: vscode.Uri): Promise { + const badge = { + badge: 'M', + color: new vscode.ThemeColor('gitDecoration.modifiedResourceForeground'), + tooltip: 'This function has undeployed changes', + propagate: true, + } + + if (uri.scheme === 'lambda') { + const [region, name] = uri.path.split('/') + const lambda: LambdaFunction = { region, name } + if (await getFunctionInfo(lambda, 'undeployed')) { + badge.propagate = false + return badge + } + } else { + try { + const lambda = this.getLambdaFromPath(uri) + if (lambda && (await this.isFileModifiedAfterDeployment(uri.fsPath, lambda))) { + return badge + } + } catch { + return undefined + } + } + } + + public async addBadge(fileUri: vscode.Uri, functionUri: vscode.Uri) { + this._onDidChangeFileDecorations.fire(vscode.Uri.file(fileUri.fsPath)) + this._onDidChangeFileDecorations.fire(functionUri) + } + + public async removeBadge(fileUri: vscode.Uri, functionUri: vscode.Uri) { + // We need to propagate the badge removal down to all files in the dir + for (const path of await this.getFilePaths(fileUri.fsPath)) { + const subUri = vscode.Uri.file(path) + this._onDidChangeFileDecorations.fire(subUri) + } + this._onDidChangeFileDecorations.fire(functionUri) + } + + private async getFilePaths(basePath: string) { + const files = await fs.readdir(basePath) + const subFiles: string[] = [basePath] + for (const file of files) { + const [fileName, type] = file + const filePath = path.join(basePath, fileName) + if (type === vscode.FileType.Directory) { + subFiles.push(...(await this.getFilePaths(filePath))) + } else { + subFiles.push(filePath) + } + } + + return subFiles + } + + private getLambdaFromPath(uri: vscode.Uri): LambdaFunction { + const pathParts = uri.fsPath.split(path.sep) + const lambdaIndex = pathParts.indexOf('lambda') + if (lambdaIndex === -1 || lambdaIndex + 2 >= pathParts.length) { + throw new Error('Invalid path') + } + const region = pathParts[lambdaIndex + 1] + const name = pathParts[lambdaIndex + 2] + return { region, name } + } + + private async isFileModifiedAfterDeployment(filePath: string, lambda: LambdaFunction): Promise { + try { + const { lastDeployed, undeployed } = await getFunctionInfo(lambda) + if (!lastDeployed || !undeployed) { + return false + } + + const fileStat = await fs.stat(filePath) + return fileStat.mtime > lastDeployed + } catch { + return false + } + } +} diff --git a/packages/core/src/lambda/explorer/lambdaNodes.ts b/packages/core/src/lambda/explorer/lambdaNodes.ts index 9adff42f04d..077572feda7 100644 --- a/packages/core/src/lambda/explorer/lambdaNodes.ts +++ b/packages/core/src/lambda/explorer/lambdaNodes.ts @@ -15,12 +15,13 @@ import { PlaceholderNode } from '../../shared/treeview/nodes/placeholderNode' import { makeChildrenNodes } from '../../shared/treeview/utils' import { toArrayAsync, toMap, updateInPlace } from '../../shared/utilities/collectionUtils' import { listLambdaFunctions } from '../utils' -import { LambdaFunctionNode } from './lambdaFunctionNode' +import { + contextValueLambdaFunction, + contextValueLambdaFunctionImportable, + LambdaFunctionNode, +} from './lambdaFunctionNode' import { samLambdaImportableRuntimes } from '../models/samLambdaRuntime' -export const contextValueLambdaFunction = 'awsRegionFunctionNode' -export const contextValueLambdaFunctionImportable = 'awsRegionFunctionNodeDownloadable' - /** * An AWS Explorer node representing the Lambda Service. * Contains Lambda Functions for a specific region as child nodes. @@ -70,10 +71,10 @@ function makeLambdaFunctionNode( regionCode: string, configuration: Lambda.FunctionConfiguration ): LambdaFunctionNode { - const node = new LambdaFunctionNode(parent, regionCode, configuration) - node.contextValue = samLambdaImportableRuntimes.contains(node.configuration.Runtime ?? '') + const contextValue = samLambdaImportableRuntimes.contains(configuration.Runtime ?? '') ? contextValueLambdaFunctionImportable : contextValueLambdaFunction + const node = new LambdaFunctionNode(parent, regionCode, configuration, contextValue) return node } diff --git a/packages/core/src/lambda/models/samLambdaRuntime.ts b/packages/core/src/lambda/models/samLambdaRuntime.ts index 58311474d41..d6d8683e28b 100644 --- a/packages/core/src/lambda/models/samLambdaRuntime.ts +++ b/packages/core/src/lambda/models/samLambdaRuntime.ts @@ -68,7 +68,7 @@ export const javaRuntimes: ImmutableSet = ImmutableSet([ 'java21', ]) export const dotNetRuntimes: ImmutableSet = ImmutableSet(['dotnet6', 'dotnet8']) -export const rubyRuntimes: ImmutableSet = ImmutableSet(['ruby3.2', 'ruby3.3']) +export const rubyRuntimes: ImmutableSet = ImmutableSet(['ruby3.2', 'ruby3.3', 'ruby3.4']) /** * Deprecated runtimes can be found at https://docs.aws.amazon.com/lambda/latest/dg/runtime-support-policy.html @@ -125,7 +125,11 @@ export const samArmLambdaRuntimes: ImmutableSet = ImmutableSet const cloud9SupportedRuntimes: ImmutableSet = ImmutableSet.union([nodeJsRuntimes, pythonRuntimes]) // only interpreted languages are importable as compiled languages won't provide a useful artifact for editing. -export const samLambdaImportableRuntimes: ImmutableSet = ImmutableSet.union([nodeJsRuntimes, pythonRuntimes]) +export const samLambdaImportableRuntimes: ImmutableSet = ImmutableSet.union([ + nodeJsRuntimes, + pythonRuntimes, + rubyRuntimes, +]) export function samLambdaCreatableRuntimes(cloud9: boolean = isCloud9()): ImmutableSet { return cloud9 ? cloud9SupportedRuntimes : samZipLambdaRuntimes diff --git a/packages/core/src/lambda/uriHandlers.ts b/packages/core/src/lambda/uriHandlers.ts new file mode 100644 index 00000000000..b5d6b4d6661 --- /dev/null +++ b/packages/core/src/lambda/uriHandlers.ts @@ -0,0 +1,62 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as vscode from 'vscode' +import * as nls from 'vscode-nls' + +import { SearchParams } from '../shared/vscode/uriHandler' +import { openLambdaFolderForEdit } from './commands/editLambda' +import { showConfirmationMessage } from '../shared/utilities/messages' +import globals from '../shared/extensionGlobals' +import { getFunctionWithCredentials } from '../shared/clients/lambdaClient' +import { telemetry } from '../shared/telemetry/telemetry' +import { ToolkitError } from '../shared/errors' + +const localize = nls.loadMessageBundle() + +export function registerLambdaUriHandler() { + async function openFunctionHandler(params: ReturnType) { + await telemetry.lambda_uriHandler.run(async () => { + try { + // We just want to be able to get the function - if it fails we abort and throw the error + await getFunctionWithCredentials(params.region, params.functionName) + + if (params.isCfn === 'true') { + const response = await showConfirmationMessage({ + prompt: localize( + 'AWS.lambda.open.confirmInStack', + 'The function you are attempting to open is in a CloudFormation stack. Editing the function code could lead to stack drift.' + ), + confirm: localize('AWS.lambda.open.confirmStack', 'Confirm'), + cancel: localize('AWS.lambda.open.cancelStack', 'Cancel'), + }) + if (!response) { + return + } + } + await openLambdaFolderForEdit(params.functionName, params.region) + } catch (e) { + throw new ToolkitError(`Unable to get function ${params.functionName} in region ${params.region}: ${e}`) + } + }) + } + + return vscode.Disposable.from( + globals.uriHandler.onPath('/lambda/load-function', openFunctionHandler, parseOpenParams) + ) +} + +// Sample url: +// vscode://AmazonWebServices.aws-toolkit-vscode/lambda/load-function?functionName=fnf-func-1®ion=us-east-1&isCfn=true +export function parseOpenParams(query: SearchParams) { + return { + functionName: query.getOrThrow( + 'functionName', + localize('AWS.lambda.open.missingName', 'A function name must be provided') + ), + region: query.getOrThrow('region', localize('AWS.lambda.open.missingRegion', 'A region must be provided')), + isCfn: query.get('isCfn'), + } +} diff --git a/packages/core/src/lambda/utils.ts b/packages/core/src/lambda/utils.ts index 79054e02cac..4bb9063fedd 100644 --- a/packages/core/src/lambda/utils.ts +++ b/packages/core/src/lambda/utils.ts @@ -6,17 +6,21 @@ import * as nls from 'vscode-nls' const localize = nls.loadMessageBundle() +import path from 'path' import xml2js = require('xml2js') import { Lambda } from 'aws-sdk' import * as vscode from 'vscode' import { CloudFormationClient, StackSummary } from '../shared/clients/cloudFormation' -import { LambdaClient } from '../shared/clients/lambdaClient' +import { DefaultLambdaClient, LambdaClient } from '../shared/clients/lambdaClient' import { getFamily, getNodeMajorVersion, RuntimeFamily } from './models/samLambdaRuntime' import { getLogger } from '../shared/logger/logger' import { HttpResourceFetcher } from '../shared/resourcefetcher/httpResourceFetcher' import { FileResourceFetcher } from '../shared/resourcefetcher/fileResourceFetcher' import { sampleRequestManifestPath } from './constants' import globals from '../shared/extensionGlobals' +import { tempDirPath } from '../shared/filesystemUtilities' +import { LambdaFunction } from './commands/uploadLambda' +import { fs } from '../shared/fs/fs' export async function* listCloudFormationStacks(client: CloudFormationClient): AsyncIterableIterator { // TODO: this 'loading' message needs to go under each regional entry @@ -46,6 +50,23 @@ export async function* listLambdaFunctions(client: LambdaClient): AsyncIterableI } } +export async function* listLayerVersions( + client: LambdaClient, + name: string +): AsyncIterableIterator { + const status = vscode.window.setStatusBarMessage( + localize('AWS.message.statusBar.loading.lambda', 'Loading Lambda Layer Versions...') + ) + + try { + yield* client.listLayerVersions(name) + } finally { + if (status) { + status.dispose() + } + } +} + /** * Returns filename and function name corresponding to a Lambda.FunctionConfiguration * Only works for supported languages (Python/JS) @@ -70,6 +91,9 @@ export function getLambdaDetails(configuration: Lambda.FunctionConfiguration): { } break } + case RuntimeFamily.Ruby: + runtimeExtension = 'rb' + break default: throw new Error(`Toolkit does not currently support imports for runtime: ${configuration.Runtime}`) } @@ -124,3 +148,77 @@ async function getSampleRequestManifest(): Promise { } return httpResp.text() } + +function getInfoLocation(lambda: LambdaFunction): string { + return path.join(getTempRegionLocation(lambda.region), `.${lambda.name}`) +} + +export async function getCodeShaLive(lambda: LambdaFunction): Promise { + const lambdaClient = new DefaultLambdaClient(lambda.region) + const func = await lambdaClient.getFunction(lambda.name) + return func.Configuration?.CodeSha256 +} + +export async function compareCodeSha(lambda: LambdaFunction): Promise { + const local = await getFunctionInfo(lambda, 'sha') + const remote = await getCodeShaLive(lambda) + getLogger().info(`local: ${local}, remote: ${remote}`) + return local === remote +} + +export async function getFunctionInfo(lambda: LambdaFunction, field?: 'lastDeployed' | 'undeployed' | 'sha') { + try { + const data = JSON.parse(await fs.readFileText(getInfoLocation(lambda))) + getLogger().debug('Data returned from getFunctionInfo for %s: %O', lambda.name, data) + return field ? data[field] : data + } catch { + return field ? undefined : {} + } +} + +export async function setFunctionInfo( + lambda: LambdaFunction, + info: { lastDeployed?: number; undeployed?: boolean; sha?: string } +) { + try { + const existing = await getFunctionInfo(lambda) + const updated = { + lastDeployed: info.lastDeployed ?? existing.lastDeployed, + undeployed: info.undeployed ?? true, + sha: info.sha ?? (await getCodeShaLive(lambda)), + } + await fs.writeFile(getInfoLocation(lambda), JSON.stringify(updated)) + } catch (err) { + getLogger().warn(`codesha: unable to save information at key "${lambda.name}: %s"`, err) + } +} + +export const lambdaTempPath = path.join(tempDirPath, 'lambda') + +export function getTempRegionLocation(region: string) { + return path.join(lambdaTempPath, region) +} + +export function getTempLocation(functionName: string, region: string) { + return path.join(getTempRegionLocation(region), functionName) +} + +type LambdaEdit = { + location: string + functionName: string + region: string + configuration?: Lambda.FunctionConfiguration +} + +// Array to keep the list of functions that are being edited. +export const lambdaEdits: LambdaEdit[] = [] + +// Given a particular function and region, it returns the full LambdaEdit object +export function getLambdaEditFromNameRegion(name: string, functionRegion: string) { + return lambdaEdits.find(({ functionName, region }) => functionName === name && region === functionRegion) +} + +// Given a particular localPath, it returns the full LambdaEdit object +export function getLambdaEditFromLocation(functionLocation: string) { + return lambdaEdits.find(({ location }) => location === functionLocation) +} diff --git a/packages/core/src/login/webview/vue/login.vue b/packages/core/src/login/webview/vue/login.vue index 312aa18029b..7973844f4d4 100644 --- a/packages/core/src/login/webview/vue/login.vue +++ b/packages/core/src/login/webview/vue/login.vue @@ -239,6 +239,11 @@
IAM Credentials:
Credentials will be added to the appropriate ~/.aws/ files
+ Learn More
Profile Name
The identifier for these credentials
export class DefaultLambdaClient { @@ -80,6 +85,39 @@ export class DefaultLambdaClient { } } + public async getLayerVersion(name: string, version: number): Promise { + getLogger().debug(`getLayerVersion called for LayerName: ${name}, VersionNumber ${version}`) + const client = await this.createSdkClient() + + try { + const response = await client.getLayerVersion({ LayerName: name, VersionNumber: version }).promise() + // prune `Code` from logs so we don't reveal a signed link to customer resources. + getLogger().debug('getLayerVersion returned response (code section pruned): %O', { + ...response, + Code: 'Pruned', + }) + return response + } catch (e) { + getLogger().error('Failed to get function: %s', e) + throw e + } + } + + public async *listLayerVersions(name: string): AsyncIterableIterator { + const client = await this.createSdkClient() + + const request: Lambda.ListLayerVersionsRequest = { LayerName: name } + do { + const response: Lambda.ListLayerVersionsResponse = await client.listLayerVersions(request).promise() + + if (response.LayerVersions) { + yield* response.LayerVersions + } + + request.Marker = response.NextMarker + } while (request.Marker) + } + public async getFunctionUrlConfigs(name: string): Promise { getLogger().debug(`GetFunctionUrlConfig called for function: ${name}`) const client = await this.createSdkClient() @@ -128,3 +166,21 @@ export class DefaultLambdaClient { ) } } + +export async function getFunctionWithCredentials(region: string, name: string): Promise { + const connection = await getIAMConnection({ + prompt: true, + messageText: 'Opening a Lambda Function requires you to be authenticated.', + }) + + if (!connection) { + throw new CancellationError('user') + } + + const credentials = + connection.type === 'iam' ? await connection.getCredentials() : fromSSO({ profile: connection.id }) + const client = new LambdaSdkClient({ region, credentials }) + + const command = new GetFunctionCommand({ FunctionName: name }) + return client.send(command) +} diff --git a/packages/core/src/shared/cloudformation/cloudformation.ts b/packages/core/src/shared/cloudformation/cloudformation.ts index 5d08bb836dc..690a5aee73c 100644 --- a/packages/core/src/shared/cloudformation/cloudformation.ts +++ b/packages/core/src/shared/cloudformation/cloudformation.ts @@ -16,6 +16,10 @@ import { isUntitledScheme, normalizeVSCodeUri } from '../utilities/vsCodeUtils' export const SERVERLESS_API_TYPE = 'AWS::Serverless::Api' // eslint-disable-line @typescript-eslint/naming-convention export const SERVERLESS_FUNCTION_TYPE = 'AWS::Serverless::Function' // eslint-disable-line @typescript-eslint/naming-convention export const LAMBDA_FUNCTION_TYPE = 'AWS::Lambda::Function' // eslint-disable-line @typescript-eslint/naming-convention +export const LAMBDA_LAYER_TYPE = 'AWS::Lambda::LayerVersion' // eslint-disable-line @typescript-eslint/naming-convention +export const LAMBDA_URL_TYPE = 'AWS::Lambda::Url' // eslint-disable-line @typescript-eslint/naming-convention +export const SERVERLESS_LAYER_TYPE = 'AWS::Serverless::LayerVersion' // eslint-disable-line @typescript-eslint/naming-convention + export const serverlessTableType = 'AWS::Serverless::SimpleTable' export const s3BucketType = 'AWS::S3::Bucket' export const appRunnerType = 'AWS::AppRunner::Service' diff --git a/packages/core/src/shared/telemetry/vscodeTelemetry.json b/packages/core/src/shared/telemetry/vscodeTelemetry.json index b28aeec4847..8b04d7b3b0b 100644 --- a/packages/core/src/shared/telemetry/vscodeTelemetry.json +++ b/packages/core/src/shared/telemetry/vscodeTelemetry.json @@ -1110,6 +1110,10 @@ { "name": "docdb_addRegion", "description": "User clicked on add region command" + }, + { + "name": "appbuilder_lambda2sam", + "description": "User click Convert a lambda function to SAM project" } ] } diff --git a/packages/core/src/test/awsService/appBuilder/lambda2sam/lambda2sam.test.ts b/packages/core/src/test/awsService/appBuilder/lambda2sam/lambda2sam.test.ts new file mode 100644 index 00000000000..d26d0131d1e --- /dev/null +++ b/packages/core/src/test/awsService/appBuilder/lambda2sam/lambda2sam.test.ts @@ -0,0 +1,272 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as sinon from 'sinon' +import assert from 'assert' +import { LambdaFunctionNode } from '../../../../lambda/explorer/lambdaFunctionNode' +import { DefaultLambdaClient } from '../../../../shared/clients/lambdaClient' +import { Template } from '../../../../shared/cloudformation/cloudformation' +import * as lambda2sam from '../../../../awsService/appBuilder/lambda2sam/lambda2sam' +import * as authUtils from '../../../../auth/utils' +import * as utils from '../../../../awsService/appBuilder/utils' + +describe('lambda2sam', function () { + let sandbox: sinon.SinonSandbox + + beforeEach(function () { + sandbox = sinon.createSandbox() + }) + + afterEach(function () { + sandbox.restore() + }) + + describe('ifSamTemplate', function () { + it('returns true when transform is a string and starts with AWS::Serverless', function () { + const template: Template = { + Transform: 'AWS::Serverless-2016-10-31', + } + assert.strictEqual(lambda2sam.ifSamTemplate(template), true) + }) + + it('returns false when transform is a string and does not start with AWS::Serverless', function () { + const template: Template = { + Transform: 'AWS::Other-Transform', + } + assert.strictEqual(lambda2sam.ifSamTemplate(template), false) + }) + + it('returns true when transform is an array and at least one starts with AWS::Serverless', function () { + const template: Template = { + Transform: ['AWS::Serverless-2016-10-31', 'AWS::Other-Transform'] as any, + } + assert.strictEqual(lambda2sam.ifSamTemplate(template), true) + }) + + it('returns false when transform is an array and none start with AWS::Serverless', function () { + const template: Template = { + Transform: ['AWS::Other-Transform-1', 'AWS::Other-Transform-2'] as any, + } + assert.strictEqual(lambda2sam.ifSamTemplate(template), false) + }) + + it('returns false when transform is not present', function () { + const template: Template = {} + assert.strictEqual(lambda2sam.ifSamTemplate(template), false) + }) + + it('returns false when transform is an unsupported type', function () { + const template: Template = { + Transform: { some: 'object' } as any, + } + assert.strictEqual(lambda2sam.ifSamTemplate(template), false) + }) + }) + + describe('extractLogicalIdFromIntrinsic', function () { + it('extracts logical ID from Ref intrinsic', function () { + const value = { Ref: 'MyResource' } + assert.strictEqual(lambda2sam.extractLogicalIdFromIntrinsic(value), 'MyResource') + }) + + it('extracts logical ID from GetAtt intrinsic with Arn attribute', function () { + const value = { 'Fn::GetAtt': ['MyResource', 'Arn'] } + assert.strictEqual(lambda2sam.extractLogicalIdFromIntrinsic(value), 'MyResource') + }) + + it('returns undefined for GetAtt intrinsic with non-Arn attribute', function () { + const value = { 'Fn::GetAtt': ['MyResource', 'Name'] } + assert.strictEqual(lambda2sam.extractLogicalIdFromIntrinsic(value), undefined) + }) + + it('returns undefined for non-intrinsic values', function () { + assert.strictEqual(lambda2sam.extractLogicalIdFromIntrinsic('not-an-intrinsic'), undefined) + assert.strictEqual(lambda2sam.extractLogicalIdFromIntrinsic({ NotIntrinsic: 'value' }), undefined) + assert.strictEqual(lambda2sam.extractLogicalIdFromIntrinsic(undefined), undefined) + }) + }) + + describe('callExternalApiForCfnTemplate', function () { + let lambdaClientStub: sinon.SinonStubbedInstance + let cfnClientStub: any + + beforeEach(function () { + lambdaClientStub = sandbox.createStubInstance(DefaultLambdaClient) + // Stub at prototype level to avoid TypeScript errors + sandbox + .stub(DefaultLambdaClient.prototype, 'getFunction') + .callsFake((name) => lambdaClientStub.getFunction(name)) + + // Mock CloudFormation client for the new external API calls - now returns Promises directly + cfnClientStub = { + getGeneratedTemplate: sandbox.stub().resolves({ + Status: 'COMPLETE', + TemplateBody: JSON.stringify({ + AWSTemplateFormatVersion: '2010-09-09', + Resources: { + testFunc: { + DeletionPolicy: 'Retain', + Properties: { + Code: { + S3Bucket: 'aws-sam-cli-managed-default-samclisourcebucket-1n8tvb0jdhsd', + S3Key: '1d1c93ec17af7e2666ee20ea1a215c77', + }, + Environment: { + Variables: { + KEY: 'value', + }, + }, + FunctionName: 'myFunction', + Handler: 'index.handler', + MemorySize: 128, + PackageType: 'Zip', + Role: 'arn:aws:iam::123456789012:role/lambda-role', + Runtime: 'nodejs18.x', + Timeout: 3, + }, + Type: 'AWS::Lambda::Function', + }, + }, + }), + }), + describeGeneratedTemplate: sandbox.stub().resolves({ + Status: 'COMPLETE', + Resources: [ + { + LogicalResourceId: 'testFunc', + ResourceType: 'AWS::Lambda::Function', + ResourceIdentifier: { + FunctionName: 'myFunction', + }, + }, + ], + }), + } + sandbox.stub(utils, 'getCFNClient').resolves(cfnClientStub) + + // Mock IAM connection + const mockConnection = { + type: 'iam' as const, + id: 'test-connection', + label: 'Test Connection', + state: 'valid' as const, + getCredentials: sandbox.stub().resolves({ + accessKeyId: 'test-key', + secretAccessKey: 'test-secret', + }), + } + sandbox.stub(authUtils, 'getIAMConnection').resolves(mockConnection) + + // Mock fetch response + sandbox.stub(global, 'fetch').resolves({ + ok: true, + json: sandbox.stub().resolves({ + cloudFormationTemplateId: 'test-template-id', + }), + } as any) + }) + + it('creates a basic CloudFormation template for the Lambda function', async function () { + const lambdaNode = { + name: 'myFunction', + regionCode: 'us-east-2', + arn: 'arn:aws:lambda:us-east-2:123456789012:function:myFunction', + } as LambdaFunctionNode + + lambdaClientStub.getFunction.resolves({ + Configuration: { + FunctionName: 'myFunction', + Handler: 'index.handler', + Role: 'arn:aws:iam::123456789012:role/lambda-role', + Runtime: 'nodejs18.x', + Timeout: 3, + MemorySize: 128, + Environment: { Variables: { KEY: 'value' } }, + }, + }) + + // Create a simple mock template that matches the Template type + const mockTemplate = { + AWSTemplateFormatVersion: '2010-09-09', + Resources: { + testFunc: { + DeletionPolicy: 'Retain', + Properties: { + Code: { + S3Bucket: 'aws-sam-cli-managed-default-samclisourcebucket-1n8tvb0jdhsd', + S3Key: '1d1c93ec17af7e2666ee20ea1a215c77', + }, + Environment: { + Variables: { + KEY: 'value', + }, + }, + FunctionName: 'myFunction', + Handler: 'index.handler', + MemorySize: 128, + PackageType: 'Zip', + Role: 'arn:aws:iam::123456789012:role/lambda-role', + Runtime: 'nodejs18.x', + Timeout: 3, + }, + Type: 'AWS::Lambda::Function', + }, + }, + } + const mockList = [ + { + LogicalResourceId: 'testFunc', + ResourceIdentifier: { + FunctionName: 'myFunction', + }, + ResourceType: 'AWS::Lambda::Function', + }, + ] + + const result = await lambda2sam.callExternalApiForCfnTemplate(lambdaNode) + // Verify the result structure matches expected format + assert.strictEqual(Array.isArray(result), true) + assert.strictEqual(result.length, 2) + const [template, resourcesToImport] = result + assert.strictEqual(typeof template, 'object') + assert.strictEqual(Array.isArray(resourcesToImport), true) + assert.strictEqual(resourcesToImport.length, 1) + assert.strictEqual(resourcesToImport[0].ResourceType, 'AWS::Lambda::Function') + assert.strictEqual(resourcesToImport[0].LogicalResourceId, 'testFunc') + assert.deepStrictEqual(result, [mockTemplate, mockList]) + }) + }) + + describe('determineStackAssociation', function () { + let lambdaClientStub: sinon.SinonStubbedInstance + + beforeEach(function () { + lambdaClientStub = sandbox.createStubInstance(DefaultLambdaClient) + sandbox + .stub(DefaultLambdaClient.prototype, 'getFunction') + .callsFake((name) => lambdaClientStub.getFunction(name)) + }) + + it('returns undefined when Lambda has no tags', async function () { + const lambdaNode = { + name: 'myFunction', + regionCode: 'us-west-2', + } as LambdaFunctionNode + + lambdaClientStub.getFunction.resolves({}) + + // Skip CloudFormation mocking for now + // This is difficult to mock correctly without errors and would be better tested with integration tests + + const result = await lambda2sam.determineStackAssociation(lambdaNode) + + assert.strictEqual(result, undefined) + assert.strictEqual(lambdaClientStub.getFunction.calledOnceWith(lambdaNode.name), true) + }) + + // For this function, additional testing would require complex mocking of the AWS SDK + // Consider adding more specific test cases in an integration test + }) +}) diff --git a/packages/core/src/test/awsService/appBuilder/lambda2sam/lambda2samCoreLogic.test.ts b/packages/core/src/test/awsService/appBuilder/lambda2sam/lambda2samCoreLogic.test.ts new file mode 100644 index 00000000000..552d0104b7e --- /dev/null +++ b/packages/core/src/test/awsService/appBuilder/lambda2sam/lambda2samCoreLogic.test.ts @@ -0,0 +1,784 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as sinon from 'sinon' +import assert from 'assert' +import * as vscode from 'vscode' +import * as lambda2sam from '../../../../awsService/appBuilder/lambda2sam/lambda2sam' +import * as cloudFormation from '../../../../shared/cloudformation/cloudformation' +import * as utils from '../../../../awsService/appBuilder/utils' +import * as walkthrough from '../../../../awsService/appBuilder/walkthrough' +import * as authUtils from '../../../../auth/utils' +import { getTestWindow } from '../../../shared/vscode/window' +import { fs } from '../../../../shared' +import { DefaultLambdaClient } from '../../../../shared/clients/lambdaClient' +import { LambdaFunctionNode } from '../../../../lambda/explorer/lambdaFunctionNode' +import { ToolkitError } from '../../../../shared/errors' +import os from 'os' +import path from 'path' +import { LAMBDA_FUNCTION_TYPE } from '../../../../shared/cloudformation/cloudformation' +import { ResourcesToImport } from 'aws-sdk/clients/cloudformation' + +describe('lambda2samCoreLogic', function () { + let sandbox: sinon.SinonSandbox + let tempDir: string + let lambdaClientStub: sinon.SinonStubbedInstance + let cfnClientStub: any + let downloadUnzipStub: sinon.SinonStub + + beforeEach(async function () { + sandbox = sinon.createSandbox() + tempDir = path.join(os.tmpdir(), `aws-toolkit-test-${Date.now()}`) + + // Create temp directory for tests - actually create it, don't stub + if (!(await fs.exists(vscode.Uri.file(tempDir)))) { + await fs.mkdir(vscode.Uri.file(tempDir)) + } + + // Create Lambda client stub with necessary properties + lambdaClientStub = sandbox.createStubInstance(DefaultLambdaClient) + Object.defineProperty(lambdaClientStub, 'defaultTimeoutInMs', { + value: 5 * 60 * 1000, // 5 minutes + configurable: true, + }) + Object.defineProperty(lambdaClientStub, 'createSdkClient', { + value: () => Promise.resolve({}), + configurable: true, + }) + + sandbox.stub(utils, 'getLambdaClient').returns(lambdaClientStub as any) + + // Mock CloudFormation client - now returns Promises directly (no .promise() method) + cfnClientStub = { + describeStackResource: sandbox.stub().resolves({ + StackResourceDetail: { + PhysicalResourceId: 'test-physical-id', + }, + }), + describeStackResources: sandbox.stub().resolves({ + StackResources: [ + { LogicalResourceId: 'testResource', PhysicalResourceId: 'test-physical-id' }, + { LogicalResourceId: 'prefixTestResource', PhysicalResourceId: 'prefix-test-physical-id' }, + ], + }), + describeStacks: sandbox.stub().resolves({ + Stacks: [ + { + StackId: 'stack-id', + StackName: 'test-stack', + StackStatus: 'CREATE_COMPLETE', + }, + ], + }), + getTemplate: sandbox.stub().resolves({ + TemplateBody: '{"Resources": {"TestFunc": {"Type": "AWS::Lambda::Function"}}}', + }), + getGeneratedTemplate: sandbox.stub().resolves({ + Status: 'COMPLETE', + TemplateBody: + '{"Resources": {"TestFunc": {"Type": "AWS::Lambda::Function", "Properties": {"FunctionName": "test-function"}}}}', + }), + describeGeneratedTemplate: sandbox.stub().resolves({ + Status: 'COMPLETE', + Resources: [ + { + LogicalResourceId: 'TestFunc', + ResourceType: 'AWS::Lambda::Function', + ResourceIdentifier: { + FunctionName: 'arn:aws:lambda:us-east-2:123456789012:function:test-function', + }, + }, + ], + }), + createChangeSet: sandbox.stub().resolves({ Id: 'change-set-id' }), + waitFor: sandbox.stub().resolves(), + executeChangeSet: sandbox.stub().resolves(), + describeChangeSet: sandbox.stub().resolves({ + StatusReason: 'Test reason', + }), + } + sandbox.stub(utils, 'getCFNClient').resolves(cfnClientStub) + + // Setup test window to return appropriate values + getTestWindow().onDidShowMessage((msg) => { + if (msg.message.includes('Enter Stack Name')) { + msg.selectItem('test-stack') + } + }) + + getTestWindow().onDidShowDialog((dialog) => { + dialog.selectItem(vscode.Uri.file(tempDir)) + }) + + // Stub downloadUnzip function + downloadUnzipStub = sandbox.stub(utils, 'downloadUnzip').callsFake(async (url, outputPath) => { + // Create a mock file structure for testing purposes + if (!(await fs.exists(outputPath))) { + await fs.mkdir(outputPath) + } + + await fs.writeFile( + vscode.Uri.joinPath(outputPath, 'index.js'), + 'exports.handler = async (event) => { return "Hello World" };' + ) + await fs.writeFile( + vscode.Uri.joinPath(outputPath, 'package.json'), + JSON.stringify( + { + name: 'test-lambda', + version: '1.0.0', + description: 'Test Lambda function', + }, + undefined, + 2 + ) + ) + }) + + // Stub workspace functions + sandbox.stub(vscode.workspace, 'openTextDocument').resolves({} as any) + sandbox.stub(vscode.window, 'showTextDocument').resolves() + }) + + afterEach(async function () { + sandbox.restore() + + // Clean up the temp directory + if (await fs.exists(vscode.Uri.file(tempDir))) { + await fs.delete(vscode.Uri.file(tempDir), { recursive: true, force: true }) + } + }) + + describe('processLambdaUrlResources', function () { + it('converts Lambda URL resources to FunctionUrlConfig', async function () { + // Setup resources with Lambda URL - using 'as any' to bypass strict typing for tests + const resources: cloudFormation.TemplateResources = { + TestFunc: { + Type: cloudFormation.SERVERLESS_FUNCTION_TYPE, + Properties: { + FunctionName: 'test-function', + PackageType: 'Zip', + }, + }, + TestFuncUrl: { + Type: cloudFormation.LAMBDA_URL_TYPE, + Properties: { + TargetFunctionArn: { Ref: 'TestFunc' }, + AuthType: 'NONE', + }, + }, + } as any + + // Call the function + await lambda2sam.processLambdaUrlResources(resources) + + // Verify URL resource is removed + assert.strictEqual(resources['TestFuncUrl'], undefined) + + // Verify FunctionUrlConfig added to function resource using non-null assertion + assert.deepStrictEqual(resources['TestFunc']!.Properties!.FunctionUrlConfig, { + AuthType: 'NONE', + Cors: undefined, + InvokeMode: undefined, + }) + }) + + it('skips URL resources with Qualifier property', async function () { + // Setup resources with Lambda URL including Qualifier - using 'as any' to bypass strict typing for tests + const resources: cloudFormation.TemplateResources = { + TestFunc: { + Type: cloudFormation.SERVERLESS_FUNCTION_TYPE, + Properties: { + FunctionName: 'test-function', + PackageType: 'Zip', + }, + }, + TestFuncUrl: { + Type: cloudFormation.LAMBDA_URL_TYPE, + Properties: { + TargetFunctionArn: { Ref: 'TestFunc' }, + AuthType: 'NONE', + Qualifier: 'prod', + }, + }, + } as any + + // Call the function + await lambda2sam.processLambdaUrlResources(resources) + + // Verify URL resource is still there (not transformed) + assert.notStrictEqual(resources['TestFuncUrl'], undefined) + + // Verify function resource doesn't have FunctionUrlConfig using non-null assertion + assert.strictEqual(resources['TestFunc']!.Properties!.FunctionUrlConfig, undefined) + }) + }) + + describe('processLambdaResources', function () { + it('transforms AWS::Lambda::Function to AWS::Serverless::Function', async function () { + // Setup resources with Lambda function - using 'as any' to bypass strict typing for tests + const resources: cloudFormation.TemplateResources = { + TestFunc: { + Type: cloudFormation.LAMBDA_FUNCTION_TYPE, + Properties: { + FunctionName: 'test-function', + Handler: 'index.handler', + Runtime: 'nodejs18.x', + Code: { + S3Bucket: 'test-bucket', + S3Key: 'test-key', + }, + Tags: [ + { Key: 'test-key', Value: 'test-value' }, + { Key: 'lambda:createdBy', Value: 'test' }, + ], + TracingConfig: { + Mode: 'Active', + }, + PackageType: 'Zip', + }, + }, + } as any + + const stackInfo = { + stackId: 'stack-id', + stackName: 'test-stack', + isSamTemplate: false, + template: {}, + } + + const projectDir = vscode.Uri.file(tempDir) + + // Add necessary stub for getFunction + lambdaClientStub.getFunction.resolves({ + Code: { Location: 'https://lambda-function-code.zip' }, + }) + + // Call the function + await lambda2sam.processLambdaResources(resources, projectDir, stackInfo, 'us-west-2') + + // Verify function type was transformed using non-null assertions + assert.strictEqual(resources['TestFunc']!.Type, cloudFormation.SERVERLESS_FUNCTION_TYPE) + + // Verify properties were transformed correctly using non-null assertions + assert.strictEqual(resources['TestFunc']!.Properties!.Code, undefined) + assert.strictEqual(resources['TestFunc']!.Properties!.CodeUri, 'TestFunc') + assert.strictEqual(resources['TestFunc']!.Properties!.Tracing, 'Active') + assert.strictEqual(resources['TestFunc']!.Properties!.TracingConfig, undefined) + assert.deepStrictEqual(resources['TestFunc']!.Properties!.Tags, { + 'test-key': 'test-value', + }) + + // Verify downloadLambdaFunctionCode was called + assert.strictEqual(downloadUnzipStub.calledOnce, true) + }) + + it('updates CodeUri for AWS::Serverless::Function', async function () { + // Setup resources with Serverless function - using 'as any' to bypass strict typing for tests + const resources: cloudFormation.TemplateResources = { + TestFunc: { + Type: cloudFormation.SERVERLESS_FUNCTION_TYPE, + Properties: { + FunctionName: 'test-function', + Handler: 'index.handler', + Runtime: 'nodejs18.x', + CodeUri: 's3://test-bucket/test-key', + PackageType: 'Zip', + }, + }, + } as any + + const stackInfo = { + stackId: 'stack-id', + stackName: 'test-stack', + isSamTemplate: false, + template: {}, + } + + const projectDir = vscode.Uri.file(tempDir) + + // Add necessary stub for getFunction + lambdaClientStub.getFunction.resolves({ + Code: { Location: 'https://lambda-function-code.zip' }, + }) + + // Call the function + await lambda2sam.processLambdaResources(resources, projectDir, stackInfo, 'us-west-2') + + // Verify CodeUri was updated using non-null assertions + assert.strictEqual(resources['TestFunc']!.Properties!.CodeUri, 'TestFunc') + + // Verify downloadLambdaFunctionCode was called + assert.strictEqual(downloadUnzipStub.calledOnce, true) + }) + }) + + describe('processLambdaLayerResources', function () { + it('transforms AWS::Lambda::LayerVersion to AWS::Serverless::LayerVersion', async function () { + // Setup resources with Lambda layer - using 'as any' to bypass strict typing for tests + const resources: cloudFormation.TemplateResources = { + TestLayer: { + Type: cloudFormation.LAMBDA_LAYER_TYPE, + Properties: { + LayerName: 'test-layer', + Content: { + S3Bucket: 'test-bucket', + S3Key: 'test-key', + }, + CompatibleRuntimes: ['nodejs18.x'], + }, + }, + } as any + + const stackInfo = { + stackId: 'stack-id', + stackName: 'test-stack', + isSamTemplate: false, + template: {}, + } + + const projectDir = vscode.Uri.file(tempDir) + + // Setup layer version stub + cfnClientStub.describeStackResource.resolves({ + StackResourceDetail: { + PhysicalResourceId: 'arn:aws:lambda:us-west-2:123456789012:layer:my-layer:1', + }, + }) + + lambdaClientStub.getLayerVersion.resolves({ + Content: { Location: 'https://lambda-layer-code.zip' }, + }) + + // Call the function + await lambda2sam.processLambdaLayerResources(resources, projectDir, stackInfo, 'us-west-2') + + // Verify layer type was transformed using non-null assertions + assert.strictEqual(resources['TestLayer']!.Type, cloudFormation.SERVERLESS_LAYER_TYPE) + + // Verify properties were transformed correctly using non-null assertions + assert.strictEqual(resources['TestLayer']!.Properties!.Content, undefined) + assert.strictEqual(resources['TestLayer']!.Properties!.ContentUri, 'TestLayer') + assert.deepStrictEqual(resources['TestLayer']!.Properties!.CompatibleRuntimes, ['nodejs18.x']) + + // Verify downloadLayerVersionResrouceByName was called (through downloadUnzip) + assert.strictEqual(downloadUnzipStub.calledOnce, true) + }) + + it('preserves AWS::Serverless::LayerVersion properties', async function () { + // Setup resources with Serverless layer - using 'as any' to bypass strict typing for tests + const resources: cloudFormation.TemplateResources = { + TestLayer: { + Type: cloudFormation.SERVERLESS_LAYER_TYPE, + Properties: { + LayerName: 'test-layer', + ContentUri: 's3://test-bucket/test-key', + CompatibleRuntimes: ['nodejs18.x'], + }, + }, + } as any + + const stackInfo = { + stackId: 'stack-id', + stackName: 'test-stack', + isSamTemplate: false, + template: {}, + } + + const projectDir = vscode.Uri.file(tempDir) + + // Setup layer version stub + cfnClientStub.describeStackResource.resolves({ + StackResourceDetail: { + PhysicalResourceId: 'arn:aws:lambda:us-west-2:123456789012:layer:my-layer:1', + }, + }) + + lambdaClientStub.getLayerVersion.resolves({ + Content: { Location: 'https://lambda-layer-code.zip' }, + }) + + // Call the function + await lambda2sam.processLambdaLayerResources(resources, projectDir, stackInfo, 'us-west-2') + + // Verify layer type is still serverless using non-null assertions + assert.strictEqual(resources['TestLayer']!.Type, cloudFormation.SERVERLESS_LAYER_TYPE) + + // Verify ContentUri was updated using non-null assertions + assert.strictEqual(resources['TestLayer']!.Properties!.ContentUri, 'TestLayer') + + // Verify downloadLayerVersionResrouceByName was called (through downloadUnzip) + assert.strictEqual(downloadUnzipStub.calledOnce, true) + }) + }) + + describe('deployCfnTemplate', function () { + it('deploys a CloudFormation template and returns stack info', async function () { + // Setup CloudFormation template - using 'as any' to bypass strict typing for tests + const template: cloudFormation.Template = { + AWSTemplateFormatVersion: '2010-09-09', + Resources: { + TestFunc: { + Type: cloudFormation.LAMBDA_FUNCTION_TYPE, + Properties: { + FunctionName: 'test-function', + PackageType: 'Zip', + }, + }, + }, + } as any + + // Setup Lambda node + const lambdaNode = { + name: 'test-function', + regionCode: 'us-west-2', + } as LambdaFunctionNode + + const resourceToImport: ResourcesToImport = [ + { + ResourceType: LAMBDA_FUNCTION_TYPE, + LogicalResourceId: 'TestFunc', + ResourceIdentifier: { + FunctionName: lambdaNode.name, + }, + }, + ] + + // Call the function + const result = await lambda2sam.deployCfnTemplate( + template, + resourceToImport, + 'test-stack', + lambdaNode.regionCode + ) + + // Verify createChangeSet was called with correct parameters + assert.strictEqual(cfnClientStub.createChangeSet.called, true) + const createChangeSetArgs = cfnClientStub.createChangeSet.firstCall.args[0] + assert.strictEqual(createChangeSetArgs.StackName, 'test-stack') + assert.strictEqual(createChangeSetArgs.ChangeSetType, 'IMPORT') + + // Verify waitFor and executeChangeSet were called + assert.strictEqual(cfnClientStub.waitFor.calledWith('changeSetCreateComplete'), true) + assert.strictEqual(cfnClientStub.executeChangeSet.called, true) + + // Verify describeStacks was called to get stack ID + assert.strictEqual(cfnClientStub.describeStacks.called, true) + + // Verify result structure + assert.strictEqual(result.stackId, 'stack-id') + assert.strictEqual(result.stackName, 'test-stack') + assert.strictEqual(result.isSamTemplate, false) + assert.deepStrictEqual(result.template, template) + }) + + it('throws an error when change set creation fails', async function () { + // Setup CloudFormation template - using 'as any' to bypass strict typing for tests + const template: cloudFormation.Template = { + AWSTemplateFormatVersion: '2010-09-09', + Resources: { + TestFunc: { + Type: cloudFormation.LAMBDA_FUNCTION_TYPE, + Properties: { + FunctionName: 'test-function', + PackageType: 'Zip', + }, + }, + }, + } as any + + // Setup Lambda node + const lambdaNode = { + name: 'test-function', + regionCode: 'us-west-2', + } as LambdaFunctionNode + + // Make createChangeSet fail + cfnClientStub.createChangeSet.resolves({}) // No Id + + const resourceToImport: ResourcesToImport = [ + { + ResourceType: LAMBDA_FUNCTION_TYPE, + LogicalResourceId: 'TestFunc', + ResourceIdentifier: { + FunctionName: lambdaNode.name, + }, + }, + ] + + // Call the function and expect error + await assert.rejects( + lambda2sam.deployCfnTemplate(template, resourceToImport, 'test-stack', lambdaNode.regionCode), + (err: ToolkitError) => { + assert.strictEqual(err.message.includes('Failed to create change set'), true) + return true + } + ) + }) + }) + + describe('callExternalApiForCfnTemplate', function () { + it('extracts function name from ARN in ResourceIdentifier', async function () { + // Setup Lambda node + const lambdaNode = { + name: 'test-function', + regionCode: 'us-east-2', + arn: 'arn:aws:lambda:us-east-2:123456789012:function:test-function', + } as LambdaFunctionNode + + // Mock IAM connection + const mockConnection = { + type: 'iam' as const, + id: 'test-connection', + label: 'Test Connection', + state: 'valid' as const, + getCredentials: sandbox.stub().resolves({ + accessKeyId: 'test-key', + secretAccessKey: 'test-secret', + }), + } + sandbox.stub(authUtils, 'getIAMConnection').resolves(mockConnection) + + // Mock fetch response + const mockFetch = sandbox.stub(global, 'fetch').resolves({ + ok: true, + json: sandbox.stub().resolves({ + cloudFormationTemplateId: 'test-template-id', + }), + } as any) + + // Setup CloudFormation client to return ARN in ResourceIdentifier + cfnClientStub.describeGeneratedTemplate.resolves({ + Status: 'COMPLETE', + Resources: [ + { + LogicalResourceId: 'TestFunc', + ResourceType: 'AWS::Lambda::Function', + ResourceIdentifier: { + FunctionName: 'arn:aws:lambda:us-east-2:123456789012:function:test-function', + }, + }, + ], + }) + + // Call the function + const [_, resourcesToImport] = await lambda2sam.callExternalApiForCfnTemplate(lambdaNode) + + // Verify that the ARN was converted to just the function name + assert.strictEqual(resourcesToImport.length, 1) + assert.strictEqual(resourcesToImport[0].ResourceType, 'AWS::Lambda::Function') + assert.strictEqual(resourcesToImport[0].LogicalResourceId, 'TestFunc') + assert.strictEqual(resourcesToImport[0].ResourceIdentifier!.FunctionName, 'test-function') + + // Verify API calls were made + assert.strictEqual(mockFetch.calledOnce, true) + assert.strictEqual(cfnClientStub.getGeneratedTemplate.calledOnce, true) + assert.strictEqual(cfnClientStub.describeGeneratedTemplate.calledOnce, true) + }) + + it('preserves function name when not an ARN', async function () { + // Setup Lambda node + const lambdaNode = { + name: 'test-function', + regionCode: 'us-east-2', + arn: 'arn:aws:lambda:us-east-2:123456789012:function:test-function', + } as LambdaFunctionNode + + // Mock IAM connection + const mockConnection = { + type: 'iam' as const, + id: 'test-connection', + label: 'Test Connection', + state: 'valid' as const, + getCredentials: sandbox.stub().resolves({ + accessKeyId: 'test-key', + secretAccessKey: 'test-secret', + }), + } + sandbox.stub(authUtils, 'getIAMConnection').resolves(mockConnection) + + // Mock fetch response + sandbox.stub(global, 'fetch').resolves({ + ok: true, + json: sandbox.stub().resolves({ + cloudFormationTemplateId: 'test-template-id', + }), + } as any) + + // Setup CloudFormation client to return plain function name + cfnClientStub.describeGeneratedTemplate.resolves({ + Status: 'COMPLETE', + Resources: [ + { + LogicalResourceId: 'TestFunc', + ResourceType: 'AWS::Lambda::Function', + ResourceIdentifier: { + FunctionName: 'test-function', + }, + }, + ], + }) + + // Call the function + const [_, resourcesToImport] = await lambda2sam.callExternalApiForCfnTemplate(lambdaNode) + + // Verify that the function name was preserved + assert.strictEqual(resourcesToImport.length, 1) + assert.strictEqual(resourcesToImport[0].ResourceIdentifier!.FunctionName, 'test-function') + }) + + it('handles non-Lambda resources without modification', async function () { + // Setup Lambda node + const lambdaNode = { + name: 'test-function', + regionCode: 'us-east-2', + arn: 'arn:aws:lambda:us-east-2:123456789012:function:test-function', + } as LambdaFunctionNode + + // Mock IAM connection + const mockConnection = { + type: 'iam' as const, + id: 'test-connection', + label: 'Test Connection', + state: 'valid' as const, + getCredentials: sandbox.stub().resolves({ + accessKeyId: 'test-key', + secretAccessKey: 'test-secret', + }), + } + sandbox.stub(authUtils, 'getIAMConnection').resolves(mockConnection) + + // Mock fetch response + sandbox.stub(global, 'fetch').resolves({ + ok: true, + json: sandbox.stub().resolves({ + cloudFormationTemplateId: 'test-template-id', + }), + } as any) + + // Setup CloudFormation client to return mixed resource types + cfnClientStub.describeGeneratedTemplate.resolves({ + Status: 'COMPLETE', + Resources: [ + { + LogicalResourceId: 'TestFunc', + ResourceType: 'AWS::Lambda::Function', + ResourceIdentifier: { + FunctionName: 'arn:aws:lambda:us-east-2:123456789012:function:test-function', + }, + }, + { + LogicalResourceId: 'TestRole', + ResourceType: 'AWS::IAM::Role', + ResourceIdentifier: { + RoleName: 'test-role', + }, + }, + ], + }) + + // Call the function + const [_, resourcesToImport] = await lambda2sam.callExternalApiForCfnTemplate(lambdaNode) + + // Verify that Lambda function ARN was converted but IAM role was not + assert.strictEqual(resourcesToImport.length, 2) + + const lambdaResource = resourcesToImport.find((r) => r.ResourceType === 'AWS::Lambda::Function') + const iamResource = resourcesToImport.find((r) => r.ResourceType === 'AWS::IAM::Role') + + assert.strictEqual(lambdaResource!.ResourceIdentifier!.FunctionName, 'test-function') + assert.strictEqual(iamResource!.ResourceIdentifier!.RoleName, 'test-role') + }) + }) + + describe('lambdaToSam', function () { + it('converts a Lambda function to a SAM project', async function () { + // Setup Lambda node + const lambdaNode = { + name: 'test-function', + regionCode: 'us-west-2', + } as LambdaFunctionNode + + // Setup AWS Lambda client responses + lambdaClientStub.getFunction.resolves({ + Tags: { + 'aws:cloudformation:stack-id': 'stack-id', + 'aws:cloudformation:stack-name': 'test-stack', + }, + Configuration: { + FunctionName: 'test-function', + Handler: 'index.handler', + Runtime: 'nodejs18.x', + }, + Code: { + Location: 'https://lambda-function-code.zip', + }, + }) + + // Setup CloudFormation client responses + cfnClientStub.describeStacks.resolves({ + Stacks: [ + { + StackId: 'stack-id', + StackName: 'test-stack', + StackStatus: 'CREATE_COMPLETE', + }, + ], + }) + + cfnClientStub.getTemplate.resolves({ + TemplateBody: JSON.stringify({ + AWSTemplateFormatVersion: '2010-09-09', + Transform: 'AWS::Serverless-2016-10-31', + Resources: { + TestFunc: { + Type: 'AWS::Serverless::Function', + Properties: { + FunctionName: 'test-function', + Handler: 'index.handler', + Runtime: 'nodejs18.x', + CodeUri: 's3://test-bucket/test-key', + PackageType: 'Zip', + }, + }, + }, + }), + }) + + // Setup test window to return a project directory + getTestWindow().onDidShowDialog((dialog) => { + dialog.selectItem(vscode.Uri.file(tempDir)) + }) + // Spy on walkthrough.openProjectInWorkspace + const openProjectStub = sandbox.stub(walkthrough, 'openProjectInWorkspace') + + // Call the function + await lambda2sam.lambdaToSam(lambdaNode) + + assert.strictEqual( + await fs.exists(vscode.Uri.joinPath(vscode.Uri.file(tempDir), 'test-stack', 'template.yaml').fsPath), + true, + 'template.yaml was not written' + ) + assert.strictEqual( + await fs.exists(vscode.Uri.joinPath(vscode.Uri.file(tempDir), 'test-stack', 'README.md').fsPath), + true, + 'README.md was not written' + ) + assert.strictEqual( + await fs.exists(vscode.Uri.joinPath(vscode.Uri.file(tempDir), 'test-stack', 'samconfig.toml').fsPath), + true, + 'samconfig.toml was not written' + ) + + // Verify that project was opened in workspace + assert.strictEqual(openProjectStub.calledOnce, true) + assert.strictEqual( + openProjectStub.firstCall.args[0].fsPath, + vscode.Uri.joinPath(vscode.Uri.file(tempDir), 'test-stack').fsPath + ) + }) + }) +}) diff --git a/packages/core/src/test/awsService/appBuilder/lambda2sam/lambda2samDownload.test.ts b/packages/core/src/test/awsService/appBuilder/lambda2sam/lambda2samDownload.test.ts new file mode 100644 index 00000000000..9c4d3122918 --- /dev/null +++ b/packages/core/src/test/awsService/appBuilder/lambda2sam/lambda2samDownload.test.ts @@ -0,0 +1,312 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as sinon from 'sinon' +import assert from 'assert' +import * as vscode from 'vscode' +import * as lambda2sam from '../../../../awsService/appBuilder/lambda2sam/lambda2sam' +import * as utils from '../../../../awsService/appBuilder/utils' +import { fs } from '../../../../shared' +import { DefaultLambdaClient } from '../../../../shared/clients/lambdaClient' +import os from 'os' +import path from 'path' +import { LAMBDA_FUNCTION_TYPE, LAMBDA_LAYER_TYPE } from '../../../../shared/cloudformation/cloudformation' + +describe('lambda2samDownload', function () { + let sandbox: sinon.SinonSandbox + let tempDir: string + let lambdaClientStub: sinon.SinonStubbedInstance + let cfnClientStub: any + let downloadUnzipStub: sinon.SinonStub + + beforeEach(async function () { + sandbox = sinon.createSandbox() + tempDir = path.join(os.tmpdir(), `aws-toolkit-test-${Date.now()}`) + + // Create temp directory for tests - actually create it, don't stub + if (!(await fs.exists(vscode.Uri.file(tempDir)))) { + await fs.mkdir(vscode.Uri.file(tempDir)) + } + + // Create Lambda client stub with necessary properties + lambdaClientStub = sandbox.createStubInstance(DefaultLambdaClient) + // Add required properties that aren't stubbed automatically + Object.defineProperty(lambdaClientStub, 'defaultTimeoutInMs', { + value: 5 * 60 * 1000, // 5 minutes + configurable: true, + }) + Object.defineProperty(lambdaClientStub, 'createSdkClient', { + value: () => Promise.resolve({}), + configurable: true, + }) + + sandbox.stub(utils, 'getLambdaClient').returns(lambdaClientStub as any) + + // Stub CloudFormation client - now returns Promises directly (no .promise() method) + cfnClientStub = { + describeStackResource: sandbox.stub().resolves({ + StackResourceDetail: { + PhysicalResourceId: 'test-physical-id', + }, + }), + describeStackResources: sandbox.stub().resolves({ + StackResources: [ + { LogicalResourceId: 'testResource', PhysicalResourceId: 'test-physical-id' }, + { LogicalResourceId: 'prefixTestResource', PhysicalResourceId: 'prefix-test-physical-id' }, + ], + }), + } + sandbox.stub(utils, 'getCFNClient').resolves(cfnClientStub) + + // Stub downloadUnzip function to create actual files in the temp directory + downloadUnzipStub = sandbox.stub(utils, 'downloadUnzip').callsFake(async (url, outputPath) => { + // Create a mock file structure for testing purposes + + // Create the output directory if it doesn't exist + if (!(await fs.exists(outputPath))) { + await fs.mkdir(outputPath) + } + + // Create a simple file to simulate extracted content + await fs.writeFile( + vscode.Uri.joinPath(outputPath, 'index.js'), + 'exports.handler = async (event) => { return "Hello World" };' + ) + + // Create a package.json file + await fs.writeFile( + vscode.Uri.joinPath(outputPath, 'package.json'), + JSON.stringify( + { + name: 'test-lambda', + version: '1.0.0', + description: 'Test Lambda function', + }, + undefined, + 2 + ) + ) + }) + }) + + afterEach(async function () { + sandbox.restore() + + // Clean up the temp directory after each test + if (await fs.exists(vscode.Uri.file(tempDir))) { + await fs.delete(vscode.Uri.file(tempDir), { recursive: true, force: true }) + } + }) + + describe('getPhysicalIdfromCFNResourceName', function () { + it('returns the physical ID when an exact match is found', async function () { + const result = await lambda2sam.getPhysicalIdfromCFNResourceName( + 'testResource', + 'us-west-2', + 'stack-id', + LAMBDA_FUNCTION_TYPE + ) + + assert.strictEqual(cfnClientStub.describeStackResource.calledOnce, true) + assert.strictEqual(cfnClientStub.describeStackResource.firstCall.args[0].StackName, 'stack-id') + assert.strictEqual(cfnClientStub.describeStackResource.firstCall.args[0].LogicalResourceId, 'testResource') + assert.strictEqual(result, 'test-physical-id') + }) + + it('returns a prefix match when exact match fails', async function () { + // Make exact match fail + cfnClientStub.describeStackResource.rejects(new Error('Resource not found')) + + const result = await lambda2sam.getPhysicalIdfromCFNResourceName( + 'prefix', + 'us-west-2', + 'stack-id', + LAMBDA_LAYER_TYPE + ) + + assert.strictEqual(cfnClientStub.describeStackResources.calledOnce, true) + assert.strictEqual(cfnClientStub.describeStackResources.firstCall.args[0].StackName, 'stack-id') + assert.strictEqual(result, 'prefix-test-physical-id') + }) + + it('returns undefined when no match is found', async function () { + // Make exact match fail + cfnClientStub.describeStackResource.rejects(new Error('Resource not found')) + + // Return empty resources + cfnClientStub.describeStackResources.resolves({ StackResources: [] }) + + const result = await lambda2sam.getPhysicalIdfromCFNResourceName( + 'nonexistent', + 'us-west-2', + 'stack-id', + LAMBDA_LAYER_TYPE + ) + assert.strictEqual(result, undefined) + }) + }) + + describe('downloadLambdaFunctionCode', function () { + it('uses physical ID from CloudFormation when not provided', async function () { + const targetDir = vscode.Uri.file(tempDir) + const resourceName = 'testResource' + const stackInfo = { stackId: 'stack-id', stackName: 'test-stack', isSamTemplate: false, template: {} } + + lambdaClientStub.getFunction.resolves({ + Code: { Location: 'https://lambda-function-code.zip' }, + }) + + await lambda2sam.downloadLambdaFunctionCode(resourceName, stackInfo, targetDir, 'us-west-2') + + // Verify CloudFormation was called to get physical ID + assert.strictEqual(cfnClientStub.describeStackResource.calledOnce, true) + + // Verify Lambda client was called with correct physical ID + assert.strictEqual(lambdaClientStub.getFunction.calledOnce, true) + assert.strictEqual(lambdaClientStub.getFunction.firstCall.args[0], 'test-physical-id') + + // Verify downloadUnzip was called with correct parameters + assert.strictEqual(downloadUnzipStub.calledOnce, true) + assert.strictEqual(downloadUnzipStub.firstCall.args[0], 'https://lambda-function-code.zip') + assert.strictEqual( + downloadUnzipStub.firstCall.args[1].fsPath, + vscode.Uri.joinPath(targetDir, resourceName).fsPath + ) + + // Verify files were actually created in the temp directory + const outputDir = vscode.Uri.joinPath(targetDir, resourceName) + assert.strictEqual(await fs.exists(outputDir), true) + assert.strictEqual(await fs.exists(vscode.Uri.joinPath(outputDir, 'index.js')), true) + assert.strictEqual(await fs.exists(vscode.Uri.joinPath(outputDir, 'package.json')), true) + }) + + it('uses provided physical ID when available', async function () { + const targetDir = vscode.Uri.file(tempDir) + const resourceName = 'testResource' + const physicalResourceId = 'provided-physical-id' + const stackInfo = { stackId: 'stack-id', stackName: 'test-stack', isSamTemplate: false, template: {} } + + lambdaClientStub.getFunction.resolves({ + Code: { Location: 'https://lambda-function-code.zip' }, + }) + + await lambda2sam.downloadLambdaFunctionCode( + resourceName, + stackInfo, + targetDir, + 'us-west-2', + physicalResourceId + ) + + // Verify CloudFormation was NOT called to get physical ID + assert.strictEqual(cfnClientStub.describeStackResource.called, false) + + // Verify Lambda client was called with provided physical ID + assert.strictEqual(lambdaClientStub.getFunction.calledOnce, true) + assert.strictEqual(lambdaClientStub.getFunction.firstCall.args[0], physicalResourceId) + + // Verify files were actually created in the temp directory + const outputDir = vscode.Uri.joinPath(targetDir, resourceName) + assert.strictEqual(await fs.exists(outputDir), true) + assert.strictEqual(await fs.exists(vscode.Uri.joinPath(outputDir, 'index.js')), true) + assert.strictEqual(await fs.exists(vscode.Uri.joinPath(outputDir, 'package.json')), true) + }) + + it('throws an error when code location is missing', async function () { + const targetDir = vscode.Uri.file(tempDir) + const resourceName = 'testResource' + const stackInfo = { stackId: 'stack-id', stackName: 'test-stack', isSamTemplate: false, template: {} } + + lambdaClientStub.getFunction.resolves({ + Code: {}, // No Location + }) + + await assert.rejects( + lambda2sam.downloadLambdaFunctionCode(resourceName, stackInfo, targetDir, 'us-west-2'), + /Could not determine code location/ + ) + }) + }) + + describe('downloadLayerVersionResourceByName', function () { + it('extracts layer name and version from ARN and downloads content', async function () { + const targetDir = vscode.Uri.file(tempDir) + const resourceName = 'testLayer' + const stackInfo = { stackId: 'stack-id', stackName: 'test-stack', isSamTemplate: false, template: {} } + + // Return an ARN for a layer version + cfnClientStub.describeStackResource.resolves({ + StackResourceDetail: { + PhysicalResourceId: 'arn:aws:lambda:us-west-2:123456789012:layer:my-layer:1', + }, + }) + + lambdaClientStub.getLayerVersion.resolves({ + Content: { Location: 'https://lambda-layer-code.zip' }, + }) + + await lambda2sam.downloadLayerVersionResourceByName(resourceName, stackInfo, targetDir, 'us-west-2') + + // Verify Lambda client was called with correct layer name and version + assert.strictEqual(lambdaClientStub.getLayerVersion.calledOnce, true) + assert.strictEqual(lambdaClientStub.getLayerVersion.firstCall.args[0], 'my-layer') + assert.strictEqual(lambdaClientStub.getLayerVersion.firstCall.args[1], 1) + + // Verify downloadUnzip was called with correct parameters + assert.strictEqual(downloadUnzipStub.calledOnce, true) + assert.strictEqual(downloadUnzipStub.firstCall.args[0], 'https://lambda-layer-code.zip') + assert.strictEqual( + downloadUnzipStub.firstCall.args[1].fsPath, + vscode.Uri.joinPath(targetDir, resourceName).fsPath + ) + + // Verify files were actually created in the temp directory + const outputDir = vscode.Uri.joinPath(targetDir, resourceName) + assert.strictEqual(await fs.exists(outputDir), true) + assert.strictEqual(await fs.exists(vscode.Uri.joinPath(outputDir, 'index.js')), true) + assert.strictEqual(await fs.exists(vscode.Uri.joinPath(outputDir, 'package.json')), true) + }) + + it('throws an error when ARN format is invalid', async function () { + const targetDir = vscode.Uri.file(tempDir) + const resourceName = 'testLayer' + const stackInfo = { stackId: 'stack-id', stackName: 'test-stack', isSamTemplate: false, template: {} } + + // Return an invalid ARN + cfnClientStub.describeStackResource.resolves({ + StackResourceDetail: { + PhysicalResourceId: 'arn:aws:lambda:us-west-2:123456789012:layer:my-layer', // Missing version + }, + }) + + await assert.rejects( + lambda2sam.downloadLayerVersionResourceByName(resourceName, stackInfo, targetDir, 'us-west-2'), + /Invalid layer ARN format/ + ) + }) + + it('throws an error when layer content location is missing', async function () { + const targetDir = vscode.Uri.file(tempDir) + const resourceName = 'testLayer' + const stackInfo = { stackId: 'stack-id', stackName: 'test-stack', isSamTemplate: false, template: {} } + + // Return an ARN for a layer version + cfnClientStub.describeStackResource.resolves({ + StackResourceDetail: { + PhysicalResourceId: 'arn:aws:lambda:us-west-2:123456789012:layer:my-layer:1', + }, + }) + + lambdaClientStub.getLayerVersion.resolves({ + Content: {}, // No Location + }) + + await assert.rejects( + lambda2sam.downloadLayerVersionResourceByName(resourceName, stackInfo, targetDir, 'us-west-2'), + /Could not determine code location for layer/ + ) + }) + }) +}) diff --git a/packages/core/src/test/awsService/appBuilder/utils.test.ts b/packages/core/src/test/awsService/appBuilder/utils.test.ts index d74cfc77802..eaaa69254d7 100644 --- a/packages/core/src/test/awsService/appBuilder/utils.test.ts +++ b/packages/core/src/test/awsService/appBuilder/utils.test.ts @@ -12,9 +12,20 @@ import fs from '../../../shared/fs/fs' import { ResourceNode } from '../../../awsService/appBuilder/explorer/nodes/resourceNode' import path from 'path' import { SERVERLESS_FUNCTION_TYPE } from '../../../shared/cloudformation/cloudformation' -import { runOpenHandler, runOpenTemplate } from '../../../awsService/appBuilder/utils' +import { + runOpenHandler, + runOpenTemplate, + isPermissionError, + EnhancedLambdaClient, + EnhancedCloudFormationClient, + getLambdaClient, + getCFNClient, +} from '../../../awsService/appBuilder/utils' import { TreeNode } from '../../../shared/treeview/resourceTreeDataProvider' import { assertTextEditorContains } from '../../testUtil' +import { DefaultLambdaClient } from '../../../shared/clients/lambdaClient' +import { ToolkitError } from '../../../shared/errors' +import globals from '../../../shared/extensionGlobals' interface TestScenario { runtime: string @@ -303,4 +314,553 @@ describe('AppBuilder Utils', function () { assert(showCommand.notCalled) }) }) + + describe('Permission Error Handling', function () { + let sandbox: sinon.SinonSandbox + + beforeEach(function () { + sandbox = sinon.createSandbox() + }) + + afterEach(function () { + sandbox.restore() + }) + + describe('isPermissionError', function () { + it('should return true for AccessDeniedException', function () { + const error = Object.assign(new Error('Access denied'), { + code: 'AccessDeniedException', + time: new Date(), + statusCode: 403, + }) + assert.strictEqual(isPermissionError(error), true) + }) + + it('should return true for UnauthorizedOperation', function () { + const error = Object.assign(new Error('Unauthorized'), { + code: 'UnauthorizedOperation', + time: new Date(), + statusCode: 403, + }) + assert.strictEqual(isPermissionError(error), true) + }) + + it('should return true for Forbidden', function () { + const error = Object.assign(new Error('Forbidden'), { + code: 'Forbidden', + time: new Date(), + statusCode: 403, + }) + assert.strictEqual(isPermissionError(error), true) + }) + + it('should return true for AccessDenied', function () { + const error = Object.assign(new Error('Access denied'), { + code: 'AccessDenied', + time: new Date(), + statusCode: 403, + }) + assert.strictEqual(isPermissionError(error), true) + }) + + it('should return true for 403 status code', function () { + const error = Object.assign(new Error('Forbidden'), { + code: 'SomeError', + statusCode: 403, + time: new Date(), + }) + assert.strictEqual(isPermissionError(error), true) + }) + + it('should return false for non-permission errors', function () { + const error = Object.assign(new Error('Resource not found'), { + code: 'ResourceNotFoundException', + time: new Date(), + statusCode: 404, + }) + assert.strictEqual(isPermissionError(error), false) + }) + + it('should return false for non-AWS errors', function () { + const error = new Error('Regular error') + assert.strictEqual(isPermissionError(error), false) + }) + + it('should return false for undefined', function () { + assert.strictEqual(isPermissionError(undefined), false) + }) + }) + + describe('EnhancedLambdaClient', function () { + let mockLambdaClient: sinon.SinonStubbedInstance + let enhancedClient: EnhancedLambdaClient + + beforeEach(function () { + mockLambdaClient = sandbox.createStubInstance(DefaultLambdaClient) + // Add missing properties that EnhancedLambdaClient expects + Object.defineProperty(mockLambdaClient, 'defaultTimeoutInMs', { + value: 5 * 60 * 1000, + configurable: true, + }) + Object.defineProperty(mockLambdaClient, 'createSdkClient', { + value: sandbox.stub().resolves({}), + configurable: true, + }) + enhancedClient = new EnhancedLambdaClient(mockLambdaClient as any, 'us-east-1') + }) + + it('should enhance permission errors for getFunction', async function () { + const permissionError = Object.assign(new Error('Access denied'), { + code: 'AccessDeniedException', + time: new Date(), + statusCode: 403, + }) + mockLambdaClient.getFunction.rejects(permissionError) + + try { + await enhancedClient.getFunction('test-function') + assert.fail('Expected error to be thrown') + } catch (error) { + assert(error instanceof ToolkitError) + assert( + error.message.includes('Permission denied: Missing required permissions for lambda:getFunction') + ) + assert(error.message.includes('lambda:GetFunction')) + assert(error.message.includes('arn:aws:lambda:us-east-1:*:function:test-function')) + assert(error.message.includes('To fix this issue:')) + assert(error.message.includes('Documentation:')) + } + }) + + it('should pass through non-permission errors for getFunction', async function () { + const nonPermissionError = new Error('Function not found') + mockLambdaClient.getFunction.rejects(nonPermissionError) + + try { + await enhancedClient.getFunction('test-function') + assert.fail('Expected error to be thrown') + } catch (error) { + assert.strictEqual(error, nonPermissionError) + } + }) + + it('should enhance permission errors for listFunctions', async function () { + const permissionError = Object.assign(new Error('Access denied'), { + code: 'AccessDeniedException', + time: new Date(), + statusCode: 403, + }) + + // Create a mock async generator that throws the error + const mockAsyncGenerator = async function* (): AsyncIterableIterator { + throw permissionError + yield // This line will never be reached but satisfies ESLint require-yield rule + } + mockLambdaClient.listFunctions.returns(mockAsyncGenerator()) + + try { + const iterator = enhancedClient.listFunctions() + await iterator.next() + assert.fail('Expected error to be thrown') + } catch (error) { + assert(error instanceof ToolkitError) + assert( + error.message.includes( + 'Permission denied: Missing required permissions for lambda:listFunctions' + ) + ) + assert(error.message.includes('lambda:ListFunctions')) + } + }) + + it('should enhance permission errors for deleteFunction', async function () { + const permissionError = Object.assign(new Error('Access denied'), { + code: 'AccessDeniedException', + time: new Date(), + statusCode: 403, + }) + mockLambdaClient.deleteFunction.rejects(permissionError) + + try { + await enhancedClient.deleteFunction('test-function') + assert.fail('Expected error to be thrown') + } catch (error) { + assert(error instanceof ToolkitError) + assert( + error.message.includes( + 'Permission denied: Missing required permissions for lambda:deleteFunction' + ) + ) + assert(error.message.includes('lambda:DeleteFunction')) + assert(error.message.includes('arn:aws:lambda:us-east-1:*:function:test-function')) + } + }) + + it('should enhance permission errors for invoke', async function () { + const permissionError = Object.assign(new Error('Access denied'), { + code: 'AccessDeniedException', + time: new Date(), + statusCode: 403, + }) + mockLambdaClient.invoke.rejects(permissionError) + + try { + await enhancedClient.invoke('test-function', '{}') + assert.fail('Expected error to be thrown') + } catch (error) { + assert(error instanceof ToolkitError) + assert(error.message.includes('Permission denied: Missing required permissions for lambda:invoke')) + assert(error.message.includes('lambda:InvokeFunction')) + assert(error.message.includes('arn:aws:lambda:us-east-1:*:function:test-function')) + } + }) + + it('should enhance permission errors for getLayerVersion', async function () { + const permissionError = Object.assign(new Error('Access denied'), { + code: 'AccessDeniedException', + time: new Date(), + statusCode: 403, + }) + mockLambdaClient.getLayerVersion.rejects(permissionError) + + try { + await enhancedClient.getLayerVersion('test-layer', 1) + assert.fail('Expected error to be thrown') + } catch (error) { + assert(error instanceof ToolkitError) + assert( + error.message.includes( + 'Permission denied: Missing required permissions for lambda:getLayerVersion' + ) + ) + assert(error.message.includes('lambda:GetLayerVersion')) + assert(error.message.includes('arn:aws:lambda:us-east-1:*:layer:test-layer:1')) + } + }) + + it('should enhance permission errors for updateFunctionCode', async function () { + const permissionError = Object.assign(new Error('Access denied'), { + code: 'AccessDeniedException', + time: new Date(), + statusCode: 403, + }) + mockLambdaClient.updateFunctionCode.rejects(permissionError) + + try { + await enhancedClient.updateFunctionCode('test-function', new Uint8Array()) + assert.fail('Expected error to be thrown') + } catch (error) { + assert(error instanceof ToolkitError) + assert( + error.message.includes( + 'Permission denied: Missing required permissions for lambda:updateFunctionCode' + ) + ) + assert(error.message.includes('lambda:UpdateFunctionCode')) + assert(error.message.includes('arn:aws:lambda:us-east-1:*:function:test-function')) + } + }) + + it('should return successful results when no errors occur', async function () { + const mockResponse = { Configuration: { FunctionName: 'test-function' } } + mockLambdaClient.getFunction.resolves(mockResponse) + + const result = await enhancedClient.getFunction('test-function') + assert.strictEqual(result, mockResponse) + }) + }) + + describe('EnhancedCloudFormationClient', function () { + let mockCfnClient: any + let enhancedClient: EnhancedCloudFormationClient + + beforeEach(function () { + // Create a mock CloudFormation client with all required methods + mockCfnClient = { + describeStacks: sandbox.stub(), + getTemplate: sandbox.stub(), + createChangeSet: sandbox.stub(), + describeStackResource: sandbox.stub(), + describeStackResources: sandbox.stub(), + } + enhancedClient = new EnhancedCloudFormationClient(mockCfnClient, 'us-east-1') + }) + + it('should enhance permission errors for describeStacks', async function () { + const permissionError = Object.assign(new Error('Access denied'), { + code: 'AccessDeniedException', + time: new Date(), + statusCode: 403, + }) + mockCfnClient.describeStacks.returns({ + promise: sandbox.stub().rejects(permissionError), + } as any) + + try { + await enhancedClient.describeStacks({ StackName: 'test-stack' }) + assert.fail('Expected error to be thrown') + } catch (error) { + assert(error instanceof ToolkitError) + assert( + error.message.includes( + 'Permission denied: Missing required permissions for cloudformation:describeStacks' + ) + ) + assert(error.message.includes('cloudformation:DescribeStacks')) + assert(error.message.includes('arn:aws:cloudformation:us-east-1:*:stack/test-stack/*')) + assert(error.message.includes('To fix this issue:')) + assert(error.message.includes('Documentation:')) + } + }) + + it('should enhance permission errors for getTemplate', async function () { + const permissionError = Object.assign(new Error('Access denied'), { + code: 'AccessDeniedException', + time: new Date(), + statusCode: 403, + }) + mockCfnClient.getTemplate.returns({ + promise: sandbox.stub().rejects(permissionError), + } as any) + + try { + await enhancedClient.getTemplate({ StackName: 'test-stack' }) + assert.fail('Expected error to be thrown') + } catch (error) { + assert(error instanceof ToolkitError) + assert( + error.message.includes( + 'Permission denied: Missing required permissions for cloudformation:getTemplate' + ) + ) + assert(error.message.includes('cloudformation:GetTemplate')) + assert(error.message.includes('arn:aws:cloudformation:us-east-1:*:stack/test-stack/*')) + } + }) + + it('should enhance permission errors for createChangeSet', async function () { + const permissionError = Object.assign(new Error('Access denied'), { + code: 'AccessDeniedException', + time: new Date(), + statusCode: 403, + }) + mockCfnClient.createChangeSet.returns({ + promise: sandbox.stub().rejects(permissionError), + } as any) + + try { + await enhancedClient.createChangeSet({ + StackName: 'test-stack', + ChangeSetName: 'test-changeset', + TemplateBody: '{}', + }) + assert.fail('Expected error to be thrown') + } catch (error) { + assert(error instanceof ToolkitError) + assert( + error.message.includes( + 'Permission denied: Missing required permissions for cloudformation:createChangeSet' + ) + ) + assert(error.message.includes('cloudformation:CreateChangeSet')) + assert(error.message.includes('arn:aws:cloudformation:us-east-1:*:stack/test-stack/*')) + } + }) + + it('should enhance permission errors for describeStackResource', async function () { + const permissionError = Object.assign(new Error('Access denied'), { + code: 'AccessDeniedException', + time: new Date(), + statusCode: 403, + }) + mockCfnClient.describeStackResource.returns({ + promise: sandbox.stub().rejects(permissionError), + } as any) + + try { + await enhancedClient.describeStackResource({ + StackName: 'test-stack', + LogicalResourceId: 'TestResource', + }) + assert.fail('Expected error to be thrown') + } catch (error) { + assert(error instanceof ToolkitError) + assert( + error.message.includes( + 'Permission denied: Missing required permissions for cloudformation:describeStackResource' + ) + ) + assert(error.message.includes('cloudformation:DescribeStackResource')) + assert(error.message.includes('arn:aws:cloudformation:us-east-1:*:stack/test-stack/*')) + } + }) + + it('should enhance permission errors for describeStackResources', async function () { + const permissionError = Object.assign(new Error('Access denied'), { + code: 'AccessDeniedException', + time: new Date(), + statusCode: 403, + }) + mockCfnClient.describeStackResources.returns({ + promise: sandbox.stub().rejects(permissionError), + } as any) + + try { + await enhancedClient.describeStackResources({ StackName: 'test-stack' }) + assert.fail('Expected error to be thrown') + } catch (error) { + assert(error instanceof ToolkitError) + assert( + error.message.includes( + 'Permission denied: Missing required permissions for cloudformation:describeStackResources' + ) + ) + assert(error.message.includes('cloudformation:DescribeStackResources')) + assert(error.message.includes('arn:aws:cloudformation:us-east-1:*:stack/test-stack/*')) + } + }) + + it('should pass through non-permission errors', async function () { + const nonPermissionError = new Error('Stack not found') + mockCfnClient.describeStacks.returns({ + promise: sandbox.stub().rejects(nonPermissionError), + } as any) + + try { + await enhancedClient.describeStacks({ StackName: 'test-stack' }) + assert.fail('Expected error to be thrown') + } catch (error) { + assert.strictEqual(error, nonPermissionError) + } + }) + + it('should return successful results when no errors occur', async function () { + const mockResponse = { Stacks: [{ StackName: 'test-stack' }] } + mockCfnClient.describeStacks.returns({ + promise: sandbox.stub().resolves(mockResponse), + } as any) + + const result = await enhancedClient.describeStacks({ StackName: 'test-stack' }) + assert.strictEqual(result, mockResponse) + }) + }) + + describe('Client Factory Functions', function () { + beforeEach(function () { + // Stub the global SDK client builder + sandbox.stub(globals.sdkClientBuilder, 'createAwsService').resolves({} as any) + }) + + it('should return EnhancedLambdaClient from getLambdaClient', function () { + const client = getLambdaClient('us-east-1') + assert(client instanceof EnhancedLambdaClient) + }) + + it('should return EnhancedCloudFormationClient from getCFNClient', async function () { + const client = await getCFNClient('us-east-1') + assert(client instanceof EnhancedCloudFormationClient) + }) + }) + + describe('Error Message Content', function () { + let mockLambdaClient: sinon.SinonStubbedInstance + let enhancedClient: EnhancedLambdaClient + + beforeEach(function () { + mockLambdaClient = sandbox.createStubInstance(DefaultLambdaClient) + // Add missing properties that EnhancedLambdaClient expects + Object.defineProperty(mockLambdaClient, 'defaultTimeoutInMs', { + value: 5 * 60 * 1000, + configurable: true, + }) + Object.defineProperty(mockLambdaClient, 'createSdkClient', { + value: sandbox.stub().resolves({}), + configurable: true, + }) + enhancedClient = new EnhancedLambdaClient(mockLambdaClient as any, 'us-west-2') + }) + + it('should include all required elements in enhanced error message', async function () { + const permissionError = Object.assign(new Error('Access denied'), { + code: 'AccessDeniedException', + time: new Date(), + statusCode: 403, + }) + mockLambdaClient.getFunction.rejects(permissionError) + + try { + await enhancedClient.getFunction('my-test-function') + assert.fail('Expected error to be thrown') + } catch (error) { + assert(error instanceof ToolkitError) + + // Check that the error message contains all expected elements + const message = error.message + + // Main error description + assert(message.includes('Permission denied: Missing required permissions for lambda:getFunction')) + + // Required permissions section + assert(message.includes('Required permissions:')) + assert(message.includes('- lambda:GetFunction')) + + // Resource ARN + assert(message.includes('Resource: arn:aws:lambda:us-west-2:*:function:my-test-function')) + + // Instructions + assert(message.includes('To fix this issue:')) + assert(message.includes('1. Contact your AWS administrator')) + assert(message.includes('2. Add these permissions to your IAM user/role policy')) + assert(message.includes('3. If using IAM roles, ensure the role has these permissions attached')) + + // Documentation link + assert( + message.includes( + 'Documentation: https://docs.aws.amazon.com/lambda/latest/api/API_GetFunction.html' + ) + ) + + // Check error details + assert.strictEqual(error.code, 'InsufficientPermissions') + assert(error.details) + assert.strictEqual(error.details.service, 'lambda') + assert.strictEqual(error.details.action, 'getFunction') + assert.deepStrictEqual(error.details.requiredPermissions, ['lambda:GetFunction']) + assert.strictEqual( + error.details.resourceArn, + 'arn:aws:lambda:us-west-2:*:function:my-test-function' + ) + } + }) + + it('should handle errors without resource ARN', async function () { + const permissionError = Object.assign(new Error('Access denied'), { + code: 'AccessDeniedException', + time: new Date(), + statusCode: 403, + }) + + // Create a mock async generator that throws the error + const mockAsyncGenerator = async function* (): AsyncIterableIterator { + throw permissionError + yield // This line will never be reached but satisfies ESLint require-yield rule + } + mockLambdaClient.listFunctions.returns(mockAsyncGenerator()) + + try { + const iterator = enhancedClient.listFunctions() + await iterator.next() + assert.fail('Expected error to be thrown') + } catch (error) { + assert(error instanceof ToolkitError) + + const message = error.message + assert(message.includes('Permission denied: Missing required permissions for lambda:listFunctions')) + assert(message.includes('- lambda:ListFunctions')) + // Should not include Resource line for operations without specific resources + assert(!message.includes('Resource: arn:')) + } + }) + }) + }) }) diff --git a/packages/core/src/test/lambda/commands/editLambda.test.ts b/packages/core/src/test/lambda/commands/editLambda.test.ts new file mode 100644 index 00000000000..9c38f767885 --- /dev/null +++ b/packages/core/src/test/lambda/commands/editLambda.test.ts @@ -0,0 +1,251 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as vscode from 'vscode' +import assert from 'assert' +import * as sinon from 'sinon' +import { + editLambda, + watchForUpdates, + promptForSync, + deployFromTemp, + openLambdaFolderForEdit, +} from '../../../lambda/commands/editLambda' +import { LambdaFunction } from '../../../lambda/commands/uploadLambda' +import * as downloadLambda from '../../../lambda/commands/downloadLambda' +import * as uploadLambda from '../../../lambda/commands/uploadLambda' +import * as utils from '../../../lambda/utils' +import * as messages from '../../../shared/utilities/messages' +import fs from '../../../shared/fs/fs' +import { LambdaFunctionNodeDecorationProvider } from '../../../lambda/explorer/lambdaFunctionNodeDecorationProvider' +import path from 'path' + +describe('editLambda', function () { + let mockLambda: LambdaFunction + let mockTemp: string + let mockUri: vscode.Uri + + // Stub variables + let getFunctionInfoStub: sinon.SinonStub + let setFunctionInfoStub: sinon.SinonStub + let compareCodeShaStub: sinon.SinonStub + let downloadLambdaStub: sinon.SinonStub + let openLambdaFileStub: sinon.SinonStub + let runUploadDirectoryStub: sinon.SinonStub + let showConfirmationMessageStub: sinon.SinonStub + let createFileSystemWatcherStub: sinon.SinonStub + let executeCommandStub: sinon.SinonStub + let existsDirStub: sinon.SinonStub + let mkdirStub: sinon.SinonStub + let promptDeployStub: sinon.SinonStub + + beforeEach(function () { + mockLambda = { + name: 'test-function', + region: 'us-east-1', + configuration: { + FunctionName: 'test-function', + CodeSha256: 'test-sha', + Runtime: 'nodejs18.x', + }, + } + mockTemp = utils.getTempLocation(mockLambda.name, mockLambda.region) + mockUri = vscode.Uri.file(mockTemp) + + // Create stubs + getFunctionInfoStub = sinon.stub(utils, 'getFunctionInfo').resolves(undefined) + setFunctionInfoStub = sinon.stub(utils, 'setFunctionInfo').resolves() + compareCodeShaStub = sinon.stub(utils, 'compareCodeSha').resolves(true) + downloadLambdaStub = sinon.stub(downloadLambda, 'downloadLambdaInLocation').resolves() + openLambdaFileStub = sinon.stub(downloadLambda, 'openLambdaFile').resolves() + runUploadDirectoryStub = sinon.stub(uploadLambda, 'runUploadDirectory').resolves() + showConfirmationMessageStub = sinon.stub(messages, 'showConfirmationMessage').resolves(true) + createFileSystemWatcherStub = sinon.stub(vscode.workspace, 'createFileSystemWatcher').returns({ + onDidChange: sinon.stub(), + onDidCreate: sinon.stub(), + onDidDelete: sinon.stub(), + dispose: sinon.stub(), + } as any) + executeCommandStub = sinon.stub(vscode.commands, 'executeCommand').resolves() + existsDirStub = sinon.stub(fs, 'existsDir').resolves(true) + mkdirStub = sinon.stub(fs, 'mkdir').resolves() + promptDeployStub = sinon.stub().resolves(true) + sinon.replace(require('../../../lambda/commands/editLambda'), 'promptDeploy', promptDeployStub) + + // Other stubs + sinon.stub(utils, 'lambdaEdits').value([]) + sinon.stub(utils, 'getLambdaDetails').returns({ fileName: 'index.js', functionName: 'test-function' }) + sinon.stub(fs, 'readdir').resolves([]) + sinon.stub(fs, 'delete').resolves() + sinon.stub(fs, 'stat').resolves({ ctime: Date.now() } as any) + sinon.stub(vscode.workspace, 'saveAll').resolves(true) + sinon.stub(LambdaFunctionNodeDecorationProvider.prototype, 'addBadge').resolves() + sinon.stub(LambdaFunctionNodeDecorationProvider.prototype, 'removeBadge').resolves() + sinon.stub(LambdaFunctionNodeDecorationProvider, 'getInstance').returns({ + addBadge: sinon.stub().resolves(), + removeBadge: sinon.stub().resolves(), + } as any) + }) + + afterEach(function () { + sinon.restore() + }) + + describe('editLambda', function () { + it('returns early if folder already exists in workspace', async function () { + sinon.stub(vscode.workspace, 'workspaceFolders').value([{ uri: vscode.Uri.file(mockTemp) }]) + + const result = await editLambda(mockLambda) + + assert.strictEqual(result, mockTemp) + }) + + it('downloads lambda when no local code exists', async function () { + await editLambda(mockLambda) + + assert(downloadLambdaStub.calledOnce) + }) + + it('prompts for overwrite when local code differs from remote', async function () { + getFunctionInfoStub.resolves('old-sha') + compareCodeShaStub.resolves(false) + + await editLambda(mockLambda) + + assert(showConfirmationMessageStub.calledOnce) + }) + + it('opens existing file when user declines overwrite', async function () { + getFunctionInfoStub.resolves('old-sha') + compareCodeShaStub.resolves(false) + showConfirmationMessageStub.resolves(false) + + await editLambda(mockLambda) + + assert(openLambdaFileStub.calledOnce) + }) + + it('sets up file watcher after download', async function () { + const watcherStub = { + onDidChange: sinon.stub(), + onDidCreate: sinon.stub(), + onDidDelete: sinon.stub(), + } + createFileSystemWatcherStub.returns(watcherStub) + + await editLambda(mockLambda) + + assert(watcherStub.onDidChange.calledOnce) + assert(watcherStub.onDidCreate.calledOnce) + assert(watcherStub.onDidDelete.calledOnce) + }) + }) + + describe('watchForUpdates', function () { + it('creates file system watcher with correct pattern', function () { + const watcher = { + onDidChange: sinon.stub(), + onDidCreate: sinon.stub(), + onDidDelete: sinon.stub(), + } + createFileSystemWatcherStub.returns(watcher) + + watchForUpdates(mockLambda, mockUri) + + assert(createFileSystemWatcherStub.calledOnce) + const pattern = createFileSystemWatcherStub.firstCall.args[0] + assert(pattern instanceof vscode.RelativePattern) + }) + + it('sets up change, create, and delete handlers', function () { + const watcher = { + onDidChange: sinon.stub(), + onDidCreate: sinon.stub(), + onDidDelete: sinon.stub(), + } + createFileSystemWatcherStub.returns(watcher) + + watchForUpdates(mockLambda, mockUri) + + assert(watcher.onDidChange.calledOnce) + assert(watcher.onDidCreate.calledOnce) + assert(watcher.onDidDelete.calledOnce) + }) + }) + + describe('promptForSync', function () { + it('returns early if directory does not exist', async function () { + existsDirStub.resolves(false) + + await promptForSync(mockLambda, mockUri, vscode.Uri.file('/test/file.js')) + + assert(setFunctionInfoStub.notCalled) + }) + }) + + describe('deployFromTemp', function () { + it('uploads without confirmation when code is up to date', async function () { + await deployFromTemp(mockLambda, mockUri) + + assert(showConfirmationMessageStub.notCalled) + assert(runUploadDirectoryStub.calledOnce) + }) + + it('prompts for confirmation when code is outdated', async function () { + compareCodeShaStub.resolves(false) + + await deployFromTemp(mockLambda, mockUri) + + assert(showConfirmationMessageStub.calledOnce) + }) + + it('does not upload when user declines overwrite', async function () { + compareCodeShaStub.resolves(false) + showConfirmationMessageStub.resolves(false) + + await deployFromTemp(mockLambda, mockUri) + + assert(runUploadDirectoryStub.notCalled) + }) + + it('updates function info after successful upload', async function () { + await deployFromTemp(mockLambda, mockUri) + + assert(runUploadDirectoryStub.calledOnce) + assert( + setFunctionInfoStub.calledWith(mockLambda, { + lastDeployed: sinon.match.number, + undeployed: false, + }) + ) + }) + }) + + describe('openLambdaFolderForEdit', function () { + it('focuses existing workspace folder if already open', async function () { + const subfolderPath = path.normalize(path.join(mockTemp, 'subfolder')) + sinon.stub(vscode.workspace, 'workspaceFolders').value([{ uri: vscode.Uri.file(subfolderPath) }]) + + await openLambdaFolderForEdit('test-function', 'us-east-1') + + assert(executeCommandStub.calledWith('workbench.action.focusSideBar')) + assert(executeCommandStub.calledWith('workbench.view.explorer')) + }) + + it('opens new folder when not in workspace', async function () { + sinon.stub(vscode.workspace, 'workspaceFolders').value([]) + + await openLambdaFolderForEdit('test-function', 'us-east-1') + + assert(mkdirStub.calledOnce) + assert( + executeCommandStub.calledWith('vscode.openFolder', sinon.match.any, { + newWindow: true, + noRecentEntry: true, + }) + ) + }) + }) +}) diff --git a/packages/core/src/test/lambda/explorer/lambdaFunctionFileNode.test.ts b/packages/core/src/test/lambda/explorer/lambdaFunctionFileNode.test.ts new file mode 100644 index 00000000000..59235bf7558 --- /dev/null +++ b/packages/core/src/test/lambda/explorer/lambdaFunctionFileNode.test.ts @@ -0,0 +1,58 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import assert from 'assert' +import { LambdaFunctionNode } from '../../../lambda/explorer/lambdaFunctionNode' +import { TestAWSTreeNode } from '../../shared/treeview/nodes/testAWSTreeNode' +import { LambdaFunctionFileNode } from '../../../lambda/explorer/lambdaFunctionFileNode' +import path from 'path' + +describe('LambdaFunctionFileNode', function () { + const fakeFunctionConfig = { + FunctionName: 'testFunctionName', + FunctionArn: 'testFunctionARN', + } + const fakeFilename = 'fakeFile' + const fakeRegion = 'fakeRegion' + const functionNode = new LambdaFunctionNode(new TestAWSTreeNode('test node'), fakeRegion, fakeFunctionConfig) + const filePath = path.join( + '/tmp/aws-toolkit-vscode/lambda', + fakeRegion, + fakeFunctionConfig.FunctionName, + fakeFilename + ) + + let testNode: LambdaFunctionFileNode + + before(async function () { + testNode = new LambdaFunctionFileNode(functionNode, fakeFilename, filePath) + }) + + it('instantiates without issue', function () { + assert.ok(testNode) + }) + + it('initializes the parent node', function () { + assert.equal(testNode.parent, functionNode, 'unexpected parent node') + }) + + it('initializes the label', function () { + assert.equal(testNode.label, fakeFilename) + }) + + it('has no children', async function () { + const childNodes = await testNode.getChildren() + assert.ok(childNodes) + assert.strictEqual(childNodes.length, 0, 'Expected zero children') + }) + + it('has correct command', function () { + assert.deepStrictEqual(testNode.command, { + command: 'aws.openLambdaFile', + title: 'Open file', + arguments: [filePath], + }) + }) +}) diff --git a/packages/core/src/test/lambda/explorer/lambdaFunctionFolderNode.test.ts b/packages/core/src/test/lambda/explorer/lambdaFunctionFolderNode.test.ts new file mode 100644 index 00000000000..67471bc4e24 --- /dev/null +++ b/packages/core/src/test/lambda/explorer/lambdaFunctionFolderNode.test.ts @@ -0,0 +1,57 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import assert from 'assert' +import { LambdaFunctionNode } from '../../../lambda/explorer/lambdaFunctionNode' +import { TestAWSTreeNode } from '../../shared/treeview/nodes/testAWSTreeNode' +import path from 'path' +import { LambdaFunctionFolderNode } from '../../../lambda/explorer/lambdaFunctionFolderNode' +import { fs } from '../../../shared/fs/fs' + +describe('LambdaFunctionFileNode', function () { + const fakeFunctionConfig = { + FunctionName: 'testFunctionName', + FunctionArn: 'testFunctionARN', + } + const fakeRegion = 'fakeRegion' + const fakeSubFolder = 'fakeSubFolder' + const fakeFile = 'fakeFilename' + const functionNode = new LambdaFunctionNode(new TestAWSTreeNode('test node'), fakeRegion, fakeFunctionConfig) + + const regionPath = path.join('/tmp/aws-toolkit-vscode/lambda', fakeRegion) + const functionPath = path.join(regionPath, fakeFunctionConfig.FunctionName) + const subFolderPath = path.join(functionPath, fakeSubFolder) + + let testNode: LambdaFunctionFolderNode + + before(async function () { + await fs.mkdir(subFolderPath) + await fs.writeFile(path.join(subFolderPath, fakeFile), 'fakefilecontent') + + testNode = new LambdaFunctionFolderNode(functionNode, fakeSubFolder, subFolderPath) + }) + + after(async function () { + await fs.delete(regionPath, { recursive: true }) + }) + + it('instantiates without issue', function () { + assert.ok(testNode) + }) + + it('initializes the parent node', function () { + assert.equal(testNode.parent, functionNode, 'unexpected parent node') + }) + + it('initializes the label', function () { + assert.equal(testNode.label, fakeSubFolder) + }) + + it('loads function files', async function () { + const functionFiles = await testNode.loadFunctionFiles() + assert.equal(functionFiles.length, 1) + assert.equal(functionFiles[0].label, fakeFile) + }) +}) diff --git a/packages/core/src/test/lambda/explorer/lambdaFunctionNode.test.ts b/packages/core/src/test/lambda/explorer/lambdaFunctionNode.test.ts index cb9ffc9b5f2..184bdd915b8 100644 --- a/packages/core/src/test/lambda/explorer/lambdaFunctionNode.test.ts +++ b/packages/core/src/test/lambda/explorer/lambdaFunctionNode.test.ts @@ -4,23 +4,54 @@ */ import assert from 'assert' -import { Lambda } from 'aws-sdk' import * as os from 'os' import { LambdaFunctionNode } from '../../../lambda/explorer/lambdaFunctionNode' import { TestAWSTreeNode } from '../../shared/treeview/nodes/testAWSTreeNode' +import path from 'path' +import { fs } from '../../../shared/fs/fs' +import { + contextValueLambdaFunction, + contextValueLambdaFunctionImportable, +} from '../../../lambda/explorer/lambdaFunctionNode' +import sinon from 'sinon' +import * as editLambdaModule from '../../../lambda/commands/editLambda' describe('LambdaFunctionNode', function () { const parentNode = new TestAWSTreeNode('test node') + const fakeRegion = 'fakeRegion' + const fakeFilename = 'fakeFilename' + + const fakeFunctionConfig = { + FunctionName: 'testFunctionName', + FunctionArn: 'testFunctionARN', + } + + const regionPath = path.join('/tmp/aws-toolkit-vscode/lambda', fakeRegion) + const functionPath = path.join(regionPath, fakeFunctionConfig.FunctionName) + const filePath = path.join(functionPath, fakeFilename) + let testNode: LambdaFunctionNode - let fakeFunctionConfig: Lambda.FunctionConfiguration - before(function () { - fakeFunctionConfig = { - FunctionName: 'testFunctionName', - FunctionArn: 'testFunctionARN', - } + let editLambdaStub: sinon.SinonStub + + before(async function () { + await fs.mkdir(functionPath) + await fs.writeFile(filePath, 'fakefilecontent') + + // Stub the editLambdaCommand to return the function path + editLambdaStub = sinon.stub(editLambdaModule, 'editLambdaCommand').resolves(functionPath) + + testNode = new LambdaFunctionNode( + parentNode, + 'someregioncode', + fakeFunctionConfig, + contextValueLambdaFunctionImportable + ) + }) - testNode = new LambdaFunctionNode(parentNode, 'someregioncode', fakeFunctionConfig) + after(async function () { + await fs.delete(regionPath, { recursive: true }) + editLambdaStub.restore() }) it('instantiates without issue', async function () { @@ -43,6 +74,11 @@ describe('LambdaFunctionNode', function () { assert.strictEqual(testNode.functionName, fakeFunctionConfig.FunctionName) }) + it('initializes resourceUri', async function () { + assert.strictEqual(testNode.resourceUri?.scheme, 'lambda') + assert.strictEqual(testNode.resourceUri?.path, `someregioncode/${fakeFunctionConfig.FunctionName}`) + }) + it('initializes the tooltip', async function () { assert.strictEqual( testNode.tooltip, @@ -50,9 +86,27 @@ describe('LambdaFunctionNode', function () { ) }) - it('has no children', async function () { + it('loads function files', async function () { + const functionFiles = await testNode.loadFunctionFiles(functionPath) + assert.equal(functionFiles.length, 1) + assert.equal(functionFiles[0].label, fakeFilename) + }) + + it('has child if importable', async function () { const childNodes = await testNode.getChildren() assert.ok(childNodes) - assert.strictEqual(childNodes.length, 0, 'Expected node to have no children') + assert.equal(childNodes.length, 1, 'Expected node to have one child, should be "failed to load resources"') + }) + + it('is not collapsible if not importable', async function () { + const nonImportableNode = new LambdaFunctionNode( + parentNode, + fakeRegion, + fakeFunctionConfig, + contextValueLambdaFunction + ) + const childNodes = await nonImportableNode.getChildren() + assert.ok(childNodes) + assert.equal(childNodes.length, 0, 'Expected node to have no children') }) }) diff --git a/packages/core/src/test/lambda/explorer/lambdaFunctionNodeDecorationProvider.test.ts b/packages/core/src/test/lambda/explorer/lambdaFunctionNodeDecorationProvider.test.ts new file mode 100644 index 00000000000..19a2662815f --- /dev/null +++ b/packages/core/src/test/lambda/explorer/lambdaFunctionNodeDecorationProvider.test.ts @@ -0,0 +1,147 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import assert from 'assert' +import * as vscode from 'vscode' +import * as sinon from 'sinon' +import * as path from 'path' +import { LambdaFunctionNodeDecorationProvider } from '../../../lambda/explorer/lambdaFunctionNodeDecorationProvider' +import * as utils from '../../../lambda/utils' +import { fs } from '../../../shared/fs/fs' + +describe('LambdaFunctionNodeDecorationProvider', function () { + let provider: LambdaFunctionNodeDecorationProvider + let getFunctionInfoStub: sinon.SinonStub + let fsStatStub: sinon.SinonStub + let fsReaddirStub: sinon.SinonStub + + const filepath = path.join(utils.getTempLocation('test-function', 'us-east-1'), 'index.js') + const functionUri = vscode.Uri.parse('lambda:us-east-1/test-function') + const fileUri = vscode.Uri.file(filepath) + + beforeEach(function () { + provider = LambdaFunctionNodeDecorationProvider.getInstance() + getFunctionInfoStub = sinon.stub(utils, 'getFunctionInfo') + fsStatStub = sinon.stub(fs, 'stat') + fsReaddirStub = sinon.stub(fs, 'readdir') + }) + + afterEach(function () { + sinon.restore() + }) + + describe('provideFileDecoration', function () { + it('returns decoration for lambda URI with undeployed changes', async function () { + getFunctionInfoStub.resolves(true) + + const decoration = await provider.provideFileDecoration(functionUri) + + assert.ok(decoration) + assert.strictEqual(decoration.badge, 'M') + assert.strictEqual(decoration.tooltip, 'This function has undeployed changes') + assert.strictEqual(decoration.propagate, false) + }) + + it('returns undefined for lambda URI without undeployed changes', async function () { + getFunctionInfoStub.resolves(false) + + const decoration = await provider.provideFileDecoration(functionUri) + + assert.strictEqual(decoration, undefined) + }) + + it('returns decoration for file URI with modifications after deployment', async function () { + const lastDeployed = 1 + const fileModified = 2 + + getFunctionInfoStub.resolves({ lastDeployed, undeployed: true }) + fsStatStub.resolves({ mtime: fileModified }) + + const decoration = await provider.provideFileDecoration(fileUri) + + assert.ok(decoration) + assert.strictEqual(decoration.badge, 'M') + assert.strictEqual(decoration.tooltip, 'This function has undeployed changes') + assert.strictEqual(decoration.propagate, true) + }) + + it('returns undefined for file URI without modifications after deployment', async function () { + const lastDeployed = 2 + const fileModified = 1 + + getFunctionInfoStub.resolves({ lastDeployed, undeployed: true }) + fsStatStub.resolves({ mtime: fileModified }) + + const decoration = await provider.provideFileDecoration(fileUri) + + assert.strictEqual(decoration, undefined) + }) + + it('returns undefined for file URI when no deployment info exists', async function () { + getFunctionInfoStub.resolves(undefined) + + const decoration = await provider.provideFileDecoration(fileUri) + + assert.strictEqual(decoration, undefined) + }) + + it('returns undefined for file URI that does not match lambda pattern', async function () { + const uri = vscode.Uri.file(path.join('not', 'in', 'tmp')) + + const decoration = await provider.provideFileDecoration(uri) + + assert.strictEqual(decoration, undefined) + }) + + it('handles errors gracefully when checking file modification', async function () { + getFunctionInfoStub.resolves(0) + fsStatStub.rejects(new Error('File not found')) + + const decoration = await provider.provideFileDecoration(fileUri) + + assert.strictEqual(decoration, undefined) + }) + }) + + describe('addBadge', function () { + it('fires decoration change events for both URIs', async function () { + const fileUri = vscode.Uri.file(path.join('test', 'file.js')) + const functionUri = vscode.Uri.parse('lambda:us-east-1/test-function') + + let eventCount = 0 + const disposable = provider.onDidChangeFileDecorations(() => { + eventCount++ + }) + + await provider.addBadge(fileUri, functionUri) + + assert.strictEqual(eventCount, 2) + disposable.dispose() + }) + }) + + describe('getFilePaths', function () { + it('returns all file paths recursively', async function () { + const basePath = path.join('test', 'dir') + + // Mock first readdir call + fsReaddirStub.onFirstCall().resolves([ + ['file1.js', vscode.FileType.File], + ['subdir', vscode.FileType.Directory], + ]) + + // Mock second readdir call for subdirectory + fsReaddirStub.onSecondCall().resolves([['file2.js', vscode.FileType.File]]) + + // Access private method through any cast for testing + const paths = await (provider as any).getFilePaths(basePath) + + assert.ok(paths.includes(basePath)) + assert.ok(paths.includes(path.join('test', 'dir', 'file1.js'))) + assert.ok(paths.includes(path.join('test', 'dir', 'subdir'))) + assert.ok(paths.includes(path.join('test', 'dir', 'subdir', 'file2.js'))) + }) + }) +}) diff --git a/packages/core/src/test/lambda/explorer/lambdaNodes.test.ts b/packages/core/src/test/lambda/explorer/lambdaNodes.test.ts index ba494680911..2c94d28ba9b 100644 --- a/packages/core/src/test/lambda/explorer/lambdaNodes.test.ts +++ b/packages/core/src/test/lambda/explorer/lambdaNodes.test.ts @@ -4,8 +4,8 @@ */ import assert from 'assert' -import { LambdaFunctionNode } from '../../../lambda/explorer/lambdaFunctionNode' -import { contextValueLambdaFunction, LambdaNode } from '../../../lambda/explorer/lambdaNodes' +import { contextValueLambdaFunction, LambdaFunctionNode } from '../../../lambda/explorer/lambdaFunctionNode' +import { LambdaNode } from '../../../lambda/explorer/lambdaNodes' import { asyncGenerator } from '../../../shared/utilities/collectionUtils' import { assertNodeListOnlyHasErrorNode, diff --git a/packages/core/src/test/lambda/uriHandlers.test.ts b/packages/core/src/test/lambda/uriHandlers.test.ts new file mode 100644 index 00000000000..f3e8ae7c368 --- /dev/null +++ b/packages/core/src/test/lambda/uriHandlers.test.ts @@ -0,0 +1,36 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import assert from 'assert' +import { SearchParams } from '../../shared/vscode/uriHandler' +import { parseOpenParams } from '../../lambda/uriHandlers' +import { globals } from '../../shared' + +describe('Lambda URI Handler', function () { + describe('load-function', function () { + it('registers for "/lambda/load-function"', function () { + assert.throws(() => globals.uriHandler.onPath('/lambda/load-function', () => {})) + }) + + it('parses parameters', function () { + let query = new SearchParams({ + functionName: 'example', + }) + assert.throws(() => parseOpenParams(query), /A region must be provided/) + query = new SearchParams({ + region: 'example', + }) + assert.throws(() => parseOpenParams(query), /A function name must be provided/) + + const valid = { + functionName: 'example', + region: 'example', + isCfn: 'false', + } + query = new SearchParams(valid) + assert.deepEqual(parseOpenParams(query), valid) + }) + }) +}) diff --git a/packages/core/src/test/lambda/utils.test.ts b/packages/core/src/test/lambda/utils.test.ts index b7c1edb0aa4..72f5d3e63e4 100644 --- a/packages/core/src/test/lambda/utils.test.ts +++ b/packages/core/src/test/lambda/utils.test.ts @@ -4,9 +4,30 @@ */ import assert from 'assert' -import { getLambdaDetails } from '../../lambda/utils' +import * as sinon from 'sinon' +import { + getLambdaDetails, + getTempLocation, + getTempRegionLocation, + getFunctionInfo, + setFunctionInfo, + compareCodeSha, + lambdaEdits, + getLambdaEditFromNameRegion, + getLambdaEditFromLocation, +} from '../../lambda/utils' +import { LambdaFunction } from '../../lambda/commands/uploadLambda' +import { DefaultLambdaClient } from '../../shared/clients/lambdaClient' +import { fs } from '../../shared/fs/fs' +import { tempDirPath } from '../../shared/filesystemUtilities' +import path from 'path' -describe('lambda utils', async function () { +describe('lambda utils', function () { + const mockLambda = { + name: 'test-function', + region: 'us-east-1', + configuration: { FunctionName: 'test-function' }, + } describe('getLambdaDetails', function () { it('returns valid filenames and function names', function () { const jsNonNestedParsedName = getLambdaDetails({ @@ -52,4 +73,162 @@ describe('lambda utils', async function () { assert.throws(() => getLambdaDetails({ Runtime: 'COBOL-60', Handler: 'asdf.asdf' })) }) }) + + describe('getTempLocation', function () { + it('returns correct temp location path', function () { + const result = getTempLocation('test-function', 'us-east-1') + const expected = path.join(tempDirPath, 'lambda', 'us-east-1', 'test-function') + assert.strictEqual(result, expected) + }) + }) + + describe('getTempRegionLocation', function () { + it('returns correct temp region path', function () { + const result = getTempRegionLocation('us-west-2') + const expected = path.join(tempDirPath, 'lambda', 'us-west-2') + assert.strictEqual(result, expected) + }) + }) + + describe('getFunctionInfo', function () { + afterEach(function () { + sinon.restore() + }) + + it('returns parsed data when file exists', async function () { + const mockData = { lastDeployed: 123456, undeployed: false, sha: 'test-sha' } + sinon.stub(fs, 'readFileText').resolves(JSON.stringify(mockData)) + + const result = await getFunctionInfo(mockLambda) + assert.deepStrictEqual(result, mockData) + }) + + it('returns specific field when requested', async function () { + const mockData = { lastDeployed: 123456, undeployed: false, sha: 'test-sha' } + sinon.stub(fs, 'readFileText').resolves(JSON.stringify(mockData)) + + const result = await getFunctionInfo(mockLambda, 'sha') + assert.strictEqual(result, 'test-sha') + }) + + it('returns empty object when file does not exist', async function () { + sinon.stub(fs, 'readFileText').rejects(new Error('File not found')) + + const result = await getFunctionInfo(mockLambda) + assert.deepStrictEqual(result, {}) + }) + }) + + describe('setFunctionInfo', function () { + let mockLambda: LambdaFunction + + beforeEach(function () { + mockLambda = { + name: 'test-function', + region: 'us-east-1', + configuration: { FunctionName: 'test-function' }, + } + }) + + afterEach(function () { + sinon.restore() + }) + + it('merges with existing data', async function () { + const existingData = { lastDeployed: 123456, undeployed: true, sha: 'old-sha' } + sinon.stub(fs, 'readFileText').resolves(JSON.stringify(existingData)) + const writeStub = sinon.stub(fs, 'writeFile').resolves() + sinon.stub(DefaultLambdaClient.prototype, 'getFunction').resolves({ + Configuration: { CodeSha256: 'new-sha' }, + } as any) + + await setFunctionInfo(mockLambda, { undeployed: false }) + + assert(writeStub.calledOnce) + const writtenData = JSON.parse(writeStub.firstCall.args[1] as string) + assert.strictEqual(writtenData.lastDeployed, 123456) + assert.strictEqual(writtenData.undeployed, false) + assert.strictEqual(writtenData.sha, 'new-sha') + }) + }) + + describe('compareCodeSha', function () { + let mockLambda: LambdaFunction + + beforeEach(function () { + mockLambda = { + name: 'test-function', + region: 'us-east-1', + configuration: { FunctionName: 'test-function' }, + } + }) + + afterEach(function () { + sinon.restore() + }) + + it('returns true when local and remote SHA match', async function () { + sinon.stub(fs, 'readFileText').resolves(JSON.stringify({ sha: 'same-sha' })) + sinon.stub(DefaultLambdaClient.prototype, 'getFunction').resolves({ + Configuration: { CodeSha256: 'same-sha' }, + } as any) + + const result = await compareCodeSha(mockLambda) + assert.strictEqual(result, true) + }) + + it('returns false when local and remote SHA differ', async function () { + sinon.stub(fs, 'readFileText').resolves(JSON.stringify({ sha: 'local-sha' })) + sinon.stub(DefaultLambdaClient.prototype, 'getFunction').resolves({ + Configuration: { CodeSha256: 'remote-sha' }, + } as any) + + const result = await compareCodeSha(mockLambda) + assert.strictEqual(result, false) + }) + }) + + describe('lambdaEdits array functions', function () { + beforeEach(function () { + lambdaEdits.length = 0 + lambdaEdits.push( + { + location: '/tmp/func1', + functionName: 'func1', + region: 'us-east-1', + }, + { + location: '/tmp/func2', + functionName: 'func2', + region: 'us-west-2', + } + ) + }) + + describe('getLambdaEditFromNameRegion', function () { + it('finds edit by name and region', function () { + const result = getLambdaEditFromNameRegion('func1', 'us-east-1') + assert.strictEqual(result?.functionName, 'func1') + assert.strictEqual(result?.region, 'us-east-1') + }) + + it('returns undefined when not found', function () { + const result = getLambdaEditFromNameRegion('nonexistent', 'us-east-1') + assert.strictEqual(result, undefined) + }) + }) + + describe('getLambdaEditFromLocation', function () { + it('finds edit by location', function () { + const result = getLambdaEditFromLocation('/tmp/func2') + assert.strictEqual(result?.functionName, 'func2') + assert.strictEqual(result?.location, '/tmp/func2') + }) + + it('returns undefined when not found', function () { + const result = getLambdaEditFromLocation('/tmp/nonexistent') + assert.strictEqual(result, undefined) + }) + }) + }) }) diff --git a/packages/toolkit/.changes/next-release/Feature-b2f13a50-0474-464c-b503-611edce7c356.json b/packages/toolkit/.changes/next-release/Feature-b2f13a50-0474-464c-b503-611edce7c356.json new file mode 100644 index 00000000000..d9d220cf51f --- /dev/null +++ b/packages/toolkit/.changes/next-release/Feature-b2f13a50-0474-464c-b503-611edce7c356.json @@ -0,0 +1,4 @@ +{ + "type": "Feature", + "description": "Lambda to SAM Transformation: AWS Toolkit Explorer now can convert existing Lambda functions into SAM (Serverless Application Model) projects. This conversion creates a project structure that's ready for local development and can be managed using Application Builder" +} diff --git a/packages/toolkit/.changes/next-release/Feature-fc398d68-b7e1-4f37-8746-867381f402c6.json b/packages/toolkit/.changes/next-release/Feature-fc398d68-b7e1-4f37-8746-867381f402c6.json new file mode 100644 index 00000000000..0e73c5e6c84 --- /dev/null +++ b/packages/toolkit/.changes/next-release/Feature-fc398d68-b7e1-4f37-8746-867381f402c6.json @@ -0,0 +1,4 @@ +{ + "type": "Feature", + "description": "Lambda Quick Edit: AWS Toolkit Explorer now offers a streamlined editing experience for Lambda functions. Download a function's code with double-click, make local modifications, and easily synchronize changes back to the cloud." +} diff --git a/packages/toolkit/package.json b/packages/toolkit/package.json index aab186d7535..1f618511d11 100644 --- a/packages/toolkit/package.json +++ b/packages/toolkit/package.json @@ -826,6 +826,10 @@ "command": "aws.downloadStateMachineDefinition", "when": "false" }, + { + "command": "aws.toolkit.lambda.convertToSam", + "when": "false" + }, { "command": "aws.ecr.createRepository", "when": "false" @@ -1665,6 +1669,16 @@ "when": "view =~ /^(aws.explorer|aws.appBuilder|aws.appBuilderForFileExplorer)$/ && viewItem =~ /^(awsRegionFunctionNode|awsRegionFunctionNodeDownloadable)$/ || viewItem == awsAppBuilderDeployedNode", "group": "0@2" }, + { + "command": "aws.lambda.openWorkspace", + "when": "view == aws.explorer && viewItem == awsRegionFunctionNodeDownloadable", + "group": "0@6" + }, + { + "command": "aws.toolkit.lambda.convertToSam", + "when": "view == aws.explorer && viewItem == awsRegionFunctionNodeDownloadable", + "group": "0@3" + }, { "command": "aws.uploadLambda", "when": "view =~ /^(aws.explorer|aws.appBuilder|aws.appBuilderForFileExplorer)$/ && viewItem =~ /^(awsRegionFunctionNode|awsRegionFunctionNodeDownloadable)$/ || viewItem == awsAppBuilderDeployedNode", @@ -2190,6 +2204,16 @@ "when": "view =~ /^(aws.explorer|aws.appBuilder|aws.appBuilderForFileExplorer)$/ && viewItem =~ /^(awsRegionFunctionNode|awsRegionFunctionNodeDownloadable|awsCloudFormationFunctionNode)$/", "group": "inline@2" }, + { + "command": "aws.quickDeployLambda", + "when": "view == aws.explorer && viewItem == awsRegionFunctionNodeDownloadable", + "group": "inline@3" + }, + { + "command": "aws.toolkit.lambda.convertToSam", + "when": "view == aws.explorer && viewItem == awsRegionFunctionNodeDownloadable", + "group": "inline@4" + }, { "command": "aws.docdb.createCluster", "when": "view == aws.explorer && viewItem == awsDocDBNode", @@ -2276,7 +2300,7 @@ }, { "command": "aws.appBuilder.tailLogs", - "when": "view =~ /^(aws.explorer|aws.appBuilder|aws.appBuilderForFileExplorer)$/ && viewItem =~ /^(awsRegionFunctionNode|awsRegionFunctionNodeDownloadable|awsCloudFormationFunctionNode)$/", + "when": "view =~ /^(aws.appBuilder|aws.appBuilderForFileExplorer)$/ && viewItem =~ /^(awsRegionFunctionNode|awsRegionFunctionNodeDownloadable|awsCloudFormationFunctionNode)$/", "group": "inline@3" } ], @@ -3017,6 +3041,21 @@ } } }, + { + "command": "aws.toolkit.lambda.convertToSam", + "title": "%AWS.command.lambda.convertToSam%", + "category": "%AWS.title%", + "enablement": "viewItem == awsRegionFunctionNodeDownloadable", + "cloud9": { + "cn": { + "category": "%AWS.title.cn%" + } + }, + "icon": { + "light": "resources/icons/aws/lambda/create-stack.svg", + "dark": "resources/icons/aws/lambda/create-stack-light.svg" + } + }, { "command": "aws.downloadLambda", "title": "%AWS.command.downloadLambda%", @@ -3028,6 +3067,17 @@ } } }, + { + "command": "aws.lambda.openWorkspace", + "title": "%AWS.command.openLambdaWorkspace%", + "category": "%AWS.title%", + "enablement": "viewItem == awsRegionFunctionNodeDownloadable", + "cloud9": { + "cn": { + "category": "%AWS.title.cn%" + } + } + }, { "command": "aws.uploadLambda", "title": "%AWS.command.uploadLambda%", @@ -3037,7 +3087,27 @@ "cn": { "category": "%AWS.title.cn%" } - } + }, + "icon": "$(cloud-upload)" + }, + { + "command": "aws.openLambdaFile", + "title": "%AWS.command.openLambdaFile%", + "category": "%AWS.title%", + "enablement": "isCloud9 || !aws.isWebExtHost", + "icon": "$(preview)" + }, + { + "command": "aws.quickDeployLambda", + "title": "%AWS.command.quickDeployLambda%", + "category": "%AWS.title%", + "enablement": "viewItem == awsRegionFunctionNodeDownloadable", + "cloud9": { + "cn": { + "category": "%AWS.title.cn%" + } + }, + "icon": "$(cloud-upload)" }, { "command": "aws.deleteLambda", @@ -4565,110 +4635,124 @@ "fontCharacter": "\\f1d0" } }, - "aws-lambda-function": { + "aws-lambda-create-stack": { "description": "AWS Contributed Icon", "default": { "fontPath": "./resources/fonts/aws-toolkit-icons.woff", "fontCharacter": "\\f1d1" } }, - "aws-mynah-MynahIconBlack": { + "aws-lambda-create-stack-light": { "description": "AWS Contributed Icon", "default": { "fontPath": "./resources/fonts/aws-toolkit-icons.woff", "fontCharacter": "\\f1d2" } }, - "aws-mynah-MynahIconWhite": { + "aws-lambda-function": { "description": "AWS Contributed Icon", "default": { "fontPath": "./resources/fonts/aws-toolkit-icons.woff", "fontCharacter": "\\f1d3" } }, - "aws-mynah-logo": { + "aws-mynah-MynahIconBlack": { "description": "AWS Contributed Icon", "default": { "fontPath": "./resources/fonts/aws-toolkit-icons.woff", "fontCharacter": "\\f1d4" } }, - "aws-redshift-cluster": { + "aws-mynah-MynahIconWhite": { "description": "AWS Contributed Icon", "default": { "fontPath": "./resources/fonts/aws-toolkit-icons.woff", "fontCharacter": "\\f1d5" } }, - "aws-redshift-cluster-connected": { + "aws-mynah-logo": { "description": "AWS Contributed Icon", "default": { "fontPath": "./resources/fonts/aws-toolkit-icons.woff", "fontCharacter": "\\f1d6" } }, - "aws-redshift-database": { + "aws-redshift-cluster": { "description": "AWS Contributed Icon", "default": { "fontPath": "./resources/fonts/aws-toolkit-icons.woff", "fontCharacter": "\\f1d7" } }, - "aws-redshift-redshift-cluster-connected": { + "aws-redshift-cluster-connected": { "description": "AWS Contributed Icon", "default": { "fontPath": "./resources/fonts/aws-toolkit-icons.woff", "fontCharacter": "\\f1d8" } }, - "aws-redshift-schema": { + "aws-redshift-database": { "description": "AWS Contributed Icon", "default": { "fontPath": "./resources/fonts/aws-toolkit-icons.woff", "fontCharacter": "\\f1d9" } }, - "aws-redshift-table": { + "aws-redshift-redshift-cluster-connected": { "description": "AWS Contributed Icon", "default": { "fontPath": "./resources/fonts/aws-toolkit-icons.woff", "fontCharacter": "\\f1da" } }, - "aws-s3-bucket": { + "aws-redshift-schema": { "description": "AWS Contributed Icon", "default": { "fontPath": "./resources/fonts/aws-toolkit-icons.woff", "fontCharacter": "\\f1db" } }, - "aws-s3-create-bucket": { + "aws-redshift-table": { "description": "AWS Contributed Icon", "default": { "fontPath": "./resources/fonts/aws-toolkit-icons.woff", "fontCharacter": "\\f1dc" } }, - "aws-schemas-registry": { + "aws-s3-bucket": { "description": "AWS Contributed Icon", "default": { "fontPath": "./resources/fonts/aws-toolkit-icons.woff", "fontCharacter": "\\f1dd" } }, - "aws-schemas-schema": { + "aws-s3-create-bucket": { "description": "AWS Contributed Icon", "default": { "fontPath": "./resources/fonts/aws-toolkit-icons.woff", "fontCharacter": "\\f1de" } }, - "aws-stepfunctions-preview": { + "aws-schemas-registry": { "description": "AWS Contributed Icon", "default": { "fontPath": "./resources/fonts/aws-toolkit-icons.woff", "fontCharacter": "\\f1df" } + }, + "aws-schemas-schema": { + "description": "AWS Contributed Icon", + "default": { + "fontPath": "./resources/fonts/aws-toolkit-icons.woff", + "fontCharacter": "\\f1e0" + } + }, + "aws-stepfunctions-preview": { + "description": "AWS Contributed Icon", + "default": { + "fontPath": "./resources/fonts/aws-toolkit-icons.woff", + "fontCharacter": "\\f1e1" + } } }, "notebooks": [