From 1d1942e17950430e33aa6789eae2265bfa51ba5d Mon Sep 17 00:00:00 2001 From: John Ajera <37360952+jajera@users.noreply.github.com> Date: Sun, 22 Mar 2026 14:17:02 +0000 Subject: [PATCH] feat: initial commit first release --- .github/workflows/commitmsg-conform.yml | 14 + .github/workflows/markdown-lint.yml | 14 + .github/workflows/terraform-docs.yml | 13 + .github/workflows/terraform-lint-validate.yml | 13 + .../workflows/terraform-tag-and-release.yml | 12 + .gitignore | 21 + .vscode/cspell.json | 4 + .vscode/extensions.json | 19 + .vscode/settings.json | 218 +++ README.md | 46 +- examples/basic/README.md | 39 + examples/basic/function/lambda_function.py | 18 + examples/basic/main.tf | 67 + examples/basic/outputs.tf | 30 + examples/basic/variables.tf | 41 + examples/basic/versions.tf | 14 + examples/custom-scaling/README.md | 43 + .../function/lambda_function.py | 29 + examples/custom-scaling/main.tf | 104 + examples/custom-scaling/outputs.tf | 30 + examples/custom-scaling/variables.tf | 41 + examples/custom-scaling/versions.tf | 14 + examples/waf-loki-promtail/README.md | 155 ++ .../function-waf/country-centroids.json | 1 + .../waf-loki-promtail/function-waf/index.mjs | 114 ++ .../function-waf/package-lock.json | 1673 +++++++++++++++++ .../function-waf/package.json | 6 + examples/waf-loki-promtail/main.tf | 449 +++++ examples/waf-loki-promtail/outputs.tf | 55 + .../templates/dashboards/waf-geomap.json | 107 ++ .../templates/dashboards/waf-overview.json | 145 ++ .../templates/dashboards/waf.json | 32 + .../templates/user_data.sh.tftpl | 145 ++ .../terraform.tfvars.example | 33 + examples/waf-loki-promtail/variables.tf | 84 + examples/waf-loki-promtail/versions.tf | 18 + examples/waf-loki/README.md | 141 ++ examples/waf-loki/function-waf/index.mjs | 89 + .../waf-loki/function-waf/package-lock.json | 1673 +++++++++++++++++ examples/waf-loki/function-waf/package.json | 6 + examples/waf-loki/main.tf | 373 ++++ examples/waf-loki/outputs.tf | 50 + .../templates/dashboards/waf-overview.json | 145 ++ .../waf-loki/templates/dashboards/waf.json | 32 + .../templates/promtail/waf-like-sample.jsonl | 3 + .../waf-loki/templates/user_data.sh.tftpl | 141 ++ examples/waf-loki/terraform.tfvars.example | 33 + examples/waf-loki/variables.tf | 83 + examples/waf-loki/versions.tf | 18 + main.tf | 75 + modules/lambda_managed_function/README.md | 115 ++ modules/lambda_managed_function/main.tf | 119 ++ modules/lambda_managed_function/outputs.tf | 44 + modules/lambda_managed_function/variables.tf | 166 ++ modules/lambda_managed_function/versions.tf | 10 + modules/lambda_managed_instance/README.md | 94 + modules/lambda_managed_instance/main.tf | 78 + modules/lambda_managed_instance/outputs.tf | 14 + modules/lambda_managed_instance/variables.tf | 72 + modules/lambda_managed_instance/versions.tf | 10 + modules/vpc/README.md | 31 + modules/vpc/main.tf | 142 ++ modules/vpc/outputs.tf | 29 + modules/vpc/variables.tf | 62 + modules/vpc/versions.tf | 10 + outputs.tf | 30 + terraform.tfvars.example | 8 + tests/stack.tftest.hcl | 56 + tests/vpc.tftest.hcl | 58 + variables.tf | 41 + versions.tf | 14 + 71 files changed, 7925 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/commitmsg-conform.yml create mode 100644 .github/workflows/markdown-lint.yml create mode 100644 .github/workflows/terraform-docs.yml create mode 100644 .github/workflows/terraform-lint-validate.yml create mode 100644 .github/workflows/terraform-tag-and-release.yml create mode 100644 .gitignore create mode 100644 .vscode/cspell.json create mode 100644 .vscode/extensions.json create mode 100644 .vscode/settings.json create mode 100644 examples/basic/README.md create mode 100644 examples/basic/function/lambda_function.py create mode 100644 examples/basic/main.tf create mode 100644 examples/basic/outputs.tf create mode 100644 examples/basic/variables.tf create mode 100644 examples/basic/versions.tf create mode 100644 examples/custom-scaling/README.md create mode 100644 examples/custom-scaling/function/lambda_function.py create mode 100644 examples/custom-scaling/main.tf create mode 100644 examples/custom-scaling/outputs.tf create mode 100644 examples/custom-scaling/variables.tf create mode 100644 examples/custom-scaling/versions.tf create mode 100644 examples/waf-loki-promtail/README.md create mode 100644 examples/waf-loki-promtail/function-waf/country-centroids.json create mode 100644 examples/waf-loki-promtail/function-waf/index.mjs create mode 100644 examples/waf-loki-promtail/function-waf/package-lock.json create mode 100644 examples/waf-loki-promtail/function-waf/package.json create mode 100644 examples/waf-loki-promtail/main.tf create mode 100644 examples/waf-loki-promtail/outputs.tf create mode 100644 examples/waf-loki-promtail/templates/dashboards/waf-geomap.json create mode 100644 examples/waf-loki-promtail/templates/dashboards/waf-overview.json create mode 100644 examples/waf-loki-promtail/templates/dashboards/waf.json create mode 100644 examples/waf-loki-promtail/templates/user_data.sh.tftpl create mode 100644 examples/waf-loki-promtail/terraform.tfvars.example create mode 100644 examples/waf-loki-promtail/variables.tf create mode 100644 examples/waf-loki-promtail/versions.tf create mode 100644 examples/waf-loki/README.md create mode 100644 examples/waf-loki/function-waf/index.mjs create mode 100644 examples/waf-loki/function-waf/package-lock.json create mode 100644 examples/waf-loki/function-waf/package.json create mode 100644 examples/waf-loki/main.tf create mode 100644 examples/waf-loki/outputs.tf create mode 100644 examples/waf-loki/templates/dashboards/waf-overview.json create mode 100644 examples/waf-loki/templates/dashboards/waf.json create mode 100644 examples/waf-loki/templates/promtail/waf-like-sample.jsonl create mode 100644 examples/waf-loki/templates/user_data.sh.tftpl create mode 100644 examples/waf-loki/terraform.tfvars.example create mode 100644 examples/waf-loki/variables.tf create mode 100644 examples/waf-loki/versions.tf create mode 100644 main.tf create mode 100644 modules/lambda_managed_function/README.md create mode 100644 modules/lambda_managed_function/main.tf create mode 100644 modules/lambda_managed_function/outputs.tf create mode 100644 modules/lambda_managed_function/variables.tf create mode 100644 modules/lambda_managed_function/versions.tf create mode 100644 modules/lambda_managed_instance/README.md create mode 100644 modules/lambda_managed_instance/main.tf create mode 100644 modules/lambda_managed_instance/outputs.tf create mode 100644 modules/lambda_managed_instance/variables.tf create mode 100644 modules/lambda_managed_instance/versions.tf create mode 100644 modules/vpc/README.md create mode 100644 modules/vpc/main.tf create mode 100644 modules/vpc/outputs.tf create mode 100644 modules/vpc/variables.tf create mode 100644 modules/vpc/versions.tf create mode 100644 outputs.tf create mode 100644 terraform.tfvars.example create mode 100644 tests/stack.tftest.hcl create mode 100644 tests/vpc.tftest.hcl create mode 100644 variables.tf create mode 100644 versions.tf 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..069a79e --- /dev/null +++ b/.github/workflows/terraform-tag-and-release.yml @@ -0,0 +1,12 @@ +name: Terraform Tag and Release +on: + workflow_run: + workflows: ["Generate terraform docs"] + types: + - completed + +permissions: + contents: write +jobs: + terraform-tag-and-release: + uses: actionsforge/actions/.github/workflows/terraform-tag-and-release.yml@main diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4073879 --- /dev/null +++ b/.gitignore @@ -0,0 +1,21 @@ +# Caller-managed Lambda zip build outputs +**/.build/ + +# Node.js Lambda examples: install deps locally; do not commit node_modules +examples/**/node_modules/ + +# Terraform +.terraform/ +.terraform.lock.hcl +*.tfstate +*.tfstate.* +*.tfvars +!*.tfvars.example +crash.log +crash.*.log +override.tf +override.tf.json +*_override.tf +*_override.tf.json +.terraformrc +terraform.rc diff --git a/.vscode/cspell.json b/.vscode/cspell.json new file mode 100644 index 0000000..5711674 --- /dev/null +++ b/.vscode/cspell.json @@ -0,0 +1,4 @@ +{ + "words": [ + ] +} diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000..4291bb0 --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,19 @@ +{ + "recommendations": [ + "davidanson.vscode-markdownlint", + "eamodio.gitlens", + "esbenp.prettier-vscode", + "foxundermoon.shell-format", + "Gruntfuggly.todo-tree", + "hashicorp.terraform", + "mhutchie.git-graph", + "ms-python.autopep8", + "ms-python.debugpy", + "ms-python.python", + "ms-python.black-formatter", + "ms-python.flake8", + "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..02ce36f --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,218 @@ +{ + "files.associations": { + "*.dockerfile": "dockerfile", + "*.sh.tpl": "shellscript", + "docker-compose*.yml": "yaml", + "Dockerfile*": "dockerfile", + "*.py.tpl": "python", + "*.yaml.tpl": "yaml", + "*.yml.tpl": "yaml", + "*.tf": "terraform", + "*.tfvars": "terraform" + }, + "files.exclude": { + "**/.git": true, + "**/.svn": true, + "**/.hg": true, + "**/CVS": true, + "**/.DS_Store": true, + "__debug_bin": true, + "vendor/": true, + "go.sum": true, + "**/__pycache__": true, + "**/*.pyc": true, + "**/.pytest_cache": true, + "**/.mypy_cache": true, + "**/node_modules": true, + "**/.terraform": true, + "**/.terragrunt-cache": true, + "**/Thumbs.db": true, + "**/.ruff_cache": true, + "**/.coverage": true, + "**/htmlcov": true, + "**/*.tfstate": true, + "**/*.tfstate.*": true + }, + "files.trimTrailingWhitespace": true, + "files.trimFinalNewlines": true, + "files.insertFinalNewline": true, + "files.eol": "\n", + "remote.extensionKind": { + "ms-azuretools.vscode-docker": "ui", + "ms-vscode-remote.remote-containers": "ui" + }, + "editor.formatOnSave": true, + "editor.formatOnPaste": true, + "editor.rulers": [79], + "editor.wordWrap": "wordWrapColumn", + "editor.wordWrapColumn": 79, + "editor.tabSize": 4, + "editor.insertSpaces": true, + "editor.detectIndentation": false, + "editor.trimAutoWhitespace": true, + "editor.codeActionsOnSave": { + "source.fixAll": "explicit", + "source.organizeImports": "explicit", + "source.fixAll.ruff": "explicit" + }, + "prettier.requireConfig": true, + "workbench.iconTheme": "vscode-icons", + "workbench.colorTheme": "Visual Studio Dark", + "[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" + }, + "[jsonc]": { + "editor.formatOnSave": true, + "editor.defaultFormatter": "esbenp.prettier-vscode" + }, + "[shellscript]": { + "editor.defaultFormatter": "foxundermoon.shell-format" + }, + "[terraform]": { + "editor.formatOnSave": true, + "editor.defaultFormatter": "hashicorp.terraform", + "editor.insertSpaces": true, + "editor.tabSize": 2 + }, + "[yaml]": { + "editor.formatOnSave": true, + "editor.defaultFormatter": "esbenp.prettier-vscode", + "editor.insertSpaces": true, + "editor.tabSize": 2 + }, + "[yml]": { + "editor.formatOnSave": true, + "editor.defaultFormatter": "esbenp.prettier-vscode", + "editor.insertSpaces": true, + "editor.tabSize": 2 + }, + "[python]": { + "editor.formatOnSave": true, + "editor.formatOnPaste": true, + "editor.defaultFormatter": "charliermarsh.ruff", + "editor.codeActionsOnSave": { + "source.fixAll": "explicit", + "source.organizeImports": "explicit", + "source.fixAll.ruff": "explicit" + }, + "editor.rulers": [79], + "editor.wordWrapColumn": 79, + "editor.tabSize": 4, + "editor.insertSpaces": true + }, + "python.formatting.provider": "none", + "python.formatting.blackArgs": [ + "--line-length=79", + "--target-version=py39", + "--skip-string-normalization", + "--config=lambdas/pyproject.toml" + ], + "python.linting.enabled": true, + "python.linting.lintOnSave": true, + "python.linting.flake8Enabled": true, + "python.linting.flake8Args": [ + "--max-line-length=79", + "--extend-ignore=E203,W503,E501", + "--max-complexity=10" + ], + "ruff.enable": true, + "ruff.fixAll": true, + "ruff.organizeImports": true, + "ruff.lint.enable": true, + "ruff.format.enable": true, + "ruff.codeAction.fixViolation": { + "enable": true + }, + "ruff.codeAction.disableRuleComment": { + "enable": true + }, + "python.linting.mypyEnabled": true, + "python.linting.mypyArgs": [ + "--config-file=lambdas/pyproject.toml" + ], + "python.linting.pylintEnabled": false, + "isort.args": [ + "--settings-path=lambdas/pyproject.toml" + ], + "isort.check": true, + "python.analysis.typeCheckingMode": "off", + "python.analysis.autoImportCompletions": true, + "python.analysis.autoSearchPaths": true, + "python.analysis.diagnosticMode": "workspace", + "python.analysis.completeFunctionParens": true, + "python.analysis.autoFormatStrings": true, + "python.testing.pytestEnabled": true, + "python.testing.unittestEnabled": false, + "python.testing.pytestArgs": [ + "lambdas" + ], + "python.testing.autoTestDiscoverOnSaveEnabled": true, + "python.defaultInterpreterPath": "/usr/local/bin/python", + "python.terminal.activateEnvironment": false, + "autoDocstring.docstringFormat": "google", + "autoDocstring.startOnNewLine": false, + "autoDocstring.includeExtendedSummary": true, + "git.autofetch": true, + "git.confirmSync": false, + "git.enableSmartCommit": true, + "terminal.integrated.defaultProfile.linux": "bash", + "terminal.integrated.copyOnSelection": true, + "search.exclude": { + "**/node_modules": true, + "**/bower_components": true, + "**/*.code-search": true, + "**/__pycache__": true, + "**/.pytest_cache": true, + "**/.mypy_cache": true, + "**/.ruff_cache": true + }, + "markdown.extension.toc.levels": "2..6", + "markdown.extension.print.absoluteImgPath": false, + "yaml.format.enable": true, + "yaml.format.singleQuote": false, + "yaml.format.bracketSpacing": true, + "python.analysis.indexing": true, + "python.analysis.packageIndexDepths": [ + { + "name": "boto3", + "depth": 2 + }, + { + "name": "botocore", + "depth": 2 + } + ], + "files.watcherExclude": { + "**/.git/objects/**": true, + "**/.git/subtree-cache/**": true, + "**/node_modules/*/**": true, + "**/__pycache__/**": true, + "**/.pytest_cache/**": true, + "**/.mypy_cache/**": true, + "**/.terraform/**": true, + "**/.terragrunt-cache/**": true + }, + "terraform.format.enable": true, + "terraform.lint.enable": true, + + "[markdown]": { + "editor.defaultFormatter": "davidanson.vscode-markdownlint", + "editor.formatOnSave": true + }, + "markdownlint.config": { + "MD060": { + "style": "any" + } + } +} diff --git a/README.md b/README.md index f263ced..bb970fe 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,46 @@ # terraform-aws-lambda-managed-instance -Terraform module to run AWS Lambda on Managed Instances (EC2-backed) with a capacity provider and VPC config. + +Terraform modules and examples for **AWS Lambda Managed Instances (LMI)**—capacity providers in your VPC, functions that run on managed instance capacity, and optional supporting stacks. + +## Modules + +| Module | Role | +| --- | --- | +| [modules/vpc](modules/vpc/) | VPC with public and private subnets and a single NAT Gateway | +| [modules/lambda_managed_instance](modules/lambda_managed_instance/) | IAM, **CreateCapacityProvider**, and operator wiring for LMI in your subnets and security groups | +| [modules/lambda_managed_function](modules/lambda_managed_function/) | Execution role, log group, and **`aws_lambda_function`** with **`capacity_provider_config`** (publish-on-deploy, optional extra IAM policies) | + +Use the modules from your own root module; paths can be local or `git::` (see [Terraform module sources](https://developer.hashicorp.com/terraform/language/modules/sources)). + +```hcl +module "vpc" { + source = "tfstack/lambda-managed-instance/aws//modules/vpc" + # ... +} + +module "lmi" { + source = ""tfstack/lambda-managed-instance/aws//modules/lambda_managed_instance" + subnet_ids = module.vpc.private_subnet_ids + security_group_ids = [aws_security_group.app.id] + # ... +} +``` + +## Repository layout + +| Path | Purpose | +| --- | --- | +| **Root** (`main.tf`, `variables.tf`, …) | Smoke-test stack: VPC + security group + LMI + sample **Python 3.14** function—used by **`terraform test`** in CI | +| [examples/basic](examples/basic/) | Same stack as root; separate directory and state—see [examples/basic/README.md](examples/basic/README.md) | +| [examples/custom-scaling](examples/custom-scaling/) | Manual scaling mode and instance-type constraints—see [examples/custom-scaling/README.md](examples/custom-scaling/README.md) | +| [examples/waf-loki](examples/waf-loki/) | End-to-end demo: existing WAF log bucket → S3 event → **Node.js 24** LMI function → Loki + Grafana on EC2 behind an ALB—see [examples/waf-loki/README.md](examples/waf-loki/README.md) | +| [tests/stack.tftest.hcl](tests/stack.tftest.hcl) | **`terraform test`** with **`mock_provider "aws"`** (plan-only, no credentials) | + +## Examples (summary) + +- **Root / basic** — Fastest way to prove LMI in a fresh VPC (two AZs by default). +- **custom-scaling** — Shows **`scaling_mode = "Manual"`** and **`allowed_instance_types`** when you want explicit instance families. +- **waf-loki** — Full walkthrough-style path: S3 notifications, optional **WAFv2** logging to the same bucket, observability on private EC2, Grafana on a CIDR-restricted public ALB. Requires an **existing** S3 bucket, **archive** and **http** providers (declared in that example’s `versions.tf`), and a region where LMI is enabled. + + + diff --git a/examples/basic/README.md b/examples/basic/README.md new file mode 100644 index 0000000..46130a0 --- /dev/null +++ b/examples/basic/README.md @@ -0,0 +1,39 @@ +# Basic example + +Minimal **VPC** (`modules/vpc`) + **security group** + two modules: + +- **`lambda_managed_instance`** — capacity provider, operator role, and service-linked role wiring +- **`lambda_managed_function`** — execution role, CloudWatch log group, and **`python3.14`** sample function (via **`capacity_provider_arn`** from the first module) + +Same shape as the **repository root** stack, but with its own working directory and state file. + +Defaults use **`lmi-basic`** and **`10.0.0.0/16`** (two AZs). Use a different `vpc_cidr` / `name_prefix` in `terraform.tfvars` if you deploy multiple examples in one account and region. + +## Apply + +```bash +cd examples/basic +terraform init +terraform plan +terraform apply +``` + +Optional: add a `terraform.tfvars` file to override `aws_region`, `vpc_cidr`, `name_prefix`, or other variables from `variables.tf`. + +## Invoke after apply + +```bash +aws lambda invoke \ + --function-name "$(terraform output -raw lambda_function_name):$(terraform output -raw lambda_version)" \ + --payload '{"test":true}' \ + --cli-binary-format raw-in-base64-out /tmp/out.json && cat /tmp/out.json +``` + +First apply can take several minutes while capacity and the published version become active. + +## Related examples + +- [examples/custom-scaling](../custom-scaling/) — manual scaling and allowed instance types +- [examples/waf-loki](../waf-loki/) — walkthrough demo base (three AZs by default) + +Walkthrough site: [aws-lambda-managed-instance-walkthrough](https://github.com/jajera/aws-lambda-managed-instance-walkthrough). diff --git a/examples/basic/function/lambda_function.py b/examples/basic/function/lambda_function.py new file mode 100644 index 0000000..b731b36 --- /dev/null +++ b/examples/basic/function/lambda_function.py @@ -0,0 +1,18 @@ +import json +import logging + +logger = logging.getLogger() +logger.setLevel(logging.INFO) + + +def lambda_handler(event, context): + logger.info(json.dumps({"request_id": context.aws_request_id, "event": event})) + return { + "statusCode": 200, + "body": json.dumps( + { + "message": "Hello from Lambda Managed Instances", + "request_id": context.aws_request_id, + } + ), + } diff --git a/examples/basic/main.tf b/examples/basic/main.tf new file mode 100644 index 0000000..553fdcc --- /dev/null +++ b/examples/basic/main.tf @@ -0,0 +1,67 @@ +provider "aws" { + region = var.aws_region +} + +data "archive_file" "lambda_zip" { + type = "zip" + source_dir = "${path.module}/function" + output_path = "${path.module}/.build/lambda.zip" +} + +module "vpc" { + source = "../../modules/vpc" + + vpc_name = "${var.name_prefix}-vpc" + vpc_cidr = var.vpc_cidr + tags = var.tags + availability_zones = var.availability_zones + public_subnet_cidrs = var.public_subnet_cidrs + private_subnet_cidrs = var.private_subnet_cidrs +} + +resource "aws_security_group" "lmi" { + name_prefix = "${var.name_prefix}-lmi-" + description = "Lambda Managed Instances capacity provider ENIs; egress for CloudWatch Logs and Lambda control plane" + vpc_id = module.vpc.vpc_id + + egress { + from_port = 0 + to_port = 0 + protocol = "-1" + cidr_blocks = ["0.0.0.0/0"] + } + + tags = merge(var.tags, { Name = "${var.name_prefix}-lmi" }) + + lifecycle { + create_before_destroy = true + } +} + +module "lambda_managed_instance" { + source = "../../modules/lambda_managed_instance" + + capacity_provider_name = "${var.name_prefix}-capacity" + iam_role_name_prefix = var.name_prefix + + subnet_ids = module.vpc.private_subnet_ids + security_group_ids = [aws_security_group.lmi.id] + + tags = var.tags +} + +module "lambda_managed_function" { + source = "../../modules/lambda_managed_function" + + function_name = "${var.name_prefix}-fn" + capacity_provider_arn = module.lambda_managed_instance.capacity_provider_arn + iam_role_name_prefix = var.name_prefix + description = "Example Lambda Managed Instance function" + + filename = data.archive_file.lambda_zip.output_path + source_code_hash = data.archive_file.lambda_zip.output_base64sha256 + + runtime = "python3.14" + + tags = var.tags +} diff --git a/examples/basic/outputs.tf b/examples/basic/outputs.tf new file mode 100644 index 0000000..ede54ab --- /dev/null +++ b/examples/basic/outputs.tf @@ -0,0 +1,30 @@ +output "vpc_id" { + value = module.vpc.vpc_id +} + +output "private_subnet_ids" { + value = module.vpc.private_subnet_ids +} + +output "capacity_provider_name" { + value = module.lambda_managed_instance.capacity_provider_name +} + +output "lambda_function_name" { + value = module.lambda_managed_function.lambda_function_name +} + +output "lambda_qualified_invoke_arn" { + description = "Use this ARN with aws lambda invoke (published version)" + value = module.lambda_managed_function.lambda_qualified_invoke_arn +} + +output "lambda_version" { + description = "Published Lambda version (use with function_name for invoke)" + value = module.lambda_managed_function.lambda_version +} + +output "lambda_log_group_name" { + description = "CloudWatch log group; tail logs or set alarms here" + value = module.lambda_managed_function.lambda_log_group_name +} diff --git a/examples/basic/variables.tf b/examples/basic/variables.tf new file mode 100644 index 0000000..c22da5f --- /dev/null +++ b/examples/basic/variables.tf @@ -0,0 +1,41 @@ +variable "aws_region" { + description = "AWS region for all resources. Lambda Managed Instances are only available in a subset of regions; see repository README." + type = string + default = "ap-southeast-2" +} + +variable "name_prefix" { + description = "Prefix for all resource names" + type = string + default = "lmi-basic" +} + +variable "vpc_cidr" { + description = "VPC IPv4 CIDR" + type = string + default = "10.0.0.0/16" +} + +variable "availability_zones" { + description = "AZs for subnet pairs" + type = list(string) + default = ["ap-southeast-2a", "ap-southeast-2b"] +} + +variable "public_subnet_cidrs" { + description = "Public subnet CIDRs (NAT + IGW path)" + type = list(string) + default = ["10.0.1.0/24", "10.0.2.0/24"] +} + +variable "private_subnet_cidrs" { + description = "Private subnet CIDRs (Lambda managed instances)" + type = list(string) + default = ["10.0.101.0/24", "10.0.102.0/24"] +} + +variable "tags" { + description = "Common tags applied to all resources" + type = map(string) + default = {} +} diff --git a/examples/basic/versions.tf b/examples/basic/versions.tf new file mode 100644 index 0000000..6033fa0 --- /dev/null +++ b/examples/basic/versions.tf @@ -0,0 +1,14 @@ +terraform { + required_version = ">= 1.7.0" + + required_providers { + archive = { + source = "hashicorp/archive" + version = ">= 2.4.0" + } + aws = { + source = "hashicorp/aws" + version = ">= 6.0.0" + } + } +} diff --git a/examples/custom-scaling/README.md b/examples/custom-scaling/README.md new file mode 100644 index 0000000..9c65011 --- /dev/null +++ b/examples/custom-scaling/README.md @@ -0,0 +1,43 @@ +# Custom scaling example + +Same **VPC** + **security group** layout as [examples/basic](../basic/), with **`python3.14`** and the split modules: + +**`lambda_managed_instance`** (fleet / capacity provider): + +- **`scaling_mode = "Manual"`** with a **CPU target** (`cpu_target_utilization`) so the pool tracks average CPU utilisation +- **`allowed_instance_types`** — a pinned allowlist of x86_64 sizes (for example `m7i.2xlarge`, `c7i.4xlarge`) so placement stays predictable + +**`lambda_managed_function`** (one function on that provider): + +- **`reserved_concurrent_executions`**, **`per_execution_environment_max_concurrency`**, **`environment_variables`**, and structured logging (`log_format`, `application_log_level`) + +Defaults use **`lmi-custom`** and **`10.1.0.0/16`** so you can run alongside **basic** (`10.0.0.0/16`) without overlapping CIDRs. + +## Apply + +```bash +cd examples/custom-scaling +terraform init +terraform plan +terraform apply +``` + +Optional: add a `terraform.tfvars` file to override variables from `variables.tf` (defaults are set for a non-overlapping CIDR with `examples/basic`). + +Tight **allowed** lists can reduce placement flexibility if a type is capacity-constrained in your region — keep at least two sizes in the allowlist where possible. + +## Invoke after apply + +```bash +aws lambda invoke \ + --function-name "$(terraform output -raw lambda_function_name):$(terraform output -raw lambda_version)" \ + --payload '{"test":true}' \ + --cli-binary-format raw-in-base64-out /tmp/out.json && cat /tmp/out.json +``` + +## Related examples + +- [examples/basic](../basic/) — defaults-only Auto scaling +- [examples/waf-loki](../waf-loki/) — walkthrough networking lab stack + +Product reference: [Scaling Lambda Managed Instances](https://docs.aws.amazon.com/lambda/latest/dg/lambda-managed-instances-scaling.html) (instance type selection). diff --git a/examples/custom-scaling/function/lambda_function.py b/examples/custom-scaling/function/lambda_function.py new file mode 100644 index 0000000..38fc0b7 --- /dev/null +++ b/examples/custom-scaling/function/lambda_function.py @@ -0,0 +1,29 @@ +import json +import logging +import os + +logger = logging.getLogger() +logger.setLevel(os.environ.get("LOG_LEVEL", "INFO")) + + +def lambda_handler(event, context): + logger.debug(json.dumps({"request_id": context.aws_request_id, "event": event})) + logger.info( + json.dumps( + { + "request_id": context.aws_request_id, + "env": os.environ.get("ENV", "unknown"), + "memory_limit_mb": context.memory_limit_in_mb, + } + ) + ) + return { + "statusCode": 200, + "body": json.dumps( + { + "message": "Hello from Lambda Managed Instances (custom scaling)", + "request_id": context.aws_request_id, + "env": os.environ.get("ENV", "unknown"), + } + ), + } diff --git a/examples/custom-scaling/main.tf b/examples/custom-scaling/main.tf new file mode 100644 index 0000000..791959d --- /dev/null +++ b/examples/custom-scaling/main.tf @@ -0,0 +1,104 @@ +provider "aws" { + region = var.aws_region +} + +data "archive_file" "lambda_zip" { + type = "zip" + source_dir = "${path.module}/function" + output_path = "${path.module}/.build/lambda.zip" +} + +module "vpc" { + source = "../../modules/vpc" + + vpc_name = "${var.name_prefix}-vpc" + vpc_cidr = var.vpc_cidr + tags = var.tags + availability_zones = var.availability_zones + public_subnet_cidrs = var.public_subnet_cidrs + private_subnet_cidrs = var.private_subnet_cidrs +} + +resource "aws_security_group" "lmi" { + name_prefix = "${var.name_prefix}-lmi-" + description = "Lambda Managed Instances capacity provider ENIs; egress for CloudWatch Logs and Lambda control plane" + vpc_id = module.vpc.vpc_id + + egress { + from_port = 0 + to_port = 0 + protocol = "-1" + cidr_blocks = ["0.0.0.0/0"] + } + + tags = merge(var.tags, { Name = "${var.name_prefix}-lmi" }) + + lifecycle { + create_before_destroy = true + } +} + +# Manual scaling with a pinned set of compute-optimised x86_64 instance types. +# Lambda will only place managed instances on types in the allowlist, giving you +# predictable CPU-to-memory ratios for CPU-bound workloads. The CPU target policy +# scales the pool in/out to maintain 60 % average utilisation. +module "lambda_managed_instance" { + source = "../../modules/lambda_managed_instance" + + capacity_provider_name = "${var.name_prefix}-capacity" + iam_role_name_prefix = var.name_prefix + + # x86_64 compute-optimised instances — balanced CPU/memory for CPU-bound tasks. + # Specify at least two sizes so Lambda has flexibility when a type is constrained. + architectures = ["x86_64"] + allowed_instance_types = ["m7i.2xlarge", "m7i.4xlarge", "c7i.2xlarge", "c7i.4xlarge"] + + # Manual mode: the pool scales to maintain cpu_target_utilization across all instances. + scaling_mode = "Manual" + cpu_target_utilization = 60 + max_vcpu_count = 64 + + subnet_ids = module.vpc.private_subnet_ids + security_group_ids = [aws_security_group.lmi.id] + + tags = var.tags +} + +module "lambda_managed_function" { + source = "../../modules/lambda_managed_function" + + function_name = "${var.name_prefix}-fn" + capacity_provider_arn = module.lambda_managed_instance.capacity_provider_arn + iam_role_name_prefix = var.name_prefix + description = "Example Lambda Managed Instance — manual scaling with specific instance types" + + filename = data.archive_file.lambda_zip.output_path + source_code_hash = data.archive_file.lambda_zip.output_base64sha256 + + runtime = "python3.14" + architectures = ["x86_64"] + memory_size = 4096 + timeout = 60 + ephemeral_storage_size = 1024 + + # Cap concurrent invocations to prevent runaway spend if traffic spikes unexpectedly. + reserved_concurrent_executions = 50 + + # Each execution environment handles up to 8 concurrent requests before Lambda + # places additional requests on a new environment. Lower than the default (10) + # to reduce head-of-line blocking for this CPU-bound workload. + per_execution_environment_max_concurrency = 8 + + environment_variables = { + ENV = "production" + LOG_LEVEL = "INFO" + } + + # JSON structured logging with DEBUG-level app output for detailed tracing. + log_format = "JSON" + application_log_level = "DEBUG" + system_log_level = "INFO" + log_retention_days = 30 + + tags = var.tags +} diff --git a/examples/custom-scaling/outputs.tf b/examples/custom-scaling/outputs.tf new file mode 100644 index 0000000..ede54ab --- /dev/null +++ b/examples/custom-scaling/outputs.tf @@ -0,0 +1,30 @@ +output "vpc_id" { + value = module.vpc.vpc_id +} + +output "private_subnet_ids" { + value = module.vpc.private_subnet_ids +} + +output "capacity_provider_name" { + value = module.lambda_managed_instance.capacity_provider_name +} + +output "lambda_function_name" { + value = module.lambda_managed_function.lambda_function_name +} + +output "lambda_qualified_invoke_arn" { + description = "Use this ARN with aws lambda invoke (published version)" + value = module.lambda_managed_function.lambda_qualified_invoke_arn +} + +output "lambda_version" { + description = "Published Lambda version (use with function_name for invoke)" + value = module.lambda_managed_function.lambda_version +} + +output "lambda_log_group_name" { + description = "CloudWatch log group; tail logs or set alarms here" + value = module.lambda_managed_function.lambda_log_group_name +} diff --git a/examples/custom-scaling/variables.tf b/examples/custom-scaling/variables.tf new file mode 100644 index 0000000..85d71dd --- /dev/null +++ b/examples/custom-scaling/variables.tf @@ -0,0 +1,41 @@ +variable "aws_region" { + description = "AWS region for all resources. Lambda Managed Instances are only available in a subset of regions; see repository README." + type = string + default = "ap-southeast-2" +} + +variable "name_prefix" { + description = "Prefix for all resource names" + type = string + default = "lmi-custom" +} + +variable "vpc_cidr" { + description = "VPC IPv4 CIDR" + type = string + default = "10.1.0.0/16" +} + +variable "availability_zones" { + description = "AZs for subnet pairs" + type = list(string) + default = ["ap-southeast-2a", "ap-southeast-2b"] +} + +variable "public_subnet_cidrs" { + description = "Public subnet CIDRs (NAT + IGW path)" + type = list(string) + default = ["10.1.1.0/24", "10.1.2.0/24"] +} + +variable "private_subnet_cidrs" { + description = "Private subnet CIDRs (Lambda managed instances)" + type = list(string) + default = ["10.1.101.0/24", "10.1.102.0/24"] +} + +variable "tags" { + description = "Common tags applied to all resources" + type = map(string) + default = {} +} diff --git a/examples/custom-scaling/versions.tf b/examples/custom-scaling/versions.tf new file mode 100644 index 0000000..6033fa0 --- /dev/null +++ b/examples/custom-scaling/versions.tf @@ -0,0 +1,14 @@ +terraform { + required_version = ">= 1.7.0" + + required_providers { + archive = { + source = "hashicorp/archive" + version = ">= 2.4.0" + } + aws = { + source = "hashicorp/aws" + version = ">= 6.0.0" + } + } +} diff --git a/examples/waf-loki-promtail/README.md b/examples/waf-loki-promtail/README.md new file mode 100644 index 0000000..13e55aa --- /dev/null +++ b/examples/waf-loki-promtail/README.md @@ -0,0 +1,155 @@ +# WAF → S3 → LMI → Loki → Grafana (+ WAF country geomap) + +This example matches **`examples/waf-loki`** (Lambda ingests WAF objects from S3 into Loki) and adds a third Grafana dashboard: **WAF geographic (country)**. The observability host runs **Docker Compose with Loki and Grafana only** — no Promtail and no Docker-socket log scraping. + +A dedicated **S3 bootstrap bucket** holds dashboard JSON; first-boot `user_data` runs `aws s3 cp` so the rendered `user_data` string stays under the EC2 **16 KiB** limit. + +**Further reading — different architecture:** [cloudbuildlab/grafana-waf-analytics](https://github.com/cloudbuildlab/grafana-waf-analytics/tree/1-first-release) (1-first-release) uses **systemd timers**, **`aws s3 sync`** of WAF logs onto the instance, **host-installed** Loki/Promtail/Grafana, the **Infinity** datasource, and static **country coordinate** JSON. This repository’s example keeps the **LMI Lambda → Loki** path and adds **`geo_lat` / `geo_lon`** on each line at ingest (see below) so Grafana can use **coordinate** mode on the geomap (reliable) instead of panel-side country lookup. + +End-to-end flow: WAF log objects land in S3, an S3 event triggers a **Node.js 24 Lambda Managed Instance function** that decompresses and pushes each log line to **Loki** on EC2; **Grafana** shows WAF logs, overview metrics, and a country map behind a public ALB. + +## What this stack creates + +| Resource | Purpose | +|----------|---------| +| VPC (3 AZs, public + private subnets) | All resources run here | +| `lambda_managed_instance` | LMI capacity provider | +| `lambda_managed_function_waf` (Node.js 24) | Reads gzip WAF log objects from S3, pushes lines to Loki | +| Existing S3 bucket (`var.waf_logs_bucket_name`) | **Not created here** — you provision the bucket separately; this stack adds the event notification and (optionally) WAF logging to it | +| IAM policy `waf_s3_read` | Scoped to the WAF log bucket; attached to the WAF Lambda only | +| EC2 (`t3.small`, private subnet) | Runs **Loki + Grafana** via Docker Compose; Loki data on root EBS | +| S3 bucket (`obs_bootstrap`, `force_destroy`) | Holds Grafana dashboard JSON; the obs instance runs `aws s3 cp` at bootstrap | +| ALB (public, HTTP 80) | Fronts Grafana port 3000; restricted to deployer IP by default (TLS not configured; use for demos only) | + +## Prerequisites + +- Terraform >= 1.7, AWS provider >= 6.0, **archive** provider >= 2.4 (declared in `versions.tf`) +- An AWS account in a [region where LMI is available](https://docs.aws.amazon.com/lambda/latest/dg/lambda-managed-instances.html) +- AWS credentials with IAM, Lambda, EC2, S3, ALB, and SSM permissions +- An **existing** S3 bucket whose name you set as `waf_logs_bucket_name` in `terraform.tfvars`. The bucket must exist before `terraform apply`. For [WAFv2 logging to S3](https://docs.aws.amazon.com/waf/latest/developerguide/logging-s3.html), the name must start with `aws-waf-logs-`. +- IAM for the Terraform principal: permission to update **bucket notifications** on that bucket (`s3:PutBucketNotificationConfiguration` and related reads), in addition to the usual Lambda / EC2 / ALB permissions. + +## Apply + +The WAF ingest code is plain ESM (`function-waf/index.mjs`). **Node.js 24 on Lambda includes AWS SDK for JavaScript v3**, so you do not need `npm install` in `function-waf/` for deployment; the zip excludes any local `node_modules` if present. + +`alb_ingress_cidrs` defaults to your current public IP via `checkip.amazonaws.com`. Plan/apply must reach that URL from the machine running Terraform, or set `alb_ingress_cidrs` explicitly in `terraform.tfvars`. + +```bash +cd examples/waf-loki-promtail +cp terraform.tfvars.example terraform.tfvars # set waf_logs_bucket_name (required); edit region / name_prefix +terraform init +terraform plan +terraform apply +``` + +First apply takes several minutes: capacity provider creation and the EC2 Docker Compose startup both take time. Grafana becomes reachable when the ALB target group health check passes (`/api/health`). + +## Outputs + +```bash +terraform output grafana_url # http:// — open in browser +terraform output waf_logs_bucket # bucket name for WAF delivery or manual test uploads +terraform output obs_bootstrap_bucket # S3 bucket for dashboard JSON at first boot +terraform output obs_instance_id # EC2 ID — connect via SSM Session Manager +terraform output loki_push_url # Loki HTTP push endpoint used by Lambda +``` + +## Grafana access + +Open the `grafana_url` output in a browser. Default credentials: **admin / admin**. Grafana prompts to change the password on first login. + +Three dashboards are provisioned under the **WAF** folder: + +| Dashboard | Data | +|-----------|------| +| **WAF Logs** | `{source="waf"}` — raw stream | +| **WAF overview** | `{source="waf"}` — rates by action, rule, client IP, method | +| **WAF geographic (country)** | `{source="waf"}` — geomap plots **`geo_lat` / `geo_lon`** (numeric coordinates) | + +**Geomap (why coordinates):** Grafana’s country **lookup** mode with Loki log lines is easy to misconfigure. The WAF Lambda therefore adds **`geo_lat`** and **`geo_lon`** to each JSON line when **`httpRequest.country`** is a known ISO 3166-1 alpha-2 code: it looks up a **bounding-box centre** from `function-waf/country-centroids.json` (~241 codes). Those positions are **country-level**, not exact client locations. The dashboard uses **one** JSON extract from `Line` and geomap **coordinates** mode. + +Centroids were generated by merging public datasets from [samayo/country-json](https://github.com/samayo/country-json) (`country-by-geo-coordinates.json` + `country-by-abbreviation.json`); regenerate by re-running the merge script if you change sources. + +AWS documents WAF log fields in [Logging field list](https://docs.aws.amazon.com/waf/latest/developerguide/logging-fields.html). + +**Already-ingested data** in Loki from **before** this change has no `geo_lat` / `geo_lon`; the map query uses `|= "geo_lat"`. Re-upload a test object or wait for new WAF delivery after **`terraform apply`** updates the function. + +## Testing the ingest path + +Upload any gzip-compressed WAF log file to the WAF log bucket. By default the S3 notification only fires for keys ending in **`.gz`** (`waf_logs_object_suffix`). The Lambda splits large files into multiple Loki push requests so a single object does not exceed a safe payload size. + +Set `waf_logs_object_suffix = ""` in `terraform.tfvars` if you want every new object to invoke the function (only on buckets dedicated to this flow). + +```bash +# Sample line includes httpRequest.country so the geomap has something to plot +echo '{"timestamp":1735700000000,"action":"ALLOW","terminatingRuleId":"Default_Action","httpRequest":{"clientIp":"1.2.3.4","country":"US","uri":"/","httpMethod":"GET"}}' \ + | gzip > /tmp/test-waf.log.gz + +aws s3 cp /tmp/test-waf.log.gz \ + "s3://$(terraform output -raw waf_logs_bucket)/test/test-waf.log.gz" +``` + +After a few seconds, the line appears under `{source="waf"}` in Grafana Explore. The geomap shows a point once the Lambda has added **`geo_lat` / `geo_lon`** for that country code. + +## WAFv2 log delivery (optional) + +If you have an existing WAFv2 Web ACL, pass its ARN to enable automatic log delivery: + +```hcl +web_acl_arn = "arn:aws:wafv2:ap-southeast-2:123456789012:regional/webacl/my-acl/..." +``` + +WAF delivers gzip log files to the S3 bucket, which the Lambda picks up automatically. The bucket must still satisfy [WAF logging requirements](https://docs.aws.amazon.com/waf/latest/developerguide/logging-s3.html) (naming, optional dedicated bucket, and resource policy for the delivery service). + +## Connecting to the EC2 host (no SSH required) + +```bash +aws ssm start-session --target "$(terraform output -raw obs_instance_id)" +``` + +Check Compose status: + +```bash +docker compose -f /opt/obs/docker-compose.yaml ps +docker compose -f /opt/obs/docker-compose.yaml logs --tail 50 loki +``` + +## Data durability notes + +Loki stores chunks and index on the **EC2 root EBS volume** (30 GB gp3). Data survives instance stop/start (same volume retained). **Instance replacement** (Terraform `taint`, terminated by AWS, etc.) provisions a new root volume — Loki query history is lost, but all raw WAF log objects remain in S3 and can be re-uploaded to re-drive ingestion. + +The observability EC2 uses **`user_data_replace_on_change = true`**. Any change to `templates/user_data.sh.tftpl` plans a **new instance**. Changes to dashboard JSON under `templates/dashboards/` update **S3 objects**; replace the instance (or re-run apply when `user_data` changes) to re-fetch files at boot. + +### Grafana: missing dashboards or empty geomap + +1. SSM to the instance: `ls -la /opt/obs/grafana/dashboards/` (expect `waf.json`, `waf-overview.json`, `waf-geomap.json`). +2. `sudo tail -n 200 /var/log/cloud-init-output.log` — `aws s3 cp`, Python JSON validation, or Compose errors. +3. **Empty map:** confirm lines include **`geo_lat`** (expand JSON in the log row). If not, the line was ingested by an **older** Lambda revision — run **`terraform apply`** and **re-drive** an S3 object (new upload or re-copy) so ingest runs again. Confirm **`httpRequest.country`** is a **known ISO2** code present in `country-centroids.json`. + +## Approximate cost (ballpark, us-east-1-style pricing, running 24/7) + +| Component | ~Monthly | +|-----------|---------| +| LMI capacity (t3 equivalent, minimal) | $15–40 | +| EC2 `t3.small` | ~$15 | +| ALB | ~$18 | +| NAT Gateway | ~$35 | +| S3 + data transfer | < $5 | +| **Total** | **~$90–115** | + +Costs scale with Lambda invocations and WAF log volume. + +## Destroy + +```bash +terraform destroy +``` + +The WAF log bucket is **not** managed by Terraform; `terraform destroy` removes the notification and other stack resources but **does not delete** the bucket or its objects. The **obs bootstrap** bucket is managed here with **`force_destroy = true`**, so `terraform destroy` empties and removes it along with the uploaded dashboard objects. + +If you previously applied an older revision of this example that **created** the bucket with Terraform, run `terraform state rm` on the removed bucket resources (`aws_s3_bucket.waf_logs` and any related `aws_s3_bucket_*` blocks) before the next `apply`, so Terraform does not plan to destroy a bucket you still need. + +## Documentation + +Walkthrough site: [aws-lambda-managed-instance-walkthrough](https://github.com/jajera/aws-lambda-managed-instance-walkthrough) diff --git a/examples/waf-loki-promtail/function-waf/country-centroids.json b/examples/waf-loki-promtail/function-waf/country-centroids.json new file mode 100644 index 0000000..1a84dab --- /dev/null +++ b/examples/waf-loki-promtail/function-waf/country-centroids.json @@ -0,0 +1 @@ +{"AF":{"lat":33.93045,"lon":67.6789},"AL":{"lat":41.157,"lon":20.18125},"DZ":{"lat":28.02685,"lon":1.6528149999999995},"AS":{"lat":-12.7161,"lon":-170.25400000000002},"AD":{"lat":42.542249999999996,"lon":1.596865},"AO":{"lat":-11.209465000000002,"lon":17.88065},"AI":{"lat":18.225099999999998,"lon":-63.07215},"AQ":{"lat":-75.2577,"lon":0},"AG":{"lat":17.3632,"lon":-61.7894},"AR":{"lat":-38.4213,"lon":-63.5874},"AM":{"lat":40.06615,"lon":45.1111},"AW":{"lat":12.5177,"lon":-69.96515},"AU":{"lat":-26.8534,"lon":133.275},"AT":{"lat":47.69695,"lon":13.346525},"AZ":{"lat":40.147400000000005,"lon":47.5721},"BS":{"lat":24.88595,"lon":-76.7099},"BH":{"lat":26.039749999999998,"lon":50.55929999999999},"BD":{"lat":23.687600000000003,"lon":90.351},"BB":{"lat":13.18355,"lon":-59.53465},"BY":{"lat":53.7111,"lon":27.97385},"BE":{"lat":50.4995,"lon":4.4754000000000005},"BZ":{"lat":17.19295,"lon":-88.5009},"BJ":{"lat":9.322025,"lon":2.3131375},"BM":{"lat":32.3202,"lon":-64.774},"BT":{"lat":27.5157,"lon":90.44245000000001},"BO":{"lat":-16.288335,"lon":-63.54945},"BA":{"lat":43.89265,"lon":17.67055},"BW":{"lat":-22.344,"lon":24.68015},"BV":{"lat":-54.43135,"lon":3.41174},"BR":{"lat":-14.242910000000002,"lon":-53.18925},"IO":{"lat":-6.35318,"lon":71.8766},"BN":{"lat":4.525125,"lon":114.715},"BG":{"lat":42.72985,"lon":25.4917},"BF":{"lat":12.241855,"lon":-1.5567599999999997},"BI":{"lat":-3.3879149999999996,"lon":29.9204},"KH":{"lat":12.54775,"lon":104.98400000000001},"CM":{"lat":7.3653249999999995,"lon":12.34343},"CA":{"lat":62.3933,"lon":-96.81815},"CV":{"lat":16.0026,"lon":-24.014049999999997},"KY":{"lat":19.51235,"lon":-80.58005},"CF":{"lat":6.6140550000000005,"lon":20.94175},"TD":{"lat":15.445734999999999,"lon":18.7381},"CL":{"lat":-36.7166,"lon":-73.60175},"CN":{"lat":34.66815,"lon":104.16585},"CX":{"lat":-10.49145,"lon":105.62299999999999},"CC":{"lat":0,"lon":0},"CO":{"lat":4.5773150000000005,"lon":-74.29894999999999},"KM":{"lat":-11.87515,"lon":43.877},"CG":{"lat":0,"lon":0},"CK":{"lat":-15.983649999999999,"lon":-159.203},"CR":{"lat":9.62489,"lon":-84.2533},"HR":{"lat":44.4873,"lon":16.4603},"CU":{"lat":21.52705,"lon":-79.5446},"CY":{"lat":35.1674,"lon":33.435500000000005},"CZ":{"lat":49.8009,"lon":15.47815},"DK":{"lat":56.1554,"lon":11.617204999999998},"DJ":{"lat":11.80835,"lon":42.59525},"DM":{"lat":15.41675,"lon":-61.364149999999995},"DO":{"lat":18.73655,"lon":-70.16175},"TP":{"lat":-8.79973,"lon":125.67750000000001},"EC":{"lat":-1.7799,"lon":-78.13159999999999},"EG":{"lat":26.696350000000002,"lon":30.7982},"SV":{"lat":13.7969,"lon":-88.91045},"GQ":{"lat":1.633925,"lon":10.34128},"ER":{"lat":15.18135,"lon":39.786699999999996},"EE":{"lat":58.596199999999996,"lon":25.0238},"SZ":{"lat":-26.518349999999998,"lon":31.465700000000002},"ET":{"lat":9.14811,"lon":40.49305},"FK":{"lat":-51.8006,"lon":-59.52885},"FO":{"lat":61.897800000000004,"lon":-6.92879},"FJ":{"lat":0,"lon":0},"FI":{"lat":64.95245,"lon":26.0689},"FR":{"lat":46.2322,"lon":2.20967},"GF":{"lat":3.951795,"lon":-53.078199999999995},"PF":{"lat":-17.778585,"lon":-143.9035},"TF":{"lat":-43.762950000000004,"lon":63.88455},"GA":{"lat":-0.8281000000000001,"lon":11.598885},"GM":{"lat":13.44545,"lon":-15.31145},"GE":{"lat":42.31985,"lon":43.36805},"DE":{"lat":51.1657,"lon":10.45277},"GH":{"lat":7.95501,"lon":-1.03182},"GI":{"lat":36.1322,"lon":-5.35227},"GR":{"lat":38.2753,"lon":23.81035},"GL":{"lat":71.7024,"lon":-42.17715},"GD":{"lat":12.1526,"lon":-61.68955},"GP":{"lat":16.1922,"lon":-61.272400000000005},"GU":{"lat":13.4441,"lon":144.7875},"GT":{"lat":15.776250000000001,"lon":-90.22975},"GN":{"lat":9.934875,"lon":-11.283835},"GW":{"lat":11.80255,"lon":-15.177},"GY":{"lat":4.866325,"lon":-58.93255},"HT":{"lat":19.0544,"lon":-73.04599999999999},"HM":{"lat":-53.0507,"lon":73.2278},"VA":{"lat":0,"lon":0},"HN":{"lat":14.74635,"lon":-86.2531},"HK":{"lat":22.356499999999997,"lon":114.1365},"HU":{"lat":47.16465,"lon":19.50895},"IS":{"lat":64.96395,"lon":-19.02115},"IN":{"lat":21.12567,"lon":82.795},"ID":{"lat":-2.51874,"lon":118.01565},"IR":{"lat":32.42065,"lon":53.6824},"IQ":{"lat":33.2237,"lon":43.685900000000004},"IE":{"lat":53.41975,"lon":-8.240495},"IL":{"lat":31.41835,"lon":35.07355},"IT":{"lat":41.873999999999995,"lon":12.564145},"CI":{"lat":7.546835,"lon":-5.5470999999999995},"JM":{"lat":18.1153,"lon":-77.27345},"JP":{"lat":34.8863,"lon":134.38},"JO":{"lat":31.2768,"lon":37.1306},"KZ":{"lat":0,"lon":0},"KE":{"lat":0.17094500000000012,"lon":37.903999999999996},"KI":{"lat":-3.3636649999999997,"lon":9.670500000000004},"KW":{"lat":29.31025,"lon":47.49355},"KG":{"lat":41.2055,"lon":74.7799},"LA":{"lat":18.205199999999998,"lon":103.89500000000001},"LV":{"lat":56.8756,"lon":24.60775},"LB":{"lat":33.87265,"lon":35.87675},"LS":{"lat":-29.62055,"lon":28.24745},"LR":{"lat":6.452425,"lon":-9.428605000000001},"LY":{"lat":0,"lon":0},"LI":{"lat":47.1595,"lon":9.553655},"LT":{"lat":55.174099999999996,"lon":23.9067},"LU":{"lat":49.815749999999994,"lon":6.131515},"MO":{"lat":22.201349999999998,"lon":113.5475},"MK":{"lat":41.611000000000004,"lon":21.7514},"MG":{"lat":-18.7772,"lon":46.85435},"MW":{"lat":-13.246269999999999,"lon":34.2954},"MY":{"lat":4.109321,"lon":109.45570000000001},"MV":{"lat":3.199448,"lon":73.16525},"ML":{"lat":17.57975,"lon":-3.9988149999999996},"MT":{"lat":35.944199999999995,"lon":14.379950000000001},"MH":{"lat":10.103819999999999,"lon":168.7285},"MQ":{"lat":14.63555,"lon":-61.022800000000004},"MR":{"lat":21.006800000000002,"lon":-10.947085000000001},"MU":{"lat":-15.4225,"lon":60.00645},"YT":{"lat":-12.8245,"lon":45.16545},"MX":{"lat":23.62485,"lon":-102.5787},"FM":{"lat":0,"lon":0},"MD":{"lat":46.97955,"lon":28.37715},"MC":{"lat":43.73835,"lon":7.42445},"MN":{"lat":46.86095,"lon":103.83685},"ME":{"lat":null,"lon":null},"MS":{"lat":16.749450000000003,"lon":-62.192750000000004},"MA":{"lat":31.792299999999997,"lon":-7.080175},"MZ":{"lat":-18.6703,"lon":35.530150000000006},"MM":{"lat":0,"lon":0},"NA":{"lat":-22.96565,"lon":18.48615},"NR":{"lat":-0.5283195,"lon":166.922},"NP":{"lat":28.395049999999998,"lon":84.1278},"NL":{"lat":52.13305,"lon":5.29525},"AN":{"lat":0,"lon":0},"NC":{"lat":-21.1239,"lon":165.84699999999998},"NZ":{"lat":-40.83785,"lon":-6.642499999999998},"NI":{"lat":12.8667,"lon":-85.2143},"NE":{"lat":17.610999999999997,"lon":8.080925},"NG":{"lat":9.08457,"lon":8.674265},"NU":{"lat":-19.051650000000002,"lon":-169.863},"NF":{"lat":-29.02915,"lon":167.9565},"KP":{"lat":40.3397,"lon":127.4955},"GB":{"lat":0,"lon":0},"MP":{"lat":17.3318,"lon":145.4755},"NO":{"lat":64.583,"lon":17.864135},"OM":{"lat":21.5169,"lon":55.8593},"PK":{"lat":30.441850000000002,"lon":69.35975},"PW":{"lat":5.636629999999999,"lon":132.9205},"PS":{"lat":31.88145,"lon":34.895},"PA":{"lat":8.41771,"lon":-80.11275},"PG":{"lat":-6.48827,"lon":148.403},"PY":{"lat":-23.451349999999998,"lon":-58.45325},"PE":{"lat":-9.181338499999999,"lon":-75.00235},"PH":{"lat":12.881955,"lon":121.767},"PN":{"lat":0,"lon":0},"PL":{"lat":51.92275,"lon":19.13685},"PT":{"lat":39.5578,"lon":-7.844844999999999},"PR":{"lat":18.223300000000002,"lon":-66.59270000000001},"QA":{"lat":25.3188,"lon":51.1969},"RE":{"lat":-21.12605,"lon":55.5252},"RO":{"lat":45.9471,"lon":24.98055},"RU":{"lat":0,"lon":0},"RW":{"lat":-1.94708,"lon":29.8764},"SH":{"lat":-11.953655000000001,"lon":-10.029975},"KN":{"lat":17.2577,"lon":-62.706450000000004},"LC":{"lat":13.904,"lon":-60.974199999999996},"PM":{"lat":46.96615,"lon":-56.33685},"VC":{"lat":12.9809,"lon":-61.287400000000005},"WS":{"lat":-13.736550000000001,"lon":-172.10750000000002},"SM":{"lat":43.942949999999996,"lon":12.46},"ST":{"lat":0.863043,"lon":6.96827},"SA":{"lat":23.8863,"lon":45.08115},"SN":{"lat":14.49945,"lon":-14.44555},"RS":{"lat":22.005,"lon":10.50295},"SC":{"lat":-7.018794999999999,"lon":51.25125},"SL":{"lat":8.464805,"lon":-11.7959},"SG":{"lat":1.36492,"lon":103.8225},"SK":{"lat":48.66565,"lon":19.709049999999998},"SI":{"lat":46.1492,"lon":14.99295},"SB":{"lat":-9.220105,"lon":161.245},"SO":{"lat":5.152165,"lon":46.199600000000004},"ZA":{"lat":-28.483199999999997,"lon":24.677},"GS":{"lat":-56.724900000000005,"lon":-32.12525},"KR":{"lat":35.901650000000004,"lon":127.736},"SS":{"lat":7.8562449999999995,"lon":30.04045},"ES":{"lat":39.89575000000001,"lon":-2.48687},"LK":{"lat":0,"lon":0},"SD":{"lat":15.458459999999999,"lon":30.21765},"SR":{"lat":3.91785,"lon":-56.03205},"SJ":{"lat":79.99119999999999,"lon":25.49335},"SE":{"lat":62.199799999999996,"lon":17.637500000000003},"CH":{"lat":46.8155,"lon":8.224485},"SY":{"lat":34.814899999999994,"lon":39.0561},"TJ":{"lat":38.8582,"lon":71.26215},"TZ":{"lat":-6.368218,"lon":34.8852},"TH":{"lat":13.0366,"lon":101.4923},"CD":{"lat":0,"lon":0},"TG":{"lat":8.62171,"lon":0.829683},"TK":{"lat":-8.96736,"lon":-171.8555},"TO":{"lat":-18.509050000000002,"lon":-174.795},"TT":{"lat":10.6872,"lon":-61.22085},"TN":{"lat":33.89215,"lon":9.561565},"TR":{"lat":38.9615,"lon":35.25175},"TM":{"lat":38.96835,"lon":59.56285},"TC":{"lat":21.69225,"lon":-71.80375000000001},"TV":{"lat":-8.221585,"lon":177.964},"UG":{"lat":1.3651900000000001,"lon":32.30465},"UA":{"lat":48.3799,"lon":31.16815},"AE":{"lat":24.35875,"lon":53.9825},"UK":{"lat":54.6332,"lon":-3.4322799999999996},"US":{"lat":36.9664,"lon":-95.8439},"UM":{"lat":0,"lon":0},"UY":{"lat":-32.5315,"lon":-55.758300000000006},"UZ":{"lat":41.3797,"lon":64.56445},"VU":{"lat":-16.66115,"lon":168.215},"VE":{"lat":6.4141055,"lon":-66.57895},"VN":{"lat":15.974205,"lon":105.8065},"VG":{"lat":0,"lon":0},"VI":{"lat":0,"lon":0},"WF":{"lat":-13.765699999999999,"lon":-177.1735},"EH":{"lat":24.22195,"lon":-12.88674},"YE":{"lat":15.55555,"lon":48.5315},"ZM":{"lat":-13.15193,"lon":27.85255},"ZW":{"lat":-19.01325,"lon":29.14665}} \ No newline at end of file diff --git a/examples/waf-loki-promtail/function-waf/index.mjs b/examples/waf-loki-promtail/function-waf/index.mjs new file mode 100644 index 0000000..9c36aa7 --- /dev/null +++ b/examples/waf-loki-promtail/function-waf/index.mjs @@ -0,0 +1,114 @@ +import { S3Client, GetObjectCommand } from "@aws-sdk/client-s3"; +import { createGunzip } from "node:zlib"; +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const s3 = new S3Client({}); +const LOKI_URL = process.env.LOKI_URL ?? ""; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const centroids = JSON.parse(readFileSync(join(__dirname, "country-centroids.json"), "utf-8")); + +/** Stay under Loki / API Gateway style body limits; WAF JSON lines can be large. */ +const MAX_BATCH_JSON_CHARS = 900_000; + +/** ISO 3166-1 alpha-2 → bbox-centre lat/lon (country-centroids.json; see README). */ +function enrichLineWithGeoCentroid(line) { + try { + const obj = JSON.parse(line); + const raw = obj.httpRequest?.country; + if (typeof raw !== "string") return line; + const code = raw.trim().slice(0, 2).toUpperCase(); + if (code.length !== 2) return line; + const c = centroids[code]; + if (!c) return line; + obj.geo_lat = Number(c.lat.toFixed(5)); + obj.geo_lon = Number(c.lon.toFixed(5)); + return JSON.stringify(obj); + } catch { + return line; + } +} + +/** @param {import("aws-lambda").S3Event} event */ +export async function handler(event) { + if (!LOKI_URL) { + throw new Error("LOKI_URL is not set; fix Terraform wiring to the Loki push endpoint"); + } + + for (const record of event.Records ?? []) { + const bucket = record.s3.bucket.name; + const key = decodeURIComponent(record.s3.object.key.replace(/\+/g, " ")); + await ingestObject(bucket, key); + } +} + +async function ingestObject(bucket, key) { + const out = await s3.send(new GetObjectCommand({ Bucket: bucket, Key: key })); + const { Body, ContentEncoding } = out; + + const gzipByKey = key.endsWith(".gz"); + const gzipByHeader = ContentEncoding === "gzip"; + const stream = gzipByKey || gzipByHeader ? Body.pipe(createGunzip()) : Body; + + const chunks = []; + for await (const chunk of stream) chunks.push(chunk); + const text = Buffer.concat(chunks).toString("utf-8"); + + const lines = text.split("\n").filter((l) => l.trim()); + if (!lines.length) return; + + const values = lines.map((line) => { + const enriched = enrichLineWithGeoCentroid(line); + let tsNs = String(BigInt(Date.now()) * 1_000_000n); + try { + const obj = JSON.parse(enriched); + if (typeof obj.timestamp === "number") { + tsNs = String(BigInt(obj.timestamp) * 1_000_000n); + } + } catch { + // non-JSON line: use current time + } + return [tsNs, enriched]; + }); + + const labels = { source: "waf", bucket }; + for (const batch of chunkValues(values)) { + await pushToLoki(batch, labels); + } +} + +/** Split into multiple Loki requests so no single POST exceeds safe size. */ +function chunkValues(values) { + const batches = []; + let batch = []; + let approx = 0; + + for (const pair of values) { + const [tsNs, line] = pair; + const lineCost = tsNs.length + line.length + 8; + if (batch.length > 0 && approx + lineCost > MAX_BATCH_JSON_CHARS) { + batches.push(batch); + batch = []; + approx = 0; + } + batch.push(pair); + approx += lineCost; + } + if (batch.length) batches.push(batch); + return batches; +} + +async function pushToLoki(values, labels) { + const payload = { streams: [{ stream: labels, values }] }; + const res = await fetch(LOKI_URL, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + if (!res.ok) { + const detail = await res.text(); + throw new Error(`Loki push failed ${res.status}: ${detail.slice(0, 500)}`); + } +} diff --git a/examples/waf-loki-promtail/function-waf/package-lock.json b/examples/waf-loki-promtail/function-waf/package-lock.json new file mode 100644 index 0000000..83b87da --- /dev/null +++ b/examples/waf-loki-promtail/function-waf/package-lock.json @@ -0,0 +1,1673 @@ +{ + "name": "waf-loki-ingest", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "waf-loki-ingest", + "version": "1.0.0", + "dependencies": { + "@aws-sdk/client-s3": "^3.1014.0" + } + }, + "node_modules/@aws-crypto/crc32": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/crc32/-/crc32-5.2.0.tgz", + "integrity": "sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-crypto/crc32c": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/crc32c/-/crc32c-5.2.0.tgz", + "integrity": "sha512-+iWb8qaHLYKrNvGRbiYRHSdKRWhto5XlZUEBwDjYNf+ly5SVYG6zEoYIdxvf5R3zyeP16w4PLBn3rH1xc74Rag==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-crypto/sha1-browser": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/sha1-browser/-/sha1-browser-5.2.0.tgz", + "integrity": "sha512-OH6lveCFfcDjX4dbAvCFSYUjJZjDr/3XJ3xHtjn3Oj5b9RjojQo8npoLeA/bNwkOkrSQ0wgrHzXk4tDRxGKJeg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/supports-web-crypto": "^5.2.0", + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "@aws-sdk/util-locate-window": "^3.0.0", + "@smithy/util-utf8": "^2.0.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-crypto/sha1-browser/node_modules/@smithy/is-array-buffer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz", + "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/sha1-browser/node_modules/@smithy/util-buffer-from": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz", + "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/is-array-buffer": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/sha1-browser/node_modules/@smithy/util-utf8": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz", + "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/sha256-browser": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-browser/-/sha256-browser-5.2.0.tgz", + "integrity": "sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-js": "^5.2.0", + "@aws-crypto/supports-web-crypto": "^5.2.0", + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "@aws-sdk/util-locate-window": "^3.0.0", + "@smithy/util-utf8": "^2.0.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-crypto/sha256-browser/node_modules/@smithy/is-array-buffer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz", + "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/sha256-browser/node_modules/@smithy/util-buffer-from": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz", + "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/is-array-buffer": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/sha256-browser/node_modules/@smithy/util-utf8": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz", + "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/sha256-js": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-js/-/sha256-js-5.2.0.tgz", + "integrity": "sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-crypto/supports-web-crypto": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/supports-web-crypto/-/supports-web-crypto-5.2.0.tgz", + "integrity": "sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-crypto/util": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/util/-/util-5.2.0.tgz", + "integrity": "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.222.0", + "@smithy/util-utf8": "^2.0.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-crypto/util/node_modules/@smithy/is-array-buffer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz", + "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/util/node_modules/@smithy/util-buffer-from": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz", + "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/is-array-buffer": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/util/node_modules/@smithy/util-utf8": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz", + "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/client-s3": { + "version": "3.1014.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-s3/-/client-s3-3.1014.0.tgz", + "integrity": "sha512-0XLrOT4Cm3NEhhiME7l/8LbTXS4KdsbR4dSrY207KNKTcHLLTZ9EXt4ZpgnTfLvWQF3pGP2us4Zi1fYLo0N+Ow==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha1-browser": "5.2.0", + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "^3.973.23", + "@aws-sdk/credential-provider-node": "^3.972.24", + "@aws-sdk/middleware-bucket-endpoint": "^3.972.8", + "@aws-sdk/middleware-expect-continue": "^3.972.8", + "@aws-sdk/middleware-flexible-checksums": "^3.974.3", + "@aws-sdk/middleware-host-header": "^3.972.8", + "@aws-sdk/middleware-location-constraint": "^3.972.8", + "@aws-sdk/middleware-logger": "^3.972.8", + "@aws-sdk/middleware-recursion-detection": "^3.972.8", + "@aws-sdk/middleware-sdk-s3": "^3.972.23", + "@aws-sdk/middleware-ssec": "^3.972.8", + "@aws-sdk/middleware-user-agent": "^3.972.24", + "@aws-sdk/region-config-resolver": "^3.972.9", + "@aws-sdk/signature-v4-multi-region": "^3.996.11", + "@aws-sdk/types": "^3.973.6", + "@aws-sdk/util-endpoints": "^3.996.5", + "@aws-sdk/util-user-agent-browser": "^3.972.8", + "@aws-sdk/util-user-agent-node": "^3.973.10", + "@smithy/config-resolver": "^4.4.13", + "@smithy/core": "^3.23.12", + "@smithy/eventstream-serde-browser": "^4.2.12", + "@smithy/eventstream-serde-config-resolver": "^4.3.12", + "@smithy/eventstream-serde-node": "^4.2.12", + "@smithy/fetch-http-handler": "^5.3.15", + "@smithy/hash-blob-browser": "^4.2.13", + "@smithy/hash-node": "^4.2.12", + "@smithy/hash-stream-node": "^4.2.12", + "@smithy/invalid-dependency": "^4.2.12", + "@smithy/md5-js": "^4.2.12", + "@smithy/middleware-content-length": "^4.2.12", + "@smithy/middleware-endpoint": "^4.4.27", + "@smithy/middleware-retry": "^4.4.44", + "@smithy/middleware-serde": "^4.2.15", + "@smithy/middleware-stack": "^4.2.12", + "@smithy/node-config-provider": "^4.3.12", + "@smithy/node-http-handler": "^4.5.0", + "@smithy/protocol-http": "^5.3.12", + "@smithy/smithy-client": "^4.12.7", + "@smithy/types": "^4.13.1", + "@smithy/url-parser": "^4.2.12", + "@smithy/util-base64": "^4.3.2", + "@smithy/util-body-length-browser": "^4.2.2", + "@smithy/util-body-length-node": "^4.2.3", + "@smithy/util-defaults-mode-browser": "^4.3.43", + "@smithy/util-defaults-mode-node": "^4.2.47", + "@smithy/util-endpoints": "^3.3.3", + "@smithy/util-middleware": "^4.2.12", + "@smithy/util-retry": "^4.2.12", + "@smithy/util-stream": "^4.5.20", + "@smithy/util-utf8": "^4.2.2", + "@smithy/util-waiter": "^4.2.13", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/core": { + "version": "3.973.23", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.973.23.tgz", + "integrity": "sha512-aoJncvD1XvloZ9JLnKqTRL9dBy+Szkryoag9VT+V1TqsuUgIxV9cnBVM/hrDi2vE8bDqLiDR8nirdRcCdtJu0w==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.6", + "@aws-sdk/xml-builder": "^3.972.15", + "@smithy/core": "^3.23.12", + "@smithy/node-config-provider": "^4.3.12", + "@smithy/property-provider": "^4.2.12", + "@smithy/protocol-http": "^5.3.12", + "@smithy/signature-v4": "^5.3.12", + "@smithy/smithy-client": "^4.12.7", + "@smithy/types": "^4.13.1", + "@smithy/util-base64": "^4.3.2", + "@smithy/util-middleware": "^4.2.12", + "@smithy/util-utf8": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/crc64-nvme": { + "version": "3.972.5", + "resolved": "https://registry.npmjs.org/@aws-sdk/crc64-nvme/-/crc64-nvme-3.972.5.tgz", + "integrity": "sha512-2VbTstbjKdT+yKi8m7b3a9CiVac+pL/IY2PHJwsaGkkHmuuqkJZIErPck1h6P3T9ghQMLSdMPyW6Qp7Di5swFg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-env": { + "version": "3.972.21", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.21.tgz", + "integrity": "sha512-BkAfKq8Bd4shCtec1usNz//urPJF/SZy14qJyxkSaRJQ/Vv1gVh0VZSTmS7aE6aLMELkFV5wHHrS9ZcdG8Kxsg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.973.23", + "@aws-sdk/types": "^3.973.6", + "@smithy/property-provider": "^4.2.12", + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-http": { + "version": "3.972.23", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.23.tgz", + "integrity": "sha512-4XZ3+Gu5DY8/n8zQFHBgcKTF7hWQl42G6CY9xfXVo2d25FM/lYkpmuzhYopYoPL1ITWkJ2OSBQfYEu5JRfHOhA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.973.23", + "@aws-sdk/types": "^3.973.6", + "@smithy/fetch-http-handler": "^5.3.15", + "@smithy/node-http-handler": "^4.5.0", + "@smithy/property-provider": "^4.2.12", + "@smithy/protocol-http": "^5.3.12", + "@smithy/smithy-client": "^4.12.7", + "@smithy/types": "^4.13.1", + "@smithy/util-stream": "^4.5.20", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-ini": { + "version": "3.972.23", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.972.23.tgz", + "integrity": "sha512-PZLSmU0JFpNCDFReidBezsgL5ji9jOBry8CnZdw4Jj6d0K2z3Ftnp44NXgADqYx5BLMu/ZHujfeJReaDoV+IwQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.973.23", + "@aws-sdk/credential-provider-env": "^3.972.21", + "@aws-sdk/credential-provider-http": "^3.972.23", + "@aws-sdk/credential-provider-login": "^3.972.23", + "@aws-sdk/credential-provider-process": "^3.972.21", + "@aws-sdk/credential-provider-sso": "^3.972.23", + "@aws-sdk/credential-provider-web-identity": "^3.972.23", + "@aws-sdk/nested-clients": "^3.996.13", + "@aws-sdk/types": "^3.973.6", + "@smithy/credential-provider-imds": "^4.2.12", + "@smithy/property-provider": "^4.2.12", + "@smithy/shared-ini-file-loader": "^4.4.7", + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-login": { + "version": "3.972.23", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.23.tgz", + "integrity": "sha512-OmE/pSkbMM3dCj1HdOnZ5kXnKK+R/Yz+kbBugraBecp0pGAs21eEURfQRz+1N2gzIHLVyGIP1MEjk/uSrFsngg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.973.23", + "@aws-sdk/nested-clients": "^3.996.13", + "@aws-sdk/types": "^3.973.6", + "@smithy/property-provider": "^4.2.12", + "@smithy/protocol-http": "^5.3.12", + "@smithy/shared-ini-file-loader": "^4.4.7", + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-node": { + "version": "3.972.24", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.24.tgz", + "integrity": "sha512-9Jwi7aps3AfUicJyF5udYadPypPpCwUZ6BSKr/QjRbVCpRVS1wc+1Q6AEZ/qz8J4JraeRd247pSzyMQSIHVebw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/credential-provider-env": "^3.972.21", + "@aws-sdk/credential-provider-http": "^3.972.23", + "@aws-sdk/credential-provider-ini": "^3.972.23", + "@aws-sdk/credential-provider-process": "^3.972.21", + "@aws-sdk/credential-provider-sso": "^3.972.23", + "@aws-sdk/credential-provider-web-identity": "^3.972.23", + "@aws-sdk/types": "^3.973.6", + "@smithy/credential-provider-imds": "^4.2.12", + "@smithy/property-provider": "^4.2.12", + "@smithy/shared-ini-file-loader": "^4.4.7", + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-process": { + "version": "3.972.21", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.21.tgz", + "integrity": "sha512-nRxbeOJ1E1gVA0lNQezuMVndx+ZcuyaW/RB05pUsznN5BxykSlH6KkZ/7Ca/ubJf3i5N3p0gwNO5zgPSCzj+ww==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.973.23", + "@aws-sdk/types": "^3.973.6", + "@smithy/property-provider": "^4.2.12", + "@smithy/shared-ini-file-loader": "^4.4.7", + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-sso": { + "version": "3.972.23", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.972.23.tgz", + "integrity": "sha512-APUccADuYPLL0f2htpM8Z4czabSmHOdo4r41W6lKEZdy++cNJ42Radqy6x4TopENzr3hR6WYMyhiuiqtbf/nAA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.973.23", + "@aws-sdk/nested-clients": "^3.996.13", + "@aws-sdk/token-providers": "3.1014.0", + "@aws-sdk/types": "^3.973.6", + "@smithy/property-provider": "^4.2.12", + "@smithy/shared-ini-file-loader": "^4.4.7", + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-web-identity": { + "version": "3.972.23", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.23.tgz", + "integrity": "sha512-H5JNqtIwOu/feInmMMWcK0dL5r897ReEn7n2m16Dd0DPD9gA2Hg8Cq4UDzZ/9OzaLh/uqBM6seixz0U6Fi2Eag==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.973.23", + "@aws-sdk/nested-clients": "^3.996.13", + "@aws-sdk/types": "^3.973.6", + "@smithy/property-provider": "^4.2.12", + "@smithy/shared-ini-file-loader": "^4.4.7", + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-bucket-endpoint": { + "version": "3.972.8", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-bucket-endpoint/-/middleware-bucket-endpoint-3.972.8.tgz", + "integrity": "sha512-WR525Rr2QJSETa9a050isktyWi/4yIGcmY3BQ1kpHqb0LqUglQHCS8R27dTJxxWNZvQ0RVGtEZjTCbZJpyF3Aw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.6", + "@aws-sdk/util-arn-parser": "^3.972.3", + "@smithy/node-config-provider": "^4.3.12", + "@smithy/protocol-http": "^5.3.12", + "@smithy/types": "^4.13.1", + "@smithy/util-config-provider": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-expect-continue": { + "version": "3.972.8", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-expect-continue/-/middleware-expect-continue-3.972.8.tgz", + "integrity": "sha512-5DTBTiotEES1e2jOHAq//zyzCjeMB78lEHd35u15qnrid4Nxm7diqIf9fQQ3Ov0ChH1V3Vvt13thOnrACmfGVQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.6", + "@smithy/protocol-http": "^5.3.12", + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-flexible-checksums": { + "version": "3.974.3", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-flexible-checksums/-/middleware-flexible-checksums-3.974.3.tgz", + "integrity": "sha512-fB7FNLH1+VPUs0QL3PLrHW+DD4gKu6daFgWtyq3R0Y0Lx8DLZPvyGAxCZNFBxH+M2xt9KvBJX6USwjuqvitmCQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/crc32": "5.2.0", + "@aws-crypto/crc32c": "5.2.0", + "@aws-crypto/util": "5.2.0", + "@aws-sdk/core": "^3.973.23", + "@aws-sdk/crc64-nvme": "^3.972.5", + "@aws-sdk/types": "^3.973.6", + "@smithy/is-array-buffer": "^4.2.2", + "@smithy/node-config-provider": "^4.3.12", + "@smithy/protocol-http": "^5.3.12", + "@smithy/types": "^4.13.1", + "@smithy/util-middleware": "^4.2.12", + "@smithy/util-stream": "^4.5.20", + "@smithy/util-utf8": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-host-header": { + "version": "3.972.8", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-host-header/-/middleware-host-header-3.972.8.tgz", + "integrity": "sha512-wAr2REfKsqoKQ+OkNqvOShnBoh+nkPurDKW7uAeVSu6kUECnWlSJiPvnoqxGlfousEY/v9LfS9sNc46hjSYDIQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.6", + "@smithy/protocol-http": "^5.3.12", + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-location-constraint": { + "version": "3.972.8", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-location-constraint/-/middleware-location-constraint-3.972.8.tgz", + "integrity": "sha512-KaUoFuoFPziIa98DSQsTPeke1gvGXlc5ZGMhy+b+nLxZ4A7jmJgLzjEF95l8aOQN2T/qlPP3MrAyELm8ExXucw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.6", + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-logger": { + "version": "3.972.8", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-logger/-/middleware-logger-3.972.8.tgz", + "integrity": "sha512-CWl5UCM57WUFaFi5kB7IBY1UmOeLvNZAZ2/OZ5l20ldiJ3TiIz1pC65gYj8X0BCPWkeR1E32mpsCk1L1I4n+lA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.6", + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-recursion-detection": { + "version": "3.972.8", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-recursion-detection/-/middleware-recursion-detection-3.972.8.tgz", + "integrity": "sha512-BnnvYs2ZEpdlmZ2PNlV2ZyQ8j8AEkMTjN79y/YA475ER1ByFYrkVR85qmhni8oeTaJcDqbx364wDpitDAA/wCA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.6", + "@aws/lambda-invoke-store": "^0.2.2", + "@smithy/protocol-http": "^5.3.12", + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-sdk-s3": { + "version": "3.972.23", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.972.23.tgz", + "integrity": "sha512-50QgHGPQAb2veqFOmTF1A3GsAklLHZXL47KbY35khIkfbXH5PLvqpEc/gOAEBPj/yFxrlgxz/8mqWcWTNxBkwQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.973.23", + "@aws-sdk/types": "^3.973.6", + "@aws-sdk/util-arn-parser": "^3.972.3", + "@smithy/core": "^3.23.12", + "@smithy/node-config-provider": "^4.3.12", + "@smithy/protocol-http": "^5.3.12", + "@smithy/signature-v4": "^5.3.12", + "@smithy/smithy-client": "^4.12.7", + "@smithy/types": "^4.13.1", + "@smithy/util-config-provider": "^4.2.2", + "@smithy/util-middleware": "^4.2.12", + "@smithy/util-stream": "^4.5.20", + "@smithy/util-utf8": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-ssec": { + "version": "3.972.8", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-ssec/-/middleware-ssec-3.972.8.tgz", + "integrity": "sha512-wqlK0yO/TxEC2UsY9wIlqeeutF6jjLe0f96Pbm40XscTo57nImUk9lBcw0dPgsm0sppFtAkSlDrfpK+pC30Wqw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.6", + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-user-agent": { + "version": "3.972.24", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-user-agent/-/middleware-user-agent-3.972.24.tgz", + "integrity": "sha512-dLTWy6IfAMhNiSEvMr07g/qZ54be6pLqlxVblbF6AzafmmGAzMMj8qMoY9B4+YgT+gY9IcuxZslNh03L6PyMCQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.973.23", + "@aws-sdk/types": "^3.973.6", + "@aws-sdk/util-endpoints": "^3.996.5", + "@smithy/core": "^3.23.12", + "@smithy/protocol-http": "^5.3.12", + "@smithy/types": "^4.13.1", + "@smithy/util-retry": "^4.2.12", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/nested-clients": { + "version": "3.996.13", + "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.996.13.tgz", + "integrity": "sha512-ptZ1HF4yYHNJX8cgFF+8NdYO69XJKZn7ft0/ynV3c0hCbN+89fAbrLS+fqniU2tW8o9Kfqhj8FUh+IPXb2Qsuw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "^3.973.23", + "@aws-sdk/middleware-host-header": "^3.972.8", + "@aws-sdk/middleware-logger": "^3.972.8", + "@aws-sdk/middleware-recursion-detection": "^3.972.8", + "@aws-sdk/middleware-user-agent": "^3.972.24", + "@aws-sdk/region-config-resolver": "^3.972.9", + "@aws-sdk/types": "^3.973.6", + "@aws-sdk/util-endpoints": "^3.996.5", + "@aws-sdk/util-user-agent-browser": "^3.972.8", + "@aws-sdk/util-user-agent-node": "^3.973.10", + "@smithy/config-resolver": "^4.4.13", + "@smithy/core": "^3.23.12", + "@smithy/fetch-http-handler": "^5.3.15", + "@smithy/hash-node": "^4.2.12", + "@smithy/invalid-dependency": "^4.2.12", + "@smithy/middleware-content-length": "^4.2.12", + "@smithy/middleware-endpoint": "^4.4.27", + "@smithy/middleware-retry": "^4.4.44", + "@smithy/middleware-serde": "^4.2.15", + "@smithy/middleware-stack": "^4.2.12", + "@smithy/node-config-provider": "^4.3.12", + "@smithy/node-http-handler": "^4.5.0", + "@smithy/protocol-http": "^5.3.12", + "@smithy/smithy-client": "^4.12.7", + "@smithy/types": "^4.13.1", + "@smithy/url-parser": "^4.2.12", + "@smithy/util-base64": "^4.3.2", + "@smithy/util-body-length-browser": "^4.2.2", + "@smithy/util-body-length-node": "^4.2.3", + "@smithy/util-defaults-mode-browser": "^4.3.43", + "@smithy/util-defaults-mode-node": "^4.2.47", + "@smithy/util-endpoints": "^3.3.3", + "@smithy/util-middleware": "^4.2.12", + "@smithy/util-retry": "^4.2.12", + "@smithy/util-utf8": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/region-config-resolver": { + "version": "3.972.9", + "resolved": "https://registry.npmjs.org/@aws-sdk/region-config-resolver/-/region-config-resolver-3.972.9.tgz", + "integrity": "sha512-eQ+dFU05ZRC/lC2XpYlYSPlXtX3VT8sn5toxN2Fv7EXlMoA2p9V7vUBKqHunfD4TRLpxUq8Y8Ol/nCqiv327Ng==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.6", + "@smithy/config-resolver": "^4.4.13", + "@smithy/node-config-provider": "^4.3.12", + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/signature-v4-multi-region": { + "version": "3.996.11", + "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.11.tgz", + "integrity": "sha512-SKgZY7x6AloLUXO20FJGnkKJ3a6CXzNDt6PYs2yqoPzgU0xKWcUoGGJGEBTsfM5eihKW42lbwp+sXzACLbSsaA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/middleware-sdk-s3": "^3.972.23", + "@aws-sdk/types": "^3.973.6", + "@smithy/protocol-http": "^5.3.12", + "@smithy/signature-v4": "^5.3.12", + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/token-providers": { + "version": "3.1014.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1014.0.tgz", + "integrity": "sha512-gHTHNUoaOGNrSWkl32A7wFsU78jlNTlqMccLu0byUk5CysYYXaxNMIonIVr4YcykC7vgtDS5ABuz83giy6fzJA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.973.23", + "@aws-sdk/nested-clients": "^3.996.13", + "@aws-sdk/types": "^3.973.6", + "@smithy/property-provider": "^4.2.12", + "@smithy/shared-ini-file-loader": "^4.4.7", + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/types": { + "version": "3.973.6", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.973.6.tgz", + "integrity": "sha512-Atfcy4E++beKtwJHiDln2Nby8W/mam64opFPTiHEqgsthqeydFS1pY+OUlN1ouNOmf8ArPU/6cDS65anOP3KQw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/util-arn-parser": { + "version": "3.972.3", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-arn-parser/-/util-arn-parser-3.972.3.tgz", + "integrity": "sha512-HzSD8PMFrvgi2Kserxuff5VitNq2sgf3w9qxmskKDiDTThWfVteJxuCS9JXiPIPtmCrp+7N9asfIaVhBFORllA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/util-endpoints": { + "version": "3.996.5", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.996.5.tgz", + "integrity": "sha512-Uh93L5sXFNbyR5sEPMzUU8tJ++Ku97EY4udmC01nB8Zu+xfBPwpIwJ6F7snqQeq8h2pf+8SGN5/NoytfKgYPIw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.6", + "@smithy/types": "^4.13.1", + "@smithy/url-parser": "^4.2.12", + "@smithy/util-endpoints": "^3.3.3", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/util-locate-window": { + "version": "3.965.5", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-locate-window/-/util-locate-window-3.965.5.tgz", + "integrity": "sha512-WhlJNNINQB+9qtLtZJcpQdgZw3SCDCpXdUJP7cToGwHbCWCnRckGlc6Bx/OhWwIYFNAn+FIydY8SZ0QmVu3xTQ==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/util-user-agent-browser": { + "version": "3.972.8", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-browser/-/util-user-agent-browser-3.972.8.tgz", + "integrity": "sha512-B3KGXJviV2u6Cdw2SDY2aDhoJkVfY/Q/Trwk2CMSkikE1Oi6gRzxhvhIfiRpHfmIsAhV4EA54TVEX8K6CbHbkA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.6", + "@smithy/types": "^4.13.1", + "bowser": "^2.11.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-sdk/util-user-agent-node": { + "version": "3.973.10", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-node/-/util-user-agent-node-3.973.10.tgz", + "integrity": "sha512-E99zeTscCc+pTMfsvnfi6foPpKmdD1cZfOC7/P8UUrjsoQdg9VEWPRD+xdFduKnfPXwcvby58AlO9jwwF6U96g==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/middleware-user-agent": "^3.972.24", + "@aws-sdk/types": "^3.973.6", + "@smithy/node-config-provider": "^4.3.12", + "@smithy/types": "^4.13.1", + "@smithy/util-config-provider": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "aws-crt": ">=1.0.0" + }, + "peerDependenciesMeta": { + "aws-crt": { + "optional": true + } + } + }, + "node_modules/@aws-sdk/xml-builder": { + "version": "3.972.15", + "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.15.tgz", + "integrity": "sha512-PxMRlCFNiQnke9YR29vjFQwz4jq+6Q04rOVFeTDR2K7Qpv9h9FOWOxG+zJjageimYbWqE3bTuLjmryWHAWbvaA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.13.1", + "fast-xml-parser": "5.5.8", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws/lambda-invoke-store": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.2.4.tgz", + "integrity": "sha512-iY8yvjE0y651BixKNPgmv1WrQc+GZ142sb0z4gYnChDDY2YqI4P/jsSopBWrKfAt7LOJAkOXt7rC/hms+WclQQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/abort-controller": { + "version": "4.2.12", + "resolved": "https://registry.npmjs.org/@smithy/abort-controller/-/abort-controller-4.2.12.tgz", + "integrity": "sha512-xolrFw6b+2iYGl6EcOL7IJY71vvyZ0DJ3mcKtpykqPe2uscwtzDZJa1uVQXyP7w9Dd+kGwYnPbMsJrGISKiY/Q==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/chunked-blob-reader": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/@smithy/chunked-blob-reader/-/chunked-blob-reader-5.2.2.tgz", + "integrity": "sha512-St+kVicSyayWQca+I1rGitaOEH6uKgE8IUWoYnnEX26SWdWQcL6LvMSD19Lg+vYHKdT9B2Zuu7rd3i6Wnyb/iw==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/chunked-blob-reader-native": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/@smithy/chunked-blob-reader-native/-/chunked-blob-reader-native-4.2.3.tgz", + "integrity": "sha512-jA5k5Udn7Y5717L86h4EIv06wIr3xn8GM1qHRi/Nf31annXcXHJjBKvgztnbn2TxH3xWrPBfgwHsOwZf0UmQWw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-base64": "^4.3.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/config-resolver": { + "version": "4.4.13", + "resolved": "https://registry.npmjs.org/@smithy/config-resolver/-/config-resolver-4.4.13.tgz", + "integrity": "sha512-iIzMC5NmOUP6WL6o8iPBjFhUhBZ9pPjpUpQYWMUFQqKyXXzOftbfK8zcQCz/jFV1Psmf05BK5ypx4K2r4Tnwdg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/node-config-provider": "^4.3.12", + "@smithy/types": "^4.13.1", + "@smithy/util-config-provider": "^4.2.2", + "@smithy/util-endpoints": "^3.3.3", + "@smithy/util-middleware": "^4.2.12", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/core": { + "version": "3.23.12", + "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.23.12.tgz", + "integrity": "sha512-o9VycsYNtgC+Dy3I0yrwCqv9CWicDnke0L7EVOrZtJpjb2t0EjaEofmMrYc0T1Kn3yk32zm6cspxF9u9Bj7e5w==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/protocol-http": "^5.3.12", + "@smithy/types": "^4.13.1", + "@smithy/url-parser": "^4.2.12", + "@smithy/util-base64": "^4.3.2", + "@smithy/util-body-length-browser": "^4.2.2", + "@smithy/util-middleware": "^4.2.12", + "@smithy/util-stream": "^4.5.20", + "@smithy/util-utf8": "^4.2.2", + "@smithy/uuid": "^1.1.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/credential-provider-imds": { + "version": "4.2.12", + "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.2.12.tgz", + "integrity": "sha512-cr2lR792vNZcYMriSIj+Um3x9KWrjcu98kn234xA6reOAFMmbRpQMOv8KPgEmLLtx3eldU6c5wALKFqNOhugmg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/node-config-provider": "^4.3.12", + "@smithy/property-provider": "^4.2.12", + "@smithy/types": "^4.13.1", + "@smithy/url-parser": "^4.2.12", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/eventstream-codec": { + "version": "4.2.12", + "resolved": "https://registry.npmjs.org/@smithy/eventstream-codec/-/eventstream-codec-4.2.12.tgz", + "integrity": "sha512-FE3bZdEl62ojmy8x4FHqxq2+BuOHlcxiH5vaZ6aqHJr3AIZzwF5jfx8dEiU/X0a8RboyNDjmXjlbr8AdEyLgiA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/crc32": "5.2.0", + "@smithy/types": "^4.13.1", + "@smithy/util-hex-encoding": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/eventstream-serde-browser": { + "version": "4.2.12", + "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-browser/-/eventstream-serde-browser-4.2.12.tgz", + "integrity": "sha512-XUSuMxlTxV5pp4VpqZf6Sa3vT/Q75FVkLSpSSE3KkWBvAQWeuWt1msTv8fJfgA4/jcJhrbrbMzN1AC/hvPmm5A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/eventstream-serde-universal": "^4.2.12", + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/eventstream-serde-config-resolver": { + "version": "4.3.12", + "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-config-resolver/-/eventstream-serde-config-resolver-4.3.12.tgz", + "integrity": "sha512-7epsAZ3QvfHkngz6RXQYseyZYHlmWXSTPOfPmXkiS+zA6TBNo1awUaMFL9vxyXlGdoELmCZyZe1nQE+imbmV+Q==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/eventstream-serde-node": { + "version": "4.2.12", + "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-node/-/eventstream-serde-node-4.2.12.tgz", + "integrity": "sha512-D1pFuExo31854eAvg89KMn9Oab/wEeJR6Buy32B49A9Ogdtx5fwZPqBHUlDzaCDpycTFk2+fSQgX689Qsk7UGA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/eventstream-serde-universal": "^4.2.12", + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/eventstream-serde-universal": { + "version": "4.2.12", + "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-universal/-/eventstream-serde-universal-4.2.12.tgz", + "integrity": "sha512-+yNuTiyBACxOJUTvbsNsSOfH9G9oKbaJE1lNL3YHpGcuucl6rPZMi3nrpehpVOVR2E07YqFFmtwpImtpzlouHQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/eventstream-codec": "^4.2.12", + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/fetch-http-handler": { + "version": "5.3.15", + "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.3.15.tgz", + "integrity": "sha512-T4jFU5N/yiIfrtrsb9uOQn7RdELdM/7HbyLNr6uO/mpkj1ctiVs7CihVr51w4LyQlXWDpXFn4BElf1WmQvZu/A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/protocol-http": "^5.3.12", + "@smithy/querystring-builder": "^4.2.12", + "@smithy/types": "^4.13.1", + "@smithy/util-base64": "^4.3.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/hash-blob-browser": { + "version": "4.2.13", + "resolved": "https://registry.npmjs.org/@smithy/hash-blob-browser/-/hash-blob-browser-4.2.13.tgz", + "integrity": "sha512-YrF4zWKh+ghLuquldj6e/RzE3xZYL8wIPfkt0MqCRphVICjyyjH8OwKD7LLlKpVEbk4FLizFfC1+gwK6XQdR3g==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/chunked-blob-reader": "^5.2.2", + "@smithy/chunked-blob-reader-native": "^4.2.3", + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/hash-node": { + "version": "4.2.12", + "resolved": "https://registry.npmjs.org/@smithy/hash-node/-/hash-node-4.2.12.tgz", + "integrity": "sha512-QhBYbGrbxTkZ43QoTPrK72DoYviDeg6YKDrHTMJbbC+A0sml3kSjzFtXP7BtbyJnXojLfTQldGdUR0RGD8dA3w==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.13.1", + "@smithy/util-buffer-from": "^4.2.2", + "@smithy/util-utf8": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/hash-stream-node": { + "version": "4.2.12", + "resolved": "https://registry.npmjs.org/@smithy/hash-stream-node/-/hash-stream-node-4.2.12.tgz", + "integrity": "sha512-O3YbmGExeafuM/kP7Y8r6+1y0hIh3/zn6GROx0uNlB54K9oihAL75Qtc+jFfLNliTi6pxOAYZrRKD9A7iA6UFw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.13.1", + "@smithy/util-utf8": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/invalid-dependency": { + "version": "4.2.12", + "resolved": "https://registry.npmjs.org/@smithy/invalid-dependency/-/invalid-dependency-4.2.12.tgz", + "integrity": "sha512-/4F1zb7Z8LOu1PalTdESFHR0RbPwHd3FcaG1sI3UEIriQTWakysgJr65lc1jj6QY5ye7aFsisajotH6UhWfm/g==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/is-array-buffer": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-4.2.2.tgz", + "integrity": "sha512-n6rQ4N8Jj4YTQO3YFrlgZuwKodf4zUFs7EJIWH86pSCWBaAtAGBFfCM7Wx6D2bBJ2xqFNxGBSrUWswT3M0VJow==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/md5-js": { + "version": "4.2.12", + "resolved": "https://registry.npmjs.org/@smithy/md5-js/-/md5-js-4.2.12.tgz", + "integrity": "sha512-W/oIpHCpWU2+iAkfZYyGWE+qkpuf3vEXHLxQQDx9FPNZTTdnul0dZ2d/gUFrtQ5je1G2kp4cjG0/24YueG2LbQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.13.1", + "@smithy/util-utf8": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/middleware-content-length": { + "version": "4.2.12", + "resolved": "https://registry.npmjs.org/@smithy/middleware-content-length/-/middleware-content-length-4.2.12.tgz", + "integrity": "sha512-YE58Yz+cvFInWI/wOTrB+DbvUVz/pLn5mC5MvOV4fdRUc6qGwygyngcucRQjAhiCEbmfLOXX0gntSIcgMvAjmA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/protocol-http": "^5.3.12", + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/middleware-endpoint": { + "version": "4.4.27", + "resolved": "https://registry.npmjs.org/@smithy/middleware-endpoint/-/middleware-endpoint-4.4.27.tgz", + "integrity": "sha512-T3TFfUgXQlpcg+UdzcAISdZpj4Z+XECZ/cefgA6wLBd6V4lRi0svN2hBouN/be9dXQ31X4sLWz3fAQDf+nt6BA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.23.12", + "@smithy/middleware-serde": "^4.2.15", + "@smithy/node-config-provider": "^4.3.12", + "@smithy/shared-ini-file-loader": "^4.4.7", + "@smithy/types": "^4.13.1", + "@smithy/url-parser": "^4.2.12", + "@smithy/util-middleware": "^4.2.12", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/middleware-retry": { + "version": "4.4.44", + "resolved": "https://registry.npmjs.org/@smithy/middleware-retry/-/middleware-retry-4.4.44.tgz", + "integrity": "sha512-Y1Rav7m5CFRPQyM4CI0koD/bXjyjJu3EQxZZhtLGD88WIrBrQ7kqXM96ncd6rYnojwOo/u9MXu57JrEvu/nLrA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/node-config-provider": "^4.3.12", + "@smithy/protocol-http": "^5.3.12", + "@smithy/service-error-classification": "^4.2.12", + "@smithy/smithy-client": "^4.12.7", + "@smithy/types": "^4.13.1", + "@smithy/util-middleware": "^4.2.12", + "@smithy/util-retry": "^4.2.12", + "@smithy/uuid": "^1.1.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/middleware-serde": { + "version": "4.2.15", + "resolved": "https://registry.npmjs.org/@smithy/middleware-serde/-/middleware-serde-4.2.15.tgz", + "integrity": "sha512-ExYhcltZSli0pgAKOpQQe1DLFBLryeZ22605y/YS+mQpdNWekum9Ujb/jMKfJKgjtz1AZldtwA/wCYuKJgjjlg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.23.12", + "@smithy/protocol-http": "^5.3.12", + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/middleware-stack": { + "version": "4.2.12", + "resolved": "https://registry.npmjs.org/@smithy/middleware-stack/-/middleware-stack-4.2.12.tgz", + "integrity": "sha512-kruC5gRHwsCOuyCd4ouQxYjgRAym2uDlCvQ5acuMtRrcdfg7mFBg6blaxcJ09STpt3ziEkis6bhg1uwrWU7txw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/node-config-provider": { + "version": "4.3.12", + "resolved": "https://registry.npmjs.org/@smithy/node-config-provider/-/node-config-provider-4.3.12.tgz", + "integrity": "sha512-tr2oKX2xMcO+rBOjobSwVAkV05SIfUKz8iI53rzxEmgW3GOOPOv0UioSDk+J8OpRQnpnhsO3Af6IEBabQBVmiw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/property-provider": "^4.2.12", + "@smithy/shared-ini-file-loader": "^4.4.7", + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/node-http-handler": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.5.0.tgz", + "integrity": "sha512-Rnq9vQWiR1+/I6NZZMNzJHV6pZYyEHt2ZnuV3MG8z2NNenC4i/8Kzttz7CjZiHSmsN5frhXhg17z3Zqjjhmz1A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/abort-controller": "^4.2.12", + "@smithy/protocol-http": "^5.3.12", + "@smithy/querystring-builder": "^4.2.12", + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/property-provider": { + "version": "4.2.12", + "resolved": "https://registry.npmjs.org/@smithy/property-provider/-/property-provider-4.2.12.tgz", + "integrity": "sha512-jqve46eYU1v7pZ5BM+fmkbq3DerkSluPr5EhvOcHxygxzD05ByDRppRwRPPpFrsFo5yDtCYLKu+kreHKVrvc7A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/protocol-http": { + "version": "5.3.12", + "resolved": "https://registry.npmjs.org/@smithy/protocol-http/-/protocol-http-5.3.12.tgz", + "integrity": "sha512-fit0GZK9I1xoRlR4jXmbLhoN0OdEpa96ul8M65XdmXnxXkuMxM0Y8HDT0Fh0Xb4I85MBvBClOzgSrV1X2s1Hxw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/querystring-builder": { + "version": "4.2.12", + "resolved": "https://registry.npmjs.org/@smithy/querystring-builder/-/querystring-builder-4.2.12.tgz", + "integrity": "sha512-6wTZjGABQufekycfDGMEB84BgtdOE/rCVTov+EDXQ8NHKTUNIp/j27IliwP7tjIU9LR+sSzyGBOXjeEtVgzCHg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.13.1", + "@smithy/util-uri-escape": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/querystring-parser": { + "version": "4.2.12", + "resolved": "https://registry.npmjs.org/@smithy/querystring-parser/-/querystring-parser-4.2.12.tgz", + "integrity": "sha512-P2OdvrgiAKpkPNKlKUtWbNZKB1XjPxM086NeVhK+W+wI46pIKdWBe5QyXvhUm3MEcyS/rkLvY8rZzyUdmyDZBw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/service-error-classification": { + "version": "4.2.12", + "resolved": "https://registry.npmjs.org/@smithy/service-error-classification/-/service-error-classification-4.2.12.tgz", + "integrity": "sha512-LlP29oSQN0Tw0b6D0Xo6BIikBswuIiGYbRACy5ujw/JgWSzTdYj46U83ssf6Ux0GyNJVivs2uReU8pt7Eu9okQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.13.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/shared-ini-file-loader": { + "version": "4.4.7", + "resolved": "https://registry.npmjs.org/@smithy/shared-ini-file-loader/-/shared-ini-file-loader-4.4.7.tgz", + "integrity": "sha512-HrOKWsUb+otTeo1HxVWeEb99t5ER1XrBi/xka2Wv6NVmTbuCUC1dvlrksdvxFtODLBjsC+PHK+fuy2x/7Ynyiw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/signature-v4": { + "version": "5.3.12", + "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.3.12.tgz", + "integrity": "sha512-B/FBwO3MVOL00DaRSXfXfa/TRXRheagt/q5A2NM13u7q+sHS59EOVGQNfG7DkmVtdQm5m3vOosoKAXSqn/OEgw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/is-array-buffer": "^4.2.2", + "@smithy/protocol-http": "^5.3.12", + "@smithy/types": "^4.13.1", + "@smithy/util-hex-encoding": "^4.2.2", + "@smithy/util-middleware": "^4.2.12", + "@smithy/util-uri-escape": "^4.2.2", + "@smithy/util-utf8": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/smithy-client": { + "version": "4.12.7", + "resolved": "https://registry.npmjs.org/@smithy/smithy-client/-/smithy-client-4.12.7.tgz", + "integrity": "sha512-q3gqnwml60G44FECaEEsdQMplYhDMZYCtYhMCzadCnRnnHIobZJjegmdoUo6ieLQlPUzvrMdIJUpx6DoPmzANQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.23.12", + "@smithy/middleware-endpoint": "^4.4.27", + "@smithy/middleware-stack": "^4.2.12", + "@smithy/protocol-http": "^5.3.12", + "@smithy/types": "^4.13.1", + "@smithy/util-stream": "^4.5.20", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/types": { + "version": "4.13.1", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.13.1.tgz", + "integrity": "sha512-787F3yzE2UiJIQ+wYW1CVg2odHjmaWLGksnKQHUrK/lYZSEcy1msuLVvxaR/sI2/aDe9U+TBuLsXnr3vod1g0g==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/url-parser": { + "version": "4.2.12", + "resolved": "https://registry.npmjs.org/@smithy/url-parser/-/url-parser-4.2.12.tgz", + "integrity": "sha512-wOPKPEpso+doCZGIlr+e1lVI6+9VAKfL4kZWFgzVgGWY2hZxshNKod4l2LXS3PRC9otH/JRSjtEHqQ/7eLciRA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/querystring-parser": "^4.2.12", + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-base64": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@smithy/util-base64/-/util-base64-4.3.2.tgz", + "integrity": "sha512-XRH6b0H/5A3SgblmMa5ErXQ2XKhfbQB+Fm/oyLZ2O2kCUrwgg55bU0RekmzAhuwOjA9qdN5VU2BprOvGGUkOOQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^4.2.2", + "@smithy/util-utf8": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-body-length-browser": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@smithy/util-body-length-browser/-/util-body-length-browser-4.2.2.tgz", + "integrity": "sha512-JKCrLNOup3OOgmzeaKQwi4ZCTWlYR5H4Gm1r2uTMVBXoemo1UEghk5vtMi1xSu2ymgKVGW631e2fp9/R610ZjQ==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-body-length-node": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/@smithy/util-body-length-node/-/util-body-length-node-4.2.3.tgz", + "integrity": "sha512-ZkJGvqBzMHVHE7r/hcuCxlTY8pQr1kMtdsVPs7ex4mMU+EAbcXppfo5NmyxMYi2XU49eqaz56j2gsk4dHHPG/g==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-buffer-from": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-4.2.2.tgz", + "integrity": "sha512-FDXD7cvUoFWwN6vtQfEta540Y/YBe5JneK3SoZg9bThSoOAC/eGeYEua6RkBgKjGa/sz6Y+DuBZj3+YEY21y4Q==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/is-array-buffer": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-config-provider": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@smithy/util-config-provider/-/util-config-provider-4.2.2.tgz", + "integrity": "sha512-dWU03V3XUprJwaUIFVv4iOnS1FC9HnMHDfUrlNDSh4315v0cWyaIErP8KiqGVbf5z+JupoVpNM7ZB3jFiTejvQ==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-defaults-mode-browser": { + "version": "4.3.43", + "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-browser/-/util-defaults-mode-browser-4.3.43.tgz", + "integrity": "sha512-Qd/0wCKMaXxev/z00TvNzGCH2jlKKKxXP1aDxB6oKwSQthe3Og2dMhSayGCnsma1bK/kQX1+X7SMP99t6FgiiQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/property-provider": "^4.2.12", + "@smithy/smithy-client": "^4.12.7", + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-defaults-mode-node": { + "version": "4.2.47", + "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-node/-/util-defaults-mode-node-4.2.47.tgz", + "integrity": "sha512-qSRbYp1EQ7th+sPFuVcVO05AE0QH635hycdEXlpzIahqHHf2Fyd/Zl+8v0XYMJ3cgDVPa0lkMefU7oNUjAP+DQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/config-resolver": "^4.4.13", + "@smithy/credential-provider-imds": "^4.2.12", + "@smithy/node-config-provider": "^4.3.12", + "@smithy/property-provider": "^4.2.12", + "@smithy/smithy-client": "^4.12.7", + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-endpoints": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/@smithy/util-endpoints/-/util-endpoints-3.3.3.tgz", + "integrity": "sha512-VACQVe50j0HZPjpwWcjyT51KUQ4AnsvEaQ2lKHOSL4mNLD0G9BjEniQ+yCt1qqfKfiAHRAts26ud7hBjamrwig==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/node-config-provider": "^4.3.12", + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-hex-encoding": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@smithy/util-hex-encoding/-/util-hex-encoding-4.2.2.tgz", + "integrity": "sha512-Qcz3W5vuHK4sLQdyT93k/rfrUwdJ8/HZ+nMUOyGdpeGA1Wxt65zYwi3oEl9kOM+RswvYq90fzkNDahPS8K0OIg==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-middleware": { + "version": "4.2.12", + "resolved": "https://registry.npmjs.org/@smithy/util-middleware/-/util-middleware-4.2.12.tgz", + "integrity": "sha512-Er805uFUOvgc0l8nv0e0su0VFISoxhJ/AwOn3gL2NWNY2LUEldP5WtVcRYSQBcjg0y9NfG8JYrCJaYDpupBHJQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-retry": { + "version": "4.2.12", + "resolved": "https://registry.npmjs.org/@smithy/util-retry/-/util-retry-4.2.12.tgz", + "integrity": "sha512-1zopLDUEOwumjcHdJ1mwBHddubYF8GMQvstVCLC54Y46rqoHwlIU+8ZzUeaBcD+WCJHyDGSeZ2ml9YSe9aqcoQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/service-error-classification": "^4.2.12", + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-stream": { + "version": "4.5.20", + "resolved": "https://registry.npmjs.org/@smithy/util-stream/-/util-stream-4.5.20.tgz", + "integrity": "sha512-4yXLm5n/B5SRBR2p8cZ90Sbv4zL4NKsgxdzCzp/83cXw2KxLEumt5p+GAVyRNZgQOSrzXn9ARpO0lUe8XSlSDw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/fetch-http-handler": "^5.3.15", + "@smithy/node-http-handler": "^4.5.0", + "@smithy/types": "^4.13.1", + "@smithy/util-base64": "^4.3.2", + "@smithy/util-buffer-from": "^4.2.2", + "@smithy/util-hex-encoding": "^4.2.2", + "@smithy/util-utf8": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-uri-escape": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@smithy/util-uri-escape/-/util-uri-escape-4.2.2.tgz", + "integrity": "sha512-2kAStBlvq+lTXHyAZYfJRb/DfS3rsinLiwb+69SstC9Vb0s9vNWkRwpnj918Pfi85mzi42sOqdV72OLxWAISnw==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-utf8": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-4.2.2.tgz", + "integrity": "sha512-75MeYpjdWRe8M5E3AW0O4Cx3UadweS+cwdXjwYGBW5h/gxxnbeZ877sLPX/ZJA9GVTlL/qG0dXP29JWFCD1Ayw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-waiter": { + "version": "4.2.13", + "resolved": "https://registry.npmjs.org/@smithy/util-waiter/-/util-waiter-4.2.13.tgz", + "integrity": "sha512-2zdZ9DTHngRtcYxJK1GUDxruNr53kv5W2Lupe0LMU+Imr6ohQg8M2T14MNkj1Y0wS3FFwpgpGQyvuaMF7CiTmQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/abort-controller": "^4.2.12", + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/uuid": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@smithy/uuid/-/uuid-1.1.2.tgz", + "integrity": "sha512-O/IEdcCUKkubz60tFbGA7ceITTAJsty+lBjNoorP4Z6XRqaFb/OjQjZODophEcuq68nKm6/0r+6/lLQ+XVpk8g==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/bowser": { + "version": "2.14.1", + "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.14.1.tgz", + "integrity": "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==", + "license": "MIT" + }, + "node_modules/fast-xml-builder": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.1.4.tgz", + "integrity": "sha512-f2jhpN4Eccy0/Uz9csxh3Nu6q4ErKxf0XIsasomfOihuSUa3/xw6w8dnOtCDgEItQFJG8KyXPzQXzcODDrrbOg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "path-expression-matcher": "^1.1.3" + } + }, + "node_modules/fast-xml-parser": { + "version": "5.5.8", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.5.8.tgz", + "integrity": "sha512-Z7Fh2nVQSb2d+poDViM063ix2ZGt9jmY1nWhPfHBOK2Hgnb/OW3P4Et3P/81SEej0J7QbWtJqxO05h8QYfK7LQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "fast-xml-builder": "^1.1.4", + "path-expression-matcher": "^1.2.0", + "strnum": "^2.2.0" + }, + "bin": { + "fxparser": "src/cli/cli.js" + } + }, + "node_modules/path-expression-matcher": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.2.0.tgz", + "integrity": "sha512-DwmPWeFn+tq7TiyJ2CxezCAirXjFxvaiD03npak3cRjlP9+OjTmSy1EpIrEbh+l6JgUundniloMLDQ/6VTdhLQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/strnum": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.2.1.tgz", + "integrity": "sha512-BwRvNd5/QoAtyW1na1y1LsJGQNvRlkde6Q/ipqqEaivoMdV+B1OMOTVdwR+N/cwVUcIt9PYyHmV8HyexCZSupg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT" + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + } + } +} diff --git a/examples/waf-loki-promtail/function-waf/package.json b/examples/waf-loki-promtail/function-waf/package.json new file mode 100644 index 0000000..03d85f4 --- /dev/null +++ b/examples/waf-loki-promtail/function-waf/package.json @@ -0,0 +1,6 @@ +{ + "name": "waf-loki-ingest", + "version": "1.0.0", + "description": "WAF S3 log ingest to Loki push — runs on Lambda Managed Instance (Node.js 24, x86_64)", + "type": "module" +} diff --git a/examples/waf-loki-promtail/main.tf b/examples/waf-loki-promtail/main.tf new file mode 100644 index 0000000..e9ac730 --- /dev/null +++ b/examples/waf-loki-promtail/main.tf @@ -0,0 +1,449 @@ +provider "aws" { + region = var.aws_region +} + +module "vpc" { + source = "../../modules/vpc" + + vpc_name = "${var.name_prefix}-vpc" + vpc_cidr = var.vpc_cidr + tags = var.tags + availability_zones = var.availability_zones + public_subnet_cidrs = var.public_subnet_cidrs + private_subnet_cidrs = var.private_subnet_cidrs +} + +resource "aws_security_group" "lmi" { + name_prefix = "${var.name_prefix}-lmi-" + description = "Lambda Managed Instances capacity provider ENIs; egress for CloudWatch Logs and Lambda control plane" + vpc_id = module.vpc.vpc_id + + egress { + from_port = 0 + to_port = 0 + protocol = "-1" + cidr_blocks = ["0.0.0.0/0"] + } + + tags = merge( + var.tags, + { Name = "${var.name_prefix}-lmi" } + ) + + lifecycle { + create_before_destroy = true + } +} + +# Walkthrough reference: every lambda_managed_instance input is explicit below (values match +# module defaults unless noted) so the site can document each argument without inferring defaults. +module "lambda_managed_instance" { + source = "../../modules/lambda_managed_instance" + + # --- Identity + capacity_provider_name = "${var.name_prefix}-capacity" + iam_role_name_prefix = var.name_prefix # walkthrough: use name_prefix; module default is "lmi" + + # --- Capacity provider and scaling + max_vcpu_count = 16 + scaling_mode = "Auto" + cpu_target_utilization = 70 # used when scaling_mode = "Manual"; ignored for Auto + + allowed_instance_types = [] # mutually exclusive with excluded_instance_types + excluded_instance_types = [] + + # --- VPC placement (capacity provider) + subnet_ids = module.vpc.private_subnet_ids + security_group_ids = [aws_security_group.lmi.id] + + tags = var.tags +} + +# ── Data sources (shared by WAF + observability sections) ─────────────────── + +data "http" "my_public_ip" { + url = "https://checkip.amazonaws.com/" +} + +data "aws_ami" "al2023" { + most_recent = true + owners = ["amazon"] + filter { + name = "name" + values = ["al2023-ami-*-x86_64"] + } +} + +locals { + my_public_ip_cidr = "${trimspace(data.http.my_public_ip.response_body)}/32" + alb_ingress_cidrs = length(var.alb_ingress_cidrs) > 0 ? var.alb_ingress_cidrs : [local.my_public_ip_cidr] + loki_push_url = "http://${aws_instance.obs.private_ip}:3100/loki/api/v1/push" +} + +# ── WAF log bucket (existing; not managed by this stack) ───────────────────── +# Terraform only reads the bucket for IAM, notifications, and optional WAF logging. +# Create and secure the bucket outside this configuration. + +data "aws_s3_bucket" "waf_logs" { + bucket = var.waf_logs_bucket_name +} + +# ── IAM: WAF Lambda S3 read ────────────────────────────────────────────────── + +resource "aws_iam_policy" "waf_s3_read" { + name_prefix = "${var.name_prefix}-waf-s3-" + description = "Allow WAF ingest Lambda to read objects from the WAF log bucket" + + policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Effect = "Allow" + Action = ["s3:GetObject"] + Resource = "${data.aws_s3_bucket.waf_logs.arn}/*" + }, + { + Effect = "Allow" + Action = ["s3:ListBucket"] + Resource = data.aws_s3_bucket.waf_logs.arn + } + ] + }) +} + +# ── WAF ingest Lambda (Node.js 24) ─────────────────────────────────────────── + +data "archive_file" "waf_zip" { + type = "zip" + source_dir = "${path.module}/function-waf" + output_path = "${path.module}/.build/waf.zip" + # Runtime ships AWS SDK v3; local node_modules (if present) must not inflate the artifact. + excludes = ["node_modules/**"] +} + +module "lambda_managed_function_waf" { + source = "../../modules/lambda_managed_function" + + # --- Identity + function_name = "${var.name_prefix}-waf-fn" + capacity_provider_arn = module.lambda_managed_instance.capacity_provider_arn + iam_role_name_prefix = "${var.name_prefix}-waf" + description = "WAF S3 log ingest - reads gzip WAF log objects and pushes to Loki" + + # --- Deployment artifact + filename = data.archive_file.waf_zip.output_path + source_code_hash = data.archive_file.waf_zip.output_base64sha256 + + # --- Function + runtime = "nodejs24.x" + handler = "index.handler" + architectures = ["x86_64"] + + memory_size = 2048 + timeout = 60 + ephemeral_storage_size = 512 + + layers = [] + environment_variables = { LOKI_URL = local.loki_push_url } + reserved_concurrent_executions = -1 + + # --- Logging + log_retention_days = 14 + cloudwatch_log_group_prevent_destroy = false + log_format = "JSON" + application_log_level = "INFO" + system_log_level = "WARN" + + # --- Concurrency + per_execution_environment_max_concurrency = 10 + + # --- IAM: add S3 read for waf_logs bucket + additional_execution_policy_arns = [aws_iam_policy.waf_s3_read.arn] + + tags = var.tags +} + +resource "aws_lambda_permission" "s3_invoke_waf" { + statement_id = "AllowS3Invoke" + action = "lambda:InvokeFunction" + function_name = module.lambda_managed_function_waf.lambda_function_name + qualifier = module.lambda_managed_function_waf.lambda_version + principal = "s3.amazonaws.com" + source_arn = data.aws_s3_bucket.waf_logs.arn +} + +resource "aws_s3_bucket_notification" "waf_logs" { + bucket = data.aws_s3_bucket.waf_logs.id + + lambda_function { + # S3 requires arn:aws:lambda:...:function:name[:version]; not qualified_invoke_arn (API Gateway style). + lambda_function_arn = module.lambda_managed_function_waf.lambda_qualified_arn + events = ["s3:ObjectCreated:*"] + filter_prefix = var.waf_logs_prefix != "" ? var.waf_logs_prefix : null + filter_suffix = var.waf_logs_object_suffix != "" ? var.waf_logs_object_suffix : null + } + + depends_on = [aws_lambda_permission.s3_invoke_waf] +} + +# ── Optional: WAFv2 logging configuration ──────────────────────────────────── +# Set var.web_acl_arn to an existing WAFv2 Web ACL ARN to enable WAF log delivery. + +resource "aws_wafv2_web_acl_logging_configuration" "this" { + count = var.web_acl_arn != "" ? 1 : 0 + + resource_arn = var.web_acl_arn + log_destination_configs = [data.aws_s3_bucket.waf_logs.arn] +} + +# ── Observability: security groups ─────────────────────────────────────────── + +resource "aws_security_group" "alb" { + name_prefix = "${var.name_prefix}-alb-" + description = "Grafana ALB - HTTP ingress from allowed CIDRs" + vpc_id = module.vpc.vpc_id + + ingress { + from_port = 80 + to_port = 80 + protocol = "tcp" + cidr_blocks = local.alb_ingress_cidrs + description = "HTTP from allowed CIDRs" + } + + egress { + from_port = 0 + to_port = 0 + protocol = "-1" + cidr_blocks = ["0.0.0.0/0"] + } + + tags = merge(var.tags, { Name = "${var.name_prefix}-alb" }) + + lifecycle { + create_before_destroy = true + } +} + +resource "aws_security_group" "obs" { + name_prefix = "${var.name_prefix}-obs-" + description = "Loki + Grafana EC2 host" + vpc_id = module.vpc.vpc_id + + ingress { + from_port = 3100 + to_port = 3100 + protocol = "tcp" + security_groups = [aws_security_group.lmi.id] + description = "Loki push from Lambda ENIs" + } + + ingress { + from_port = 3000 + to_port = 3000 + protocol = "tcp" + security_groups = [aws_security_group.alb.id] + description = "Grafana from ALB" + } + + egress { + from_port = 0 + to_port = 0 + protocol = "-1" + cidr_blocks = ["0.0.0.0/0"] + } + + tags = merge(var.tags, { Name = "${var.name_prefix}-obs" }) + + lifecycle { + create_before_destroy = true + } +} + +# ── Observability: EC2 instance profile (SSM access, no SSH key required) ─── + +data "aws_iam_policy_document" "obs_assume_role" { + statement { + actions = ["sts:AssumeRole"] + principals { + type = "Service" + identifiers = ["ec2.amazonaws.com"] + } + } +} + +resource "aws_iam_role" "obs_ec2" { + name_prefix = "${var.name_prefix}-obs-" + assume_role_policy = data.aws_iam_policy_document.obs_assume_role.json + tags = var.tags +} + +resource "aws_iam_role_policy_attachment" "obs_ssm" { + role = aws_iam_role.obs_ec2.name + policy_arn = "arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore" +} + +resource "aws_iam_instance_profile" "obs_ec2" { + name_prefix = "${var.name_prefix}-obs-" + role = aws_iam_role.obs_ec2.name + tags = var.tags +} + +# ── S3 bootstrap for obs user_data (EC2 user_data max 16 KiB; three dashboards exceed that when embedded) ── + +resource "aws_s3_bucket" "obs_bootstrap" { + bucket_prefix = "${var.name_prefix}-obs-bootstrap-" + force_destroy = true + tags = merge(var.tags, { Name = "${var.name_prefix}-obs-bootstrap" }) +} + +resource "aws_s3_bucket_public_access_block" "obs_bootstrap" { + bucket = aws_s3_bucket.obs_bootstrap.id + + block_public_acls = true + block_public_policy = true + ignore_public_acls = true + restrict_public_buckets = true +} + +resource "aws_s3_bucket_server_side_encryption_configuration" "obs_bootstrap" { + bucket = aws_s3_bucket.obs_bootstrap.id + + rule { + apply_server_side_encryption_by_default { + sse_algorithm = "AES256" + } + } +} + +resource "aws_s3_object" "obs_bootstrap_grafana_waf" { + bucket = aws_s3_bucket.obs_bootstrap.id + key = "bootstrap/grafana/waf.json" + source = "${path.module}/templates/dashboards/waf.json" + etag = filemd5("${path.module}/templates/dashboards/waf.json") + content_type = "application/json" +} + +resource "aws_s3_object" "obs_bootstrap_grafana_waf_overview" { + bucket = aws_s3_bucket.obs_bootstrap.id + key = "bootstrap/grafana/waf-overview.json" + source = "${path.module}/templates/dashboards/waf-overview.json" + etag = filemd5("${path.module}/templates/dashboards/waf-overview.json") + content_type = "application/json" +} + +resource "aws_s3_object" "obs_bootstrap_grafana_waf_geomap" { + bucket = aws_s3_bucket.obs_bootstrap.id + key = "bootstrap/grafana/waf-geomap.json" + source = "${path.module}/templates/dashboards/waf-geomap.json" + etag = filemd5("${path.module}/templates/dashboards/waf-geomap.json") + content_type = "application/json" +} + +resource "aws_iam_role_policy" "obs_bootstrap_s3_read" { + name = "${var.name_prefix}-obs-bootstrap-s3-read" + role = aws_iam_role.obs_ec2.id + + policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Effect = "Allow" + Action = ["s3:GetObject"] + Resource = [ + "${aws_s3_bucket.obs_bootstrap.arn}/bootstrap/*", + ] + }, + ] + }) +} + +# ── Observability: EC2 instance (private subnet, EBS-backed Loki data) ─────── + +resource "aws_instance" "obs" { + ami = data.aws_ami.al2023.id + instance_type = var.obs_instance_type + subnet_id = module.vpc.private_subnet_ids[0] + vpc_security_group_ids = [aws_security_group.obs.id] + iam_instance_profile = aws_iam_instance_profile.obs_ec2.name + + user_data = templatefile("${path.module}/templates/user_data.sh.tftpl", { + bootstrap_bucket = aws_s3_bucket.obs_bootstrap.bucket + aws_region = var.aws_region + }) + user_data_replace_on_change = true + + depends_on = [ + aws_s3_object.obs_bootstrap_grafana_waf, + aws_s3_object.obs_bootstrap_grafana_waf_overview, + aws_s3_object.obs_bootstrap_grafana_waf_geomap, + aws_iam_role_policy.obs_bootstrap_s3_read, + ] + + metadata_options { + http_endpoint = "enabled" + http_tokens = "required" + http_put_response_hop_limit = 1 + } + + root_block_device { + volume_size = 30 + volume_type = "gp3" + encrypted = true + delete_on_termination = true + } + + tags = merge(var.tags, { Name = "${var.name_prefix}-obs" }) +} + +# ── Observability: Application Load Balancer (Grafana) ─────────────────────── + +resource "aws_lb" "grafana" { + name = "${var.name_prefix}-grafana" + internal = false + load_balancer_type = "application" + security_groups = [aws_security_group.alb.id] + subnets = module.vpc.public_subnet_ids + drop_invalid_header_fields = true + + tags = merge(var.tags, { Name = "${var.name_prefix}-grafana" }) +} + +resource "aws_lb_target_group" "grafana" { + name = "${var.name_prefix}-grafana" + port = 3000 + protocol = "HTTP" + vpc_id = module.vpc.vpc_id + target_type = "instance" + + health_check { + enabled = true + protocol = "HTTP" + path = "/api/health" + port = "traffic-port" + matcher = "200" + healthy_threshold = 2 + unhealthy_threshold = 3 + interval = 30 + timeout = 5 + } + + tags = merge(var.tags, { Name = "${var.name_prefix}-grafana" }) +} + +resource "aws_lb_listener" "grafana" { + load_balancer_arn = aws_lb.grafana.arn + port = 80 + protocol = "HTTP" + + default_action { + type = "forward" + target_group_arn = aws_lb_target_group.grafana.arn + } +} + +resource "aws_lb_target_group_attachment" "grafana" { + target_group_arn = aws_lb_target_group.grafana.arn + target_id = aws_instance.obs.id + port = 3000 +} diff --git a/examples/waf-loki-promtail/outputs.tf b/examples/waf-loki-promtail/outputs.tf new file mode 100644 index 0000000..fd00fef --- /dev/null +++ b/examples/waf-loki-promtail/outputs.tf @@ -0,0 +1,55 @@ +output "vpc_id" { + value = module.vpc.vpc_id +} + +output "private_subnet_ids" { + value = module.vpc.private_subnet_ids +} + +output "capacity_provider_name" { + value = module.lambda_managed_instance.capacity_provider_name +} + +# ── WAF ingest Lambda ──────────────────────────────────────────────────────── + +output "waf_function_name" { + description = "WAF ingest Lambda function name" + value = module.lambda_managed_function_waf.lambda_function_name +} + +output "waf_function_version" { + description = "Published WAF ingest Lambda version" + value = module.lambda_managed_function_waf.lambda_version +} + +output "waf_log_group_name" { + description = "CloudWatch log group for the WAF ingest Lambda" + value = module.lambda_managed_function_waf.lambda_log_group_name +} + +output "waf_logs_bucket" { + description = "Existing S3 bucket name wired for WAF log delivery and Lambda trigger" + value = data.aws_s3_bucket.waf_logs.id +} + +# ── Observability ──────────────────────────────────────────────────────────── + +output "grafana_url" { + description = "Grafana URL via the public ALB - open in browser (admin / admin)" + value = "http://${aws_lb.grafana.dns_name}" +} + +output "loki_push_url" { + description = "Loki push API endpoint used by the WAF ingest Lambda" + value = local.loki_push_url +} + +output "obs_instance_id" { + description = "EC2 instance ID of the Loki + Grafana host (connect via SSM Session Manager)" + value = aws_instance.obs.id +} + +output "obs_bootstrap_bucket" { + description = "S3 bucket holding Grafana dashboards and Promtail sample JSONL copied at EC2 bootstrap" + value = aws_s3_bucket.obs_bootstrap.bucket +} diff --git a/examples/waf-loki-promtail/templates/dashboards/waf-geomap.json b/examples/waf-loki-promtail/templates/dashboards/waf-geomap.json new file mode 100644 index 0000000..080dfef --- /dev/null +++ b/examples/waf-loki-promtail/templates/dashboards/waf-geomap.json @@ -0,0 +1,107 @@ +{ + "id": null, + "title": "WAF geographic (country)", + "uid": "waf-geomap", + "schemaVersion": 39, + "version": 1, + "refresh": "30s", + "time": { "from": "now-24h", "to": "now" }, + "panels": [ + { + "id": 1, + "type": "geomap", + "title": "WAF requests (country centroid)", + "description": "Lambda adds geo_lat / geo_lon from httpRequest.country using ISO2 → bounding-box centre (not client IP precision). Grafana uses coordinate mode so the map does not rely on country-code lookup in the panel.", + "gridPos": { "h": 14, "w": 24, "x": 0, "y": 0 }, + "datasource": { "type": "loki", "uid": "loki" }, + "fieldConfig": { + "defaults": { + "custom": { + "hideFrom": { "legend": false, "tooltip": false, "viz": false } + } + }, + "overrides": [] + }, + "targets": [ + { + "refId": "A", + "datasource": { "type": "loki", "uid": "loki" }, + "editorMode": "code", + "expr": "{source=\"waf\"} |= \"geo_lat\"", + "queryType": "range", + "maxLines": 2000, + "legendFormat": "" + } + ], + "transformations": [ + { + "id": "extractFields", + "options": { + "format": "json", + "keepTime": false, + "replace": false, + "source": "Line" + } + } + ], + "options": { + "basemap": { "config": {}, "name": "Layer 0", "type": "default" }, + "controls": { + "mouseWheelZoom": true, + "showAttribution": true, + "showDebug": false, + "showMeasure": false, + "showScale": true, + "showZoom": true + }, + "layers": [ + { + "type": "markers", + "name": "Country centre", + "config": { + "showLegend": true, + "style": { + "color": { "fixed": "semi-dark-blue" }, + "opacity": 0.85, + "shape": "circle", + "size": { "fixed": 12, "max": 18, "min": 6 } + } + }, + "location": { + "mode": "coords", + "latitude": "geo_lat", + "longitude": "geo_lon" + }, + "tooltip": true, + "opacity": 1 + } + ], + "tooltip": { "mode": "details" }, + "view": { "allLayers": true, "id": "zero", "lat": 20, "lon": 0, "zoom": 1.2 } + } + }, + { + "id": 2, + "type": "logs", + "title": "WAF lines (with geo when country maps)", + "gridPos": { "h": 12, "w": 24, "x": 0, "y": 14 }, + "datasource": { "type": "loki", "uid": "loki" }, + "targets": [ + { + "refId": "A", + "datasource": { "type": "loki", "uid": "loki" }, + "editorMode": "code", + "expr": "{source=\"waf\"}", + "queryType": "range" + } + ], + "options": { + "dedupStrategy": "none", + "enableLogDetails": true, + "showTime": true, + "sortOrder": "Descending", + "wrapLogMessage": true + } + } + ] +} diff --git a/examples/waf-loki-promtail/templates/dashboards/waf-overview.json b/examples/waf-loki-promtail/templates/dashboards/waf-overview.json new file mode 100644 index 0000000..a90d0c7 --- /dev/null +++ b/examples/waf-loki-promtail/templates/dashboards/waf-overview.json @@ -0,0 +1,145 @@ +{ + "id": null, + "title": "WAF overview", + "uid": "waf-overview", + "schemaVersion": 39, + "version": 1, + "refresh": "30s", + "time": { "from": "now-6h", "to": "now" }, + "panels": [ + { + "id": 1, + "type": "timeseries", + "title": "Log lines per minute", + "gridPos": { "h": 7, "w": 24, "x": 0, "y": 0 }, + "datasource": { "type": "loki", "uid": "loki" }, + "targets": [ + { + "refId": "A", + "datasource": { "type": "loki", "uid": "loki" }, + "editorMode": "code", + "expr": "sum(count_over_time({source=\"waf\"}[1m]))", + "queryType": "range", + "legendFormat": "lines/min" + } + ], + "fieldConfig": { "defaults": { "unit": "none" }, "overrides": [] }, + "options": { + "legend": { "displayMode": "list", "placement": "bottom", "showLegend": true }, + "tooltip": { "mode": "single" } + } + }, + { + "id": 2, + "type": "timeseries", + "title": "Requests by action (ALLOW / BLOCK / COUNT / CAPTCHA)", + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 7 }, + "datasource": { "type": "loki", "uid": "loki" }, + "targets": [ + { + "refId": "A", + "datasource": { "type": "loki", "uid": "loki" }, + "editorMode": "code", + "expr": "sum by (action) (count_over_time({source=\"waf\"} | json | action != \"\" [1m]))", + "queryType": "range", + "legendFormat": "{{action}}" + } + ], + "fieldConfig": { "defaults": {}, "overrides": [] }, + "options": { + "legend": { "displayMode": "list", "placement": "bottom", "showLegend": true }, + "tooltip": { "mode": "multi" } + } + }, + { + "id": 3, + "type": "timeseries", + "title": "By terminatingRuleId", + "description": "Which rule ended evaluation (Default_Action for many allows).", + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 7 }, + "datasource": { "type": "loki", "uid": "loki" }, + "targets": [ + { + "refId": "A", + "datasource": { "type": "loki", "uid": "loki" }, + "editorMode": "code", + "expr": "sum by (terminatingRuleId) (count_over_time({source=\"waf\"} | json | terminatingRuleId != \"\" [1m]))", + "queryType": "range", + "legendFormat": "{{terminatingRuleId}}" + } + ], + "fieldConfig": { "defaults": {}, "overrides": [] }, + "options": { + "legend": { "displayMode": "list", "placement": "bottom", "showLegend": true }, + "tooltip": { "mode": "multi" } + } + }, + { + "id": 4, + "type": "timeseries", + "title": "Top client IPs (httpRequest.clientIp)", + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 15 }, + "datasource": { "type": "loki", "uid": "loki" }, + "targets": [ + { + "refId": "A", + "datasource": { "type": "loki", "uid": "loki" }, + "editorMode": "code", + "expr": "topk(10, sum by (clientIp) (count_over_time({source=\"waf\"} | regexp `\"clientIp\":\"(?P[^\"]+)\"` | clientIp != \"\" [1m])))", + "queryType": "range", + "legendFormat": "{{clientIp}}" + } + ], + "fieldConfig": { "defaults": {}, "overrides": [] }, + "options": { + "legend": { "displayMode": "list", "placement": "bottom", "showLegend": true }, + "tooltip": { "mode": "multi" } + } + }, + { + "id": 5, + "type": "timeseries", + "title": "By HTTP method", + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 15 }, + "datasource": { "type": "loki", "uid": "loki" }, + "targets": [ + { + "refId": "A", + "datasource": { "type": "loki", "uid": "loki" }, + "editorMode": "code", + "expr": "sum by (method) (count_over_time({source=\"waf\"} | regexp `\"httpMethod\":\"(?P[A-Z]+)\"` | method != \"\" [1m]))", + "queryType": "range", + "legendFormat": "{{method}}" + } + ], + "fieldConfig": { "defaults": {}, "overrides": [] }, + "options": { + "legend": { "displayMode": "list", "placement": "bottom", "showLegend": true }, + "tooltip": { "mode": "multi" } + } + }, + { + "id": 6, + "type": "logs", + "title": "BLOCK only (quick triage)", + "gridPos": { "h": 10, "w": 24, "x": 0, "y": 23 }, + "datasource": { "type": "loki", "uid": "loki" }, + "targets": [ + { + "refId": "A", + "datasource": { "type": "loki", "uid": "loki" }, + "editorMode": "code", + "expr": "{source=\"waf\"} | json | action=\"BLOCK\"", + "queryType": "range" + } + ], + "options": { + "dedupStrategy": "none", + "enableLogDetails": true, + "showTime": true, + "sortOrder": "Descending", + "wrapLogMessage": true + } + } + ] +} diff --git a/examples/waf-loki-promtail/templates/dashboards/waf.json b/examples/waf-loki-promtail/templates/dashboards/waf.json new file mode 100644 index 0000000..c1019dc --- /dev/null +++ b/examples/waf-loki-promtail/templates/dashboards/waf.json @@ -0,0 +1,32 @@ +{ + "id": null, + "title": "WAF Logs", + "uid": "waf-logs", + "schemaVersion": 39, + "version": 1, + "refresh": "30s", + "time": { "from": "now-1h", "to": "now" }, + "panels": [ + { + "id": 1, + "title": "WAF Log Stream", + "type": "logs", + "gridPos": { "h": 20, "w": 24, "x": 0, "y": 0 }, + "datasource": { "type": "loki", "uid": "loki" }, + "targets": [ + { + "refId": "A", + "datasource": { "type": "loki", "uid": "loki" }, + "expr": "{source=\"waf\"}" + } + ], + "options": { + "dedupStrategy": "none", + "enableLogDetails": true, + "showTime": true, + "sortOrder": "Descending", + "wrapLogMessage": true + } + } + ] +} diff --git a/examples/waf-loki-promtail/templates/user_data.sh.tftpl b/examples/waf-loki-promtail/templates/user_data.sh.tftpl new file mode 100644 index 0000000..76b117c --- /dev/null +++ b/examples/waf-loki-promtail/templates/user_data.sh.tftpl @@ -0,0 +1,145 @@ +#!/bin/bash +set -euxo pipefail + +# ── Install Docker and AWS CLI (S3 bootstrap for dashboards; keeps user_data under 16 KiB) ── +dnf install -y docker awscli +systemctl enable --now docker +usermod -aG docker ec2-user +export AWS_DEFAULT_REGION='${aws_region}' +BOOTSTRAP_BUCKET='${bootstrap_bucket}' + +# ── Install Docker Compose v2 plugin ──────────────────────────────────────── +COMPOSE_VERSION="2.33.1" +mkdir -p /usr/local/lib/docker/cli-plugins +curl -fsSL "https://github.com/docker/compose/releases/download/v$${COMPOSE_VERSION}/docker-compose-linux-x86_64" \ + -o /usr/local/lib/docker/cli-plugins/docker-compose +chmod +x /usr/local/lib/docker/cli-plugins/docker-compose + +# ── Working directory structure ────────────────────────────────────────────── +mkdir -p /opt/obs/grafana/provisioning/datasources +mkdir -p /opt/obs/grafana/provisioning/dashboards +mkdir -p /opt/obs/grafana/dashboards + +# ── Loki config (monolithic, filesystem storage on EBS) ───────────────────── +cat > /opt/obs/loki-config.yaml << 'LOKI_EOF' +auth_enabled: false + +server: + http_listen_port: 3100 + grpc_listen_port: 9096 + +common: + instance_addr: 127.0.0.1 + path_prefix: /loki + storage: + filesystem: + chunks_directory: /loki/chunks + rules_directory: /loki/rules + replication_factor: 1 + ring: + kvstore: + store: inmemory + +query_range: + results_cache: + cache: + embedded_cache: + enabled: true + max_size_mb: 100 + +schema_config: + configs: + - from: 2020-10-24 + store: tsdb + object_store: filesystem + schema: v13 + index: + prefix: index_ + period: 24h +LOKI_EOF + +# ── Docker Compose: Loki + Grafana only (WAF lines enter Loki via Lambda) ──── +cat > /opt/obs/docker-compose.yaml << 'COMPOSE_EOF' +services: + loki: + image: grafana/loki:3.4.2 + ports: + - "3100:3100" + volumes: + - loki-data:/loki + - /opt/obs/loki-config.yaml:/etc/loki/loki-config.yaml:ro + command: -config.file=/etc/loki/loki-config.yaml + restart: unless-stopped + healthcheck: + test: ["CMD-SHELL", "wget --no-verbose --tries=1 --spider http://127.0.0.1:3100/ready || exit 1"] + interval: 30s + timeout: 10s + retries: 5 + start_period: 30s + + grafana: + image: grafana/grafana:11.5.2 + ports: + - "3000:3000" + volumes: + - grafana-data:/var/lib/grafana + - /opt/obs/grafana/provisioning:/etc/grafana/provisioning:ro + - /opt/obs/grafana/dashboards:/var/lib/grafana/dashboards:ro + depends_on: + loki: + condition: service_healthy + restart: unless-stopped + +volumes: + loki-data: + grafana-data: +COMPOSE_EOF + +# ── Grafana: Loki datasource ───────────────────────────────────────────────── +cat > /opt/obs/grafana/provisioning/datasources/loki.yaml << 'DS_EOF' +apiVersion: 1 +datasources: + - name: Loki + type: loki + uid: loki + url: http://loki:3100 + access: proxy + isDefault: true + jsonData: + maxLines: 1000 +DS_EOF + +# ── Grafana: dashboard provider ────────────────────────────────────────────── +cat > /opt/obs/grafana/provisioning/dashboards/provider.yaml << 'PROV_EOF' +apiVersion: 1 +providers: + - name: default + orgId: 1 + folder: WAF + type: file + disableDeletion: false + updateIntervalSeconds: 30 + options: + path: /var/lib/grafana/dashboards +PROV_EOF + +# ── Grafana dashboards from S3 ─────────────────────────────────────────────── +aws s3 cp "s3://$BOOTSTRAP_BUCKET/bootstrap/grafana/waf.json" /opt/obs/grafana/dashboards/waf.json +aws s3 cp "s3://$BOOTSTRAP_BUCKET/bootstrap/grafana/waf-overview.json" /opt/obs/grafana/dashboards/waf-overview.json +aws s3 cp "s3://$BOOTSTRAP_BUCKET/bootstrap/grafana/waf-geomap.json" /opt/obs/grafana/dashboards/waf-geomap.json +chmod a+r /opt/obs/grafana/dashboards/*.json +python3 - <<'PY' +import json +for p in ( + "/opt/obs/grafana/dashboards/waf.json", + "/opt/obs/grafana/dashboards/waf-overview.json", + "/opt/obs/grafana/dashboards/waf-geomap.json", +): + with open(p, encoding="utf-8") as f: + json.load(f) + print("validated dashboard json:", p) +PY + +# ── Start Compose ──────────────────────────────────────────────────────────── +cd /opt/obs +docker compose up -d diff --git a/examples/waf-loki-promtail/terraform.tfvars.example b/examples/waf-loki-promtail/terraform.tfvars.example new file mode 100644 index 0000000..6d47f6d --- /dev/null +++ b/examples/waf-loki-promtail/terraform.tfvars.example @@ -0,0 +1,33 @@ +# Copy to terraform.tfvars and adjust for your account/region. +# Use a region where Lambda Managed Instances is available (see repository README). +# Defaults use 10.0.0.0/16; change if this CIDR is in use in your account. + +# aws_region = "ap-southeast-2" +# name_prefix = "lmi-waf-loki" + +# tags = { +# Project = "waf-loki-promtail-walkthrough" +# } + +# Required: name of an existing bucket (this example does not create it). +# For WAFv2 logging to S3, use a name starting with aws-waf-logs-. +# waf_logs_bucket_name = "aws-waf-logs-my-demo-123456789012" + +# ── WAF ingest ──────────────────────────────────────────────────────────────── +# Scope the S3 event trigger to a specific key prefix (optional). +# waf_logs_prefix = "AWSLogs/" + +# Suffix filter for notifications; default ".gz" matches WAF delivery. Use "" to trigger on any key (careful on shared buckets). +# waf_logs_object_suffix = ".gz" + +# ARN of an existing WAFv2 Web ACL to enable automatic WAF log delivery to S3. +# Leave empty to upload test objects manually. +# web_acl_arn = "arn:aws:wafv2:ap-southeast-2:123456789012:regional/webacl/my-acl/..." + +# ── Observability ───────────────────────────────────────────────────────────── +# CIDR blocks allowed to reach the Grafana ALB on port 80. +# Defaults to your current public IP via checkip.amazonaws.com. +# alb_ingress_cidrs = ["203.0.113.10/32"] + +# EC2 instance type for the Loki + Grafana host (default: t3.small). +# obs_instance_type = "t3.small" diff --git a/examples/waf-loki-promtail/variables.tf b/examples/waf-loki-promtail/variables.tf new file mode 100644 index 0000000..9a8e8bf --- /dev/null +++ b/examples/waf-loki-promtail/variables.tf @@ -0,0 +1,84 @@ +variable "aws_region" { + description = "AWS region for all resources. Lambda Managed Instances (capacity providers) are only available in a subset of regions; see repository README." + type = string + default = "ap-southeast-2" +} + +variable "name_prefix" { + description = "Prefix for VPC and Lambda resource names" + type = string + default = "example" +} + +variable "vpc_cidr" { + description = "VPC IPv4 CIDR" + type = string + default = "10.0.0.0/16" +} + +variable "availability_zones" { + description = "Availability zones; the VPC module creates one public and one private subnet per AZ (length must match public_subnet_cidrs and private_subnet_cidrs)" + type = list(string) + default = ["ap-southeast-2a", "ap-southeast-2b", "ap-southeast-2c"] +} + +variable "public_subnet_cidrs" { + description = "Public subnet CIDRs (NAT + IGW path)" + type = list(string) + default = ["10.0.1.0/24", "10.0.2.0/24", "10.0.3.0/24"] +} + +variable "private_subnet_cidrs" { + description = "Private subnet CIDRs (Lambda managed instances)" + type = list(string) + default = ["10.0.101.0/24", "10.0.102.0/24", "10.0.103.0/24"] +} + +variable "tags" { + description = "Common tags" + type = map(string) + default = {} +} + +# ── WAF ingest ────────────────────────────────────────────────────────────── + +variable "waf_logs_bucket_name" { + description = <<-EOT + Name of an existing S3 bucket for WAF log objects and the Lambda trigger. This stack does not create the bucket. + For AWS WAFv2 direct log delivery to S3, the bucket name must start with aws-waf-logs-. + EOT + type = string +} + +variable "waf_logs_prefix" { + description = "Optional S3 key prefix to scope the WAF log trigger (e.g. \"AWSLogs/\"). Empty means all objects in the bucket." + type = string + default = "" +} + +variable "waf_logs_object_suffix" { + description = "S3 notification filter_suffix (e.g. \".gz\" for WAF delivery). Empty string omits the suffix filter so any object key can trigger the Lambda (useful for uncompressed test uploads; avoid on shared buckets)." + type = string + default = ".gz" +} + +variable "web_acl_arn" { + description = "ARN of an existing WAFv2 Web ACL. When set, an aws_wafv2_web_acl_logging_configuration resource directs WAF logs to the waf_logs bucket." + type = string + default = "" +} + +# ── Observability stack ────────────────────────────────────────────────────── + +variable "alb_ingress_cidrs" { + description = "CIDR blocks allowed to reach the Grafana ALB on port 80. Leave empty to automatically restrict access to only the deployer's current public IP." + type = list(string) + default = [] +} + +variable "obs_instance_type" { + description = "EC2 instance type for the Loki + Grafana host" + type = string + default = "t3.small" +} + diff --git a/examples/waf-loki-promtail/versions.tf b/examples/waf-loki-promtail/versions.tf new file mode 100644 index 0000000..990654c --- /dev/null +++ b/examples/waf-loki-promtail/versions.tf @@ -0,0 +1,18 @@ +terraform { + required_version = ">= 1.7.0" + + required_providers { + archive = { + source = "hashicorp/archive" + version = ">= 2.4.0" + } + aws = { + source = "hashicorp/aws" + version = ">= 6.0.0" + } + http = { + source = "hashicorp/http" + version = ">= 3.4.0" + } + } +} diff --git a/examples/waf-loki/README.md b/examples/waf-loki/README.md new file mode 100644 index 0000000..30acac0 --- /dev/null +++ b/examples/waf-loki/README.md @@ -0,0 +1,141 @@ +# WAF → S3 → LMI → Loki → Grafana demo + +End-to-end walkthrough example: AWS WAF log objects land in S3, an S3 event triggers a **Node.js 24 Lambda Managed Instance function** that decompresses and pushes each log line to **Loki** running on EC2, and **Grafana** surfaces a live log stream behind a public ALB. + +## What this stack creates + +| Resource | Purpose | +|----------|---------| +| VPC (3 AZs, public + private subnets) | All resources run here | +| `lambda_managed_instance` | LMI capacity provider | +| `lambda_managed_function_waf` (Node.js 24) | Reads gzip WAF log objects from S3, pushes lines to Loki | +| Existing S3 bucket (`var.waf_logs_bucket_name`) | **Not created here** — you provision the bucket separately; this stack adds the event notification and (optionally) WAF logging to it | +| IAM policy `waf_s3_read` | Scoped to the WAF log bucket; attached to the WAF Lambda only | +| EC2 (`t3.small`, private subnet) | Runs Loki + Grafana via Docker Compose; Loki data on root EBS | +| ALB (public, HTTP 80) | Fronts Grafana port 3000; restricted to deployer IP by default (TLS not configured; use for demos only) | + +## Prerequisites + +- Terraform >= 1.7, AWS provider >= 6.0, **archive** provider >= 2.4 (declared in `versions.tf`) +- An AWS account in a [region where LMI is available](https://docs.aws.amazon.com/lambda/latest/dg/lambda-managed-instances.html) +- AWS credentials with IAM, Lambda, EC2, S3, ALB, and SSM permissions +- An **existing** S3 bucket whose name you set as `waf_logs_bucket_name` in `terraform.tfvars`. The bucket must exist before `terraform apply`. For [WAFv2 logging to S3](https://docs.aws.amazon.com/waf/latest/developerguide/logging-s3.html), the name must start with `aws-waf-logs-`. +- IAM for the Terraform principal: permission to update **bucket notifications** on that bucket (`s3:PutBucketNotificationConfiguration` and related reads), in addition to the usual Lambda / EC2 / ALB permissions. + +## Apply + +The WAF ingest code is plain ESM (`function-waf/index.mjs`). **Node.js 24 on Lambda includes AWS SDK for JavaScript v3**, so you do not need `npm install` in `function-waf/` for deployment; the zip excludes any local `node_modules` if present. + +`alb_ingress_cidrs` defaults to your current public IP via `checkip.amazonaws.com`. Plan/apply must reach that URL from the machine running Terraform, or set `alb_ingress_cidrs` explicitly in `terraform.tfvars`. + +```bash +cd examples/waf-loki +cp terraform.tfvars.example terraform.tfvars # set waf_logs_bucket_name (required); edit region / name_prefix +terraform init +terraform plan +terraform apply +``` + +First apply takes several minutes: capacity provider creation and the EC2 Docker Compose startup both take time. Grafana becomes reachable when the ALB target group health check passes (`/api/health`). + +## Outputs + +```bash +terraform output grafana_url # http:// — open in browser +terraform output waf_logs_bucket # bucket name for WAF delivery or manual test uploads +terraform output obs_instance_id # EC2 ID — connect via SSM Session Manager +terraform output loki_push_url # Loki HTTP push endpoint used by Lambda +``` + +## Grafana access + +Open the `grafana_url` output in a browser. Default credentials: **admin / admin**. Grafana prompts to change the password on first login. + +Two dashboards are provisioned under the **WAF** folder: **WAF Logs** (raw stream) and **WAF overview** (rates by action, terminating rule, top client IPs, HTTP method, and a BLOCK-only log panel). Dashboard JSON lives in `templates/dashboards/` and is injected into `user_data` at `terraform apply` time (base64 via `templatefile`), so the instance always gets the same bytes as those files. Queries use `| json` where fields are top-level on the WAF line; client IP and HTTP method panels use a regexp on the raw JSON line for nested `httpRequest` fields. + +## Testing the ingest path + +Upload any gzip-compressed WAF log file to the WAF log bucket. By default the S3 notification only fires for keys ending in **`.gz`** (`waf_logs_object_suffix`). The Lambda splits large files into multiple Loki push requests so a single object does not exceed a safe payload size. + +Set `waf_logs_object_suffix = ""` in `terraform.tfvars` if you want every new object to invoke the function (only on buckets dedicated to this flow). + +```bash +# Create a sample WAF log in WAF JSONL format +echo '{"timestamp":1735700000000,"action":"ALLOW","terminatingRuleId":"Default_Action","httpRequest":{"clientIp":"1.2.3.4","uri":"/","httpMethod":"GET"}}' \ + | gzip > /tmp/test-waf.log.gz + +aws s3 cp /tmp/test-waf.log.gz \ + "s3://$(terraform output -raw waf_logs_bucket)/test/test-waf.log.gz" +``` + +After a few seconds, the log line appears in Grafana Explore under the `{source="waf"}` query. + +## WAFv2 log delivery (optional) + +If you have an existing WAFv2 Web ACL, pass its ARN to enable automatic log delivery: + +```hcl +web_acl_arn = "arn:aws:wafv2:ap-southeast-2:123456789012:regional/webacl/my-acl/..." +``` + +WAF delivers gzip log files to the S3 bucket, which the Lambda picks up automatically. The bucket must still satisfy [WAF logging requirements](https://docs.aws.amazon.com/waf/latest/developerguide/logging-s3.html) (naming, optional dedicated bucket, and resource policy for the delivery service). + +## Connecting to the EC2 host (no SSH required) + +```bash +aws ssm start-session --target "$(terraform output -raw obs_instance_id)" +``` + +Check Compose status: + +```bash +docker compose -f /opt/obs/docker-compose.yaml ps +docker compose -f /opt/obs/docker-compose.yaml logs --tail 50 loki +``` + +## Data durability notes + +Loki stores chunks and index on the **EC2 root EBS volume** (30 GB gp3). Data survives instance stop/start (same volume retained). **Instance replacement** (Terraform `taint`, terminated by AWS, etc.) provisions a new root volume — Loki query history is lost, but all raw WAF log objects remain in S3 and can be re-uploaded to re-drive ingestion. + +The observability EC2 uses **`user_data_replace_on_change = true`**. Any change to `templates/user_data.sh.tftpl` or `templates/dashboards/*.json` plans a **new instance** (user data only runs at launch). Expect brief Grafana/Loki downtime while the replacement registers with the ALB target group. + +### Grafana: missing **WAF overview** after replace + +User data must finish and both JSON files must be valid before `docker compose up` runs. + +1. SSM to the instance and list files: `ls -la /opt/obs/grafana/dashboards/` (expect `waf.json` and `waf-overview.json`). +2. Read bootstrap output: `sudo tail -n 200 /var/log/cloud-init-output.log` — look for `validated dashboard json:` or a Python `JSONDecodeError`. +3. Check Grafana: `sudo docker compose -f /opt/obs/docker-compose.yaml logs --tail 100 grafana` for provisioning errors. + +`/var/log/cloud-init.log` is not world-readable on Amazon Linux (typically `root:adm`, mode `640`). Use **`sudo cat`** / **`sudo tail`**; for script output, prefer **`cloud-init-output.log`** as in step 2. + +To confirm the instance received the **current** Terraform user data, search the debug log for the IMDS fetch line, for example: `sudo grep user-data /var/log/cloud-init.log | head -1`. With the `templatefile` + `filebase64` dashboards, the line should report on the order of **11 KiB** (e.g. `... user-data (200, 11267b)`). A value around **5 KiB** means the launch used an **older** user-data payload; run **`terraform apply`** from the updated example so `user_data_replace_on_change` provisions a new instance. + +If dashboards are missing locally but JSON validates, confirm you ran **`terraform apply`** after pulling changes (the rendered `user_data` string must include the new base64 payloads). On Windows, ensure `templates/**/*.json` and `*.tftpl` use LF line endings (see repo `.gitattributes`). + +## Approximate cost (ballpark, us-east-1-style pricing, running 24/7) + +| Component | ~Monthly | +|-----------|---------| +| LMI capacity (t3 equivalent, minimal) | $15–40 | +| EC2 `t3.small` | ~$15 | +| ALB | ~$18 | +| NAT Gateway | ~$35 | +| S3 + data transfer | < $5 | +| **Total** | **~$90–115** | + +Costs scale with Lambda invocations and WAF log volume. + +## Destroy + +```bash +terraform destroy +``` + +The WAF log bucket is **not** managed by Terraform; `terraform destroy` removes the notification and other stack resources but **does not delete** the bucket or its objects. + +If you previously applied an older revision of this example that **created** the bucket with Terraform, run `terraform state rm` on the removed bucket resources (`aws_s3_bucket.waf_logs` and any related `aws_s3_bucket_*` blocks) before the next `apply`, so Terraform does not plan to destroy a bucket you still need. + +## Documentation + +Walkthrough site: [aws-lambda-managed-instance-walkthrough](https://github.com/jajera/aws-lambda-managed-instance-walkthrough) diff --git a/examples/waf-loki/function-waf/index.mjs b/examples/waf-loki/function-waf/index.mjs new file mode 100644 index 0000000..6886b96 --- /dev/null +++ b/examples/waf-loki/function-waf/index.mjs @@ -0,0 +1,89 @@ +import { S3Client, GetObjectCommand } from "@aws-sdk/client-s3"; +import { createGunzip } from "node:zlib"; + +const s3 = new S3Client({}); +const LOKI_URL = process.env.LOKI_URL ?? ""; + +/** Stay under Loki / API Gateway style body limits; WAF JSON lines can be large. */ +const MAX_BATCH_JSON_CHARS = 900_000; + +/** @param {import("aws-lambda").S3Event} event */ +export async function handler(event) { + if (!LOKI_URL) { + throw new Error("LOKI_URL is not set; fix Terraform wiring to the Loki push endpoint"); + } + + for (const record of event.Records ?? []) { + const bucket = record.s3.bucket.name; + const key = decodeURIComponent(record.s3.object.key.replace(/\+/g, " ")); + await ingestObject(bucket, key); + } +} + +async function ingestObject(bucket, key) { + const out = await s3.send(new GetObjectCommand({ Bucket: bucket, Key: key })); + const { Body, ContentEncoding } = out; + + const gzipByKey = key.endsWith(".gz"); + const gzipByHeader = ContentEncoding === "gzip"; + const stream = gzipByKey || gzipByHeader ? Body.pipe(createGunzip()) : Body; + + const chunks = []; + for await (const chunk of stream) chunks.push(chunk); + const text = Buffer.concat(chunks).toString("utf-8"); + + const lines = text.split("\n").filter((l) => l.trim()); + if (!lines.length) return; + + const values = lines.map((line) => { + let tsNs = String(BigInt(Date.now()) * 1_000_000n); + try { + const obj = JSON.parse(line); + if (typeof obj.timestamp === "number") { + tsNs = String(BigInt(obj.timestamp) * 1_000_000n); + } + } catch { + // non-JSON line: use current time + } + return [tsNs, line]; + }); + + const labels = { source: "waf", bucket }; + for (const batch of chunkValues(values)) { + await pushToLoki(batch, labels); + } +} + +/** Split into multiple Loki requests so no single POST exceeds safe size. */ +function chunkValues(values) { + const batches = []; + let batch = []; + let approx = 0; + + for (const pair of values) { + const [tsNs, line] = pair; + const lineCost = tsNs.length + line.length + 8; + if (batch.length > 0 && approx + lineCost > MAX_BATCH_JSON_CHARS) { + batches.push(batch); + batch = []; + approx = 0; + } + batch.push(pair); + approx += lineCost; + } + if (batch.length) batches.push(batch); + return batches; +} + +async function pushToLoki(values, labels) { + const payload = { streams: [{ stream: labels, values }] }; + const res = await fetch(LOKI_URL, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + if (!res.ok) { + const detail = await res.text(); + throw new Error(`Loki push failed ${res.status}: ${detail.slice(0, 500)}`); + } +} diff --git a/examples/waf-loki/function-waf/package-lock.json b/examples/waf-loki/function-waf/package-lock.json new file mode 100644 index 0000000..83b87da --- /dev/null +++ b/examples/waf-loki/function-waf/package-lock.json @@ -0,0 +1,1673 @@ +{ + "name": "waf-loki-ingest", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "waf-loki-ingest", + "version": "1.0.0", + "dependencies": { + "@aws-sdk/client-s3": "^3.1014.0" + } + }, + "node_modules/@aws-crypto/crc32": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/crc32/-/crc32-5.2.0.tgz", + "integrity": "sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-crypto/crc32c": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/crc32c/-/crc32c-5.2.0.tgz", + "integrity": "sha512-+iWb8qaHLYKrNvGRbiYRHSdKRWhto5XlZUEBwDjYNf+ly5SVYG6zEoYIdxvf5R3zyeP16w4PLBn3rH1xc74Rag==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-crypto/sha1-browser": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/sha1-browser/-/sha1-browser-5.2.0.tgz", + "integrity": "sha512-OH6lveCFfcDjX4dbAvCFSYUjJZjDr/3XJ3xHtjn3Oj5b9RjojQo8npoLeA/bNwkOkrSQ0wgrHzXk4tDRxGKJeg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/supports-web-crypto": "^5.2.0", + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "@aws-sdk/util-locate-window": "^3.0.0", + "@smithy/util-utf8": "^2.0.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-crypto/sha1-browser/node_modules/@smithy/is-array-buffer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz", + "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/sha1-browser/node_modules/@smithy/util-buffer-from": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz", + "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/is-array-buffer": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/sha1-browser/node_modules/@smithy/util-utf8": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz", + "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/sha256-browser": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-browser/-/sha256-browser-5.2.0.tgz", + "integrity": "sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-js": "^5.2.0", + "@aws-crypto/supports-web-crypto": "^5.2.0", + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "@aws-sdk/util-locate-window": "^3.0.0", + "@smithy/util-utf8": "^2.0.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-crypto/sha256-browser/node_modules/@smithy/is-array-buffer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz", + "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/sha256-browser/node_modules/@smithy/util-buffer-from": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz", + "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/is-array-buffer": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/sha256-browser/node_modules/@smithy/util-utf8": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz", + "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/sha256-js": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-js/-/sha256-js-5.2.0.tgz", + "integrity": "sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-crypto/supports-web-crypto": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/supports-web-crypto/-/supports-web-crypto-5.2.0.tgz", + "integrity": "sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-crypto/util": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/util/-/util-5.2.0.tgz", + "integrity": "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.222.0", + "@smithy/util-utf8": "^2.0.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-crypto/util/node_modules/@smithy/is-array-buffer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz", + "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/util/node_modules/@smithy/util-buffer-from": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz", + "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/is-array-buffer": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/util/node_modules/@smithy/util-utf8": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz", + "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/client-s3": { + "version": "3.1014.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-s3/-/client-s3-3.1014.0.tgz", + "integrity": "sha512-0XLrOT4Cm3NEhhiME7l/8LbTXS4KdsbR4dSrY207KNKTcHLLTZ9EXt4ZpgnTfLvWQF3pGP2us4Zi1fYLo0N+Ow==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha1-browser": "5.2.0", + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "^3.973.23", + "@aws-sdk/credential-provider-node": "^3.972.24", + "@aws-sdk/middleware-bucket-endpoint": "^3.972.8", + "@aws-sdk/middleware-expect-continue": "^3.972.8", + "@aws-sdk/middleware-flexible-checksums": "^3.974.3", + "@aws-sdk/middleware-host-header": "^3.972.8", + "@aws-sdk/middleware-location-constraint": "^3.972.8", + "@aws-sdk/middleware-logger": "^3.972.8", + "@aws-sdk/middleware-recursion-detection": "^3.972.8", + "@aws-sdk/middleware-sdk-s3": "^3.972.23", + "@aws-sdk/middleware-ssec": "^3.972.8", + "@aws-sdk/middleware-user-agent": "^3.972.24", + "@aws-sdk/region-config-resolver": "^3.972.9", + "@aws-sdk/signature-v4-multi-region": "^3.996.11", + "@aws-sdk/types": "^3.973.6", + "@aws-sdk/util-endpoints": "^3.996.5", + "@aws-sdk/util-user-agent-browser": "^3.972.8", + "@aws-sdk/util-user-agent-node": "^3.973.10", + "@smithy/config-resolver": "^4.4.13", + "@smithy/core": "^3.23.12", + "@smithy/eventstream-serde-browser": "^4.2.12", + "@smithy/eventstream-serde-config-resolver": "^4.3.12", + "@smithy/eventstream-serde-node": "^4.2.12", + "@smithy/fetch-http-handler": "^5.3.15", + "@smithy/hash-blob-browser": "^4.2.13", + "@smithy/hash-node": "^4.2.12", + "@smithy/hash-stream-node": "^4.2.12", + "@smithy/invalid-dependency": "^4.2.12", + "@smithy/md5-js": "^4.2.12", + "@smithy/middleware-content-length": "^4.2.12", + "@smithy/middleware-endpoint": "^4.4.27", + "@smithy/middleware-retry": "^4.4.44", + "@smithy/middleware-serde": "^4.2.15", + "@smithy/middleware-stack": "^4.2.12", + "@smithy/node-config-provider": "^4.3.12", + "@smithy/node-http-handler": "^4.5.0", + "@smithy/protocol-http": "^5.3.12", + "@smithy/smithy-client": "^4.12.7", + "@smithy/types": "^4.13.1", + "@smithy/url-parser": "^4.2.12", + "@smithy/util-base64": "^4.3.2", + "@smithy/util-body-length-browser": "^4.2.2", + "@smithy/util-body-length-node": "^4.2.3", + "@smithy/util-defaults-mode-browser": "^4.3.43", + "@smithy/util-defaults-mode-node": "^4.2.47", + "@smithy/util-endpoints": "^3.3.3", + "@smithy/util-middleware": "^4.2.12", + "@smithy/util-retry": "^4.2.12", + "@smithy/util-stream": "^4.5.20", + "@smithy/util-utf8": "^4.2.2", + "@smithy/util-waiter": "^4.2.13", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/core": { + "version": "3.973.23", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.973.23.tgz", + "integrity": "sha512-aoJncvD1XvloZ9JLnKqTRL9dBy+Szkryoag9VT+V1TqsuUgIxV9cnBVM/hrDi2vE8bDqLiDR8nirdRcCdtJu0w==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.6", + "@aws-sdk/xml-builder": "^3.972.15", + "@smithy/core": "^3.23.12", + "@smithy/node-config-provider": "^4.3.12", + "@smithy/property-provider": "^4.2.12", + "@smithy/protocol-http": "^5.3.12", + "@smithy/signature-v4": "^5.3.12", + "@smithy/smithy-client": "^4.12.7", + "@smithy/types": "^4.13.1", + "@smithy/util-base64": "^4.3.2", + "@smithy/util-middleware": "^4.2.12", + "@smithy/util-utf8": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/crc64-nvme": { + "version": "3.972.5", + "resolved": "https://registry.npmjs.org/@aws-sdk/crc64-nvme/-/crc64-nvme-3.972.5.tgz", + "integrity": "sha512-2VbTstbjKdT+yKi8m7b3a9CiVac+pL/IY2PHJwsaGkkHmuuqkJZIErPck1h6P3T9ghQMLSdMPyW6Qp7Di5swFg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-env": { + "version": "3.972.21", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.21.tgz", + "integrity": "sha512-BkAfKq8Bd4shCtec1usNz//urPJF/SZy14qJyxkSaRJQ/Vv1gVh0VZSTmS7aE6aLMELkFV5wHHrS9ZcdG8Kxsg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.973.23", + "@aws-sdk/types": "^3.973.6", + "@smithy/property-provider": "^4.2.12", + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-http": { + "version": "3.972.23", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.23.tgz", + "integrity": "sha512-4XZ3+Gu5DY8/n8zQFHBgcKTF7hWQl42G6CY9xfXVo2d25FM/lYkpmuzhYopYoPL1ITWkJ2OSBQfYEu5JRfHOhA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.973.23", + "@aws-sdk/types": "^3.973.6", + "@smithy/fetch-http-handler": "^5.3.15", + "@smithy/node-http-handler": "^4.5.0", + "@smithy/property-provider": "^4.2.12", + "@smithy/protocol-http": "^5.3.12", + "@smithy/smithy-client": "^4.12.7", + "@smithy/types": "^4.13.1", + "@smithy/util-stream": "^4.5.20", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-ini": { + "version": "3.972.23", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.972.23.tgz", + "integrity": "sha512-PZLSmU0JFpNCDFReidBezsgL5ji9jOBry8CnZdw4Jj6d0K2z3Ftnp44NXgADqYx5BLMu/ZHujfeJReaDoV+IwQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.973.23", + "@aws-sdk/credential-provider-env": "^3.972.21", + "@aws-sdk/credential-provider-http": "^3.972.23", + "@aws-sdk/credential-provider-login": "^3.972.23", + "@aws-sdk/credential-provider-process": "^3.972.21", + "@aws-sdk/credential-provider-sso": "^3.972.23", + "@aws-sdk/credential-provider-web-identity": "^3.972.23", + "@aws-sdk/nested-clients": "^3.996.13", + "@aws-sdk/types": "^3.973.6", + "@smithy/credential-provider-imds": "^4.2.12", + "@smithy/property-provider": "^4.2.12", + "@smithy/shared-ini-file-loader": "^4.4.7", + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-login": { + "version": "3.972.23", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.23.tgz", + "integrity": "sha512-OmE/pSkbMM3dCj1HdOnZ5kXnKK+R/Yz+kbBugraBecp0pGAs21eEURfQRz+1N2gzIHLVyGIP1MEjk/uSrFsngg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.973.23", + "@aws-sdk/nested-clients": "^3.996.13", + "@aws-sdk/types": "^3.973.6", + "@smithy/property-provider": "^4.2.12", + "@smithy/protocol-http": "^5.3.12", + "@smithy/shared-ini-file-loader": "^4.4.7", + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-node": { + "version": "3.972.24", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.24.tgz", + "integrity": "sha512-9Jwi7aps3AfUicJyF5udYadPypPpCwUZ6BSKr/QjRbVCpRVS1wc+1Q6AEZ/qz8J4JraeRd247pSzyMQSIHVebw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/credential-provider-env": "^3.972.21", + "@aws-sdk/credential-provider-http": "^3.972.23", + "@aws-sdk/credential-provider-ini": "^3.972.23", + "@aws-sdk/credential-provider-process": "^3.972.21", + "@aws-sdk/credential-provider-sso": "^3.972.23", + "@aws-sdk/credential-provider-web-identity": "^3.972.23", + "@aws-sdk/types": "^3.973.6", + "@smithy/credential-provider-imds": "^4.2.12", + "@smithy/property-provider": "^4.2.12", + "@smithy/shared-ini-file-loader": "^4.4.7", + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-process": { + "version": "3.972.21", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.21.tgz", + "integrity": "sha512-nRxbeOJ1E1gVA0lNQezuMVndx+ZcuyaW/RB05pUsznN5BxykSlH6KkZ/7Ca/ubJf3i5N3p0gwNO5zgPSCzj+ww==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.973.23", + "@aws-sdk/types": "^3.973.6", + "@smithy/property-provider": "^4.2.12", + "@smithy/shared-ini-file-loader": "^4.4.7", + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-sso": { + "version": "3.972.23", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.972.23.tgz", + "integrity": "sha512-APUccADuYPLL0f2htpM8Z4czabSmHOdo4r41W6lKEZdy++cNJ42Radqy6x4TopENzr3hR6WYMyhiuiqtbf/nAA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.973.23", + "@aws-sdk/nested-clients": "^3.996.13", + "@aws-sdk/token-providers": "3.1014.0", + "@aws-sdk/types": "^3.973.6", + "@smithy/property-provider": "^4.2.12", + "@smithy/shared-ini-file-loader": "^4.4.7", + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-web-identity": { + "version": "3.972.23", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.23.tgz", + "integrity": "sha512-H5JNqtIwOu/feInmMMWcK0dL5r897ReEn7n2m16Dd0DPD9gA2Hg8Cq4UDzZ/9OzaLh/uqBM6seixz0U6Fi2Eag==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.973.23", + "@aws-sdk/nested-clients": "^3.996.13", + "@aws-sdk/types": "^3.973.6", + "@smithy/property-provider": "^4.2.12", + "@smithy/shared-ini-file-loader": "^4.4.7", + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-bucket-endpoint": { + "version": "3.972.8", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-bucket-endpoint/-/middleware-bucket-endpoint-3.972.8.tgz", + "integrity": "sha512-WR525Rr2QJSETa9a050isktyWi/4yIGcmY3BQ1kpHqb0LqUglQHCS8R27dTJxxWNZvQ0RVGtEZjTCbZJpyF3Aw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.6", + "@aws-sdk/util-arn-parser": "^3.972.3", + "@smithy/node-config-provider": "^4.3.12", + "@smithy/protocol-http": "^5.3.12", + "@smithy/types": "^4.13.1", + "@smithy/util-config-provider": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-expect-continue": { + "version": "3.972.8", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-expect-continue/-/middleware-expect-continue-3.972.8.tgz", + "integrity": "sha512-5DTBTiotEES1e2jOHAq//zyzCjeMB78lEHd35u15qnrid4Nxm7diqIf9fQQ3Ov0ChH1V3Vvt13thOnrACmfGVQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.6", + "@smithy/protocol-http": "^5.3.12", + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-flexible-checksums": { + "version": "3.974.3", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-flexible-checksums/-/middleware-flexible-checksums-3.974.3.tgz", + "integrity": "sha512-fB7FNLH1+VPUs0QL3PLrHW+DD4gKu6daFgWtyq3R0Y0Lx8DLZPvyGAxCZNFBxH+M2xt9KvBJX6USwjuqvitmCQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/crc32": "5.2.0", + "@aws-crypto/crc32c": "5.2.0", + "@aws-crypto/util": "5.2.0", + "@aws-sdk/core": "^3.973.23", + "@aws-sdk/crc64-nvme": "^3.972.5", + "@aws-sdk/types": "^3.973.6", + "@smithy/is-array-buffer": "^4.2.2", + "@smithy/node-config-provider": "^4.3.12", + "@smithy/protocol-http": "^5.3.12", + "@smithy/types": "^4.13.1", + "@smithy/util-middleware": "^4.2.12", + "@smithy/util-stream": "^4.5.20", + "@smithy/util-utf8": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-host-header": { + "version": "3.972.8", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-host-header/-/middleware-host-header-3.972.8.tgz", + "integrity": "sha512-wAr2REfKsqoKQ+OkNqvOShnBoh+nkPurDKW7uAeVSu6kUECnWlSJiPvnoqxGlfousEY/v9LfS9sNc46hjSYDIQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.6", + "@smithy/protocol-http": "^5.3.12", + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-location-constraint": { + "version": "3.972.8", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-location-constraint/-/middleware-location-constraint-3.972.8.tgz", + "integrity": "sha512-KaUoFuoFPziIa98DSQsTPeke1gvGXlc5ZGMhy+b+nLxZ4A7jmJgLzjEF95l8aOQN2T/qlPP3MrAyELm8ExXucw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.6", + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-logger": { + "version": "3.972.8", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-logger/-/middleware-logger-3.972.8.tgz", + "integrity": "sha512-CWl5UCM57WUFaFi5kB7IBY1UmOeLvNZAZ2/OZ5l20ldiJ3TiIz1pC65gYj8X0BCPWkeR1E32mpsCk1L1I4n+lA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.6", + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-recursion-detection": { + "version": "3.972.8", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-recursion-detection/-/middleware-recursion-detection-3.972.8.tgz", + "integrity": "sha512-BnnvYs2ZEpdlmZ2PNlV2ZyQ8j8AEkMTjN79y/YA475ER1ByFYrkVR85qmhni8oeTaJcDqbx364wDpitDAA/wCA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.6", + "@aws/lambda-invoke-store": "^0.2.2", + "@smithy/protocol-http": "^5.3.12", + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-sdk-s3": { + "version": "3.972.23", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.972.23.tgz", + "integrity": "sha512-50QgHGPQAb2veqFOmTF1A3GsAklLHZXL47KbY35khIkfbXH5PLvqpEc/gOAEBPj/yFxrlgxz/8mqWcWTNxBkwQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.973.23", + "@aws-sdk/types": "^3.973.6", + "@aws-sdk/util-arn-parser": "^3.972.3", + "@smithy/core": "^3.23.12", + "@smithy/node-config-provider": "^4.3.12", + "@smithy/protocol-http": "^5.3.12", + "@smithy/signature-v4": "^5.3.12", + "@smithy/smithy-client": "^4.12.7", + "@smithy/types": "^4.13.1", + "@smithy/util-config-provider": "^4.2.2", + "@smithy/util-middleware": "^4.2.12", + "@smithy/util-stream": "^4.5.20", + "@smithy/util-utf8": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-ssec": { + "version": "3.972.8", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-ssec/-/middleware-ssec-3.972.8.tgz", + "integrity": "sha512-wqlK0yO/TxEC2UsY9wIlqeeutF6jjLe0f96Pbm40XscTo57nImUk9lBcw0dPgsm0sppFtAkSlDrfpK+pC30Wqw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.6", + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-user-agent": { + "version": "3.972.24", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-user-agent/-/middleware-user-agent-3.972.24.tgz", + "integrity": "sha512-dLTWy6IfAMhNiSEvMr07g/qZ54be6pLqlxVblbF6AzafmmGAzMMj8qMoY9B4+YgT+gY9IcuxZslNh03L6PyMCQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.973.23", + "@aws-sdk/types": "^3.973.6", + "@aws-sdk/util-endpoints": "^3.996.5", + "@smithy/core": "^3.23.12", + "@smithy/protocol-http": "^5.3.12", + "@smithy/types": "^4.13.1", + "@smithy/util-retry": "^4.2.12", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/nested-clients": { + "version": "3.996.13", + "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.996.13.tgz", + "integrity": "sha512-ptZ1HF4yYHNJX8cgFF+8NdYO69XJKZn7ft0/ynV3c0hCbN+89fAbrLS+fqniU2tW8o9Kfqhj8FUh+IPXb2Qsuw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "^3.973.23", + "@aws-sdk/middleware-host-header": "^3.972.8", + "@aws-sdk/middleware-logger": "^3.972.8", + "@aws-sdk/middleware-recursion-detection": "^3.972.8", + "@aws-sdk/middleware-user-agent": "^3.972.24", + "@aws-sdk/region-config-resolver": "^3.972.9", + "@aws-sdk/types": "^3.973.6", + "@aws-sdk/util-endpoints": "^3.996.5", + "@aws-sdk/util-user-agent-browser": "^3.972.8", + "@aws-sdk/util-user-agent-node": "^3.973.10", + "@smithy/config-resolver": "^4.4.13", + "@smithy/core": "^3.23.12", + "@smithy/fetch-http-handler": "^5.3.15", + "@smithy/hash-node": "^4.2.12", + "@smithy/invalid-dependency": "^4.2.12", + "@smithy/middleware-content-length": "^4.2.12", + "@smithy/middleware-endpoint": "^4.4.27", + "@smithy/middleware-retry": "^4.4.44", + "@smithy/middleware-serde": "^4.2.15", + "@smithy/middleware-stack": "^4.2.12", + "@smithy/node-config-provider": "^4.3.12", + "@smithy/node-http-handler": "^4.5.0", + "@smithy/protocol-http": "^5.3.12", + "@smithy/smithy-client": "^4.12.7", + "@smithy/types": "^4.13.1", + "@smithy/url-parser": "^4.2.12", + "@smithy/util-base64": "^4.3.2", + "@smithy/util-body-length-browser": "^4.2.2", + "@smithy/util-body-length-node": "^4.2.3", + "@smithy/util-defaults-mode-browser": "^4.3.43", + "@smithy/util-defaults-mode-node": "^4.2.47", + "@smithy/util-endpoints": "^3.3.3", + "@smithy/util-middleware": "^4.2.12", + "@smithy/util-retry": "^4.2.12", + "@smithy/util-utf8": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/region-config-resolver": { + "version": "3.972.9", + "resolved": "https://registry.npmjs.org/@aws-sdk/region-config-resolver/-/region-config-resolver-3.972.9.tgz", + "integrity": "sha512-eQ+dFU05ZRC/lC2XpYlYSPlXtX3VT8sn5toxN2Fv7EXlMoA2p9V7vUBKqHunfD4TRLpxUq8Y8Ol/nCqiv327Ng==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.6", + "@smithy/config-resolver": "^4.4.13", + "@smithy/node-config-provider": "^4.3.12", + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/signature-v4-multi-region": { + "version": "3.996.11", + "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.11.tgz", + "integrity": "sha512-SKgZY7x6AloLUXO20FJGnkKJ3a6CXzNDt6PYs2yqoPzgU0xKWcUoGGJGEBTsfM5eihKW42lbwp+sXzACLbSsaA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/middleware-sdk-s3": "^3.972.23", + "@aws-sdk/types": "^3.973.6", + "@smithy/protocol-http": "^5.3.12", + "@smithy/signature-v4": "^5.3.12", + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/token-providers": { + "version": "3.1014.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1014.0.tgz", + "integrity": "sha512-gHTHNUoaOGNrSWkl32A7wFsU78jlNTlqMccLu0byUk5CysYYXaxNMIonIVr4YcykC7vgtDS5ABuz83giy6fzJA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.973.23", + "@aws-sdk/nested-clients": "^3.996.13", + "@aws-sdk/types": "^3.973.6", + "@smithy/property-provider": "^4.2.12", + "@smithy/shared-ini-file-loader": "^4.4.7", + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/types": { + "version": "3.973.6", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.973.6.tgz", + "integrity": "sha512-Atfcy4E++beKtwJHiDln2Nby8W/mam64opFPTiHEqgsthqeydFS1pY+OUlN1ouNOmf8ArPU/6cDS65anOP3KQw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/util-arn-parser": { + "version": "3.972.3", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-arn-parser/-/util-arn-parser-3.972.3.tgz", + "integrity": "sha512-HzSD8PMFrvgi2Kserxuff5VitNq2sgf3w9qxmskKDiDTThWfVteJxuCS9JXiPIPtmCrp+7N9asfIaVhBFORllA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/util-endpoints": { + "version": "3.996.5", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.996.5.tgz", + "integrity": "sha512-Uh93L5sXFNbyR5sEPMzUU8tJ++Ku97EY4udmC01nB8Zu+xfBPwpIwJ6F7snqQeq8h2pf+8SGN5/NoytfKgYPIw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.6", + "@smithy/types": "^4.13.1", + "@smithy/url-parser": "^4.2.12", + "@smithy/util-endpoints": "^3.3.3", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/util-locate-window": { + "version": "3.965.5", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-locate-window/-/util-locate-window-3.965.5.tgz", + "integrity": "sha512-WhlJNNINQB+9qtLtZJcpQdgZw3SCDCpXdUJP7cToGwHbCWCnRckGlc6Bx/OhWwIYFNAn+FIydY8SZ0QmVu3xTQ==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/util-user-agent-browser": { + "version": "3.972.8", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-browser/-/util-user-agent-browser-3.972.8.tgz", + "integrity": "sha512-B3KGXJviV2u6Cdw2SDY2aDhoJkVfY/Q/Trwk2CMSkikE1Oi6gRzxhvhIfiRpHfmIsAhV4EA54TVEX8K6CbHbkA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.6", + "@smithy/types": "^4.13.1", + "bowser": "^2.11.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-sdk/util-user-agent-node": { + "version": "3.973.10", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-node/-/util-user-agent-node-3.973.10.tgz", + "integrity": "sha512-E99zeTscCc+pTMfsvnfi6foPpKmdD1cZfOC7/P8UUrjsoQdg9VEWPRD+xdFduKnfPXwcvby58AlO9jwwF6U96g==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/middleware-user-agent": "^3.972.24", + "@aws-sdk/types": "^3.973.6", + "@smithy/node-config-provider": "^4.3.12", + "@smithy/types": "^4.13.1", + "@smithy/util-config-provider": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "aws-crt": ">=1.0.0" + }, + "peerDependenciesMeta": { + "aws-crt": { + "optional": true + } + } + }, + "node_modules/@aws-sdk/xml-builder": { + "version": "3.972.15", + "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.15.tgz", + "integrity": "sha512-PxMRlCFNiQnke9YR29vjFQwz4jq+6Q04rOVFeTDR2K7Qpv9h9FOWOxG+zJjageimYbWqE3bTuLjmryWHAWbvaA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.13.1", + "fast-xml-parser": "5.5.8", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws/lambda-invoke-store": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.2.4.tgz", + "integrity": "sha512-iY8yvjE0y651BixKNPgmv1WrQc+GZ142sb0z4gYnChDDY2YqI4P/jsSopBWrKfAt7LOJAkOXt7rC/hms+WclQQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/abort-controller": { + "version": "4.2.12", + "resolved": "https://registry.npmjs.org/@smithy/abort-controller/-/abort-controller-4.2.12.tgz", + "integrity": "sha512-xolrFw6b+2iYGl6EcOL7IJY71vvyZ0DJ3mcKtpykqPe2uscwtzDZJa1uVQXyP7w9Dd+kGwYnPbMsJrGISKiY/Q==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/chunked-blob-reader": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/@smithy/chunked-blob-reader/-/chunked-blob-reader-5.2.2.tgz", + "integrity": "sha512-St+kVicSyayWQca+I1rGitaOEH6uKgE8IUWoYnnEX26SWdWQcL6LvMSD19Lg+vYHKdT9B2Zuu7rd3i6Wnyb/iw==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/chunked-blob-reader-native": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/@smithy/chunked-blob-reader-native/-/chunked-blob-reader-native-4.2.3.tgz", + "integrity": "sha512-jA5k5Udn7Y5717L86h4EIv06wIr3xn8GM1qHRi/Nf31annXcXHJjBKvgztnbn2TxH3xWrPBfgwHsOwZf0UmQWw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-base64": "^4.3.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/config-resolver": { + "version": "4.4.13", + "resolved": "https://registry.npmjs.org/@smithy/config-resolver/-/config-resolver-4.4.13.tgz", + "integrity": "sha512-iIzMC5NmOUP6WL6o8iPBjFhUhBZ9pPjpUpQYWMUFQqKyXXzOftbfK8zcQCz/jFV1Psmf05BK5ypx4K2r4Tnwdg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/node-config-provider": "^4.3.12", + "@smithy/types": "^4.13.1", + "@smithy/util-config-provider": "^4.2.2", + "@smithy/util-endpoints": "^3.3.3", + "@smithy/util-middleware": "^4.2.12", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/core": { + "version": "3.23.12", + "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.23.12.tgz", + "integrity": "sha512-o9VycsYNtgC+Dy3I0yrwCqv9CWicDnke0L7EVOrZtJpjb2t0EjaEofmMrYc0T1Kn3yk32zm6cspxF9u9Bj7e5w==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/protocol-http": "^5.3.12", + "@smithy/types": "^4.13.1", + "@smithy/url-parser": "^4.2.12", + "@smithy/util-base64": "^4.3.2", + "@smithy/util-body-length-browser": "^4.2.2", + "@smithy/util-middleware": "^4.2.12", + "@smithy/util-stream": "^4.5.20", + "@smithy/util-utf8": "^4.2.2", + "@smithy/uuid": "^1.1.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/credential-provider-imds": { + "version": "4.2.12", + "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.2.12.tgz", + "integrity": "sha512-cr2lR792vNZcYMriSIj+Um3x9KWrjcu98kn234xA6reOAFMmbRpQMOv8KPgEmLLtx3eldU6c5wALKFqNOhugmg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/node-config-provider": "^4.3.12", + "@smithy/property-provider": "^4.2.12", + "@smithy/types": "^4.13.1", + "@smithy/url-parser": "^4.2.12", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/eventstream-codec": { + "version": "4.2.12", + "resolved": "https://registry.npmjs.org/@smithy/eventstream-codec/-/eventstream-codec-4.2.12.tgz", + "integrity": "sha512-FE3bZdEl62ojmy8x4FHqxq2+BuOHlcxiH5vaZ6aqHJr3AIZzwF5jfx8dEiU/X0a8RboyNDjmXjlbr8AdEyLgiA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/crc32": "5.2.0", + "@smithy/types": "^4.13.1", + "@smithy/util-hex-encoding": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/eventstream-serde-browser": { + "version": "4.2.12", + "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-browser/-/eventstream-serde-browser-4.2.12.tgz", + "integrity": "sha512-XUSuMxlTxV5pp4VpqZf6Sa3vT/Q75FVkLSpSSE3KkWBvAQWeuWt1msTv8fJfgA4/jcJhrbrbMzN1AC/hvPmm5A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/eventstream-serde-universal": "^4.2.12", + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/eventstream-serde-config-resolver": { + "version": "4.3.12", + "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-config-resolver/-/eventstream-serde-config-resolver-4.3.12.tgz", + "integrity": "sha512-7epsAZ3QvfHkngz6RXQYseyZYHlmWXSTPOfPmXkiS+zA6TBNo1awUaMFL9vxyXlGdoELmCZyZe1nQE+imbmV+Q==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/eventstream-serde-node": { + "version": "4.2.12", + "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-node/-/eventstream-serde-node-4.2.12.tgz", + "integrity": "sha512-D1pFuExo31854eAvg89KMn9Oab/wEeJR6Buy32B49A9Ogdtx5fwZPqBHUlDzaCDpycTFk2+fSQgX689Qsk7UGA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/eventstream-serde-universal": "^4.2.12", + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/eventstream-serde-universal": { + "version": "4.2.12", + "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-universal/-/eventstream-serde-universal-4.2.12.tgz", + "integrity": "sha512-+yNuTiyBACxOJUTvbsNsSOfH9G9oKbaJE1lNL3YHpGcuucl6rPZMi3nrpehpVOVR2E07YqFFmtwpImtpzlouHQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/eventstream-codec": "^4.2.12", + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/fetch-http-handler": { + "version": "5.3.15", + "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.3.15.tgz", + "integrity": "sha512-T4jFU5N/yiIfrtrsb9uOQn7RdELdM/7HbyLNr6uO/mpkj1ctiVs7CihVr51w4LyQlXWDpXFn4BElf1WmQvZu/A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/protocol-http": "^5.3.12", + "@smithy/querystring-builder": "^4.2.12", + "@smithy/types": "^4.13.1", + "@smithy/util-base64": "^4.3.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/hash-blob-browser": { + "version": "4.2.13", + "resolved": "https://registry.npmjs.org/@smithy/hash-blob-browser/-/hash-blob-browser-4.2.13.tgz", + "integrity": "sha512-YrF4zWKh+ghLuquldj6e/RzE3xZYL8wIPfkt0MqCRphVICjyyjH8OwKD7LLlKpVEbk4FLizFfC1+gwK6XQdR3g==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/chunked-blob-reader": "^5.2.2", + "@smithy/chunked-blob-reader-native": "^4.2.3", + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/hash-node": { + "version": "4.2.12", + "resolved": "https://registry.npmjs.org/@smithy/hash-node/-/hash-node-4.2.12.tgz", + "integrity": "sha512-QhBYbGrbxTkZ43QoTPrK72DoYviDeg6YKDrHTMJbbC+A0sml3kSjzFtXP7BtbyJnXojLfTQldGdUR0RGD8dA3w==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.13.1", + "@smithy/util-buffer-from": "^4.2.2", + "@smithy/util-utf8": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/hash-stream-node": { + "version": "4.2.12", + "resolved": "https://registry.npmjs.org/@smithy/hash-stream-node/-/hash-stream-node-4.2.12.tgz", + "integrity": "sha512-O3YbmGExeafuM/kP7Y8r6+1y0hIh3/zn6GROx0uNlB54K9oihAL75Qtc+jFfLNliTi6pxOAYZrRKD9A7iA6UFw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.13.1", + "@smithy/util-utf8": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/invalid-dependency": { + "version": "4.2.12", + "resolved": "https://registry.npmjs.org/@smithy/invalid-dependency/-/invalid-dependency-4.2.12.tgz", + "integrity": "sha512-/4F1zb7Z8LOu1PalTdESFHR0RbPwHd3FcaG1sI3UEIriQTWakysgJr65lc1jj6QY5ye7aFsisajotH6UhWfm/g==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/is-array-buffer": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-4.2.2.tgz", + "integrity": "sha512-n6rQ4N8Jj4YTQO3YFrlgZuwKodf4zUFs7EJIWH86pSCWBaAtAGBFfCM7Wx6D2bBJ2xqFNxGBSrUWswT3M0VJow==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/md5-js": { + "version": "4.2.12", + "resolved": "https://registry.npmjs.org/@smithy/md5-js/-/md5-js-4.2.12.tgz", + "integrity": "sha512-W/oIpHCpWU2+iAkfZYyGWE+qkpuf3vEXHLxQQDx9FPNZTTdnul0dZ2d/gUFrtQ5je1G2kp4cjG0/24YueG2LbQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.13.1", + "@smithy/util-utf8": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/middleware-content-length": { + "version": "4.2.12", + "resolved": "https://registry.npmjs.org/@smithy/middleware-content-length/-/middleware-content-length-4.2.12.tgz", + "integrity": "sha512-YE58Yz+cvFInWI/wOTrB+DbvUVz/pLn5mC5MvOV4fdRUc6qGwygyngcucRQjAhiCEbmfLOXX0gntSIcgMvAjmA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/protocol-http": "^5.3.12", + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/middleware-endpoint": { + "version": "4.4.27", + "resolved": "https://registry.npmjs.org/@smithy/middleware-endpoint/-/middleware-endpoint-4.4.27.tgz", + "integrity": "sha512-T3TFfUgXQlpcg+UdzcAISdZpj4Z+XECZ/cefgA6wLBd6V4lRi0svN2hBouN/be9dXQ31X4sLWz3fAQDf+nt6BA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.23.12", + "@smithy/middleware-serde": "^4.2.15", + "@smithy/node-config-provider": "^4.3.12", + "@smithy/shared-ini-file-loader": "^4.4.7", + "@smithy/types": "^4.13.1", + "@smithy/url-parser": "^4.2.12", + "@smithy/util-middleware": "^4.2.12", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/middleware-retry": { + "version": "4.4.44", + "resolved": "https://registry.npmjs.org/@smithy/middleware-retry/-/middleware-retry-4.4.44.tgz", + "integrity": "sha512-Y1Rav7m5CFRPQyM4CI0koD/bXjyjJu3EQxZZhtLGD88WIrBrQ7kqXM96ncd6rYnojwOo/u9MXu57JrEvu/nLrA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/node-config-provider": "^4.3.12", + "@smithy/protocol-http": "^5.3.12", + "@smithy/service-error-classification": "^4.2.12", + "@smithy/smithy-client": "^4.12.7", + "@smithy/types": "^4.13.1", + "@smithy/util-middleware": "^4.2.12", + "@smithy/util-retry": "^4.2.12", + "@smithy/uuid": "^1.1.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/middleware-serde": { + "version": "4.2.15", + "resolved": "https://registry.npmjs.org/@smithy/middleware-serde/-/middleware-serde-4.2.15.tgz", + "integrity": "sha512-ExYhcltZSli0pgAKOpQQe1DLFBLryeZ22605y/YS+mQpdNWekum9Ujb/jMKfJKgjtz1AZldtwA/wCYuKJgjjlg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.23.12", + "@smithy/protocol-http": "^5.3.12", + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/middleware-stack": { + "version": "4.2.12", + "resolved": "https://registry.npmjs.org/@smithy/middleware-stack/-/middleware-stack-4.2.12.tgz", + "integrity": "sha512-kruC5gRHwsCOuyCd4ouQxYjgRAym2uDlCvQ5acuMtRrcdfg7mFBg6blaxcJ09STpt3ziEkis6bhg1uwrWU7txw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/node-config-provider": { + "version": "4.3.12", + "resolved": "https://registry.npmjs.org/@smithy/node-config-provider/-/node-config-provider-4.3.12.tgz", + "integrity": "sha512-tr2oKX2xMcO+rBOjobSwVAkV05SIfUKz8iI53rzxEmgW3GOOPOv0UioSDk+J8OpRQnpnhsO3Af6IEBabQBVmiw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/property-provider": "^4.2.12", + "@smithy/shared-ini-file-loader": "^4.4.7", + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/node-http-handler": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.5.0.tgz", + "integrity": "sha512-Rnq9vQWiR1+/I6NZZMNzJHV6pZYyEHt2ZnuV3MG8z2NNenC4i/8Kzttz7CjZiHSmsN5frhXhg17z3Zqjjhmz1A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/abort-controller": "^4.2.12", + "@smithy/protocol-http": "^5.3.12", + "@smithy/querystring-builder": "^4.2.12", + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/property-provider": { + "version": "4.2.12", + "resolved": "https://registry.npmjs.org/@smithy/property-provider/-/property-provider-4.2.12.tgz", + "integrity": "sha512-jqve46eYU1v7pZ5BM+fmkbq3DerkSluPr5EhvOcHxygxzD05ByDRppRwRPPpFrsFo5yDtCYLKu+kreHKVrvc7A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/protocol-http": { + "version": "5.3.12", + "resolved": "https://registry.npmjs.org/@smithy/protocol-http/-/protocol-http-5.3.12.tgz", + "integrity": "sha512-fit0GZK9I1xoRlR4jXmbLhoN0OdEpa96ul8M65XdmXnxXkuMxM0Y8HDT0Fh0Xb4I85MBvBClOzgSrV1X2s1Hxw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/querystring-builder": { + "version": "4.2.12", + "resolved": "https://registry.npmjs.org/@smithy/querystring-builder/-/querystring-builder-4.2.12.tgz", + "integrity": "sha512-6wTZjGABQufekycfDGMEB84BgtdOE/rCVTov+EDXQ8NHKTUNIp/j27IliwP7tjIU9LR+sSzyGBOXjeEtVgzCHg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.13.1", + "@smithy/util-uri-escape": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/querystring-parser": { + "version": "4.2.12", + "resolved": "https://registry.npmjs.org/@smithy/querystring-parser/-/querystring-parser-4.2.12.tgz", + "integrity": "sha512-P2OdvrgiAKpkPNKlKUtWbNZKB1XjPxM086NeVhK+W+wI46pIKdWBe5QyXvhUm3MEcyS/rkLvY8rZzyUdmyDZBw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/service-error-classification": { + "version": "4.2.12", + "resolved": "https://registry.npmjs.org/@smithy/service-error-classification/-/service-error-classification-4.2.12.tgz", + "integrity": "sha512-LlP29oSQN0Tw0b6D0Xo6BIikBswuIiGYbRACy5ujw/JgWSzTdYj46U83ssf6Ux0GyNJVivs2uReU8pt7Eu9okQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.13.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/shared-ini-file-loader": { + "version": "4.4.7", + "resolved": "https://registry.npmjs.org/@smithy/shared-ini-file-loader/-/shared-ini-file-loader-4.4.7.tgz", + "integrity": "sha512-HrOKWsUb+otTeo1HxVWeEb99t5ER1XrBi/xka2Wv6NVmTbuCUC1dvlrksdvxFtODLBjsC+PHK+fuy2x/7Ynyiw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/signature-v4": { + "version": "5.3.12", + "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.3.12.tgz", + "integrity": "sha512-B/FBwO3MVOL00DaRSXfXfa/TRXRheagt/q5A2NM13u7q+sHS59EOVGQNfG7DkmVtdQm5m3vOosoKAXSqn/OEgw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/is-array-buffer": "^4.2.2", + "@smithy/protocol-http": "^5.3.12", + "@smithy/types": "^4.13.1", + "@smithy/util-hex-encoding": "^4.2.2", + "@smithy/util-middleware": "^4.2.12", + "@smithy/util-uri-escape": "^4.2.2", + "@smithy/util-utf8": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/smithy-client": { + "version": "4.12.7", + "resolved": "https://registry.npmjs.org/@smithy/smithy-client/-/smithy-client-4.12.7.tgz", + "integrity": "sha512-q3gqnwml60G44FECaEEsdQMplYhDMZYCtYhMCzadCnRnnHIobZJjegmdoUo6ieLQlPUzvrMdIJUpx6DoPmzANQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.23.12", + "@smithy/middleware-endpoint": "^4.4.27", + "@smithy/middleware-stack": "^4.2.12", + "@smithy/protocol-http": "^5.3.12", + "@smithy/types": "^4.13.1", + "@smithy/util-stream": "^4.5.20", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/types": { + "version": "4.13.1", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.13.1.tgz", + "integrity": "sha512-787F3yzE2UiJIQ+wYW1CVg2odHjmaWLGksnKQHUrK/lYZSEcy1msuLVvxaR/sI2/aDe9U+TBuLsXnr3vod1g0g==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/url-parser": { + "version": "4.2.12", + "resolved": "https://registry.npmjs.org/@smithy/url-parser/-/url-parser-4.2.12.tgz", + "integrity": "sha512-wOPKPEpso+doCZGIlr+e1lVI6+9VAKfL4kZWFgzVgGWY2hZxshNKod4l2LXS3PRC9otH/JRSjtEHqQ/7eLciRA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/querystring-parser": "^4.2.12", + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-base64": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@smithy/util-base64/-/util-base64-4.3.2.tgz", + "integrity": "sha512-XRH6b0H/5A3SgblmMa5ErXQ2XKhfbQB+Fm/oyLZ2O2kCUrwgg55bU0RekmzAhuwOjA9qdN5VU2BprOvGGUkOOQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^4.2.2", + "@smithy/util-utf8": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-body-length-browser": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@smithy/util-body-length-browser/-/util-body-length-browser-4.2.2.tgz", + "integrity": "sha512-JKCrLNOup3OOgmzeaKQwi4ZCTWlYR5H4Gm1r2uTMVBXoemo1UEghk5vtMi1xSu2ymgKVGW631e2fp9/R610ZjQ==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-body-length-node": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/@smithy/util-body-length-node/-/util-body-length-node-4.2.3.tgz", + "integrity": "sha512-ZkJGvqBzMHVHE7r/hcuCxlTY8pQr1kMtdsVPs7ex4mMU+EAbcXppfo5NmyxMYi2XU49eqaz56j2gsk4dHHPG/g==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-buffer-from": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-4.2.2.tgz", + "integrity": "sha512-FDXD7cvUoFWwN6vtQfEta540Y/YBe5JneK3SoZg9bThSoOAC/eGeYEua6RkBgKjGa/sz6Y+DuBZj3+YEY21y4Q==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/is-array-buffer": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-config-provider": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@smithy/util-config-provider/-/util-config-provider-4.2.2.tgz", + "integrity": "sha512-dWU03V3XUprJwaUIFVv4iOnS1FC9HnMHDfUrlNDSh4315v0cWyaIErP8KiqGVbf5z+JupoVpNM7ZB3jFiTejvQ==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-defaults-mode-browser": { + "version": "4.3.43", + "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-browser/-/util-defaults-mode-browser-4.3.43.tgz", + "integrity": "sha512-Qd/0wCKMaXxev/z00TvNzGCH2jlKKKxXP1aDxB6oKwSQthe3Og2dMhSayGCnsma1bK/kQX1+X7SMP99t6FgiiQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/property-provider": "^4.2.12", + "@smithy/smithy-client": "^4.12.7", + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-defaults-mode-node": { + "version": "4.2.47", + "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-node/-/util-defaults-mode-node-4.2.47.tgz", + "integrity": "sha512-qSRbYp1EQ7th+sPFuVcVO05AE0QH635hycdEXlpzIahqHHf2Fyd/Zl+8v0XYMJ3cgDVPa0lkMefU7oNUjAP+DQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/config-resolver": "^4.4.13", + "@smithy/credential-provider-imds": "^4.2.12", + "@smithy/node-config-provider": "^4.3.12", + "@smithy/property-provider": "^4.2.12", + "@smithy/smithy-client": "^4.12.7", + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-endpoints": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/@smithy/util-endpoints/-/util-endpoints-3.3.3.tgz", + "integrity": "sha512-VACQVe50j0HZPjpwWcjyT51KUQ4AnsvEaQ2lKHOSL4mNLD0G9BjEniQ+yCt1qqfKfiAHRAts26ud7hBjamrwig==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/node-config-provider": "^4.3.12", + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-hex-encoding": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@smithy/util-hex-encoding/-/util-hex-encoding-4.2.2.tgz", + "integrity": "sha512-Qcz3W5vuHK4sLQdyT93k/rfrUwdJ8/HZ+nMUOyGdpeGA1Wxt65zYwi3oEl9kOM+RswvYq90fzkNDahPS8K0OIg==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-middleware": { + "version": "4.2.12", + "resolved": "https://registry.npmjs.org/@smithy/util-middleware/-/util-middleware-4.2.12.tgz", + "integrity": "sha512-Er805uFUOvgc0l8nv0e0su0VFISoxhJ/AwOn3gL2NWNY2LUEldP5WtVcRYSQBcjg0y9NfG8JYrCJaYDpupBHJQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-retry": { + "version": "4.2.12", + "resolved": "https://registry.npmjs.org/@smithy/util-retry/-/util-retry-4.2.12.tgz", + "integrity": "sha512-1zopLDUEOwumjcHdJ1mwBHddubYF8GMQvstVCLC54Y46rqoHwlIU+8ZzUeaBcD+WCJHyDGSeZ2ml9YSe9aqcoQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/service-error-classification": "^4.2.12", + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-stream": { + "version": "4.5.20", + "resolved": "https://registry.npmjs.org/@smithy/util-stream/-/util-stream-4.5.20.tgz", + "integrity": "sha512-4yXLm5n/B5SRBR2p8cZ90Sbv4zL4NKsgxdzCzp/83cXw2KxLEumt5p+GAVyRNZgQOSrzXn9ARpO0lUe8XSlSDw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/fetch-http-handler": "^5.3.15", + "@smithy/node-http-handler": "^4.5.0", + "@smithy/types": "^4.13.1", + "@smithy/util-base64": "^4.3.2", + "@smithy/util-buffer-from": "^4.2.2", + "@smithy/util-hex-encoding": "^4.2.2", + "@smithy/util-utf8": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-uri-escape": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@smithy/util-uri-escape/-/util-uri-escape-4.2.2.tgz", + "integrity": "sha512-2kAStBlvq+lTXHyAZYfJRb/DfS3rsinLiwb+69SstC9Vb0s9vNWkRwpnj918Pfi85mzi42sOqdV72OLxWAISnw==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-utf8": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-4.2.2.tgz", + "integrity": "sha512-75MeYpjdWRe8M5E3AW0O4Cx3UadweS+cwdXjwYGBW5h/gxxnbeZ877sLPX/ZJA9GVTlL/qG0dXP29JWFCD1Ayw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-waiter": { + "version": "4.2.13", + "resolved": "https://registry.npmjs.org/@smithy/util-waiter/-/util-waiter-4.2.13.tgz", + "integrity": "sha512-2zdZ9DTHngRtcYxJK1GUDxruNr53kv5W2Lupe0LMU+Imr6ohQg8M2T14MNkj1Y0wS3FFwpgpGQyvuaMF7CiTmQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/abort-controller": "^4.2.12", + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/uuid": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@smithy/uuid/-/uuid-1.1.2.tgz", + "integrity": "sha512-O/IEdcCUKkubz60tFbGA7ceITTAJsty+lBjNoorP4Z6XRqaFb/OjQjZODophEcuq68nKm6/0r+6/lLQ+XVpk8g==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/bowser": { + "version": "2.14.1", + "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.14.1.tgz", + "integrity": "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==", + "license": "MIT" + }, + "node_modules/fast-xml-builder": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.1.4.tgz", + "integrity": "sha512-f2jhpN4Eccy0/Uz9csxh3Nu6q4ErKxf0XIsasomfOihuSUa3/xw6w8dnOtCDgEItQFJG8KyXPzQXzcODDrrbOg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "path-expression-matcher": "^1.1.3" + } + }, + "node_modules/fast-xml-parser": { + "version": "5.5.8", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.5.8.tgz", + "integrity": "sha512-Z7Fh2nVQSb2d+poDViM063ix2ZGt9jmY1nWhPfHBOK2Hgnb/OW3P4Et3P/81SEej0J7QbWtJqxO05h8QYfK7LQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "fast-xml-builder": "^1.1.4", + "path-expression-matcher": "^1.2.0", + "strnum": "^2.2.0" + }, + "bin": { + "fxparser": "src/cli/cli.js" + } + }, + "node_modules/path-expression-matcher": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.2.0.tgz", + "integrity": "sha512-DwmPWeFn+tq7TiyJ2CxezCAirXjFxvaiD03npak3cRjlP9+OjTmSy1EpIrEbh+l6JgUundniloMLDQ/6VTdhLQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/strnum": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.2.1.tgz", + "integrity": "sha512-BwRvNd5/QoAtyW1na1y1LsJGQNvRlkde6Q/ipqqEaivoMdV+B1OMOTVdwR+N/cwVUcIt9PYyHmV8HyexCZSupg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT" + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + } + } +} diff --git a/examples/waf-loki/function-waf/package.json b/examples/waf-loki/function-waf/package.json new file mode 100644 index 0000000..03d85f4 --- /dev/null +++ b/examples/waf-loki/function-waf/package.json @@ -0,0 +1,6 @@ +{ + "name": "waf-loki-ingest", + "version": "1.0.0", + "description": "WAF S3 log ingest to Loki push — runs on Lambda Managed Instance (Node.js 24, x86_64)", + "type": "module" +} diff --git a/examples/waf-loki/main.tf b/examples/waf-loki/main.tf new file mode 100644 index 0000000..2a39bea --- /dev/null +++ b/examples/waf-loki/main.tf @@ -0,0 +1,373 @@ +provider "aws" { + region = var.aws_region +} + +module "vpc" { + source = "../../modules/vpc" + + vpc_name = "${var.name_prefix}-vpc" + vpc_cidr = var.vpc_cidr + tags = var.tags + availability_zones = var.availability_zones + public_subnet_cidrs = var.public_subnet_cidrs + private_subnet_cidrs = var.private_subnet_cidrs +} + +resource "aws_security_group" "lmi" { + name_prefix = "${var.name_prefix}-lmi-" + description = "Lambda Managed Instances capacity provider ENIs; egress for CloudWatch Logs and Lambda control plane" + vpc_id = module.vpc.vpc_id + + egress { + from_port = 0 + to_port = 0 + protocol = "-1" + cidr_blocks = ["0.0.0.0/0"] + } + + tags = merge( + var.tags, + { Name = "${var.name_prefix}-lmi" } + ) + + lifecycle { + create_before_destroy = true + } +} + +# Walkthrough reference: every lambda_managed_instance input is explicit below (values match +# module defaults unless noted) so the site can document each argument without inferring defaults. +module "lambda_managed_instance" { + source = "../../modules/lambda_managed_instance" + + # --- Identity + capacity_provider_name = "${var.name_prefix}-capacity" + iam_role_name_prefix = var.name_prefix # walkthrough: use name_prefix; module default is "lmi" + + # --- Capacity provider and scaling + max_vcpu_count = 16 + scaling_mode = "Auto" + cpu_target_utilization = 70 # used when scaling_mode = "Manual"; ignored for Auto + + allowed_instance_types = [] # mutually exclusive with excluded_instance_types + excluded_instance_types = [] + + # --- VPC placement (capacity provider) + subnet_ids = module.vpc.private_subnet_ids + security_group_ids = [aws_security_group.lmi.id] + + tags = var.tags +} + +# ── Data sources (shared by WAF + observability sections) ─────────────────── + +data "http" "my_public_ip" { + url = "https://checkip.amazonaws.com/" +} + +data "aws_ami" "al2023" { + most_recent = true + owners = ["amazon"] + filter { + name = "name" + values = ["al2023-ami-*-x86_64"] + } +} + +locals { + my_public_ip_cidr = "${trimspace(data.http.my_public_ip.response_body)}/32" + alb_ingress_cidrs = length(var.alb_ingress_cidrs) > 0 ? var.alb_ingress_cidrs : [local.my_public_ip_cidr] + loki_push_url = "http://${aws_instance.obs.private_ip}:3100/loki/api/v1/push" +} + +# ── WAF log bucket (existing; not managed by this stack) ───────────────────── +# Terraform only reads the bucket for IAM, notifications, and optional WAF logging. +# Create and secure the bucket outside this configuration. + +data "aws_s3_bucket" "waf_logs" { + bucket = var.waf_logs_bucket_name +} + +# ── IAM: WAF Lambda S3 read ────────────────────────────────────────────────── + +resource "aws_iam_policy" "waf_s3_read" { + name_prefix = "${var.name_prefix}-waf-s3-" + description = "Allow WAF ingest Lambda to read objects from the WAF log bucket" + + policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Effect = "Allow" + Action = ["s3:GetObject"] + Resource = "${data.aws_s3_bucket.waf_logs.arn}/*" + }, + { + Effect = "Allow" + Action = ["s3:ListBucket"] + Resource = data.aws_s3_bucket.waf_logs.arn + } + ] + }) +} + +# ── WAF ingest Lambda (Node.js 24) ─────────────────────────────────────────── + +data "archive_file" "waf_zip" { + type = "zip" + source_dir = "${path.module}/function-waf" + output_path = "${path.module}/.build/waf.zip" + # Runtime ships AWS SDK v3; local node_modules (if present) must not inflate the artifact. + excludes = ["node_modules/**"] +} + +module "lambda_managed_function_waf" { + source = "../../modules/lambda_managed_function" + + # --- Identity + function_name = "${var.name_prefix}-waf-fn" + capacity_provider_arn = module.lambda_managed_instance.capacity_provider_arn + iam_role_name_prefix = "${var.name_prefix}-waf" + description = "WAF S3 log ingest - reads gzip WAF log objects and pushes to Loki" + + # --- Deployment artifact + filename = data.archive_file.waf_zip.output_path + source_code_hash = data.archive_file.waf_zip.output_base64sha256 + + # --- Function + runtime = "nodejs24.x" + handler = "index.handler" + architectures = ["x86_64"] + + memory_size = 2048 + timeout = 60 + ephemeral_storage_size = 512 + + layers = [] + environment_variables = { LOKI_URL = local.loki_push_url } + reserved_concurrent_executions = -1 + + # --- Logging + log_retention_days = 14 + cloudwatch_log_group_prevent_destroy = false + log_format = "JSON" + application_log_level = "INFO" + system_log_level = "WARN" + + # --- Concurrency + per_execution_environment_max_concurrency = 10 + + # --- IAM: add S3 read for waf_logs bucket + additional_execution_policy_arns = [aws_iam_policy.waf_s3_read.arn] + + tags = var.tags +} + +resource "aws_lambda_permission" "s3_invoke_waf" { + statement_id = "AllowS3Invoke" + action = "lambda:InvokeFunction" + function_name = module.lambda_managed_function_waf.lambda_function_name + qualifier = module.lambda_managed_function_waf.lambda_version + principal = "s3.amazonaws.com" + source_arn = data.aws_s3_bucket.waf_logs.arn +} + +resource "aws_s3_bucket_notification" "waf_logs" { + bucket = data.aws_s3_bucket.waf_logs.id + + lambda_function { + # S3 requires arn:aws:lambda:...:function:name[:version]; not qualified_invoke_arn (API Gateway style). + lambda_function_arn = module.lambda_managed_function_waf.lambda_qualified_arn + events = ["s3:ObjectCreated:*"] + filter_prefix = var.waf_logs_prefix != "" ? var.waf_logs_prefix : null + filter_suffix = var.waf_logs_object_suffix != "" ? var.waf_logs_object_suffix : null + } + + depends_on = [aws_lambda_permission.s3_invoke_waf] +} + +# ── Optional: WAFv2 logging configuration ──────────────────────────────────── +# Set var.web_acl_arn to an existing WAFv2 Web ACL ARN to enable WAF log delivery. + +resource "aws_wafv2_web_acl_logging_configuration" "this" { + count = var.web_acl_arn != "" ? 1 : 0 + + resource_arn = var.web_acl_arn + log_destination_configs = [data.aws_s3_bucket.waf_logs.arn] +} + +# ── Observability: security groups ─────────────────────────────────────────── + +resource "aws_security_group" "alb" { + name_prefix = "${var.name_prefix}-alb-" + description = "Grafana ALB - HTTP ingress from allowed CIDRs" + vpc_id = module.vpc.vpc_id + + ingress { + from_port = 80 + to_port = 80 + protocol = "tcp" + cidr_blocks = local.alb_ingress_cidrs + description = "HTTP from allowed CIDRs" + } + + egress { + from_port = 0 + to_port = 0 + protocol = "-1" + cidr_blocks = ["0.0.0.0/0"] + } + + tags = merge(var.tags, { Name = "${var.name_prefix}-alb" }) + + lifecycle { + create_before_destroy = true + } +} + +resource "aws_security_group" "obs" { + name_prefix = "${var.name_prefix}-obs-" + description = "Loki + Grafana EC2 host" + vpc_id = module.vpc.vpc_id + + ingress { + from_port = 3100 + to_port = 3100 + protocol = "tcp" + security_groups = [aws_security_group.lmi.id] + description = "Loki push from Lambda ENIs" + } + + ingress { + from_port = 3000 + to_port = 3000 + protocol = "tcp" + security_groups = [aws_security_group.alb.id] + description = "Grafana from ALB" + } + + egress { + from_port = 0 + to_port = 0 + protocol = "-1" + cidr_blocks = ["0.0.0.0/0"] + } + + tags = merge(var.tags, { Name = "${var.name_prefix}-obs" }) + + lifecycle { + create_before_destroy = true + } +} + +# ── Observability: EC2 instance profile (SSM access, no SSH key required) ─── + +data "aws_iam_policy_document" "obs_assume_role" { + statement { + actions = ["sts:AssumeRole"] + principals { + type = "Service" + identifiers = ["ec2.amazonaws.com"] + } + } +} + +resource "aws_iam_role" "obs_ec2" { + name_prefix = "${var.name_prefix}-obs-" + assume_role_policy = data.aws_iam_policy_document.obs_assume_role.json + tags = var.tags +} + +resource "aws_iam_role_policy_attachment" "obs_ssm" { + role = aws_iam_role.obs_ec2.name + policy_arn = "arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore" +} + +resource "aws_iam_instance_profile" "obs_ec2" { + name_prefix = "${var.name_prefix}-obs-" + role = aws_iam_role.obs_ec2.name + tags = var.tags +} + +# ── Observability: EC2 instance (private subnet, EBS-backed Loki data) ─────── + +resource "aws_instance" "obs" { + ami = data.aws_ami.al2023.id + instance_type = var.obs_instance_type + subnet_id = module.vpc.private_subnet_ids[0] + vpc_security_group_ids = [aws_security_group.obs.id] + iam_instance_profile = aws_iam_instance_profile.obs_ec2.name + + user_data = templatefile("${path.module}/templates/user_data.sh.tftpl", { + waf_dashboard_b64 = filebase64("${path.module}/templates/dashboards/waf.json") + waf_overview_b64 = filebase64("${path.module}/templates/dashboards/waf-overview.json") + }) + user_data_replace_on_change = true + + metadata_options { + http_endpoint = "enabled" + http_tokens = "required" + http_put_response_hop_limit = 1 + } + + root_block_device { + volume_size = 30 + volume_type = "gp3" + encrypted = true + delete_on_termination = true + } + + tags = merge(var.tags, { Name = "${var.name_prefix}-obs" }) +} + +# ── Observability: Application Load Balancer (Grafana) ─────────────────────── + +resource "aws_lb" "grafana" { + name = "${var.name_prefix}-grafana" + internal = false + load_balancer_type = "application" + security_groups = [aws_security_group.alb.id] + subnets = module.vpc.public_subnet_ids + drop_invalid_header_fields = true + + tags = merge(var.tags, { Name = "${var.name_prefix}-grafana" }) +} + +resource "aws_lb_target_group" "grafana" { + name = "${var.name_prefix}-grafana" + port = 3000 + protocol = "HTTP" + vpc_id = module.vpc.vpc_id + target_type = "instance" + + health_check { + enabled = true + protocol = "HTTP" + path = "/api/health" + port = "traffic-port" + matcher = "200" + healthy_threshold = 2 + unhealthy_threshold = 3 + interval = 30 + timeout = 5 + } + + tags = merge(var.tags, { Name = "${var.name_prefix}-grafana" }) +} + +resource "aws_lb_listener" "grafana" { + load_balancer_arn = aws_lb.grafana.arn + port = 80 + protocol = "HTTP" + + default_action { + type = "forward" + target_group_arn = aws_lb_target_group.grafana.arn + } +} + +resource "aws_lb_target_group_attachment" "grafana" { + target_group_arn = aws_lb_target_group.grafana.arn + target_id = aws_instance.obs.id + port = 3000 +} diff --git a/examples/waf-loki/outputs.tf b/examples/waf-loki/outputs.tf new file mode 100644 index 0000000..a5b2ac6 --- /dev/null +++ b/examples/waf-loki/outputs.tf @@ -0,0 +1,50 @@ +output "vpc_id" { + value = module.vpc.vpc_id +} + +output "private_subnet_ids" { + value = module.vpc.private_subnet_ids +} + +output "capacity_provider_name" { + value = module.lambda_managed_instance.capacity_provider_name +} + +# ── WAF ingest Lambda ──────────────────────────────────────────────────────── + +output "waf_function_name" { + description = "WAF ingest Lambda function name" + value = module.lambda_managed_function_waf.lambda_function_name +} + +output "waf_function_version" { + description = "Published WAF ingest Lambda version" + value = module.lambda_managed_function_waf.lambda_version +} + +output "waf_log_group_name" { + description = "CloudWatch log group for the WAF ingest Lambda" + value = module.lambda_managed_function_waf.lambda_log_group_name +} + +output "waf_logs_bucket" { + description = "Existing S3 bucket name wired for WAF log delivery and Lambda trigger" + value = data.aws_s3_bucket.waf_logs.id +} + +# ── Observability ──────────────────────────────────────────────────────────── + +output "grafana_url" { + description = "Grafana URL via the public ALB - open in browser (admin / admin)" + value = "http://${aws_lb.grafana.dns_name}" +} + +output "loki_push_url" { + description = "Loki push API endpoint used by the WAF ingest Lambda" + value = local.loki_push_url +} + +output "obs_instance_id" { + description = "EC2 instance ID of the Loki + Grafana host (connect via SSM Session Manager)" + value = aws_instance.obs.id +} diff --git a/examples/waf-loki/templates/dashboards/waf-overview.json b/examples/waf-loki/templates/dashboards/waf-overview.json new file mode 100644 index 0000000..a90d0c7 --- /dev/null +++ b/examples/waf-loki/templates/dashboards/waf-overview.json @@ -0,0 +1,145 @@ +{ + "id": null, + "title": "WAF overview", + "uid": "waf-overview", + "schemaVersion": 39, + "version": 1, + "refresh": "30s", + "time": { "from": "now-6h", "to": "now" }, + "panels": [ + { + "id": 1, + "type": "timeseries", + "title": "Log lines per minute", + "gridPos": { "h": 7, "w": 24, "x": 0, "y": 0 }, + "datasource": { "type": "loki", "uid": "loki" }, + "targets": [ + { + "refId": "A", + "datasource": { "type": "loki", "uid": "loki" }, + "editorMode": "code", + "expr": "sum(count_over_time({source=\"waf\"}[1m]))", + "queryType": "range", + "legendFormat": "lines/min" + } + ], + "fieldConfig": { "defaults": { "unit": "none" }, "overrides": [] }, + "options": { + "legend": { "displayMode": "list", "placement": "bottom", "showLegend": true }, + "tooltip": { "mode": "single" } + } + }, + { + "id": 2, + "type": "timeseries", + "title": "Requests by action (ALLOW / BLOCK / COUNT / CAPTCHA)", + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 7 }, + "datasource": { "type": "loki", "uid": "loki" }, + "targets": [ + { + "refId": "A", + "datasource": { "type": "loki", "uid": "loki" }, + "editorMode": "code", + "expr": "sum by (action) (count_over_time({source=\"waf\"} | json | action != \"\" [1m]))", + "queryType": "range", + "legendFormat": "{{action}}" + } + ], + "fieldConfig": { "defaults": {}, "overrides": [] }, + "options": { + "legend": { "displayMode": "list", "placement": "bottom", "showLegend": true }, + "tooltip": { "mode": "multi" } + } + }, + { + "id": 3, + "type": "timeseries", + "title": "By terminatingRuleId", + "description": "Which rule ended evaluation (Default_Action for many allows).", + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 7 }, + "datasource": { "type": "loki", "uid": "loki" }, + "targets": [ + { + "refId": "A", + "datasource": { "type": "loki", "uid": "loki" }, + "editorMode": "code", + "expr": "sum by (terminatingRuleId) (count_over_time({source=\"waf\"} | json | terminatingRuleId != \"\" [1m]))", + "queryType": "range", + "legendFormat": "{{terminatingRuleId}}" + } + ], + "fieldConfig": { "defaults": {}, "overrides": [] }, + "options": { + "legend": { "displayMode": "list", "placement": "bottom", "showLegend": true }, + "tooltip": { "mode": "multi" } + } + }, + { + "id": 4, + "type": "timeseries", + "title": "Top client IPs (httpRequest.clientIp)", + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 15 }, + "datasource": { "type": "loki", "uid": "loki" }, + "targets": [ + { + "refId": "A", + "datasource": { "type": "loki", "uid": "loki" }, + "editorMode": "code", + "expr": "topk(10, sum by (clientIp) (count_over_time({source=\"waf\"} | regexp `\"clientIp\":\"(?P[^\"]+)\"` | clientIp != \"\" [1m])))", + "queryType": "range", + "legendFormat": "{{clientIp}}" + } + ], + "fieldConfig": { "defaults": {}, "overrides": [] }, + "options": { + "legend": { "displayMode": "list", "placement": "bottom", "showLegend": true }, + "tooltip": { "mode": "multi" } + } + }, + { + "id": 5, + "type": "timeseries", + "title": "By HTTP method", + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 15 }, + "datasource": { "type": "loki", "uid": "loki" }, + "targets": [ + { + "refId": "A", + "datasource": { "type": "loki", "uid": "loki" }, + "editorMode": "code", + "expr": "sum by (method) (count_over_time({source=\"waf\"} | regexp `\"httpMethod\":\"(?P[A-Z]+)\"` | method != \"\" [1m]))", + "queryType": "range", + "legendFormat": "{{method}}" + } + ], + "fieldConfig": { "defaults": {}, "overrides": [] }, + "options": { + "legend": { "displayMode": "list", "placement": "bottom", "showLegend": true }, + "tooltip": { "mode": "multi" } + } + }, + { + "id": 6, + "type": "logs", + "title": "BLOCK only (quick triage)", + "gridPos": { "h": 10, "w": 24, "x": 0, "y": 23 }, + "datasource": { "type": "loki", "uid": "loki" }, + "targets": [ + { + "refId": "A", + "datasource": { "type": "loki", "uid": "loki" }, + "editorMode": "code", + "expr": "{source=\"waf\"} | json | action=\"BLOCK\"", + "queryType": "range" + } + ], + "options": { + "dedupStrategy": "none", + "enableLogDetails": true, + "showTime": true, + "sortOrder": "Descending", + "wrapLogMessage": true + } + } + ] +} diff --git a/examples/waf-loki/templates/dashboards/waf.json b/examples/waf-loki/templates/dashboards/waf.json new file mode 100644 index 0000000..c1019dc --- /dev/null +++ b/examples/waf-loki/templates/dashboards/waf.json @@ -0,0 +1,32 @@ +{ + "id": null, + "title": "WAF Logs", + "uid": "waf-logs", + "schemaVersion": 39, + "version": 1, + "refresh": "30s", + "time": { "from": "now-1h", "to": "now" }, + "panels": [ + { + "id": 1, + "title": "WAF Log Stream", + "type": "logs", + "gridPos": { "h": 20, "w": 24, "x": 0, "y": 0 }, + "datasource": { "type": "loki", "uid": "loki" }, + "targets": [ + { + "refId": "A", + "datasource": { "type": "loki", "uid": "loki" }, + "expr": "{source=\"waf\"}" + } + ], + "options": { + "dedupStrategy": "none", + "enableLogDetails": true, + "showTime": true, + "sortOrder": "Descending", + "wrapLogMessage": true + } + } + ] +} diff --git a/examples/waf-loki/templates/promtail/waf-like-sample.jsonl b/examples/waf-loki/templates/promtail/waf-like-sample.jsonl new file mode 100644 index 0000000..db8bf1d --- /dev/null +++ b/examples/waf-loki/templates/promtail/waf-like-sample.jsonl @@ -0,0 +1,3 @@ +{"timestamp":1735700000000,"action":"ALLOW","terminatingRuleId":"Default_Action","httpRequest":{"clientIp":"8.8.8.8","uri":"/","httpMethod":"GET"}} +{"timestamp":1735700001000,"action":"ALLOW","terminatingRuleId":"Default_Action","httpRequest":{"clientIp":"1.1.1.1","uri":"/api","httpMethod":"POST"}} +{"timestamp":1735700002000,"action":"BLOCK","terminatingRuleId":"AWSManagedRulesCommonRuleSet","httpRequest":{"clientIp":"203.0.113.50","uri":"/admin","httpMethod":"GET"}} diff --git a/examples/waf-loki/templates/user_data.sh.tftpl b/examples/waf-loki/templates/user_data.sh.tftpl new file mode 100644 index 0000000..3467e73 --- /dev/null +++ b/examples/waf-loki/templates/user_data.sh.tftpl @@ -0,0 +1,141 @@ +#!/bin/bash +set -euxo pipefail + +# ── Install Docker ────────────────────────────────────────────────────────── +dnf install -y docker +systemctl enable --now docker +usermod -aG docker ec2-user + +# ── Install Docker Compose v2 plugin ──────────────────────────────────────── +COMPOSE_VERSION="2.33.1" +mkdir -p /usr/local/lib/docker/cli-plugins +curl -fsSL "https://github.com/docker/compose/releases/download/v$${COMPOSE_VERSION}/docker-compose-linux-x86_64" \ + -o /usr/local/lib/docker/cli-plugins/docker-compose +chmod +x /usr/local/lib/docker/cli-plugins/docker-compose + +# ── Working directory structure ────────────────────────────────────────────── +mkdir -p /opt/obs/grafana/provisioning/datasources +mkdir -p /opt/obs/grafana/provisioning/dashboards +mkdir -p /opt/obs/grafana/dashboards + +# ── Loki config (monolithic, filesystem storage on EBS) ───────────────────── +cat > /opt/obs/loki-config.yaml << 'LOKI_EOF' +auth_enabled: false + +server: + http_listen_port: 3100 + grpc_listen_port: 9096 + +common: + instance_addr: 127.0.0.1 + path_prefix: /loki + storage: + filesystem: + chunks_directory: /loki/chunks + rules_directory: /loki/rules + replication_factor: 1 + ring: + kvstore: + store: inmemory + +query_range: + results_cache: + cache: + embedded_cache: + enabled: true + max_size_mb: 100 + +schema_config: + configs: + - from: 2020-10-24 + store: tsdb + object_store: filesystem + schema: v13 + index: + prefix: index_ + period: 24h +LOKI_EOF + +# ── Docker Compose ─────────────────────────────────────────────────────────── +cat > /opt/obs/docker-compose.yaml << 'COMPOSE_EOF' +services: + loki: + image: grafana/loki:3.4.2 + ports: + - "3100:3100" + volumes: + - loki-data:/loki + - /opt/obs/loki-config.yaml:/etc/loki/loki-config.yaml:ro + command: -config.file=/etc/loki/loki-config.yaml + restart: unless-stopped + healthcheck: + test: ["CMD-SHELL", "wget --no-verbose --tries=1 --spider http://127.0.0.1:3100/ready || exit 1"] + interval: 30s + timeout: 10s + retries: 5 + start_period: 30s + + grafana: + image: grafana/grafana:11.5.2 + ports: + - "3000:3000" + volumes: + - grafana-data:/var/lib/grafana + - /opt/obs/grafana/provisioning:/etc/grafana/provisioning:ro + - /opt/obs/grafana/dashboards:/var/lib/grafana/dashboards:ro + depends_on: + loki: + condition: service_healthy + restart: unless-stopped + +volumes: + loki-data: + grafana-data: +COMPOSE_EOF + +# ── Grafana: Loki datasource ───────────────────────────────────────────────── +cat > /opt/obs/grafana/provisioning/datasources/loki.yaml << 'DS_EOF' +apiVersion: 1 +datasources: + - name: Loki + type: loki + uid: loki + url: http://loki:3100 + access: proxy + isDefault: true + jsonData: + maxLines: 1000 +DS_EOF + +# ── Grafana: dashboard provider ────────────────────────────────────────────── +cat > /opt/obs/grafana/provisioning/dashboards/provider.yaml << 'PROV_EOF' +apiVersion: 1 +providers: + - name: default + orgId: 1 + folder: WAF + type: file + disableDeletion: false + updateIntervalSeconds: 30 + options: + path: /var/lib/grafana/dashboards +PROV_EOF + +# ── Grafana dashboards (base64 from Terraform; avoids heredoc / line-ending issues) ── +printf '%s' "${waf_dashboard_b64}" | base64 -d > /opt/obs/grafana/dashboards/waf.json +printf '%s' "${waf_overview_b64}" | base64 -d > /opt/obs/grafana/dashboards/waf-overview.json +chmod a+r /opt/obs/grafana/dashboards/*.json +python3 - <<'PY' +import json +for p in ( + "/opt/obs/grafana/dashboards/waf.json", + "/opt/obs/grafana/dashboards/waf-overview.json", +): + with open(p, encoding="utf-8") as f: + json.load(f) + print("validated dashboard json:", p) +PY + +# ── Start Compose ──────────────────────────────────────────────────────────── +cd /opt/obs +docker compose up -d diff --git a/examples/waf-loki/terraform.tfvars.example b/examples/waf-loki/terraform.tfvars.example new file mode 100644 index 0000000..f5b5629 --- /dev/null +++ b/examples/waf-loki/terraform.tfvars.example @@ -0,0 +1,33 @@ +# Copy to terraform.tfvars and adjust for your account/region. +# Use a region where Lambda Managed Instances is available (see repository README). +# Defaults use 10.0.0.0/16; change if this CIDR is in use in your account. + +# aws_region = "ap-southeast-2" +# name_prefix = "lmi-waf-loki" + +# tags = { +# Project = "waf-loki-walkthrough" +# } + +# Required: name of an existing bucket (this example does not create it). +# For WAFv2 logging to S3, use a name starting with aws-waf-logs-. +# waf_logs_bucket_name = "aws-waf-logs-my-demo-123456789012" + +# ── WAF ingest ──────────────────────────────────────────────────────────────── +# Scope the S3 event trigger to a specific key prefix (optional). +# waf_logs_prefix = "AWSLogs/" + +# Suffix filter for notifications; default ".gz" matches WAF delivery. Use "" to trigger on any key (careful on shared buckets). +# waf_logs_object_suffix = ".gz" + +# ARN of an existing WAFv2 Web ACL to enable automatic WAF log delivery to S3. +# Leave empty to upload test objects manually. +# web_acl_arn = "arn:aws:wafv2:ap-southeast-2:123456789012:regional/webacl/my-acl/..." + +# ── Observability ───────────────────────────────────────────────────────────── +# CIDR blocks allowed to reach the Grafana ALB on port 80. +# Defaults to your current public IP via checkip.amazonaws.com. +# alb_ingress_cidrs = ["203.0.113.10/32"] + +# EC2 instance type for the Loki + Grafana host (default: t3.small). +# obs_instance_type = "t3.small" diff --git a/examples/waf-loki/variables.tf b/examples/waf-loki/variables.tf new file mode 100644 index 0000000..d7aff82 --- /dev/null +++ b/examples/waf-loki/variables.tf @@ -0,0 +1,83 @@ +variable "aws_region" { + description = "AWS region for all resources. Lambda Managed Instances (capacity providers) are only available in a subset of regions; see repository README." + type = string + default = "ap-southeast-2" +} + +variable "name_prefix" { + description = "Prefix for VPC and Lambda resource names" + type = string + default = "example" +} + +variable "vpc_cidr" { + description = "VPC IPv4 CIDR" + type = string + default = "10.0.0.0/16" +} + +variable "availability_zones" { + description = "Availability zones; the VPC module creates one public and one private subnet per AZ (length must match public_subnet_cidrs and private_subnet_cidrs)" + type = list(string) + default = ["ap-southeast-2a", "ap-southeast-2b", "ap-southeast-2c"] +} + +variable "public_subnet_cidrs" { + description = "Public subnet CIDRs (NAT + IGW path)" + type = list(string) + default = ["10.0.1.0/24", "10.0.2.0/24", "10.0.3.0/24"] +} + +variable "private_subnet_cidrs" { + description = "Private subnet CIDRs (Lambda managed instances)" + type = list(string) + default = ["10.0.101.0/24", "10.0.102.0/24", "10.0.103.0/24"] +} + +variable "tags" { + description = "Common tags" + type = map(string) + default = {} +} + +# ── WAF ingest ────────────────────────────────────────────────────────────── + +variable "waf_logs_bucket_name" { + description = <<-EOT + Name of an existing S3 bucket for WAF log objects and the Lambda trigger. This stack does not create the bucket. + For AWS WAFv2 direct log delivery to S3, the bucket name must start with aws-waf-logs-. + EOT + type = string +} + +variable "waf_logs_prefix" { + description = "Optional S3 key prefix to scope the WAF log trigger (e.g. \"AWSLogs/\"). Empty means all objects in the bucket." + type = string + default = "" +} + +variable "waf_logs_object_suffix" { + description = "S3 notification filter_suffix (e.g. \".gz\" for WAF delivery). Empty string omits the suffix filter so any object key can trigger the Lambda (useful for uncompressed test uploads; avoid on shared buckets)." + type = string + default = ".gz" +} + +variable "web_acl_arn" { + description = "ARN of an existing WAFv2 Web ACL. When set, an aws_wafv2_web_acl_logging_configuration resource directs WAF logs to the waf_logs bucket." + type = string + default = "" +} + +# ── Observability stack ────────────────────────────────────────────────────── + +variable "alb_ingress_cidrs" { + description = "CIDR blocks allowed to reach the Grafana ALB on port 80. Leave empty to automatically restrict access to only the deployer's current public IP." + type = list(string) + default = [] +} + +variable "obs_instance_type" { + description = "EC2 instance type for the Loki + Grafana host" + type = string + default = "t3.small" +} diff --git a/examples/waf-loki/versions.tf b/examples/waf-loki/versions.tf new file mode 100644 index 0000000..990654c --- /dev/null +++ b/examples/waf-loki/versions.tf @@ -0,0 +1,18 @@ +terraform { + required_version = ">= 1.7.0" + + required_providers { + archive = { + source = "hashicorp/archive" + version = ">= 2.4.0" + } + aws = { + source = "hashicorp/aws" + version = ">= 6.0.0" + } + http = { + source = "hashicorp/http" + version = ">= 3.4.0" + } + } +} diff --git a/main.tf b/main.tf new file mode 100644 index 0000000..2176801 --- /dev/null +++ b/main.tf @@ -0,0 +1,75 @@ +# Root composition: wires VPC + security group + lambda_managed_instance + lambda_managed_function. +# Reusable modules live under modules/ and can be called directly from any Terraform root. + +provider "aws" { + region = var.aws_region +} + +data "archive_file" "lambda_zip" { + type = "zip" + source_dir = "${path.module}/function" + output_path = "${path.module}/.build/lambda.zip" +} + +module "vpc" { + source = "./modules/vpc" + + vpc_name = "${var.name_prefix}-vpc" + vpc_cidr = var.vpc_cidr + tags = var.tags + availability_zones = var.availability_zones + public_subnet_cidrs = var.public_subnet_cidrs + private_subnet_cidrs = var.private_subnet_cidrs +} + +resource "aws_security_group" "lmi" { + name_prefix = "${var.name_prefix}-lmi-" + description = "Lambda Managed Instances capacity provider ENIs; egress for CloudWatch Logs and Lambda control plane" + vpc_id = module.vpc.vpc_id + + egress { + from_port = 0 + to_port = 0 + protocol = "-1" + cidr_blocks = ["0.0.0.0/0"] + } + + tags = merge( + var.tags, + { Name = "${var.name_prefix}-lmi" } + ) + + lifecycle { + create_before_destroy = true + } +} + +module "lambda_managed_instance" { + source = "./modules/lambda_managed_instance" + + capacity_provider_name = "${var.name_prefix}-capacity" + iam_role_name_prefix = var.name_prefix + + subnet_ids = module.vpc.private_subnet_ids + security_group_ids = [aws_security_group.lmi.id] + + tags = var.tags +} + +module "lambda_managed_function" { + source = "./modules/lambda_managed_function" + + function_name = "${var.name_prefix}-fn" + capacity_provider_arn = module.lambda_managed_instance.capacity_provider_arn + iam_role_name_prefix = var.name_prefix + description = "Basic LMI smoke-test (root)" + + filename = data.archive_file.lambda_zip.output_path + source_code_hash = data.archive_file.lambda_zip.output_base64sha256 + + runtime = "python3.14" + memory_size = 2048 + timeout = 30 + + tags = var.tags +} diff --git a/modules/lambda_managed_function/README.md b/modules/lambda_managed_function/README.md new file mode 100644 index 0000000..3c9c399 --- /dev/null +++ b/modules/lambda_managed_function/README.md @@ -0,0 +1,115 @@ +# lambda_managed_function + +Terraform module that deploys a single Lambda function onto an existing Lambda Managed Instance (LMI) capacity provider. Call this module once per function; all functions can share the same `lambda_managed_instance` module output. + +## Resources created + +| Resource | Purpose | +| --- | --- | +| `aws_iam_role` execution | Lambda execution role (`AWSLambdaBasicExecutionRole` + any `additional_execution_policy_arns`) | +| `aws_cloudwatch_log_group` | Pre-created log group with configurable retention | +| `aws_lambda_function` | Published LMI function with configurable logging, environment, layers, and concurrency | + +## Required inputs + +| Variable | Type | Description | +| --- | --- | --- | +| `function_name` | string | Lambda function name | +| `capacity_provider_arn` | string | ARN of the capacity provider (output of `lambda_managed_instance`) | +| `filename` | string | Path to the deployment zip on disk | +| `source_code_hash` | string | Base64-encoded SHA256 of the zip | + +## Optional inputs + +### Function + +| Variable | Default | Description | +| --- | --- | --- | +| `description` | `""` | Lambda function description | +| `runtime` | `"python3.14"` | Lambda runtime identifier | +| `handler` | `"lambda_function.lambda_handler"` | Handler in `module.function` format | +| `architectures` | `["x86_64"]` | `["x86_64"]` or `["arm64"]` — must match the capacity provider | +| `memory_size` | `2048` | Memory in MB (LMI minimum: 2048) | +| `timeout` | `30` | Timeout in seconds | +| `ephemeral_storage_size` | `512` | /tmp size in MB (512–10240) | +| `layers` | `[]` | Layer ARNs to attach (max 5) | +| `environment_variables` | `{}` | Runtime environment variables | +| `reserved_concurrent_executions` | `-1` | Concurrency cap; `-1` = unreserved, `0` = throttled | + +### Logging + +| Variable | Default | Description | +| --- | --- | --- | +| `log_retention_days` | `14` | CloudWatch log group retention in days | +| `cloudwatch_log_group_prevent_destroy` | `false` | When `true`, `lifecycle.prevent_destroy` blocks Terraform from destroying the log group | +| `log_format` | `"JSON"` | `"JSON"` or `"Text"` | +| `application_log_level` | `"INFO"` | App log filter when `log_format = "JSON"` (TRACE/DEBUG/INFO/WARN/ERROR/FATAL) | +| `system_log_level` | `"WARN"` | Platform log filter when `log_format = "JSON"` (DEBUG/INFO/WARN) | + +### Concurrency + +| Variable | Default | Description | +| --- | --- | --- | +| `per_execution_environment_max_concurrency` | `10` | Concurrent invocations per execution environment — **immutable after first create** | + +### IAM + +| Variable | Default | Description | +| --- | --- | --- | +| `iam_role_name_prefix` | `"lmi"` | Prefix for the execution role name | +| `additional_execution_policy_arns` | `[]` | Extra managed policy ARNs to attach to the execution role | + +### Common + +| Variable | Default | Description | +| --- | --- | --- | +| `tags` | `{}` | Tags applied to all taggable resources | + +## Key constraints + +- **`capacity_provider_config` is immutable** after the function is first created. +- **`per_execution_environment_max_concurrency` is immutable** after the first create. +- **`architectures`** must match the capacity provider's `instance_requirements.architectures`. +- **Minimum `memory_size` is 2048 MB** — enforced by a `validation` block. + +## Usage + +```hcl +module "lmi_fleet" { + source = "./modules/lambda_managed_instance" + + capacity_provider_name = "my-fleet" + iam_role_name_prefix = "my-fleet" + + subnet_ids = module.vpc.private_subnet_ids + security_group_ids = [aws_security_group.lmi.id] + + tags = { Project = "demo" } +} + +module "my_fn" { + source = "./modules/lambda_managed_function" + + function_name = "my-fn" + capacity_provider_arn = module.lmi_fleet.capacity_provider_arn + + filename = data.archive_file.fn.output_path + source_code_hash = data.archive_file.fn.output_base64sha256 + + tags = { Project = "demo" } +} +``` + +## Outputs + +| Output | Description | +| --- | --- | +| `lambda_function_arn` | Unqualified function ARN | +| `lambda_qualified_arn` | Published version ARN (`arn:aws:lambda:...:function:name:version`); use for S3 notifications and other integrations that require a Lambda **function** ARN | +| `lambda_function_name` | Function name | +| `lambda_invoke_arn` | Invoke ARN (unqualified; use for API Gateway HTTP integrations) | +| `lambda_qualified_invoke_arn` | Invoke ARN for the published version (API Gateway style; **not** valid for S3 bucket notifications) | +| `lambda_version` | Published version number | +| `lambda_log_group_name` | CloudWatch log group name | +| `execution_role_arn` | Execution role ARN | +| `execution_role_name` | Execution role name (use to attach additional policies in the calling root) | diff --git a/modules/lambda_managed_function/main.tf b/modules/lambda_managed_function/main.tf new file mode 100644 index 0000000..e0cf7ea --- /dev/null +++ b/modules/lambda_managed_function/main.tf @@ -0,0 +1,119 @@ +# Inline JSON trust policy avoids aws_iam_policy_document data source, +# which returns invalid output under mock_provider "aws" in terraform test. +locals { + lambda_assume_role_policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Effect = "Allow" + Principal = { + Service = "lambda.amazonaws.com" + } + Action = "sts:AssumeRole" + } + ] + }) + + # Single logical log group: pick the instance that exists for this deployment. + cloudwatch_log_group_name = var.cloudwatch_log_group_prevent_destroy ? aws_cloudwatch_log_group.protected[0].name : aws_cloudwatch_log_group.unprotected[0].name +} + +# State upgrade: former single resource address (skip if not present in state). +moved { + from = aws_cloudwatch_log_group.this + to = aws_cloudwatch_log_group.unprotected[0] +} + +resource "aws_iam_role" "execution" { + name_prefix = "${var.iam_role_name_prefix}-exec-" + description = "Lambda execution role for ${var.function_name}" + assume_role_policy = local.lambda_assume_role_policy + + tags = var.tags +} + +resource "aws_iam_role_policy_attachment" "execution_basic" { + role = aws_iam_role.execution.name + policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole" +} + +# Use count (not for_each on ARNs): policy ARNs from other resources are unknown at plan +# time, and toset() would make for_each keys unknowable. Length of the list is known. +resource "aws_iam_role_policy_attachment" "execution_additional" { + count = length(var.additional_execution_policy_arns) + + role = aws_iam_role.execution.name + policy_arn = var.additional_execution_policy_arns[count.index] +} + +resource "aws_cloudwatch_log_group" "protected" { + count = var.cloudwatch_log_group_prevent_destroy ? 1 : 0 + + name = "/aws/lambda/${var.function_name}" + retention_in_days = var.log_retention_days + + lifecycle { + prevent_destroy = true + } + + tags = var.tags +} + +resource "aws_cloudwatch_log_group" "unprotected" { + count = var.cloudwatch_log_group_prevent_destroy ? 0 : 1 + + name = "/aws/lambda/${var.function_name}" + retention_in_days = var.log_retention_days + + lifecycle { + prevent_destroy = false + } + + tags = var.tags +} + +resource "aws_lambda_function" "this" { + function_name = var.function_name + description = var.description + role = aws_iam_role.execution.arn + handler = var.handler + runtime = var.runtime + architectures = var.architectures + + filename = var.filename + source_code_hash = var.source_code_hash + + memory_size = var.memory_size + timeout = var.timeout + publish = true + reserved_concurrent_executions = var.reserved_concurrent_executions + + layers = length(var.layers) > 0 ? var.layers : null + + ephemeral_storage { + size = var.ephemeral_storage_size + } + + dynamic "environment" { + for_each = length(var.environment_variables) > 0 ? [var.environment_variables] : [] + content { + variables = environment.value + } + } + + logging_config { + log_format = var.log_format + log_group = local.cloudwatch_log_group_name + application_log_level = var.log_format == "JSON" ? var.application_log_level : null + system_log_level = var.log_format == "JSON" ? var.system_log_level : null + } + + capacity_provider_config { + lambda_managed_instances_capacity_provider_config { + capacity_provider_arn = var.capacity_provider_arn + per_execution_environment_max_concurrency = var.per_execution_environment_max_concurrency + } + } + + depends_on = [aws_iam_role_policy_attachment.execution_basic] +} diff --git a/modules/lambda_managed_function/outputs.tf b/modules/lambda_managed_function/outputs.tf new file mode 100644 index 0000000..b54ba47 --- /dev/null +++ b/modules/lambda_managed_function/outputs.tf @@ -0,0 +1,44 @@ +output "lambda_function_arn" { + description = "ARN of the Lambda function (unqualified)" + value = aws_lambda_function.this.arn +} + +output "lambda_qualified_arn" { + description = "ARN of the published function version (use for S3 notifications and other event sources that require a Lambda function ARN, not invoke_arn / qualified_invoke_arn)" + value = aws_lambda_function.this.qualified_arn +} + +output "lambda_function_name" { + description = "Lambda function name" + value = aws_lambda_function.this.function_name +} + +output "lambda_qualified_invoke_arn" { + description = "Invoke ARN for the published version" + value = aws_lambda_function.this.qualified_invoke_arn +} + +output "lambda_invoke_arn" { + description = "Invoke ARN of the Lambda function (unqualified; use for API Gateway HTTP integrations)" + value = aws_lambda_function.this.invoke_arn +} + +output "lambda_version" { + description = "Published version number" + value = aws_lambda_function.this.version +} + +output "lambda_log_group_name" { + description = "CloudWatch log group name for the Lambda function (use for alarms and dashboards)" + value = local.cloudwatch_log_group_name +} + +output "execution_role_arn" { + description = "IAM role ARN used by the function at runtime" + value = aws_iam_role.execution.arn +} + +output "execution_role_name" { + description = "IAM role name used by the function at runtime (use to attach additional policies in the calling root)" + value = aws_iam_role.execution.name +} diff --git a/modules/lambda_managed_function/variables.tf b/modules/lambda_managed_function/variables.tf new file mode 100644 index 0000000..02f0452 --- /dev/null +++ b/modules/lambda_managed_function/variables.tf @@ -0,0 +1,166 @@ +variable "function_name" { + description = "Lambda function name" + type = string +} + +variable "capacity_provider_arn" { + description = "ARN of the aws_lambda_capacity_provider this function should run on (output of lambda_managed_instance module)" + type = string +} + +# Deployment artifact — owned by the caller, not the module. +# Use data "archive_file" or filebase64sha256() in the calling root, then pass the results here. + +variable "filename" { + description = "Path to the deployment zip archive on disk" + type = string +} + +variable "source_code_hash" { + description = "Base64-encoded SHA256 of the deployment zip (use archive_file output_base64sha256 or filebase64sha256())" + type = string +} + +variable "runtime" { + description = "Lambda runtime identifier" + type = string + default = "python3.14" +} + +variable "handler" { + description = "Lambda handler in module.function format" + type = string + default = "lambda_function.lambda_handler" +} + +variable "architectures" { + description = "Instruction set architecture — must be [\"x86_64\"] or [\"arm64\"] (single element, must match the capacity provider)" + type = list(string) + default = ["x86_64"] + + validation { + condition = length(var.architectures) == 1 && contains(["x86_64", "arm64"], var.architectures[0]) + error_message = "architectures must be exactly [\"x86_64\"] or [\"arm64\"]." + } +} + +variable "memory_size" { + description = "Lambda memory in MB. LMI minimum is 2048 (2 GB / 1 vCPU)." + type = number + default = 2048 + + validation { + condition = var.memory_size >= 2048 + error_message = "memory_size must be at least 2048 MB (LMI minimum: 2 GB / 1 vCPU)." + } +} + +variable "timeout" { + description = "Lambda function timeout in seconds" + type = number + default = 30 +} + +variable "description" { + description = "Lambda function description" + type = string + default = "" +} + +variable "ephemeral_storage_size" { + description = "/tmp ephemeral storage in MB (512–10240). Shared across all concurrent processes in an execution environment — use unique file names per request." + type = number + default = 512 +} + +variable "layers" { + description = "List of Lambda layer ARNs to attach to the function (max 5)" + type = list(string) + default = [] + + validation { + condition = length(var.layers) <= 5 + error_message = "Lambda supports at most 5 layers per function." + } +} + +variable "environment_variables" { + description = "Environment variables available to the Lambda function at runtime" + type = map(string) + default = {} +} + +variable "reserved_concurrent_executions" { + description = "Maximum concurrent executions for this function. -1 means unreserved (default). 0 throttles the function completely." + type = number + default = -1 +} + +variable "log_retention_days" { + description = "CloudWatch log group retention in days" + type = number + default = 14 +} + +variable "cloudwatch_log_group_prevent_destroy" { + description = "When true, the CloudWatch log group uses lifecycle.prevent_destroy so terraform destroy cannot delete it (drop from state or unset to remove). When false, the log group is deleted on destroy. Terraform does not allow variables inside lifecycle blocks, so the module uses two mutually exclusive resource instances." + type = bool + default = false +} + +variable "log_format" { + description = "CloudWatch log format: \"JSON\" (structured, supports log level filtering) or \"Text\" (plain)" + type = string + default = "JSON" + + validation { + condition = contains(["JSON", "Text"], var.log_format) + error_message = "log_format must be \"JSON\" or \"Text\"." + } +} + +variable "application_log_level" { + description = "Application log level filter when log_format is \"JSON\". One of TRACE, DEBUG, INFO, WARN, ERROR, FATAL. Ignored for Text format." + type = string + default = "INFO" + + validation { + condition = contains(["TRACE", "DEBUG", "INFO", "WARN", "ERROR", "FATAL"], var.application_log_level) + error_message = "application_log_level must be one of TRACE, DEBUG, INFO, WARN, ERROR, FATAL." + } +} + +variable "system_log_level" { + description = "Lambda platform log level filter when log_format is \"JSON\". One of DEBUG, INFO, WARN. Ignored for Text format." + type = string + default = "WARN" + + validation { + condition = contains(["DEBUG", "INFO", "WARN"], var.system_log_level) + error_message = "system_log_level must be one of DEBUG, INFO, WARN." + } +} + +variable "per_execution_environment_max_concurrency" { + description = "Max concurrent invocations per execution environment. Immutable after first function create. AWS Python runtime default is 16 per vCPU; lower values reduce memory pressure at the cost of throughput." + type = number + default = 10 +} + +variable "iam_role_name_prefix" { + description = "Prefix for the execution IAM role name" + type = string + default = "lmi" +} + +variable "additional_execution_policy_arns" { + description = "Additional managed IAM policy ARNs to attach to the Lambda execution role (e.g. for VPC access, S3, or custom permissions)" + type = list(string) + default = [] +} + +variable "tags" { + description = "Tags applied to all resources in this module" + type = map(string) + default = {} +} diff --git a/modules/lambda_managed_function/versions.tf b/modules/lambda_managed_function/versions.tf new file mode 100644 index 0000000..764b43a --- /dev/null +++ b/modules/lambda_managed_function/versions.tf @@ -0,0 +1,10 @@ +terraform { + required_version = ">= 1.5.0" + + required_providers { + aws = { + source = "hashicorp/aws" + version = ">= 6.0.0" + } + } +} diff --git a/modules/lambda_managed_instance/README.md b/modules/lambda_managed_instance/README.md new file mode 100644 index 0000000..2c1a0c1 --- /dev/null +++ b/modules/lambda_managed_instance/README.md @@ -0,0 +1,94 @@ +# lambda_managed_instance + +Terraform module that provisions a Lambda Managed Instance (LMI) **capacity provider** — the shared EC2 fleet for one or more LMI Lambda functions. Deploy one of these per fleet, then use **`lambda_managed_function`** for each function that should run on it. + +## Resources created + +| Resource | Purpose | +| --- | --- | +| `aws_iam_service_linked_role` | Fleet lifecycle SLR (import if it already exists in the account) | +| `aws_iam_role` operator | Capacity provider operator role (`AWSLambdaManagedEC2ResourceOperator`) | +| `aws_lambda_capacity_provider` | Fleet placement: VPC, subnets, SGs, instance requirements, scaling policy | + +## Required inputs + +| Variable | Type | Description | +| --- | --- | --- | +| `capacity_provider_name` | string | Capacity provider name (must be unique in the account) | +| `subnet_ids` | set(string) | Private subnet IDs for managed instances | +| `security_group_ids` | set(string) | Security groups for capacity provider ENIs | + +## Optional inputs + +### Capacity provider & scaling + +| Variable | Default | Description | +| --- | --- | --- | +| `architectures` | `["x86_64"]` | `["x86_64"]` or `["arm64"]` — must match all lambda_managed_function modules on this provider | +| `max_vcpu_count` | `16` | Maximum vCPUs in the capacity provider pool | +| `scaling_mode` | `"Auto"` | `"Auto"` (Lambda-managed) or `"Manual"` (CPU target policy) | +| `cpu_target_utilization` | `70` | CPU target % for `scaling_mode = "Manual"` | +| `allowed_instance_types` | `[]` | Allowlist of EC2 instance types. Mutually exclusive with `excluded_instance_types`. | +| `excluded_instance_types` | `[]` | Denylist of EC2 instance types; supports wildcards. Mutually exclusive with `allowed_instance_types`. | + +### IAM + +| Variable | Default | Description | +| --- | --- | --- | +| `iam_role_name_prefix` | `"lmi"` | Prefix for the operator role name | + +### Common + +| Variable | Default | Description | +| --- | --- | --- | +| `tags` | `{}` | Tags applied to all taggable resources | + +## Key constraints + +- **First capacity provider in an account** requires `iam:CreateServiceLinkedRole`. If the SLR already exists, import it before the first `apply`: + + ```bash + terraform import module..aws_iam_service_linked_role.lambda_lmi \ + arn:aws:iam:::role/aws-service-role/lambda.amazonaws.com/AWSServiceRoleForLambda + ``` + +- **`allowed_instance_types` and `excluded_instance_types` are mutually exclusive** — set at most one. +- **`architectures`** must match every `lambda_managed_function` module that references `capacity_provider_arn`. +- **Scaling:** With `scaling_mode = "Auto"`, no `scaling_policies` are sent. Set `scaling_mode = "Manual"` to activate the CPU target policy. +- **Destroy order:** `aws_lambda_capacity_provider` depends on `aws_iam_service_linked_role` — Terraform handles this automatically. + +## Usage + +```hcl +module "lmi_fleet" { + source = "./modules/lambda_managed_instance" + + capacity_provider_name = "my-fleet" + iam_role_name_prefix = "my-fleet" + + subnet_ids = module.vpc.private_subnet_ids + security_group_ids = [aws_security_group.lmi.id] + + tags = { Project = "demo" } +} + +module "my_fn" { + source = "./modules/lambda_managed_function" + + function_name = "my-fn" + capacity_provider_arn = module.lmi_fleet.capacity_provider_arn + + filename = data.archive_file.fn.output_path + source_code_hash = data.archive_file.fn.output_base64sha256 + + tags = { Project = "demo" } +} +``` + +## Outputs + +| Output | Description | +| --- | --- | +| `capacity_provider_arn` | Capacity provider ARN (pass to `lambda_managed_function`) | +| `capacity_provider_name` | Capacity provider name | +| `operator_role_arn` | Operator role ARN | diff --git a/modules/lambda_managed_instance/main.tf b/modules/lambda_managed_instance/main.tf new file mode 100644 index 0000000..c46efaf --- /dev/null +++ b/modules/lambda_managed_instance/main.tf @@ -0,0 +1,78 @@ +# The first capacity provider in an account triggers automatic SLR creation (for ec2:TerminateInstances). +# If the SLR already exists in the account, this resource will fail on create with "already exists". +# In that case, import the existing role before running plan: +# terraform import aws_iam_service_linked_role.lambda_lmi \ +# arn:aws:iam:::role/aws-service-role/lambda.amazonaws.com/AWSServiceRoleForLambda +resource "aws_iam_service_linked_role" "lambda_lmi" { + aws_service_name = "lambda.amazonaws.com" + description = "Service-linked role for Lambda Managed Instances fleet lifecycle operations" +} + +resource "aws_iam_role" "operator" { + name_prefix = "${var.iam_role_name_prefix}-op-" + description = "Lambda operator role for capacity provider ${var.capacity_provider_name}" + assume_role_policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Effect = "Allow" + Principal = { Service = "lambda.amazonaws.com" } + Action = "sts:AssumeRole" + } + ] + }) + + tags = var.tags +} + +resource "aws_iam_role_policy_attachment" "operator_managed" { + role = aws_iam_role.operator.name + policy_arn = "arn:aws:iam::aws:policy/AWSLambdaManagedEC2ResourceOperator" +} + +resource "aws_lambda_capacity_provider" "this" { + name = var.capacity_provider_name + + vpc_config { + subnet_ids = var.subnet_ids + security_group_ids = var.security_group_ids + } + + permissions_config { + capacity_provider_operator_role_arn = aws_iam_role.operator.arn + } + + instance_requirements { + architectures = var.architectures + allowed_instance_types = length(var.allowed_instance_types) > 0 ? var.allowed_instance_types : null + excluded_instance_types = length(var.excluded_instance_types) > 0 ? var.excluded_instance_types : null + } + + dynamic "capacity_provider_scaling_config" { + for_each = { + _ = { + mode = var.scaling_mode + max_vcpu = var.max_vcpu_count + cpu = var.cpu_target_utilization + } + } + content { + scaling_mode = capacity_provider_scaling_config.value.mode + max_vcpu_count = capacity_provider_scaling_config.value.max_vcpu + dynamic "scaling_policies" { + for_each = capacity_provider_scaling_config.value.mode == "Manual" ? [capacity_provider_scaling_config.value.cpu] : [] + content { + predefined_metric_type = "LambdaCapacityProviderAverageCPUUtilization" + target_value = scaling_policies.value + } + } + } + } + + tags = var.tags + + depends_on = [ + aws_iam_service_linked_role.lambda_lmi, + aws_iam_role_policy_attachment.operator_managed, + ] +} diff --git a/modules/lambda_managed_instance/outputs.tf b/modules/lambda_managed_instance/outputs.tf new file mode 100644 index 0000000..101d2a3 --- /dev/null +++ b/modules/lambda_managed_instance/outputs.tf @@ -0,0 +1,14 @@ +output "capacity_provider_arn" { + description = "ARN of the Lambda capacity provider" + value = aws_lambda_capacity_provider.this.arn +} + +output "capacity_provider_name" { + description = "Name of the Lambda capacity provider" + value = aws_lambda_capacity_provider.this.name +} + +output "operator_role_arn" { + description = "IAM role ARN Lambda uses to manage EC2 for the capacity provider" + value = aws_iam_role.operator.arn +} diff --git a/modules/lambda_managed_instance/variables.tf b/modules/lambda_managed_instance/variables.tf new file mode 100644 index 0000000..6f57511 --- /dev/null +++ b/modules/lambda_managed_instance/variables.tf @@ -0,0 +1,72 @@ +variable "capacity_provider_name" { + description = "Lambda capacity provider name" + type = string +} + +variable "subnet_ids" { + description = "Private subnet IDs where managed instances are placed (capacity provider vpc_config)" + type = set(string) +} + +variable "security_group_ids" { + description = "Security groups attached to capacity provider managed instances" + type = set(string) +} + +variable "architectures" { + description = "Instruction set architecture — must be [\"x86_64\"] or [\"arm64\"] (single element, must match any lambda_managed_function modules using this provider)" + type = list(string) + default = ["x86_64"] + + validation { + condition = length(var.architectures) == 1 && contains(["x86_64", "arm64"], var.architectures[0]) + error_message = "architectures must be exactly [\"x86_64\"] or [\"arm64\"]." + } +} + +variable "max_vcpu_count" { + description = "Maximum vCPUs for the capacity provider pool" + type = number + default = 16 +} + +variable "scaling_mode" { + description = "Capacity provider scaling mode. Auto: Lambda-managed scaling (no scaling_policies). Manual: optional CPU target via scaling_policies." + type = string + default = "Auto" + + validation { + condition = contains(["Auto", "Manual"], var.scaling_mode) + error_message = "scaling_mode must be \"Auto\" or \"Manual\"." + } +} + +variable "cpu_target_utilization" { + description = "When scaling_mode is Manual, target CPU utilisation (0–100) for LambdaCapacityProviderAverageCPUUtilization. Ignored when scaling_mode is Auto." + type = number + default = 70 +} + +variable "allowed_instance_types" { + description = "Allowlist of EC2 instance types for the capacity provider (e.g. [\"m7i.2xlarge\", \"c7i.2xlarge\"]). Mutually exclusive with excluded_instance_types. Leave empty to let Lambda choose." + type = list(string) + default = [] +} + +variable "excluded_instance_types" { + description = "Denylist of EC2 instance types for the capacity provider. Supports wildcards (e.g. [\"*.nano\", \"*.micro\"]). Mutually exclusive with allowed_instance_types. Leave empty to let Lambda choose." + type = list(string) + default = [] +} + +variable "iam_role_name_prefix" { + description = "Prefix for the operator IAM role name" + type = string + default = "lmi" +} + +variable "tags" { + description = "Tags applied to all resources in this module" + type = map(string) + default = {} +} diff --git a/modules/lambda_managed_instance/versions.tf b/modules/lambda_managed_instance/versions.tf new file mode 100644 index 0000000..764b43a --- /dev/null +++ b/modules/lambda_managed_instance/versions.tf @@ -0,0 +1,10 @@ +terraform { + required_version = ">= 1.5.0" + + required_providers { + aws = { + source = "hashicorp/aws" + version = ">= 6.0.0" + } + } +} diff --git a/modules/vpc/README.md b/modules/vpc/README.md new file mode 100644 index 0000000..4f6c6aa --- /dev/null +++ b/modules/vpc/README.md @@ -0,0 +1,31 @@ +# VPC module (slim) + +Opinionated **minimal** VPC for Lambda Managed Instances (use from the repo root, `examples/*`, or any caller): + +- One **VPC** with DNS hostnames/support enabled +- **Public** and **private** subnets (one pair per AZ you pass in) +- **Internet gateway** and public route tables (`0.0.0.0/0` → IGW) +- **NAT gateway(s)** and private routes (`0.0.0.0/0` → NAT) when `enable_nat_gateway = true` +- Default **single NAT** in the first public subnet when `single_nat_gateway = true` + +Not included (by design): IPv6, isolated/database tiers, custom NACLs, VPC interface endpoints, EKS subnet tags. + +## Usage + +```hcl +module "vpc" { + source = "./modules/vpc" + + vpc_name = "example" + vpc_cidr = "10.0.0.0/16" + availability_zones = ["ap-southeast-6a", "ap-southeast-6b"] + public_subnet_cidrs = ["10.0.0.0/24", "10.0.1.0/24"] + private_subnet_cidrs = ["10.0.8.0/24", "10.0.9.0/24"] + + tags = { + Project = "lmi-basic" + } +} +``` + +Ensure `length(availability_zones) == length(public_subnet_cidrs) == length(private_subnet_cidrs)`. diff --git a/modules/vpc/main.tf b/modules/vpc/main.tf new file mode 100644 index 0000000..256979b --- /dev/null +++ b/modules/vpc/main.tf @@ -0,0 +1,142 @@ +locals { + az_count = length(var.availability_zones) + + nat_gateway_count = var.enable_nat_gateway ? (var.single_nat_gateway ? 1 : local.az_count) : 0 +} + +resource "aws_vpc" "this" { + cidr_block = var.vpc_cidr + enable_dns_hostnames = true + enable_dns_support = true + + tags = merge( + { Name = var.vpc_name }, + var.tags + ) +} + +resource "aws_internet_gateway" "this" { + count = length(var.public_subnet_cidrs) > 0 ? 1 : 0 + + vpc_id = aws_vpc.this.id + + tags = merge( + { Name = "${var.vpc_name}-igw" }, + var.tags + ) +} + +resource "aws_subnet" "public" { + count = length(var.public_subnet_cidrs) + + vpc_id = aws_vpc.this.id + cidr_block = var.public_subnet_cidrs[count.index] + availability_zone = var.availability_zones[count.index] + map_public_ip_on_launch = true + + tags = merge( + { + Name = "${var.vpc_name}-public-${count.index + 1}" + Type = "public" + }, + var.tags + ) +} + +resource "aws_subnet" "private" { + count = length(var.private_subnet_cidrs) + + vpc_id = aws_vpc.this.id + cidr_block = var.private_subnet_cidrs[count.index] + availability_zone = var.availability_zones[count.index] + + tags = merge( + { + Name = "${var.vpc_name}-private-${count.index + 1}" + Type = "private" + }, + var.tags + ) +} + +resource "aws_eip" "nat" { + count = local.nat_gateway_count + + domain = "vpc" + + tags = merge( + { Name = "${var.vpc_name}-nat-eip-${count.index + 1}" }, + var.tags + ) + + depends_on = [aws_internet_gateway.this] +} + +resource "aws_nat_gateway" "this" { + count = local.nat_gateway_count + + allocation_id = aws_eip.nat[count.index].id + subnet_id = aws_subnet.public[count.index].id + + tags = merge( + { Name = "${var.vpc_name}-nat-${count.index + 1}" }, + var.tags + ) + + depends_on = [aws_internet_gateway.this] +} + +resource "aws_route_table" "public" { + count = length(var.public_subnet_cidrs) + + vpc_id = aws_vpc.this.id + + route { + cidr_block = "0.0.0.0/0" + gateway_id = aws_internet_gateway.this[0].id + } + + tags = merge( + { + Name = "${var.vpc_name}-public-rt-${count.index + 1}" + Type = "public" + }, + var.tags + ) +} + +resource "aws_route_table_association" "public" { + count = length(var.public_subnet_cidrs) + + subnet_id = aws_subnet.public[count.index].id + route_table_id = aws_route_table.public[count.index].id +} + +resource "aws_route_table" "private" { + count = length(var.private_subnet_cidrs) + + vpc_id = aws_vpc.this.id + + dynamic "route" { + for_each = var.enable_nat_gateway ? [1] : [] + content { + cidr_block = "0.0.0.0/0" + nat_gateway_id = var.single_nat_gateway ? aws_nat_gateway.this[0].id : aws_nat_gateway.this[count.index].id + } + } + + tags = merge( + { + Name = "${var.vpc_name}-private-rt-${count.index + 1}" + Type = "private" + }, + var.tags + ) +} + +resource "aws_route_table_association" "private" { + count = length(var.private_subnet_cidrs) + + subnet_id = aws_subnet.private[count.index].id + route_table_id = aws_route_table.private[count.index].id +} diff --git a/modules/vpc/outputs.tf b/modules/vpc/outputs.tf new file mode 100644 index 0000000..58579e4 --- /dev/null +++ b/modules/vpc/outputs.tf @@ -0,0 +1,29 @@ +output "vpc_id" { + description = "VPC ID" + value = aws_vpc.this.id +} + +output "vpc_cidr_block" { + description = "VPC IPv4 CIDR" + value = aws_vpc.this.cidr_block +} + +output "public_subnet_ids" { + description = "Public subnet IDs" + value = aws_subnet.public[*].id +} + +output "private_subnet_ids" { + description = "Private subnet IDs" + value = aws_subnet.private[*].id +} + +output "nat_gateway_ids" { + description = "NAT gateway IDs (empty if NAT disabled)" + value = aws_nat_gateway.this[*].id +} + +output "internet_gateway_id" { + description = "Internet gateway ID" + value = try(aws_internet_gateway.this[0].id, null) +} diff --git a/modules/vpc/variables.tf b/modules/vpc/variables.tf new file mode 100644 index 0000000..ac2a976 --- /dev/null +++ b/modules/vpc/variables.tf @@ -0,0 +1,62 @@ +variable "vpc_name" { + description = "Name tag for the VPC and related resources" + type = string +} + +variable "vpc_cidr" { + description = "IPv4 CIDR for the VPC" + type = string + + validation { + condition = can(cidrhost(var.vpc_cidr, 0)) + error_message = "vpc_cidr must be a valid IPv4 CIDR block." + } +} + +variable "availability_zones" { + description = "AZs for subnets (one public and one private CIDR per AZ, same order)" + type = list(string) + + validation { + condition = length(var.availability_zones) > 0 + error_message = "Provide at least one availability zone." + } +} + +variable "public_subnet_cidrs" { + description = "Public subnet CIDRs (one per AZ, same length as availability_zones)" + type = list(string) + + validation { + condition = length(var.public_subnet_cidrs) == length(var.availability_zones) + error_message = "public_subnet_cidrs must have the same length as availability_zones." + } +} + +variable "private_subnet_cidrs" { + description = "Private subnet CIDRs (one per AZ, same length as availability_zones)" + type = list(string) + + validation { + condition = length(var.private_subnet_cidrs) == length(var.availability_zones) + error_message = "private_subnet_cidrs must have the same length as availability_zones." + } +} + +variable "tags" { + description = "Tags applied to all resources" + type = map(string) + default = {} +} + +variable "enable_nat_gateway" { + description = "When true, create a NAT gateway so private subnets reach the internet" + type = bool + default = true +} + +variable "single_nat_gateway" { + description = "When true, use one NAT gateway in the first public subnet (cheaper; single-AZ egress path)" + type = bool + default = true +} diff --git a/modules/vpc/versions.tf b/modules/vpc/versions.tf new file mode 100644 index 0000000..764b43a --- /dev/null +++ b/modules/vpc/versions.tf @@ -0,0 +1,10 @@ +terraform { + required_version = ">= 1.5.0" + + required_providers { + aws = { + source = "hashicorp/aws" + version = ">= 6.0.0" + } + } +} diff --git a/outputs.tf b/outputs.tf new file mode 100644 index 0000000..e0ca3c9 --- /dev/null +++ b/outputs.tf @@ -0,0 +1,30 @@ +output "vpc_id" { + value = module.vpc.vpc_id +} + +output "private_subnet_ids" { + value = module.vpc.private_subnet_ids +} + +output "capacity_provider_name" { + value = module.lambda_managed_instance.capacity_provider_name +} + +output "lambda_function_name" { + value = module.lambda_managed_function.lambda_function_name +} + +output "lambda_qualified_invoke_arn" { + description = "Use this ARN with aws lambda invoke (published version)" + value = module.lambda_managed_function.lambda_qualified_invoke_arn +} + +output "lambda_version" { + description = "Published Lambda version (use with function_name for invoke)" + value = module.lambda_managed_function.lambda_version +} + +output "lambda_log_group_name" { + description = "CloudWatch log group — tail logs or set alarms here" + value = module.lambda_managed_function.lambda_log_group_name +} diff --git a/terraform.tfvars.example b/terraform.tfvars.example new file mode 100644 index 0000000..2bce3f6 --- /dev/null +++ b/terraform.tfvars.example @@ -0,0 +1,8 @@ +# Copy to terraform.tfvars and adjust for your account/region. +# Use a region where Lambda Managed Instances is available (see README). +# aws_region = "ap-southeast-2" +# name_prefix = "lmi-basic" + +# tags = { +# Project = "lmi-smoke-test" +# } diff --git a/tests/stack.tftest.hcl b/tests/stack.tftest.hcl new file mode 100644 index 0000000..7ecb3d0 --- /dev/null +++ b/tests/stack.tftest.hcl @@ -0,0 +1,56 @@ +# Terraform native tests (terraform test). AWS and archive providers are mocked so +# plans run without credentials or real zip files on disk. +# Requires Terraform >= 1.7. + +mock_provider "aws" {} + +mock_provider "archive" { + mock_data "archive_file" { + defaults = { + output_path = "/tmp/mock-lambda.zip" + output_base64sha256 = "bW9ja2hhc2g=" + output_md5 = "mockhash" + output_sha = "mockhash" + output_sha256 = "mockhash" + output_sha512 = "mockhash" + output_size = 1024 + source_content_filename = null + } + } +} + +run "root_stack_plan" { + command = plan + + variables { + name_prefix = "tftest-lmi" + aws_region = "ap-southeast-2" + vpc_cidr = "10.99.0.0/16" + availability_zones = ["ap-southeast-2a", "ap-southeast-2b"] + public_subnet_cidrs = ["10.99.0.0/24", "10.99.1.0/24"] + private_subnet_cidrs = ["10.99.8.0/24", "10.99.9.0/24"] + tags = { + Test = "terraform-test" + } + } + + assert { + condition = length(var.private_subnet_cidrs) == 2 && length(var.public_subnet_cidrs) == 2 + error_message = "test variables must define two public and two private subnets" + } + + assert { + condition = module.lambda_managed_function.lambda_function_name == "tftest-lmi-fn" + error_message = "lambda function name must follow name_prefix pattern" + } + + assert { + condition = module.lambda_managed_instance.capacity_provider_name == "tftest-lmi-capacity" + error_message = "capacity provider name must follow name_prefix pattern" + } + + assert { + condition = module.lambda_managed_function.lambda_log_group_name == "/aws/lambda/tftest-lmi-fn" + error_message = "log group name must be /aws/lambda/" + } +} diff --git a/tests/vpc.tftest.hcl b/tests/vpc.tftest.hcl new file mode 100644 index 0000000..4de7cfb --- /dev/null +++ b/tests/vpc.tftest.hcl @@ -0,0 +1,58 @@ +# Plan-only tests for modules/vpc in isolation. +# No AWS credentials required — mock_provider "aws" intercepts all API calls. + +mock_provider "aws" {} + +run "vpc_two_az_with_nat" { + command = plan + + module { + source = "./modules/vpc" + } + + variables { + vpc_name = "test-vpc" + vpc_cidr = "10.1.0.0/16" + availability_zones = ["ap-southeast-2a", "ap-southeast-2b"] + public_subnet_cidrs = ["10.1.0.0/24", "10.1.1.0/24"] + private_subnet_cidrs = ["10.1.8.0/24", "10.1.9.0/24"] + enable_nat_gateway = true + single_nat_gateway = true + tags = { + Test = "terraform-test" + } + } + + assert { + condition = length(var.public_subnet_cidrs) == length(var.availability_zones) + error_message = "public subnet count must match AZ count" + } + + assert { + condition = length(var.private_subnet_cidrs) == length(var.availability_zones) + error_message = "private subnet count must match AZ count" + } +} + +run "vpc_cidr_validation" { + command = plan + + module { + source = "./modules/vpc" + } + + variables { + vpc_name = "test-vpc-cidr" + vpc_cidr = "10.2.0.0/16" + availability_zones = ["ap-southeast-2a"] + public_subnet_cidrs = ["10.2.0.0/24"] + private_subnet_cidrs = ["10.2.8.0/24"] + enable_nat_gateway = false + tags = {} + } + + assert { + condition = can(cidrhost(var.vpc_cidr, 0)) + error_message = "vpc_cidr must be a valid IPv4 CIDR" + } +} diff --git a/variables.tf b/variables.tf new file mode 100644 index 0000000..c8514e9 --- /dev/null +++ b/variables.tf @@ -0,0 +1,41 @@ +variable "aws_region" { + description = "AWS region for all resources. Lambda Managed Instances (capacity providers) are only available in a subset of regions; see README." + type = string + default = "ap-southeast-2" +} + +variable "name_prefix" { + description = "Prefix for VPC and Lambda resource names" + type = string + default = "lmi-basic" +} + +variable "vpc_cidr" { + description = "VPC IPv4 CIDR" + type = string + default = "10.42.0.0/16" +} + +variable "availability_zones" { + description = "Two AZs for public/private subnet pairs" + type = list(string) + default = ["ap-southeast-2a", "ap-southeast-2b"] +} + +variable "public_subnet_cidrs" { + description = "Public subnet CIDRs (NAT + IGW path)" + type = list(string) + default = ["10.42.0.0/24", "10.42.1.0/24"] +} + +variable "private_subnet_cidrs" { + description = "Private subnet CIDRs (Lambda managed instances)" + type = list(string) + default = ["10.42.8.0/24", "10.42.9.0/24"] +} + +variable "tags" { + description = "Common tags" + type = map(string) + default = {} +} diff --git a/versions.tf b/versions.tf new file mode 100644 index 0000000..6033fa0 --- /dev/null +++ b/versions.tf @@ -0,0 +1,14 @@ +terraform { + required_version = ">= 1.7.0" + + required_providers { + archive = { + source = "hashicorp/archive" + version = ">= 2.4.0" + } + aws = { + source = "hashicorp/aws" + version = ">= 6.0.0" + } + } +}