diff --git a/.github/workflows/common-test.yml b/.github/workflows/common-test.yml index 609c0395..d5a8615e 100644 --- a/.github/workflows/common-test.yml +++ b/.github/workflows/common-test.yml @@ -567,7 +567,7 @@ jobs: role-to-assume: ${{ secrets.AWS_ROLE }} role-session-name: GitHubActions - name: Setup Terraform - uses: hashicorp/setup-terraform@v1 + uses: hashicorp/setup-terraform@v3 - name: Terraform Init run: | ./create_bucket.sh lld-terraform-basic @@ -584,3 +584,59 @@ jobs: run: npx vitest --retry 1 test/terraform-basic.test.ts - name: Test - observability mode run: OBSERVABLE_MODE=true npx vitest --retry 1 test/terraform-basic.test.ts + + test-opentofu-basic: + runs-on: ubuntu-latest + concurrency: + group: test-opentofu-basic + steps: + - uses: actions/checkout@v4 + - name: Use Node.js + uses: actions/setup-node@v4 + with: + node-version: ${{ env.node_version }} + registry-url: 'https://registry.npmjs.org' + - name: Install dependencies + run: | + node prepareForTest.js opentofu-basic + npm i + - name: Download build artifact + uses: actions/download-artifact@v4 + if: ${{ inputs.mode == 'build' }} + with: + name: dist + path: dist + - name: Install lambda-live-debugger globally + if: ${{ inputs.mode == 'global' }} + run: | + npm i lambda-live-debugger -g + working-directory: test + - name: Install lambda-live-debugger locally + if: ${{ inputs.mode == 'local' }} + run: | + npm i lambda-live-debugger + working-directory: test + - name: Configure AWS Credentials + uses: aws-actions/configure-aws-credentials@v4 + with: + aws-region: eu-west-1 + role-to-assume: ${{ secrets.AWS_ROLE }} + role-session-name: GitHubActions + - name: Setup OpenTofu + uses: opentofu/setup-opentofu@v1 + - name: OpenTofu Init + run: | + ./create_bucket.sh lld-opentofu-basic + tofu init -backend-config="bucket=lld-opentofu-basic" + working-directory: test/opentofu-basic + - name: Destroy + run: npm run destroy + working-directory: test/opentofu-basic + continue-on-error: true + - name: Deploy + run: npm run deploy + working-directory: test/opentofu-basic + - name: Test + run: npx vitest --retry 1 test/opentofu-basic.test.ts + - name: Test - observability mode + run: OBSERVABLE_MODE=true npx vitest --retry 1 test/opentofu-basic.test.ts diff --git a/.vscode/launch.json b/.vscode/launch.json index f853065e..b7f742aa 100755 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -261,6 +261,26 @@ "type": "node", "cwd": "${workspaceRoot}/test/terraform-basic" }, + { + "name": "LLDebugger - OpenTofu basic", + "program": "${workspaceRoot}/node_modules/tsx/dist/cli.mjs", + "args": ["../../src/lldebugger.ts", "--config-env=test"], + "request": "launch", + "skipFiles": ["/**"], + "console": "integratedTerminal", + "type": "node", + "cwd": "${workspaceRoot}/test/opentofu-basic" + }, + { + "name": "LLDebugger - OpenTofu basic - observability", + "program": "${workspaceRoot}/node_modules/tsx/dist/cli.mjs", + "args": ["../../src/lldebugger.ts", "--config-env=test", "-o"], + "request": "launch", + "skipFiles": ["/**"], + "console": "integratedTerminal", + "type": "node", + "cwd": "${workspaceRoot}/test/opentofu-basic" + }, { "name": "LLDebugger - CDK config", "program": "${workspaceRoot}/node_modules/tsx/dist/cli.mjs", diff --git a/README.md b/README.md index fff2ef07..e8172b59 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ It supports the following frameworks: - AWS CDK v2 - Serverless Framework v3 (SLS) and [`osls` fork](https://github.com/oss-serverless/serverless) - AWS Serverless Application Model (SAM) -- Terraform +- Terraform and OpenTofu - Any other framework or setup by implementing a simple function in TypeScript - ... (Need support for another framework? Let me know!) @@ -211,11 +211,13 @@ Use the `stage` parameter to pass the stage/environment name. Use the `config-env` parameter to pass the stage/environment name. -### Terraform +### Terraform and OpenTofu -Only the basic setup is supported. Check the [test case](https://github.com/ServerlessLife/lambda-live-debugger/tree/main/test/terraform-basic). +Multiple configurations are supported, including [serverless.tf](https://serverless.tf/) framework. You can explore [relevant test cases here](https://github.com/ServerlessLife/lambda-live-debugger/tree/main/test/terraform-basic). -I am not a Terraform developer, so I only know the basics. Please provide a sample project so I can build better support. +If you use TypeScript, Lambda Live Debugger should automatically locate source files, even when they are transpiled to JavaScript. + +If you encounter an unsupported configuration, please open a [GitHub Issue](https://github.com/ServerlessLife/lambda-live-debugger/issues). ### Custom Setup diff --git a/package-lock.json b/package-lock.json index a8b3ad40..941dc9fe 100644 --- a/package-lock.json +++ b/package-lock.json @@ -41,7 +41,8 @@ "test/osls-esbuild-cjs", "test/sam-basic", "test/sam-alt", - "test/terraform-basic" + "test/terraform-basic", + "test/opentofu-basic" ], "dependencies": { "@aws-sdk/client-cloudformation": "^3.577.0", @@ -14446,6 +14447,10 @@ "node": ">=8" } }, + "node_modules/openotofu-basic": { + "resolved": "test/opentofu-basic", + "link": true + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -19337,6 +19342,14 @@ "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", "dev": true }, + "test/opentofu-basic": { + "name": "openotofu-basic", + "version": "1.0.0", + "license": "ISC", + "devDependencies": { + "@types/aws-lambda": "^8.10.137" + } + }, "test/osls-basic": { "version": "1.0.0", "license": "ISC", diff --git a/package.json b/package.json index f04738eb..78dfc2d8 100755 --- a/package.json +++ b/package.json @@ -72,6 +72,8 @@ "test-sam-alt-observable": "npm run build && RUN_TEST_FROM_CLI=true OBSERVABLE_MODE=true vitest run test/sam-alt.test.ts", "test-terraform-basic": "npm run build && RUN_TEST_FROM_CLI=true vitest run test/terraform-basic.test.ts", "test-terraform-basic-observable": "npm run build && RUN_TEST_FROM_CLI=true OBSERVABLE_MODE=true vitest run test/terraform-basic.test.ts", + "test-opentofu-basic": "npm run build && RUN_TEST_FROM_CLI=true vitest run test/opentofu-basic.test.ts", + "test-opentofu-basic-observable": "npm run build && RUN_TEST_FROM_CLI=true OBSERVABLE_MODE=true vitest run test/opentofu-basic.test.ts", "docs:dev": "vitepress dev", "docs:build": "vitepress build", "docs:preview": "vitepress preview" @@ -154,6 +156,7 @@ "test/osls-esbuild-cjs", "test/sam-basic", "test/sam-alt", - "test/terraform-basic" + "test/terraform-basic", + "test/opentofu-basic" ] } diff --git a/src/frameworks/openTofuFramework.ts b/src/frameworks/openTofuFramework.ts new file mode 100755 index 00000000..6d2fca8c --- /dev/null +++ b/src/frameworks/openTofuFramework.ts @@ -0,0 +1,41 @@ +import { exec } from 'child_process'; +import { promisify } from 'util'; +import { TerraformFramework } from './terraformFramework.js'; + +export const execAsync = promisify(exec); + +/** + * Support for Terraform framework + */ +export class OpenTofuFramework extends TerraformFramework { + /** + * Framework name + */ + public get name(): string { + return 'opentofu'; + } + + /** + * Name of the framework in logs + */ + protected get logName(): string { + return 'OpenTofu'; + } + + /** + * Get OpenTofu state CI command + */ + protected get stateCommand(): string { + return 'tofu show --json'; + } + + /** + * + * @returns Get command to check if OpenTodu is installed + */ + protected get checkInstalledCommand(): string { + return 'tofu --version'; + } +} + +export const openTofuFramework = new OpenTofuFramework(); diff --git a/src/frameworks/terraformFramework.ts b/src/frameworks/terraformFramework.ts index 43c3d84f..aa22bffd 100755 --- a/src/frameworks/terraformFramework.ts +++ b/src/frameworks/terraformFramework.ts @@ -12,19 +12,21 @@ import { LldConfigBase } from '../types/lldConfig.js'; export const execAsync = promisify(exec); -interface TerraformState { - resources: Array<{ - type: string; - name: string; - values: { - function_name?: string; - handler?: string; - source_dir?: string; - source_file?: string; +interface TerraformResource { + type: string; + name: string; + address: string; + values: { + function_name?: string; + handler?: string; + source_dir?: string; + source_file?: string; + query?: { + source_path?: string; }; - //dependencies > depends_on - depends_on: Array; - }>; + }; + //dependencies > depends_on + depends_on: Array; } /** @@ -38,6 +40,28 @@ export class TerraformFramework implements IFramework { return 'terraform'; } + /** + * Name of the framework in logs + */ + protected get logName(): string { + return 'Terrform'; + } + + /** + * Get Terraform state CI command + */ + protected get stateCommand(): string { + return 'terraform show --json'; + } + + /** + * + * @returns Get command to check if Terraform is installed + */ + protected get checkInstalledCommand(): string { + return 'terraform --version'; + } + /** * Can this class handle the current project * @returns @@ -49,11 +73,21 @@ export class TerraformFramework implements IFramework { if (!r) { Logger.verbose( - `[Terraform] This is not a Terraform project. There are no *.tf files in ${path.resolve('.')} folder.`, + `[${this.logName}] This is not a ${this.logName} project. There are no *.tf files in ${path.resolve('.')} folder.`, ); + return false; + } else { + // check if Terraform or OpenTofu is installed + try { + await execAsync(this.checkInstalledCommand); + return true; + } catch { + Logger.verbose( + `[${this.logName}] This is not a ${this.logName} project. ${this.logName} is not installed.`, + ); + return false; + } } - - return r; } /** @@ -67,7 +101,7 @@ export class TerraformFramework implements IFramework { const lambdas = this.extractLambdaInfo(state); Logger.verbose( - '[Terraform] Found Lambdas:', + `[${this.logName}] Found Lambdas:`, JSON.stringify(lambdas, null, 2), ); @@ -76,7 +110,7 @@ export class TerraformFramework implements IFramework { const tsOutDir = await this.getTsConfigOutDir(); if (tsOutDir) { - Logger.verbose('[Terraform] tsOutDir:', tsOutDir); + Logger.verbose(`[${this.logName}] tsOutDir:`, tsOutDir); } for (const func of lambdas) { @@ -141,7 +175,7 @@ export class TerraformFramework implements IFramework { packageJsonPath, esBuildOptions: undefined, metadata: { - framework: 'terraform', + framework: this.name, }, }; @@ -151,7 +185,7 @@ export class TerraformFramework implements IFramework { return lambdasDiscovered; } - protected extractLambdaInfo(state: TerraformState) { + protected extractLambdaInfo(resources: TerraformResource[]) { const lambdas: Array<{ functionName: string; sourceDir?: string; @@ -159,10 +193,10 @@ export class TerraformFramework implements IFramework { handler: string; }> = []; - for (const resource of state.resources) { + for (const resource of resources) { if (resource.type === 'aws_lambda_function') { Logger.verbose( - '[Terraform] Found Lambda:', + `[${this.logName}] Found Lambda:`, JSON.stringify(resource, null, 2), ); @@ -186,9 +220,7 @@ export class TerraformFramework implements IFramework { if (archiveFileResourceName) { // get the resource const name = archiveFileResourceName.split('.')[2]; - const archiveFileResource = state.resources.find( - (r) => r.name === name, - ); + const archiveFileResource = resources.find((r) => r.name === name); // get source_dir or source_filename if (archiveFileResource) { @@ -197,6 +229,28 @@ export class TerraformFramework implements IFramework { } } + // get dependency "archive_prepare" = serverless.tf support + const archivePrepareResourceName = dependencies.find((dep) => + dep.includes('.archive_prepare'), + ); + + if (archivePrepareResourceName) { + // get the resource + const name = archivePrepareResourceName; + const archivePrepareResource = resources.find((r) => + r.address?.startsWith(name), + ); + + // get source_dir or source_filename + if (archivePrepareResource) { + sourceDir = + archivePrepareResource.values.query?.source_path?.replaceAll( + '"', + '', + ); + } + } + if (!sourceDir && !sourceFilename) { Logger.error(`Failed to find source code for Lambda ${functionName}`); } else { @@ -213,54 +267,66 @@ export class TerraformFramework implements IFramework { return lambdas; } - protected async readTerraformState(): Promise { + protected async readTerraformState(): Promise { // Is there a better way to get the Terraform state??? let output: any; // get state by running "terraform show --json" command try { - output = await execAsync('terraform show --json'); + Logger.verbose( + `[${this.logName}] Getting state with '${this.stateCommand}' command`, + ); + output = await execAsync(this.stateCommand); } catch (error: any) { throw new Error( - `Failed to get Terraform state from 'terraform show --json' command: ${error.message}`, + `[${this.logName}] Failed to getstate from '${this.stateCommand}' command: ${error.message}`, { cause: error }, ); } if (output.stderr) { throw new Error( - `Failed to get Terraform state from 'terraform show --json' command: ${output.stderr}`, + `[${this.logName}] Failed to get state from '${this.stateCommand}' command: ${output.stderr}`, ); } if (!output.stdout) { throw new Error( - "Failed to get Terraform state from 'terraform show --json' command", + `[${this.logName}] Failed to get state from '${this.stateCommand}' command`, ); } let jsonString: string | undefined = output.stdout; - Logger.verbose('Terraform state:', jsonString); + Logger.verbose(`[${this.logName}] State:`, jsonString); jsonString = jsonString?.split('\n').find((line) => line.startsWith('{')); if (!jsonString) { throw new Error( - 'Failed to get Terraform state. JSON string not found in the output.', + `[${this.logName}] Failed to get state. JSON string not found in the output.`, ); } try { const state = JSON.parse(jsonString); - return state.values.root_module as TerraformState; + + const rootResources: TerraformResource[] = + state.values?.root_module?.resources ?? []; + + const childResources: TerraformResource[] = + state.values?.root_module?.child_modules + ?.map((m: any) => m.resources) + .flat() ?? []; + + return [...rootResources, ...childResources] as TerraformResource[]; } catch (error: any) { //save state to file - await fs.writeFile('terraform-state.json', jsonString); - Logger.error('Failed to parse Terraform state JSON:', error); + await fs.writeFile(`${this.name}-state.json`, jsonString); + Logger.error(`[${this.logName}] Failed to parse state JSON:`, error); throw new Error( - `Failed to parse Terraform state JSON: ${error.message}`, + `Failed to parse ${this.logName} state JSON: ${error.message}`, { cause: error }, ); } @@ -283,11 +349,11 @@ export class TerraformFramework implements IFramework { } } if (!tsConfigPath) { - Logger.verbose('[Terraform] tsconfig.json not found'); + Logger.verbose(`[${this.logName}] tsconfig.json not found`); return undefined; } - Logger.verbose('[Terraform] tsconfig.json found:', tsConfigPath); + Logger.verbose(`[${this.logName}] tsconfig.json found:`, tsConfigPath); const configFile = ts.readConfigFile(tsConfigPath, ts.sys.readFile); const compilerOptions = ts.parseJsonConfigFileContent( configFile.config, diff --git a/src/index.ts b/src/index.ts index bd866519..2c1d20be 100644 --- a/src/index.ts +++ b/src/index.ts @@ -9,5 +9,6 @@ export { CdkFramework } from './frameworks/cdkFramework.js'; export { SlsFramework } from './frameworks/slsFramework.js'; export { SamFramework } from './frameworks/samFramework.js'; export { TerraformFramework } from './frameworks/terraformFramework.js'; +export { OpenTofuFramework } from './frameworks/openTofuFramework.js'; export { type IFramework } from './frameworks/iFrameworks.js'; export { type AwsConfiguration } from './types/awsConfiguration.js'; diff --git a/src/resourceDiscovery.ts b/src/resourceDiscovery.ts index 604a9ea4..fe5e674d 100644 --- a/src/resourceDiscovery.ts +++ b/src/resourceDiscovery.ts @@ -3,6 +3,7 @@ import { cdkFramework } from './frameworks/cdkFramework.js'; import { slsFramework } from './frameworks/slsFramework.js'; import { samFramework } from './frameworks/samFramework.js'; import { terraformFramework } from './frameworks/terraformFramework.js'; +import { openTofuFramework } from './frameworks/openTofuFramework.js'; import { LldConfig } from './types/lldConfig.js'; import { LambdaResource } from './types/resourcesDiscovery.js'; import { Logger } from './logger.js'; @@ -16,6 +17,7 @@ const frameworksSupported: IFramework[] = [ slsFramework, samFramework, terraformFramework, + openTofuFramework, ]; /** diff --git a/test/opentofu-basic.test.ts b/test/opentofu-basic.test.ts new file mode 100644 index 00000000..2a848835 --- /dev/null +++ b/test/opentofu-basic.test.ts @@ -0,0 +1,191 @@ +import { expect, test, describe, beforeAll, afterAll } from 'vitest'; +import { ChildProcess } from 'child_process'; +import fs from 'fs/promises'; +import { startDebugger } from './utils/startDebugger.js'; +import { expectInfraRemoved } from './utils/expectInfraRemoved.js'; +import { expectInfraDeployed } from './utils/expectInfraDeployed.js'; +import { removeInfra } from './utils/removeInfra.js'; +import { exec } from 'child_process'; +import { promisify } from 'util'; +import { callLambda } from './utils/callLambda.js'; +import { getSamplePayload } from './utils/getSamplePayload.js'; +import { validateLocalResponse } from './utils/validateLocalResponse.js'; +import { getTestProjectFolder } from './utils/getTestProjectFolder.js'; + +export const execAsync = promisify(exec); + +const observableMode = process.env.OBSERVABLE_MODE === 'true'; + +describe('opentofu-basic', async () => { + const folder = await getTestProjectFolder('opentofu-basic'); + let lldProcess: ChildProcess | undefined; + const args: string[] = []; + + beforeAll(async () => { + if (process.env.RUN_TEST_FROM_CLI === 'true') { + // localy I need to specify it is OpenTofu, because I have also Terraform installed + args.push('--framework=opentofu'); + } + + if (process.env.CI === 'true' || process.env.RUN_TEST_FROM_CLI === 'true') { + lldProcess = await startDebugger(folder, args); + } + }); + + afterAll(async () => { + // stop the debugger + lldProcess?.kill(); + }); + + test('check infra', async () => { + const lambdaName = await getFunctionName( + folder, + 'lambda-test-js-commonjs_1_name', + ); + await expectInfraDeployed(lambdaName); + }); + + test('call Lambda - testTsCommonJs', async () => { + const lambdaName = await getFunctionName( + folder, + 'lambda-test-ts-commonjs_name', + ); + + const payload = getSamplePayload(lambdaName); + const response = await callLambda(lambdaName, payload); + + expect(response.inputEvent).toEqual(payload); + expect(response.runningLocally).toEqual(!observableMode); + if (observableMode) { + await validateLocalResponse(lambdaName, payload); + } + }); + + test('call Lambda - testTsEsModule', async () => { + const lambdaName = await getFunctionName( + folder, + 'lambda-test-ts-esmodule_name', + ); + + const payload = getSamplePayload(lambdaName); + const response = await callLambda(lambdaName, payload); + + expect(response.inputEvent).toEqual(payload); + expect(response.runningLocally).toEqual(!observableMode); + if (observableMode) { + await validateLocalResponse(lambdaName, payload); + } + }); + + test('call Lambda - testJsCommonJs_1', async () => { + const lambdaName = await getFunctionName( + folder, + 'lambda-test-js-commonjs_1_name', + ); + + const payload = getSamplePayload(lambdaName); + const response = await callLambda(lambdaName, payload); + + expect(response.inputEvent).toEqual(payload); + expect(response.runningLocally).toEqual(!observableMode); + if (observableMode) { + await validateLocalResponse(lambdaName, payload); + } + }); + + test('call Lambda - testJsCommonJs_2', async () => { + const lambdaName = await getFunctionName( + folder, + 'lambda-test-js-commonjs_2_name', + ); + + const payload = getSamplePayload(lambdaName); + const response = await callLambda(lambdaName, payload); + + expect(response.inputEvent).toEqual(payload); + expect(response.runningLocally).toEqual(!observableMode); + if (observableMode) { + await validateLocalResponse(lambdaName, payload); + } + }); + + test('call Lambda - testJsCommonJs_3', async () => { + const lambdaName = await getFunctionName( + folder, + 'lambda-test-js-commonjs_3_name', + ); + + const payload = getSamplePayload(lambdaName); + const response = await callLambda(lambdaName, payload); + + expect(response.inputEvent).toEqual(payload); + expect(response.runningLocally).toEqual(!observableMode); + if (observableMode) { + await validateLocalResponse(lambdaName, payload); + } + }); + + test('call Lambda - testJsEsModule', async () => { + const lambdaName = await getFunctionName( + folder, + 'lambda-test-js-esmodule_name', + ); + + const payload = getSamplePayload(lambdaName); + const response = await callLambda(lambdaName, payload); + + expect(response.inputEvent).toEqual(payload); + expect(response.runningLocally).toEqual(!observableMode); + if (observableMode) { + await validateLocalResponse(lambdaName, payload); + } + }); + + test('remove infra', async () => { + if (process.env.CI === 'true' || process.env.RUN_TEST_FROM_CLI === 'true') { + await removeInfra(lldProcess, folder, args); + const lambdaName = await getFunctionName( + folder, + 'lambda-test-js-commonjs_1_name', + ); + await expectInfraRemoved(lambdaName); + } + }); +}); + +export async function getFunctionName(folder: string, functionName: string) { + let jsonString: string | undefined = await fs.readFile( + `${folder}/opentofu-outputs.json`, + 'utf-8', + ); + + // on CICD we get strange output + const start = jsonString.indexOf('{'); + const end = jsonString.lastIndexOf('::debug::Opentofu exited with code 0.'); + if (start > -1 && end > -1) { + jsonString = jsonString.substring(start, end); + } + + if (!jsonString) { + throw new Error('Failed to get Terraform outputs. JSON string not found.'); + } + + let outputs: any; + + try { + outputs = JSON.parse(jsonString); + } catch (e: any) { + throw new Error( + `Failed to parse Terraform outputs: ${e.message}. JSON: ${jsonString}`, + ); + } + + try { + const lambdaName = outputs[functionName].value; + return lambdaName; + } catch { + throw new Error( + `Failed to get function name for ${functionName}. Outputs: ${JSON.stringify(outputs)}`, + ); + } +} diff --git a/test/opentofu-basic/.gitignore b/test/opentofu-basic/.gitignore new file mode 100644 index 00000000..300c7cd3 --- /dev/null +++ b/test/opentofu-basic/.gitignore @@ -0,0 +1,41 @@ +# Local .terraform directories +**/.terraform/* + +# .tfstate files +*.tfstate +*.tfstate.* + +# Crash log files +crash.log + +# Exclude all .tfvars files, which are likely to contain sentitive data, such as +# password, private keys, and other secrets. These should not be part of version +# control as they are data points which are potentially sensitive and subject +# to change depending on the environment. +# +*.tfvars + +# Ignore override files as they are usually used to override resources locally and so +# are not checked in +override.tf +override.tf.json +*_override.tf +*_override.tf.json + +# Include override files you do wish to add to version control using negated pattern +# +# !example_override.tf + +# Include tfplan files to ignore the plan output of command: terraform plan -out=tfplan +# example: *tfplan* + +# Ignore CLI configuration files +.terraformrc +terraform.rc +hello-world.zip +response.json + +dist +builds +.terraform.lock.hcl +opentofu-outputs.json \ No newline at end of file diff --git a/test/opentofu-basic/create_bucket.sh b/test/opentofu-basic/create_bucket.sh new file mode 100755 index 00000000..b1f7e9f8 --- /dev/null +++ b/test/opentofu-basic/create_bucket.sh @@ -0,0 +1,24 @@ +#!/bin/bash + +# Check if a bucket name was provided +if [ "$#" -ne 1 ]; then + echo "Usage: $0 " + exit 1 +fi + +# Variables +BUCKET_NAME="$1" +REGION="eu-west-1" + +# Check if the S3 bucket exists +if aws s3 ls "s3://$BUCKET_NAME" 2>&1 | grep -q 'NoSuchBucket' +then + echo "Bucket does not exist, creating..." + # Create the S3 bucket + aws s3 mb s3://$BUCKET_NAME --region $REGION + # Enable versioning (optional) + aws s3api put-bucket-versioning --bucket $BUCKET_NAME --versioning-configuration Status=Enabled + echo "Bucket created and versioning enabled." +else + echo "Bucket already exists." +fi diff --git a/test/opentofu-basic/main.tf b/test/opentofu-basic/main.tf new file mode 100644 index 00000000..2c183cd5 --- /dev/null +++ b/test/opentofu-basic/main.tf @@ -0,0 +1,200 @@ +provider "aws" { + region = "eu-west-1" +} + +terraform { + backend "s3" { + key = "terraform.tfstate" + region = "eu-west-1" + encrypt = true + } +} + +resource "aws_iam_role" "lambda_role" { + name = "lld-openotofu-basic-lambda_execution_role" + + assume_role_policy = jsonencode({ + Version = "2012-10-17" + Statement = [{ + Action = "sts:AssumeRole" + Effect = "Allow" + Principal = { + Service = "lambda.amazonaws.com" + } + }] + }) +} + +resource "random_string" "random" { + length = 8 + special = false + upper = false +} + +resource "aws_s3_bucket" "deploy_bucket" { + bucket = "lld-openotofu-basic-deploy-${random_string.random.result}" +} + +// services/testJsCommonJs/lambda.js #1 +data "archive_file" "test-js-commonjs_1_zip" { + type = "zip" + source_file = "services/testJsCommonJs/lambda.js" + output_path = "${path.module}/dist/test-js-commonjs_1.zip" +} + +resource "aws_s3_object" "test-js-commonjs_1_zip" { + bucket = aws_s3_bucket.deploy_bucket.id + key = "test-js-commonjs_1.zip" + source = data.archive_file.test-js-commonjs_1_zip.output_path + etag = data.archive_file.test-js-commonjs_1_zip.output_md5 +} + +resource "aws_lambda_function" "test-js-commonjs_1" { + function_name = "lld-openotofu-basic-test-js-commonjs_1" + handler = "lambda.lambdaHandler" + runtime = "nodejs22.x" + + s3_bucket = aws_s3_object.test-js-commonjs_1_zip.bucket + s3_key = aws_s3_object.test-js-commonjs_1_zip.key + + source_code_hash = data.archive_file.test-js-commonjs_1_zip.output_base64sha256 + role = aws_iam_role.lambda_role.arn +} + +// services/testJsCommonJs/lambda.js #2 +data "archive_file" "test-js-commonjs_2_zip" { + type = "zip" + source_dir = "services/testJsCommonJs" + output_path = "${path.module}/dist/test-js-commonjs_2.zip" +} + +resource "aws_s3_object" "test-js-commonjs_2_zip" { + bucket = aws_s3_bucket.deploy_bucket.id + key = "test-js-commonjs_2.zip" + source = data.archive_file.test-js-commonjs_2_zip.output_path + etag = data.archive_file.test-js-commonjs_2_zip.output_md5 +} + +resource "aws_lambda_function" "test-js-commonjs_2" { + function_name = "lld-openotofu-basic-test-js-commonjs_2" + handler = "lambda.lambdaHandler" + runtime = "nodejs22.x" + + s3_bucket = aws_s3_object.test-js-commonjs_2_zip.bucket + s3_key = aws_s3_object.test-js-commonjs_2_zip.key + + source_code_hash = data.archive_file.test-js-commonjs_2_zip.output_base64sha256 + role = aws_iam_role.lambda_role.arn +} + +module "test-js-commonjs_3" { + source = "terraform-aws-modules/lambda/aws" + + function_name = "lld-openotofu-basic-test-js-commonjs_3" + handler = "lambda.lambdaHandler" + runtime = "nodejs22.x" + + source_path = "services/testJsCommonJs" +} + +// services/testJsEsModule/lambda.js +data "archive_file" "test-js-esmodule_zip" { + type = "zip" + source_dir = "services/testJsEsModule" + output_path = "${path.module}/dist/test-js-esmodule.zip" +} + +resource "aws_s3_object" "test-js-esmodule_zip" { + bucket = aws_s3_bucket.deploy_bucket.id + key = "test-js-esmodule.zip" + source = data.archive_file.test-js-esmodule_zip.output_path + etag = data.archive_file.test-js-esmodule_zip.output_md5 +} + +resource "aws_lambda_function" "test-js-esmodule" { + function_name = "lld-openotofu-basic-test-js-esmodule" + handler = "lambda.lambdaHandler" + runtime = "nodejs22.x" + + s3_bucket = aws_s3_object.test-js-esmodule_zip.bucket + s3_key = aws_s3_object.test-js-esmodule_zip.key + + source_code_hash = data.archive_file.test-js-esmodule_zip.output_base64sha256 + role = aws_iam_role.lambda_role.arn +} + +// services/testTsEsModule/dist/lambda.js +data "archive_file" "test-ts-esmodule_zip" { + type = "zip" + source_dir = "services/testTsEsModule/dist" + output_path = "${path.module}/dist/test-ts-esmodule.zip" +} + +resource "aws_s3_object" "test-ts-esmodule_zip" { + bucket = aws_s3_bucket.deploy_bucket.id + key = "test-ts-esmodule.zip" + source = data.archive_file.test-ts-esmodule_zip.output_path + etag = data.archive_file.test-ts-esmodule_zip.output_md5 +} + +resource "aws_lambda_function" "test-ts-esmodule" { + function_name = "lld-openotofu-basic-test-ts-esmodule" + handler = "lambda.lambdaHandler" + runtime = "nodejs22.x" + + s3_bucket = aws_s3_object.test-ts-esmodule_zip.bucket + s3_key = aws_s3_object.test-ts-esmodule_zip.key + + source_code_hash = data.archive_file.test-ts-esmodule_zip.output_base64sha256 + role = aws_iam_role.lambda_role.arn +} + +// services/testTsCommonJs/dist/lambda.js +data "archive_file" "test-ts-commonjs_zip" { + type = "zip" + source_dir = "services/testTsCommonJs/dist" + output_path = "${path.module}/dist/test-ts-commonjs.zip" +} + +resource "aws_s3_object" "test-ts-commonjs_zip" { + bucket = aws_s3_bucket.deploy_bucket.id + key = "test-ts-commonjs.zip" + source = data.archive_file.test-ts-commonjs_zip.output_path + etag = data.archive_file.test-ts-commonjs_zip.output_md5 +} + +resource "aws_lambda_function" "test-ts-commonjs" { + function_name = "lld-openotofu-basic-test-ts-commonjs" + handler = "lambda.lambdaHandler" + runtime = "nodejs22.x" + + s3_bucket = aws_s3_object.test-ts-commonjs_zip.bucket + s3_key = aws_s3_object.test-ts-commonjs_zip.key + + source_code_hash = data.archive_file.test-ts-commonjs_zip.output_base64sha256 + role = aws_iam_role.lambda_role.arn +} + +output "lambda-test-js-commonjs_1_name" { + value = aws_lambda_function.test-js-commonjs_1.function_name +} + +output "lambda-test-js-commonjs_2_name" { + value = aws_lambda_function.test-js-commonjs_2.function_name +} + +output "lambda-test-js-commonjs_3_name" { + value = module.test-js-commonjs_3.lambda_function_name +} + +output "lambda-test-js-esmodule_name" { + value = aws_lambda_function.test-js-esmodule.function_name +} + +output "lambda-test-ts-esmodule_name" { + value = aws_lambda_function.test-ts-esmodule.function_name +} + +output "lambda-test-ts-commonjs_name" { + value = aws_lambda_function.test-ts-commonjs.function_name +} diff --git a/test/opentofu-basic/package.json b/test/opentofu-basic/package.json new file mode 100644 index 00000000..de0a37ea --- /dev/null +++ b/test/opentofu-basic/package.json @@ -0,0 +1,20 @@ +{ + "name": "openotofu-basic", + "version": "1.0.0", + "description": "", + "main": "index.js", + "scripts": { + "init": "./create_bucket.sh lld-openotofu-basic-marko && tofu init -backend-config=\"bucket=lld-openotofu-basic-marko\"", + "build_test-ts-esmodule": "cd services/testTsEsModule && npx tsc && cp package.json ./dist && cd ../..", + "build_test-ts-commonjs": "cd services/testTsCommonJs && npx tsc && cp package.json ./dist && cd ../..", + "build": "npm run build_test-ts-esmodule && npm run build_test-ts-commonjs", + "deploy": "npm run build && tofu apply -auto-approve && tofu output -json > opentofu-outputs.json", + "destroy": "npm run build && tofu destroy -auto-approve" + }, + "keywords": [], + "author": "", + "license": "ISC", + "devDependencies": { + "@types/aws-lambda": "^8.10.137" + } +} diff --git a/test/opentofu-basic/services/testJsCommonJs/lambda.js b/test/opentofu-basic/services/testJsCommonJs/lambda.js new file mode 100755 index 00000000..4e37462d --- /dev/null +++ b/test/opentofu-basic/services/testJsCommonJs/lambda.js @@ -0,0 +1,40 @@ +const { STSClient, GetCallerIdentityCommand } = require('@aws-sdk/client-sts'); + +const stsClient = new STSClient({}); + +const lambdaHandler = async (event, context) => { + // Check context + const remainingTime = context.getRemainingTimeInMillis(); + if (remainingTime === undefined) { + throw new Error('Remaining time is undefined'); + } + + // check if SDK works + const command = new GetCallerIdentityCommand({}); + const identity = await stsClient.send(command); + + const response = { + inputEvent: event, + accountId: identity.Account, + runningLocally: process.env.IS_LOCAL === 'true', + }; + + if (process.env.IS_LOCAL === 'true') { + const fs = require('fs'); + const path = require('path'); + const filePath = path.join( + '..', + 'local_lambda_responses', + `${context.functionName}.json`, + ); + + fs.writeFileSync(filePath, JSON.stringify(response, null, 2)); + } + + return response; +}; + +// Export the lambda handler if needed, e.g., for unit testing +module.exports = { + lambdaHandler, +}; diff --git a/test/opentofu-basic/services/testJsCommonJs/package.json b/test/opentofu-basic/services/testJsCommonJs/package.json new file mode 100644 index 00000000..e4155a19 --- /dev/null +++ b/test/opentofu-basic/services/testJsCommonJs/package.json @@ -0,0 +1,8 @@ +{ + "name": "openotofu-basic-test-js-commonjs", + "version": "1.0.0", + "type": "commonjs", + "dependencies": { + "@aws-sdk/client-sts": "^3.577.0" + } +} diff --git a/test/opentofu-basic/services/testJsEsModule/lambda.js b/test/opentofu-basic/services/testJsEsModule/lambda.js new file mode 100755 index 00000000..e3508208 --- /dev/null +++ b/test/opentofu-basic/services/testJsEsModule/lambda.js @@ -0,0 +1,35 @@ +import { STSClient, GetCallerIdentityCommand } from '@aws-sdk/client-sts'; + +const stsClient = new STSClient({}); + +export const lambdaHandler = async (event, context) => { + // check context + const remainingTime = context.getRemainingTimeInMillis(); + if (remainingTime === undefined) { + throw new Error('Remaining time is undefined'); + } + + // check SDK works + const command = new GetCallerIdentityCommand({}); + const identity = await stsClient.send(command); + + const response = { + inputEvent: event, + accountId: identity.Account, + runningLocally: process.env.IS_LOCAL === 'true', + }; + + if (process.env.IS_LOCAL === 'true') { + const fs = await import('fs'); + const path = await import('path'); + const filePath = path.join( + '..', + 'local_lambda_responses', + `${context.functionName}.json`, + ); + + fs.writeFileSync(filePath, JSON.stringify(response, null, 2)); + } + + return response; +}; diff --git a/test/opentofu-basic/services/testJsEsModule/package.json b/test/opentofu-basic/services/testJsEsModule/package.json new file mode 100644 index 00000000..a8ab21c2 --- /dev/null +++ b/test/opentofu-basic/services/testJsEsModule/package.json @@ -0,0 +1,8 @@ +{ + "name": "openotofu-basic-test-js-esmodule", + "version": "1.0.0", + "type": "module", + "dependencies": { + "@aws-sdk/client-sts": "^3.577.0" + } +} diff --git a/test/opentofu-basic/services/testTsCommonJs/lambda.ts b/test/opentofu-basic/services/testTsCommonJs/lambda.ts new file mode 100755 index 00000000..70bd2e6e --- /dev/null +++ b/test/opentofu-basic/services/testTsCommonJs/lambda.ts @@ -0,0 +1,36 @@ +import { Handler } from 'aws-lambda'; +import { STSClient, GetCallerIdentityCommand } from '@aws-sdk/client-sts'; + +const stsClient = new STSClient({}); + +export const lambdaHandler: Handler = async (event, context) => { + // check context + const remainingTime = context.getRemainingTimeInMillis(); + if (remainingTime === undefined) { + throw new Error('Remaining time is undefined'); + } + + // check SDK works + const command = new GetCallerIdentityCommand({}); + const identity = await stsClient.send(command); + + const response = { + inputEvent: event, + accountId: identity.Account, + runningLocally: process.env.IS_LOCAL === 'true', + }; + + if (process.env.IS_LOCAL === 'true') { + const fs = await import('fs'); + const path = await import('path'); + const filePath = path.join( + '..', + 'local_lambda_responses', + `${context.functionName}.json`, + ); + + fs.writeFileSync(filePath, JSON.stringify(response, null, 2)); + } + + return response; +}; diff --git a/test/opentofu-basic/services/testTsCommonJs/package.json b/test/opentofu-basic/services/testTsCommonJs/package.json new file mode 100644 index 00000000..76f6f8f3 --- /dev/null +++ b/test/opentofu-basic/services/testTsCommonJs/package.json @@ -0,0 +1,11 @@ +{ + "name": "openotofu-basic-test-ts-commonjs", + "version": "1.0.0", + "type": "commonjs", + "devDependencies": { + "@tsconfig/node20": "^20.1.4" + }, + "dependencies": { + "@aws-sdk/client-sts": "^3.577.0" + } +} diff --git a/test/opentofu-basic/services/testTsCommonJs/tsconfig.json b/test/opentofu-basic/services/testTsCommonJs/tsconfig.json new file mode 100755 index 00000000..3e6a32da --- /dev/null +++ b/test/opentofu-basic/services/testTsCommonJs/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "@tsconfig/node20/tsconfig.json", + "compilerOptions": { + "moduleResolution": "node", + "module": "CommonJS", + "outDir": "./dist" + } +} diff --git a/test/opentofu-basic/services/testTsEsModule/lambda.ts b/test/opentofu-basic/services/testTsEsModule/lambda.ts new file mode 100755 index 00000000..5e17de6c --- /dev/null +++ b/test/opentofu-basic/services/testTsEsModule/lambda.ts @@ -0,0 +1,35 @@ +import { Handler } from 'aws-lambda'; +import { STSClient, GetCallerIdentityCommand } from '@aws-sdk/client-sts'; + +const stsClient = new STSClient({}); + +export const lambdaHandler: Handler = async (event, context) => { + // check context + const remainingTime = context.getRemainingTimeInMillis(); + if (remainingTime === undefined) { + throw new Error('Remaining time is undefined'); + } + + // check SDK works + const command = new GetCallerIdentityCommand({}); + const identity = await stsClient.send(command); + + const response = { + inputEvent: event, + accountId: identity.Account, + runningLocally: process.env.IS_LOCAL === 'true', + }; + + if (process.env.IS_LOCAL === 'true') { + const fs = await import('fs'); + const path = await import('path'); + const filePath = path.join( + '..', + 'local_lambda_responses', + `${context.functionName}.json`, + ); + fs.writeFileSync(filePath, JSON.stringify(response, null, 2)); + } + + return response; +}; diff --git a/test/opentofu-basic/services/testTsEsModule/package.json b/test/opentofu-basic/services/testTsEsModule/package.json new file mode 100644 index 00000000..2e6e6632 --- /dev/null +++ b/test/opentofu-basic/services/testTsEsModule/package.json @@ -0,0 +1,11 @@ +{ + "name": "openotofu-basic-test-ts-esmodule", + "version": "1.0.0", + "type": "module", + "devDependencies": { + "@tsconfig/node20": "^20.1.4" + }, + "dependencies": { + "@aws-sdk/client-sts": "^3.577.0" + } +} diff --git a/test/opentofu-basic/services/testTsEsModule/tsconfig.json b/test/opentofu-basic/services/testTsEsModule/tsconfig.json new file mode 100755 index 00000000..42181472 --- /dev/null +++ b/test/opentofu-basic/services/testTsEsModule/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "@tsconfig/node20/tsconfig.json", + "compilerOptions": { + "module": "esnext", + "moduleResolution": "node", + "outDir": "./dist" + } +} diff --git a/test/terraform-basic.test.ts b/test/terraform-basic.test.ts index da8c0619..e6d76bfb 100644 --- a/test/terraform-basic.test.ts +++ b/test/terraform-basic.test.ts @@ -103,6 +103,22 @@ describe('terraform-basic', async () => { } }); + test('call Lambda - testJsCommonJs_3', async () => { + const lambdaName = await getFunctionName( + folder, + 'lambda-test-js-commonjs_3_name', + ); + + const payload = getSamplePayload(lambdaName); + const response = await callLambda(lambdaName, payload); + + expect(response.inputEvent).toEqual(payload); + expect(response.runningLocally).toEqual(!observableMode); + if (observableMode) { + await validateLocalResponse(lambdaName, payload); + } + }); + test('call Lambda - testJsEsModule', async () => { const lambdaName = await getFunctionName( folder, diff --git a/test/terraform-basic/.gitignore b/test/terraform-basic/.gitignore index 9c182f71..7854b899 100644 --- a/test/terraform-basic/.gitignore +++ b/test/terraform-basic/.gitignore @@ -36,5 +36,6 @@ hello-world.zip response.json dist +builds .terraform.lock.hcl terraform-outputs.json \ No newline at end of file diff --git a/test/terraform-basic/main.tf b/test/terraform-basic/main.tf index aa4c0c72..767cd340 100644 --- a/test/terraform-basic/main.tf +++ b/test/terraform-basic/main.tf @@ -11,7 +11,7 @@ terraform { } resource "aws_iam_role" "lambda_role" { - name = "lambda_execution_role" + name = "lld-terraform-basic-lambda_execution_role" assume_role_policy = jsonencode({ Version = "2012-10-17" @@ -87,6 +87,16 @@ resource "aws_lambda_function" "test-js-commonjs_2" { role = aws_iam_role.lambda_role.arn } +module "test-js-commonjs_3" { + source = "terraform-aws-modules/lambda/aws" + + function_name = "lld-terraform-basic-test-js-commonjs_3" + handler = "lambda.lambdaHandler" + runtime = "nodejs22.x" + + source_path = "services/testJsCommonJs" +} + // services/testJsEsModule/lambda.js data "archive_file" "test-js-esmodule_zip" { type = "zip" @@ -173,6 +183,10 @@ output "lambda-test-js-commonjs_2_name" { value = aws_lambda_function.test-js-commonjs_2.function_name } +output "lambda-test-js-commonjs_3_name" { + value = module.test-js-commonjs_3.lambda_function_name +} + output "lambda-test-js-esmodule_name" { value = aws_lambda_function.test-js-esmodule.function_name } diff --git a/test/terraform-basic/package.json b/test/terraform-basic/package.json index 9ab3c5e5..609ab840 100644 --- a/test/terraform-basic/package.json +++ b/test/terraform-basic/package.json @@ -9,7 +9,7 @@ "build_test-ts-commonjs": "cd services/testTsCommonJs && npx tsc && cp package.json ./dist && cd ../..", "build": "npm run build_test-ts-esmodule && npm run build_test-ts-commonjs", "deploy": "npm run build && terraform apply -auto-approve && terraform output -json > terraform-outputs.json", - "destroy": "terraform destroy -auto-approve" + "destroy": "npm run build && terraform destroy -auto-approve" }, "keywords": [], "author": "",