diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile new file mode 100644 index 0000000..477f0a5 --- /dev/null +++ b/.devcontainer/Dockerfile @@ -0,0 +1,4 @@ +FROM mcr.microsoft.com/devcontainers/base:bookworm + +# Install necessary dependencies +RUN apt-get update -y && rm -rf /var/lib/apt/lists/* diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000..0dcbd3d --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,21 @@ +{ + "name": "Terraform Development Container", + "build": { + "dockerfile": "Dockerfile" + }, + "features": { + "ghcr.io/devcontainers/features/terraform:1": { + "version": "latest" + }, + "ghcr.io/dhoeric/features/terraform-docs:1": {} + }, + "customizations": { + "vscode": { + "extensions": [ + "hashicorp.terraform" + ] + } + }, + "postCreateCommand": "", + "remoteUser": "vscode" +} diff --git a/.github/workflows/commitmsg-conform.yml b/.github/workflows/commitmsg-conform.yml new file mode 100644 index 0000000..4940385 --- /dev/null +++ b/.github/workflows/commitmsg-conform.yml @@ -0,0 +1,14 @@ +name: Commit Message Conformance + +on: + pull_request: {} + +permissions: + statuses: write + checks: write + contents: read + pull-requests: read + +jobs: + commitmsg-conform: + uses: actionsforge/actions/.github/workflows/commitmsg-conform.yml@main diff --git a/.github/workflows/markdown-lint.yml b/.github/workflows/markdown-lint.yml new file mode 100644 index 0000000..034b809 --- /dev/null +++ b/.github/workflows/markdown-lint.yml @@ -0,0 +1,14 @@ +name: Markdown Lint + +on: + pull_request: {} + +permissions: + statuses: write + checks: write + contents: read + pull-requests: read + +jobs: + markdown-lint: + uses: actionsforge/actions/.github/workflows/markdown-lint.yml@main diff --git a/.github/workflows/terraform-docs.yml b/.github/workflows/terraform-docs.yml new file mode 100644 index 0000000..56aa648 --- /dev/null +++ b/.github/workflows/terraform-docs.yml @@ -0,0 +1,13 @@ +name: Generate terraform docs + +on: + push: + branches: + - main + +permissions: + contents: write + +jobs: + terraform-docs: + uses: actionsforge/actions/.github/workflows/terraform-docs.yml@main diff --git a/.github/workflows/terraform-lint-validate.yml b/.github/workflows/terraform-lint-validate.yml new file mode 100644 index 0000000..915f136 --- /dev/null +++ b/.github/workflows/terraform-lint-validate.yml @@ -0,0 +1,13 @@ +name: Terraform Lint & Validate + +on: + pull_request: {} +permissions: + statuses: write + checks: write + contents: read + pull-requests: read + +jobs: + terraform-lint-validate: + uses: actionsforge/actions/.github/workflows/terraform-lint-validate.yml@main diff --git a/.github/workflows/terraform-tag-and-release.yml b/.github/workflows/terraform-tag-and-release.yml new file mode 100644 index 0000000..7d74bb3 --- /dev/null +++ b/.github/workflows/terraform-tag-and-release.yml @@ -0,0 +1,10 @@ +name: Terraform Tag and Release +on: + push: + branches: + - main +permissions: + contents: write +jobs: + terraform-tag-and-release: + uses: actionsforge/actions/.github/workflows/terraform-tag-and-release.yml@main diff --git a/.terraform.lock.hcl b/.terraform.lock.hcl new file mode 100644 index 0000000..6556681 --- /dev/null +++ b/.terraform.lock.hcl @@ -0,0 +1,63 @@ +# This file is maintained automatically by "terraform init". +# Manual edits may be lost in future updates. + +provider "registry.terraform.io/hashicorp/archive" { + version = "2.7.1" + hashes = [ + "h1:62VrkalDPMKB9zerCBS4iKTbvxejwnAWn/XXYZZQWD4=", + "zh:19881bb356a4a656a865f48aee70c0b8a03c35951b7799b6113883f67f196e8e", + "zh:2fcfbf6318dd514863268b09bbe19bfc958339c636bcbcc3664b45f2b8bf5cc6", + "zh:3323ab9a504ce0a115c28e64d0739369fe85151291a2ce480d51ccbb0c381ac5", + "zh:362674746fb3da3ab9bd4e70c75a3cdd9801a6cf258991102e2c46669cf68e19", + "zh:7140a46d748fdd12212161445c46bbbf30a3f4586c6ac97dd497f0c2565fe949", + "zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3", + "zh:875e6ce78b10f73b1efc849bfcc7af3a28c83a52f878f503bb22776f71d79521", + "zh:b872c6ed24e38428d817ebfb214da69ea7eefc2c38e5a774db2ccd58e54d3a22", + "zh:cd6a44f731c1633ae5d37662af86e7b01ae4c96eb8b04144255824c3f350392d", + "zh:e0600f5e8da12710b0c52d6df0ba147a5486427c1a2cc78f31eea37a47ee1b07", + "zh:f21b2e2563bbb1e44e73557bcd6cdbc1ceb369d471049c40eb56cb84b6317a60", + "zh:f752829eba1cc04a479cf7ae7271526b402e206d5bcf1fcce9f535de5ff9e4e6", + ] +} + +provider "registry.terraform.io/hashicorp/aws" { + version = "5.98.0" + constraints = ">= 4.0.0" + hashes = [ + "h1:KgOCdSG6euSc2lquuFlISJU/CzQTRhAO7WoaASxLZRc=", + "zh:23377bd90204b6203b904f48f53edcae3294eb072d8fc18a4531c0cde531a3a1", + "zh:2e55a6ea14cc43b08cf82d43063e96c5c2f58ee953c2628523d0ee918fe3b609", + "zh:4885a817c16fdaaeddc5031edc9594c1f300db0e5b23be7cd76a473e7dcc7b4f", + "zh:6ca7177ad4e5c9d93dee4be1ac0792b37107df04657fddfe0c976f36abdd18b5", + "zh:78bf8eb0a67bae5dede09666676c7a38c9fb8d1b80a90ba06cf36ae268257d6f", + "zh:874b5a99457a3f88e2915df8773120846b63d820868a8f43082193f3dc84adcb", + "zh:95e1e4cf587cde4537ac9dfee9e94270652c812ab31fce3a431778c053abf354", + "zh:9b12af85486a96aedd8d7984b0ff811a4b42e3d88dad1a3fb4c0b580d04fa425", + "zh:a75145b58b241d64570803e6565c72467cd664633df32678755b51871f553e50", + "zh:aa31b13d0b0e8432940d6892a48b6268721fa54a02ed62ee42745186ee32f58d", + "zh:ae4565770f76672ce8e96528cbb66afdade1f91383123c079c7fdeafcb3d2877", + "zh:b99f042c45bf6aa69dd73f3f6d9cbe0b495b30442c526e0b3810089c059ba724", + "zh:bbb38e86d926ef101cefafe8fe090c57f2b1356eac9fc5ec81af310c50375897", + "zh:d03c89988ba4a0bd3cfc8659f951183ae7027aa8018a7ca1e53a300944af59cb", + "zh:d179ef28843fe663fc63169291a211898199009f0d3f63f0a6f65349e77727ec", + ] +} + +provider "registry.terraform.io/hashicorp/random" { + version = "3.7.2" + hashes = [ + "h1:356j/3XnXEKr9nyicLUufzoF4Yr6hRy481KIxRVpK0c=", + "zh:14829603a32e4bc4d05062f059e545a91e27ff033756b48afbae6b3c835f508f", + "zh:1527fb07d9fea400d70e9e6eb4a2b918d5060d604749b6f1c361518e7da546dc", + "zh:1e86bcd7ebec85ba336b423ba1db046aeaa3c0e5f921039b3f1a6fc2f978feab", + "zh:24536dec8bde66753f4b4030b8f3ef43c196d69cccbea1c382d01b222478c7a3", + "zh:29f1786486759fad9b0ce4fdfbbfece9343ad47cd50119045075e05afe49d212", + "zh:4d701e978c2dd8604ba1ce962b047607701e65c078cb22e97171513e9e57491f", + "zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3", + "zh:7b8434212eef0f8c83f5a90c6d76feaf850f6502b61b53c329e85b3b281cba34", + "zh:ac8a23c212258b7976e1621275e3af7099e7e4a3d4478cf8d5d2a27f3bc3e967", + "zh:b516ca74431f3df4c6cf90ddcdb4042c626e026317a33c53f0b445a3d93b720d", + "zh:dc76e4326aec2490c1600d6871a95e78f9050f9ce427c71707ea412a2f2f1a62", + "zh:eac7b63e86c749c7d48f527671c7aee5b4e26c10be6ad7232d6860167f99dbb0", + ] +} diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000..8d4eadd --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,14 @@ +{ + "recommendations": [ + "davidanson.vscode-markdownlint", + "eamodio.gitlens", + "esbenp.prettier-vscode", + "foxundermoon.shell-format", + "Gruntfuggly.todo-tree", + "hashicorp.terraform", + "mhutchie.git-graph", + "streetsidesoftware.code-spell-checker", + "usernamehw.errorlens", + "vscode-icons-team.vscode-icons" + ] +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..e081787 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,52 @@ +{ + "files.associations": { + "*.dockerfile": "dockerfile", + "*.sh.tpl": "shellscript", + "docker-compose*.yml": "yaml", + "Dockerfile*": "dockerfile" + }, + "files.exclude": { + "**/.git": true, + "**/.svn": true, + "**/.hg": true, + "**/CVS": true, + "**/.DS_Store": true, + "__debug_bin": true, + "vendor/": true, + "go.sum": true + }, + "files.trimTrailingWhitespace": true, + "files.trimFinalNewlines": true, + "files.insertFinalNewline": true, + "remote.extensionKind": { + "ms-azuretools.vscode-docker": "ui", + "ms-vscode-remote.remote-containers": "ui" + }, + "editor.formatOnSave": true, + "editor.formatOnPaste": true, + "prettier.requireConfig": true, + "workbench.iconTheme": "vscode-icons", + "[css]": { + "editor.defaultFormatter": "vscode.css-language-features", + "editor.foldingStrategy": "indentation" + }, + "[dockerfile]": { + "editor.defaultFormatter": "ms-azuretools.vscode-docker" + }, + "[html]": { + "editor.defaultFormatter": "vscode.html-language-features" + }, + "[json]": { + "editor.formatOnSave": true, + "editor.defaultFormatter": "esbenp.prettier-vscode" + }, + "[shellscript]": { + "editor.defaultFormatter": "foxundermoon.shell-format" + }, +"[terraform]": { + "editor.formatOnSave": true, + "editor.defaultFormatter": "hashicorp.terraform", + "editor.tabSize": 2, + "editor.insertSpaces": true +} +} diff --git a/README.md b/README.md index 87e62f7..95fb02e 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,6 @@ # terraform-aws-lambda-versioned -Terraform module for versioned AWS Lambda deployments using S3 and aliases + +Terraform module for versioned AWS Lambda deployments supporting both zip packages from S3 and container images from ECR. + + + diff --git a/examples/image/.terraform.lock.hcl b/examples/image/.terraform.lock.hcl new file mode 100644 index 0000000..f3f65bc --- /dev/null +++ b/examples/image/.terraform.lock.hcl @@ -0,0 +1,62 @@ +# This file is maintained automatically by "terraform init". +# Manual edits may be lost in future updates. + +provider "registry.terraform.io/hashicorp/archive" { + version = "2.7.1" + hashes = [ + "h1:62VrkalDPMKB9zerCBS4iKTbvxejwnAWn/XXYZZQWD4=", + "zh:19881bb356a4a656a865f48aee70c0b8a03c35951b7799b6113883f67f196e8e", + "zh:2fcfbf6318dd514863268b09bbe19bfc958339c636bcbcc3664b45f2b8bf5cc6", + "zh:3323ab9a504ce0a115c28e64d0739369fe85151291a2ce480d51ccbb0c381ac5", + "zh:362674746fb3da3ab9bd4e70c75a3cdd9801a6cf258991102e2c46669cf68e19", + "zh:7140a46d748fdd12212161445c46bbbf30a3f4586c6ac97dd497f0c2565fe949", + "zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3", + "zh:875e6ce78b10f73b1efc849bfcc7af3a28c83a52f878f503bb22776f71d79521", + "zh:b872c6ed24e38428d817ebfb214da69ea7eefc2c38e5a774db2ccd58e54d3a22", + "zh:cd6a44f731c1633ae5d37662af86e7b01ae4c96eb8b04144255824c3f350392d", + "zh:e0600f5e8da12710b0c52d6df0ba147a5486427c1a2cc78f31eea37a47ee1b07", + "zh:f21b2e2563bbb1e44e73557bcd6cdbc1ceb369d471049c40eb56cb84b6317a60", + "zh:f752829eba1cc04a479cf7ae7271526b402e206d5bcf1fcce9f535de5ff9e4e6", + ] +} + +provider "registry.terraform.io/hashicorp/aws" { + version = "5.98.0" + hashes = [ + "h1:KgOCdSG6euSc2lquuFlISJU/CzQTRhAO7WoaASxLZRc=", + "zh:23377bd90204b6203b904f48f53edcae3294eb072d8fc18a4531c0cde531a3a1", + "zh:2e55a6ea14cc43b08cf82d43063e96c5c2f58ee953c2628523d0ee918fe3b609", + "zh:4885a817c16fdaaeddc5031edc9594c1f300db0e5b23be7cd76a473e7dcc7b4f", + "zh:6ca7177ad4e5c9d93dee4be1ac0792b37107df04657fddfe0c976f36abdd18b5", + "zh:78bf8eb0a67bae5dede09666676c7a38c9fb8d1b80a90ba06cf36ae268257d6f", + "zh:874b5a99457a3f88e2915df8773120846b63d820868a8f43082193f3dc84adcb", + "zh:95e1e4cf587cde4537ac9dfee9e94270652c812ab31fce3a431778c053abf354", + "zh:9b12af85486a96aedd8d7984b0ff811a4b42e3d88dad1a3fb4c0b580d04fa425", + "zh:a75145b58b241d64570803e6565c72467cd664633df32678755b51871f553e50", + "zh:aa31b13d0b0e8432940d6892a48b6268721fa54a02ed62ee42745186ee32f58d", + "zh:ae4565770f76672ce8e96528cbb66afdade1f91383123c079c7fdeafcb3d2877", + "zh:b99f042c45bf6aa69dd73f3f6d9cbe0b495b30442c526e0b3810089c059ba724", + "zh:bbb38e86d926ef101cefafe8fe090c57f2b1356eac9fc5ec81af310c50375897", + "zh:d03c89988ba4a0bd3cfc8659f951183ae7027aa8018a7ca1e53a300944af59cb", + "zh:d179ef28843fe663fc63169291a211898199009f0d3f63f0a6f65349e77727ec", + ] +} + +provider "registry.terraform.io/hashicorp/random" { + version = "3.7.2" + hashes = [ + "h1:356j/3XnXEKr9nyicLUufzoF4Yr6hRy481KIxRVpK0c=", + "zh:14829603a32e4bc4d05062f059e545a91e27ff033756b48afbae6b3c835f508f", + "zh:1527fb07d9fea400d70e9e6eb4a2b918d5060d604749b6f1c361518e7da546dc", + "zh:1e86bcd7ebec85ba336b423ba1db046aeaa3c0e5f921039b3f1a6fc2f978feab", + "zh:24536dec8bde66753f4b4030b8f3ef43c196d69cccbea1c382d01b222478c7a3", + "zh:29f1786486759fad9b0ce4fdfbbfece9343ad47cd50119045075e05afe49d212", + "zh:4d701e978c2dd8604ba1ce962b047607701e65c078cb22e97171513e9e57491f", + "zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3", + "zh:7b8434212eef0f8c83f5a90c6d76feaf850f6502b61b53c329e85b3b281cba34", + "zh:ac8a23c212258b7976e1621275e3af7099e7e4a3d4478cf8d5d2a27f3bc3e967", + "zh:b516ca74431f3df4c6cf90ddcdb4042c626e026317a33c53f0b445a3d93b720d", + "zh:dc76e4326aec2490c1600d6871a95e78f9050f9ce427c71707ea412a2f2f1a62", + "zh:eac7b63e86c749c7d48f527671c7aee5b4e26c10be6ad7232d6860167f99dbb0", + ] +} diff --git a/examples/image/Dockerfile b/examples/image/Dockerfile new file mode 100644 index 0000000..a917cc9 --- /dev/null +++ b/examples/image/Dockerfile @@ -0,0 +1,5 @@ +FROM public.ecr.aws/lambda/python:3.11 + +COPY app.py ${LAMBDA_TASK_ROOT} + +CMD [ "app.handler" ] diff --git a/examples/image/README.md b/examples/image/README.md new file mode 100644 index 0000000..b5fee65 --- /dev/null +++ b/examples/image/README.md @@ -0,0 +1,61 @@ +# Terraform AWS Lambda Container Image Deployment Example + +This example demonstrates how to deploy an AWS Lambda function using a container image from ECR, with versioning support. + +## Features + +- **Container Deployment**: Deploys Lambda function using container images +- **ECR Integration**: Uses ECR for container image storage +- **IAM Roles**: Configures execution role with Lambda and ECR permissions +- **Environment Variables**: Supports custom environment variables +- **Resource Limits**: Configurable timeout and memory settings +- **Tagging**: Supports custom resource tagging + +## Usage + +### **Initialize and Apply** + +```bash +terraform init +terraform plan +terraform apply +``` + +### **Destroy Resources** + +```bash +terraform destroy +``` + +> **Warning:** Running this example creates AWS resources that incur costs. + +## Inputs + +| Name | Description | Type | Default | +|------|-------------|------|---------| +| `function_name` | Name of the Lambda function | `string` | Required | +| `package_type` | Lambda package type | `string` | `"Image"` | +| `image_uri` | URI of the container image | `string` | Required | +| `policy_arns` | List of IAM policy ARNs to attach | `list(string)` | `[]` | +| `timeout` | Function timeout in seconds | `number` | `3` | +| `memory_size` | Function memory in MB | `number` | `128` | +| `environment_variables` | Environment variables for function | `map(string)` | `{}` | +| `tags` | Resource tags | `map(string)` | `{}` | + +## Outputs + +| Name | Description | +|------|-------------| +| `function_name` | Name of the Lambda function | +| `function_arn` | ARN of the Lambda function | +| `function_invoke_arn` | Invoke ARN of the Lambda function | +| `role_arn` | ARN of the Lambda execution role | + +## Resources Created + +- **Lambda Function** with container image deployment +- **IAM Role** for Lambda execution +- **IAM Policy Attachments** for Lambda and ECR permissions +- **CloudWatch Log Group** for function logs + +This example provides a **complete Lambda deployment** using container images from ECR. diff --git a/examples/image/app.py b/examples/image/app.py new file mode 100644 index 0000000..2bfa28e --- /dev/null +++ b/examples/image/app.py @@ -0,0 +1,11 @@ +import os +import json + +def handler(event, context): + version = os.environ.get("AWS_LAMBDA_FUNCTION_VERSION", "$LATEST") + print(f"Lambda version: {version}") + + return { + "statusCode": 200, + "body": json.dumps({ "version": version }) + } diff --git a/examples/image/external/hello.zip b/examples/image/external/hello.zip new file mode 100644 index 0000000..cd1f844 Binary files /dev/null and b/examples/image/external/hello.zip differ diff --git a/examples/image/external/source/Dockerfile b/examples/image/external/source/Dockerfile new file mode 100644 index 0000000..4aacb8a --- /dev/null +++ b/examples/image/external/source/Dockerfile @@ -0,0 +1,2 @@ +FROM busybox +CMD tail -f /dev/null diff --git a/examples/image/external/source/buildspec.yml b/examples/image/external/source/buildspec.yml new file mode 100644 index 0000000..fb1df62 --- /dev/null +++ b/examples/image/external/source/buildspec.yml @@ -0,0 +1,21 @@ +version: 0.2 + +phases: + pre_build: + commands: + - echo Checking for Dockerfile... + - cat Dockerfile || echo "Dockerfile not found!" + - echo Logging into Amazon ECR... + - aws ecr get-login-password --region $AWS_REGION | docker login --username AWS --password-stdin $ECR_REPO + + build: + commands: + - echo Building Docker image... + - docker build -t $ECR_REPO:latest -f Dockerfile . + - docker tag $ECR_REPO:latest $ECR_REPO:$CODEBUILD_RESOLVED_SOURCE_VERSION + + post_build: + commands: + - echo Pushing Docker image... + - docker push $ECR_REPO:latest + - docker push $ECR_REPO:$CODEBUILD_RESOLVED_SOURCE_VERSION diff --git a/examples/image/main.tf b/examples/image/main.tf new file mode 100644 index 0000000..3d0d1f3 --- /dev/null +++ b/examples/image/main.tf @@ -0,0 +1,113 @@ +provider "aws" { + region = "ap-southeast-2" +} + +# Generate a random suffix for uniqueness +resource "random_string" "suffix" { + length = 8 + special = false + upper = false +} + +locals { + repository_name = "demo-test" + base_name = "${local.repository_name}-${random_string.suffix.result}" + app_name = "hello" + + tags = { + Environment = "dev" + Project = "example-project" + } +} + +module "ecrbuild" { + source = "tfstack/ecrbuild/aws" + + # Naming & Resource Identifiers + repository_name = local.repository_name + app_name = local.app_name + suffix = random_string.suffix.result + repository_force_delete = true + + # Application & Storage Configuration + container_source_path = "${path.module}/external/source" + container_archive_path = "${path.module}/external" + log_retention_days = 30 + + # CodeBuild Configuration + codebuild_timeout = 10 + codebuild_compute_type = "BUILD_GENERAL1_SMALL" + codebuild_image = "aws/codebuild/standard:5.0" + codebuild_environment_type = "LINUX_CONTAINER" + codebuild_buildspec = "buildspec.yml" + + codebuild_env_vars = { + ENVIRONMENT = "dev" + PROJECT = "example-project" + } + + # CodePipeline Configuration + codepipeline_stages = [ + { + name = "Source" + actions = [ + { + name = "S3-Source" + category = "Source" + owner = "AWS" + provider = "S3" + version = "1" + output_artifacts = ["source-output"] + configuration = { + S3Bucket = local.base_name + S3ObjectKey = "${local.app_name}.zip" + PollForSourceChanges = "true" + } + } + ] + }, + { + name = "Build" + actions = [ + { + name = "Build-Docker-Image" + category = "Build" + owner = "AWS" + provider = "CodeBuild" + version = "1" + input_artifacts = ["source-output"] + configuration = { + ProjectName = local.base_name + } + } + ] + } + ] + + # Tags + tags = local.tags +} + +# Create the Lambda function using a pre-built image +module "lambda" { + source = "../../" + + function_name = "hello-${random_string.suffix.result}" + package_type = "Image" + image_uri = "${module.ecrbuild.ecr_repository_url}:latest" + + policy_arns = [ + "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole", + "arn:aws:iam::aws:policy/AmazonEC2ContainerRegistryReadOnly" + ] + + environment_variables = { + ENVIRONMENT = local.tags.Environment + } + + tags = local.tags +} + +output "lambda" { + value = module.lambda +} diff --git a/examples/zip/.terraform.lock.hcl b/examples/zip/.terraform.lock.hcl new file mode 100644 index 0000000..0e217ec --- /dev/null +++ b/examples/zip/.terraform.lock.hcl @@ -0,0 +1,79 @@ +# This file is maintained automatically by "terraform init". +# Manual edits may be lost in future updates. + +provider "registry.terraform.io/hashicorp/archive" { + version = "2.7.1" + hashes = [ + "h1:62VrkalDPMKB9zerCBS4iKTbvxejwnAWn/XXYZZQWD4=", + "zh:19881bb356a4a656a865f48aee70c0b8a03c35951b7799b6113883f67f196e8e", + "zh:2fcfbf6318dd514863268b09bbe19bfc958339c636bcbcc3664b45f2b8bf5cc6", + "zh:3323ab9a504ce0a115c28e64d0739369fe85151291a2ce480d51ccbb0c381ac5", + "zh:362674746fb3da3ab9bd4e70c75a3cdd9801a6cf258991102e2c46669cf68e19", + "zh:7140a46d748fdd12212161445c46bbbf30a3f4586c6ac97dd497f0c2565fe949", + "zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3", + "zh:875e6ce78b10f73b1efc849bfcc7af3a28c83a52f878f503bb22776f71d79521", + "zh:b872c6ed24e38428d817ebfb214da69ea7eefc2c38e5a774db2ccd58e54d3a22", + "zh:cd6a44f731c1633ae5d37662af86e7b01ae4c96eb8b04144255824c3f350392d", + "zh:e0600f5e8da12710b0c52d6df0ba147a5486427c1a2cc78f31eea37a47ee1b07", + "zh:f21b2e2563bbb1e44e73557bcd6cdbc1ceb369d471049c40eb56cb84b6317a60", + "zh:f752829eba1cc04a479cf7ae7271526b402e206d5bcf1fcce9f535de5ff9e4e6", + ] +} + +provider "registry.terraform.io/hashicorp/aws" { + version = "5.98.0" + hashes = [ + "h1:KgOCdSG6euSc2lquuFlISJU/CzQTRhAO7WoaASxLZRc=", + "zh:23377bd90204b6203b904f48f53edcae3294eb072d8fc18a4531c0cde531a3a1", + "zh:2e55a6ea14cc43b08cf82d43063e96c5c2f58ee953c2628523d0ee918fe3b609", + "zh:4885a817c16fdaaeddc5031edc9594c1f300db0e5b23be7cd76a473e7dcc7b4f", + "zh:6ca7177ad4e5c9d93dee4be1ac0792b37107df04657fddfe0c976f36abdd18b5", + "zh:78bf8eb0a67bae5dede09666676c7a38c9fb8d1b80a90ba06cf36ae268257d6f", + "zh:874b5a99457a3f88e2915df8773120846b63d820868a8f43082193f3dc84adcb", + "zh:95e1e4cf587cde4537ac9dfee9e94270652c812ab31fce3a431778c053abf354", + "zh:9b12af85486a96aedd8d7984b0ff811a4b42e3d88dad1a3fb4c0b580d04fa425", + "zh:a75145b58b241d64570803e6565c72467cd664633df32678755b51871f553e50", + "zh:aa31b13d0b0e8432940d6892a48b6268721fa54a02ed62ee42745186ee32f58d", + "zh:ae4565770f76672ce8e96528cbb66afdade1f91383123c079c7fdeafcb3d2877", + "zh:b99f042c45bf6aa69dd73f3f6d9cbe0b495b30442c526e0b3810089c059ba724", + "zh:bbb38e86d926ef101cefafe8fe090c57f2b1356eac9fc5ec81af310c50375897", + "zh:d03c89988ba4a0bd3cfc8659f951183ae7027aa8018a7ca1e53a300944af59cb", + "zh:d179ef28843fe663fc63169291a211898199009f0d3f63f0a6f65349e77727ec", + ] +} + +provider "registry.terraform.io/hashicorp/random" { + version = "3.7.2" + hashes = [ + "h1:356j/3XnXEKr9nyicLUufzoF4Yr6hRy481KIxRVpK0c=", + "zh:14829603a32e4bc4d05062f059e545a91e27ff033756b48afbae6b3c835f508f", + "zh:1527fb07d9fea400d70e9e6eb4a2b918d5060d604749b6f1c361518e7da546dc", + "zh:1e86bcd7ebec85ba336b423ba1db046aeaa3c0e5f921039b3f1a6fc2f978feab", + "zh:24536dec8bde66753f4b4030b8f3ef43c196d69cccbea1c382d01b222478c7a3", + "zh:29f1786486759fad9b0ce4fdfbbfece9343ad47cd50119045075e05afe49d212", + "zh:4d701e978c2dd8604ba1ce962b047607701e65c078cb22e97171513e9e57491f", + "zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3", + "zh:7b8434212eef0f8c83f5a90c6d76feaf850f6502b61b53c329e85b3b281cba34", + "zh:ac8a23c212258b7976e1621275e3af7099e7e4a3d4478cf8d5d2a27f3bc3e967", + "zh:b516ca74431f3df4c6cf90ddcdb4042c626e026317a33c53f0b445a3d93b720d", + "zh:dc76e4326aec2490c1600d6871a95e78f9050f9ce427c71707ea412a2f2f1a62", + "zh:eac7b63e86c749c7d48f527671c7aee5b4e26c10be6ad7232d6860167f99dbb0", + ] +} + +provider "registry.terraform.io/hashicorp/template" { + version = "2.2.0" + hashes = [ + "h1:94qn780bi1qjrbC3uQtjJh3Wkfwd5+tTtJHOb7KTg9w=", + "zh:01702196f0a0492ec07917db7aaa595843d8f171dc195f4c988d2ffca2a06386", + "zh:09aae3da826ba3d7df69efeb25d146a1de0d03e951d35019a0f80e4f58c89b53", + "zh:09ba83c0625b6fe0a954da6fbd0c355ac0b7f07f86c91a2a97849140fea49603", + "zh:0e3a6c8e16f17f19010accd0844187d524580d9fdb0731f675ffcf4afba03d16", + "zh:45f2c594b6f2f34ea663704cc72048b212fe7d16fb4cfd959365fa997228a776", + "zh:77ea3e5a0446784d77114b5e851c970a3dde1e08fa6de38210b8385d7605d451", + "zh:8a154388f3708e3df5a69122a23bdfaf760a523788a5081976b3d5616f7d30ae", + "zh:992843002f2db5a11e626b3fc23dc0c87ad3729b3b3cff08e32ffb3df97edbde", + "zh:ad906f4cebd3ec5e43d5cd6dc8f4c5c9cc3b33d2243c89c5fc18f97f7277b51d", + "zh:c979425ddb256511137ecd093e23283234da0154b7fa8b21c2687182d9aea8b2", + ] +} diff --git a/examples/zip/README.md b/examples/zip/README.md new file mode 100644 index 0000000..45c0942 --- /dev/null +++ b/examples/zip/README.md @@ -0,0 +1,64 @@ +# Terraform AWS Lambda Zip Deployment Example + +This example demonstrates how to deploy an AWS Lambda function using a zip package from S3, with versioning support. + +## Features + +- **S3 Deployment**: Deploys Lambda function code from an S3 bucket +- **Versioning**: Supports S3 object versioning for rollback capabilities +- **IAM Roles**: Configures execution role with basic Lambda permissions +- **Environment Variables**: Supports custom environment variables +- **Resource Limits**: Configurable timeout and memory settings +- **Tagging**: Supports custom resource tagging + +## Usage + +### **Initialize and Apply** + +```bash +terraform init +terraform plan +terraform apply +``` + +### **Destroy Resources** + +```bash +terraform destroy +``` + +> **Warning:** Running this example creates AWS resources that incur costs. + +## Inputs + +| Name | Description | Type | Default | +|------|-------------|------|---------| +| `function_name` | Name of the Lambda function | `string` | Required | +| `handler` | Function entrypoint | `string` | Required | +| `runtime` | Lambda runtime | `string` | Required | +| `s3_bucket` | S3 bucket containing function code | `string` | Required | +| `s3_key` | S3 key of function code | `string` | Required | +| `s3_object_version` | Version ID of S3 object | `string` | Required | +| `policy_arns` | List of IAM policy ARNs to attach | `list(string)` | `[]` | +| `timeout` | Function timeout in seconds | `number` | `3` | +| `memory_size` | Function memory in MB | `number` | `128` | +| `environment_variables` | Environment variables for function | `map(string)` | `{}` | +| `tags` | Resource tags | `map(string)` | `{}` | + +## Outputs + +| Name | Description | +|------|-------------| +| `function_name` | Name of the Lambda function | +| `function_arn` | ARN of the Lambda function | +| `function_invoke_arn` | Invoke ARN of the Lambda function | +| `role_arn` | ARN of the Lambda execution role | + +## Resources Created + +- **Lambda Function** with zip package deployment +- **IAM Role** for Lambda execution +- **IAM Policy Attachments** for Lambda permissions +- **CloudWatch Log Group** for function logs + +This example provides a **complete Lambda deployment** with versioning support using S3. diff --git a/examples/zip/external/hello.zip b/examples/zip/external/hello.zip new file mode 100644 index 0000000..dbe79d2 Binary files /dev/null and b/examples/zip/external/hello.zip differ diff --git a/examples/zip/external/lambda/hello.js b/examples/zip/external/lambda/hello.js new file mode 100644 index 0000000..f2c8353 --- /dev/null +++ b/examples/zip/external/lambda/hello.js @@ -0,0 +1,9 @@ +module.exports.handler = async () => { + const version = process.env.AWS_LAMBDA_FUNCTION_VERSION; + console.log('Lambda version:', version); + + return { + statusCode: 200, + body: JSON.stringify({ version }), + }; +}; diff --git a/examples/zip/main.tf b/examples/zip/main.tf new file mode 100644 index 0000000..c7b1cae --- /dev/null +++ b/examples/zip/main.tf @@ -0,0 +1,71 @@ +provider "aws" { + region = "ap-southeast-2" +} + +# Generate a random suffix for uniqueness +resource "random_string" "suffix" { + length = 8 + special = false + upper = false +} + +locals { + tags = { + Environment = "dev" + Project = "example-project" + } +} + +# Convert code to zip file +data "archive_file" "hello" { + type = "zip" + output_path = "${path.module}/external/hello.zip" + + source { + content = file("${path.module}/external/lambda/hello.js") + filename = "hello.js" + } +} + +module "s3_bucket" { + source = "tfstack/s3/aws" + + bucket_name = "lambda-zips" + bucket_suffix = random_string.suffix.result + enable_versioning = true + + tags = local.tags +} + +resource "aws_s3_object" "hello" { + bucket = module.s3_bucket.bucket_id + key = "hello.zip" + source = data.archive_file.hello.output_path + etag = data.archive_file.hello.output_md5 +} + +module "lambda" { + source = "../../" + + function_name = "hello-${random_string.suffix.result}" + handler = "hello.handler" + runtime = "nodejs20.x" + + s3_bucket = module.s3_bucket.bucket_id + s3_key = aws_s3_object.hello.key + s3_object_version = aws_s3_object.hello.version_id + + policy_arns = [ + "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole" + ] + + environment_variables = { + ENVIRONMENT = local.tags.Environment + } + + tags = local.tags +} + +output "lambda" { + value = module.lambda +} diff --git a/main.tf b/main.tf new file mode 100644 index 0000000..54b23d1 --- /dev/null +++ b/main.tf @@ -0,0 +1,106 @@ +locals { + # Validate package type specific requirements + validate_zip_package = var.package_type == "Zip" ? ( + var.handler != null && + var.runtime != null && + var.s3_bucket != null && + var.s3_key != null && + var.s3_object_version != null + ) : true + + validate_image_package = var.package_type == "Image" ? ( + var.image_uri != null + ) : true +} + +resource "aws_iam_role" "lambda" { + name = "${var.function_name}-role" + + assume_role_policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Action = "sts:AssumeRole" + Effect = "Allow" + Principal = { + Service = "lambda.amazonaws.com" + } + } + ] + }) + + tags = var.tags +} + +resource "aws_iam_role_policy_attachment" "lambda_policies" { + for_each = toset(var.policy_arns) + + role = aws_iam_role.lambda.name + policy_arn = each.value +} + +resource "aws_lambda_function" "this" { + function_name = var.function_name + role = aws_iam_role.lambda.arn + publish = true + + # Package type specific configuration + package_type = var.package_type + handler = var.handler + runtime = var.runtime + image_uri = var.image_uri + + # S3 configuration for zip package + s3_bucket = var.s3_bucket + s3_key = var.s3_key + s3_object_version = var.s3_object_version + + # Common configuration + description = var.description + timeout = var.timeout + memory_size = var.memory_size + reserved_concurrent_executions = var.reserved_concurrent_executions + + environment { + variables = var.environment_variables + } + + dynamic "vpc_config" { + for_each = var.vpc_config != null ? [var.vpc_config] : [] + content { + subnet_ids = vpc_config.value.subnet_ids + security_group_ids = vpc_config.value.security_group_ids + } + } + + dynamic "file_system_config" { + for_each = var.file_system_config != null ? [var.file_system_config] : [] + content { + arn = file_system_config.value.arn + local_mount_path = file_system_config.value.local_mount_path + } + } + + dynamic "tracing_config" { + for_each = var.tracing_config != null ? [var.tracing_config] : [] + content { + mode = tracing_config.value.mode + } + } + + kms_key_arn = var.kms_key_arn + + tags = var.tags + + # Validate package type specific requirements + lifecycle { + precondition { + condition = local.validate_zip_package + error_message = "For Zip package type, handler, runtime, s3_bucket, s3_key, and s3_object_version are required." + } + precondition { + condition = local.validate_image_package + error_message = "For Image package type, image_uri is required." + } + } +} diff --git a/outputs.tf b/outputs.tf new file mode 100644 index 0000000..3260ad8 --- /dev/null +++ b/outputs.tf @@ -0,0 +1,19 @@ +output "arn" { + description = "ARN of the Lambda function" + value = aws_lambda_function.this.arn +} + +output "name" { + description = "Name of the Lambda function" + value = aws_lambda_function.this.function_name +} + +output "latest_version" { + description = "Latest published version of the Lambda function" + value = aws_lambda_function.this.version +} + +output "lambda_role_arn" { + description = "ARN of the Lambda IAM role" + value = aws_iam_role.lambda.arn +} diff --git a/tests/lambda-versioned.tftest.hcl b/tests/lambda-versioned.tftest.hcl new file mode 100644 index 0000000..91b4835 --- /dev/null +++ b/tests/lambda-versioned.tftest.hcl @@ -0,0 +1,87 @@ +run "setup" { + module { + source = "./tests/setup" + } +} + +run "test_lambda_versioned" { + variables { + function_name = "test-${run.setup.suffix}" + handler = "hello.handler" + runtime = "nodejs20.x" + + s3_bucket = run.setup.s3_bucket + s3_key = run.setup.s3_key + s3_object_version = run.setup.s3_object_version + + policy_arns = [ + "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole" + ] + + tags = { + Environment = "test" + Project = "test" + } + } + + # Assertions for Lambda Function + assert { + condition = aws_lambda_function.this.function_name == "test-${run.setup.suffix}" + error_message = "Lambda function name does not match expected value." + } + + assert { + condition = aws_lambda_function.this.handler == "hello.handler" + error_message = "Lambda handler does not match expected value." + } + + assert { + condition = aws_lambda_function.this.runtime == "nodejs20.x" + error_message = "Lambda runtime does not match expected value." + } + + assert { + condition = aws_lambda_function.this.s3_bucket == run.setup.s3_bucket + error_message = "Lambda S3 bucket does not match expected value." + } + + assert { + condition = aws_lambda_function.this.s3_key == run.setup.s3_key + error_message = "Lambda S3 key does not match expected value." + } + + assert { + condition = aws_lambda_function.this.s3_object_version == run.setup.s3_object_version + error_message = "Lambda S3 object version does not match expected value." + } + + assert { + condition = length(aws_lambda_function.this.tags) > 0 + error_message = "Lambda function should have tags." + } + + assert { + condition = aws_lambda_function.this.tags["Environment"] == "test" + error_message = "Lambda tag 'Environment' does not match expected value." + } + + assert { + condition = aws_lambda_function.this.tags["Project"] == "test" + error_message = "Lambda tag 'Project' does not match expected value." + } + + assert { + condition = aws_lambda_function.this.timeout == 3 + error_message = "Lambda timeout should be 3 seconds." + } + + assert { + condition = aws_lambda_function.this.memory_size == 128 + error_message = "Lambda memory size should be 128 MB." + } + + assert { + condition = aws_lambda_function.this.role != "" + error_message = "Lambda function role should not be empty." + } +} diff --git a/tests/setup/external/hello.zip b/tests/setup/external/hello.zip new file mode 100644 index 0000000..dbe79d2 Binary files /dev/null and b/tests/setup/external/hello.zip differ diff --git a/tests/setup/external/lambda/hello.js b/tests/setup/external/lambda/hello.js new file mode 100644 index 0000000..f2c8353 --- /dev/null +++ b/tests/setup/external/lambda/hello.js @@ -0,0 +1,9 @@ +module.exports.handler = async () => { + const version = process.env.AWS_LAMBDA_FUNCTION_VERSION; + console.log('Lambda version:', version); + + return { + statusCode: 200, + body: JSON.stringify({ version }), + }; +}; diff --git a/tests/setup/main.tf b/tests/setup/main.tf new file mode 100644 index 0000000..e2f0263 --- /dev/null +++ b/tests/setup/main.tf @@ -0,0 +1,54 @@ +terraform { + required_version = ">= 1.0" +} + +# Generate a random suffix for uniqueness +resource "random_string" "suffix" { + length = 8 + special = false + upper = false +} + +# Convert code to zip file +data "archive_file" "hello" { + type = "zip" + output_path = "${path.module}/external/hello.zip" + + source { + content = file("${path.module}/external/lambda/hello.js") + filename = "hello.js" + } +} + +module "s3_bucket" { + source = "tfstack/s3/aws" + + bucket_name = "lambda-zips" + bucket_suffix = random_string.suffix.result + enable_versioning = true +} + +resource "aws_s3_object" "hello" { + bucket = module.s3_bucket.bucket_id + key = "hello.zip" + source = data.archive_file.hello.output_path + etag = data.archive_file.hello.output_md5 +} + + +# Output suffix for use in tests +output "suffix" { + value = random_string.suffix.result +} + +output "s3_bucket" { + value = module.s3_bucket.bucket_id +} + +output "s3_key" { + value = aws_s3_object.hello.key +} + +output "s3_object_version" { + value = aws_s3_object.hello.version_id +} diff --git a/variables.tf b/variables.tf new file mode 100644 index 0000000..f2f0258 --- /dev/null +++ b/variables.tf @@ -0,0 +1,158 @@ +variable "function_name" { + description = "Name of the Lambda function" + type = string + + validation { + condition = length(var.function_name) > 0 + error_message = "Function name must not be empty." + } +} + +variable "handler" { + description = "Lambda function handler (required for zip package type)" + type = string + default = null +} + +variable "runtime" { + description = "Lambda function runtime (required for zip package type)" + type = string + default = null +} + +variable "package_type" { + description = "Lambda deployment package type. Valid values are Zip and Image" + type = string + default = "Zip" + validation { + condition = contains(["Zip", "Image"], var.package_type) + error_message = "Package type must be either 'Zip' or 'Image'." + } +} + +# Zip package configuration +variable "s3_bucket" { + description = "S3 bucket containing the Lambda function code (required for zip package type)" + type = string + default = null +} + +variable "s3_key" { + description = "S3 key of the Lambda function code (required for zip package type)" + type = string + default = null +} + +variable "s3_object_version" { + description = "S3 object version of the Lambda function code (required for zip package type)" + type = string + default = null +} + +# Image package configuration +variable "image_uri" { + description = "ECR image URI containing the function's deployment package (required for image package type)" + type = string + default = null +} + +# Common Lambda configuration +variable "description" { + description = "Description of the Lambda function" + type = string + default = null +} + +variable "timeout" { + description = "Lambda function timeout in seconds" + type = number + default = 3 + + validation { + condition = var.timeout >= 1 && var.timeout <= 900 + error_message = "Timeout must be between 1 and 900 seconds." + } +} + +variable "memory_size" { + description = "Lambda function memory size in MB" + type = number + default = 128 + + validation { + condition = contains([128, 256, 512, 1024, 2048, 3008], var.memory_size) + error_message = "Memory size must be one of the supported values: 128, 256, 512, 1024, 2048, 3008." + } +} + +variable "environment_variables" { + description = "Environment variables for the Lambda function" + type = map(string) + default = {} +} + +variable "kms_key_arn" { + description = "ARN of the KMS key used to encrypt environment variables" + type = string + default = null +} + +variable "vpc_config" { + description = "VPC configuration for the Lambda function" + type = object({ + subnet_ids = list(string) + security_group_ids = list(string) + }) + default = null +} + +variable "file_system_config" { + description = "File system configuration for the Lambda function" + type = object({ + arn = string + local_mount_path = string + }) + default = null +} + +variable "tracing_config" { + description = "Tracing configuration for the Lambda function" + type = object({ + mode = string + }) + default = null + + validation { + condition = var.tracing_config == null ? true : contains(["Active", "PassThrough", "Disabled"], var.tracing_config.mode) + error_message = "If provided, tracing_config.mode must be one of: Active, PassThrough, Disabled." + } +} + +variable "reserved_concurrent_executions" { + description = "Number of reserved concurrent executions for the Lambda function (-1 disables)" + type = number + default = -1 + validation { + condition = var.reserved_concurrent_executions >= -1 + error_message = "Reserved concurrency must be -1 (disabled) or a non-negative number." + } +} + +variable "policy_arns" { + description = "List of IAM policy ARNs to attach to the Lambda function role" + type = list(string) + default = [] + validation { + condition = alltrue([ + for arn in var.policy_arns : + can(regex("^arn:aws:iam::(aws|\\d{12}):policy/", arn)) + ]) + error_message = "Each policy ARN must be a valid AWS or account-scoped IAM policy ARN." + } +} + +variable "tags" { + description = "Tags to apply to the Lambda function" + type = map(string) + default = {} +} diff --git a/versions.tf b/versions.tf new file mode 100644 index 0000000..97f0cf5 --- /dev/null +++ b/versions.tf @@ -0,0 +1,10 @@ +terraform { + required_version = ">= 1.0.0" + + required_providers { + aws = { + source = "hashicorp/aws" + version = ">= 4.0.0" + } + } +}