From 821e1f5f7f5de8c10d6a32d61c4e2a171d11331a Mon Sep 17 00:00:00 2001 From: Waldemar Hummer Date: Mon, 15 Jun 2026 00:22:16 -0400 Subject: [PATCH 1/3] add sample: AWS Replicator, Proxy & Cloud Pods demo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a new sample app at samples/aws-replicator-proxy/ — a minimal product catalog (S3 + API Gateway v2 + Lambda + DynamoDB) that demonstrates three LocalStack features side by side: - Scenario 1 (Replicator): deploy to real AWS, then replicate the DynamoDB table and Lambda into LocalStack via direct boto3 calls (bypassing the replicator extension, which doesn't support Lambda yet). - Scenario 2 (Proxy): deploy everything to LocalStack, then enable the DynamoDB proxy so local Lambda calls transparently hit real AWS DynamoDB. - Scenario 3 (Cloud Pods): snapshot the full LocalStack state (DynamoDB, Lambda, S3, API GW) into a versioned pod and restore it on any machine. The frontend supports listing, adding, editing, and deleting products. CORS is handled in the Lambda so it works identically on AWS and LocalStack. Co-Authored-By: Claude Sonnet 4.6 --- samples/aws-replicator-proxy/.env.example | 7 + samples/aws-replicator-proxy/.gitignore | 4 + samples/aws-replicator-proxy/Makefile | 153 +++++++++ samples/aws-replicator-proxy/README.md | 317 ++++++++++++++++++ .../aws-replicator-proxy/frontend/index.html | 255 ++++++++++++++ .../infra/.terraform.lock.hcl | 45 +++ samples/aws-replicator-proxy/infra/main.tf | 252 ++++++++++++++ .../aws-replicator-proxy/lambda/handler.py | 71 ++++ samples/aws-replicator-proxy/proxy_config.yml | 13 + .../aws-replicator-proxy/scripts/replicate.py | 245 ++++++++++++++ samples/aws-replicator-proxy/scripts/seed.py | 48 +++ samples/aws-replicator-proxy/scripts/test.py | 67 ++++ 12 files changed, 1477 insertions(+) create mode 100644 samples/aws-replicator-proxy/.env.example create mode 100644 samples/aws-replicator-proxy/.gitignore create mode 100644 samples/aws-replicator-proxy/Makefile create mode 100644 samples/aws-replicator-proxy/README.md create mode 100644 samples/aws-replicator-proxy/frontend/index.html create mode 100644 samples/aws-replicator-proxy/infra/.terraform.lock.hcl create mode 100644 samples/aws-replicator-proxy/infra/main.tf create mode 100644 samples/aws-replicator-proxy/lambda/handler.py create mode 100644 samples/aws-replicator-proxy/proxy_config.yml create mode 100644 samples/aws-replicator-proxy/scripts/replicate.py create mode 100644 samples/aws-replicator-proxy/scripts/seed.py create mode 100644 samples/aws-replicator-proxy/scripts/test.py diff --git a/samples/aws-replicator-proxy/.env.example b/samples/aws-replicator-proxy/.env.example new file mode 100644 index 0000000..c7078c4 --- /dev/null +++ b/samples/aws-replicator-proxy/.env.example @@ -0,0 +1,7 @@ +# Copy to .env and fill in your values +LOCALSTACK_AUTH_TOKEN=ls-xxxxxxxxxxxxxxxxxxxx + +# AWS credentials — used by the aws CLI, tflocal, and localstack aws proxy +AWS_ACCESS_KEY_ID=AKIA... +AWS_SECRET_ACCESS_KEY=... +AWS_DEFAULT_REGION=us-east-1 diff --git a/samples/aws-replicator-proxy/.gitignore b/samples/aws-replicator-proxy/.gitignore new file mode 100644 index 0000000..fd3925f --- /dev/null +++ b/samples/aws-replicator-proxy/.gitignore @@ -0,0 +1,4 @@ +infra/.terraform/ +infra/terraform.tfstate +infra/terraform.tfstate.backup +lambda/*.zip diff --git a/samples/aws-replicator-proxy/Makefile b/samples/aws-replicator-proxy/Makefile new file mode 100644 index 0000000..1eab851 --- /dev/null +++ b/samples/aws-replicator-proxy/Makefile @@ -0,0 +1,153 @@ +AWS_REGION ?= us-east-1 +APP_NAME ?= product-catalog +TABLE_NAME = $(APP_NAME)-products +FUNCTION_NAME = $(APP_NAME) +POD_NAME ?= $(APP_NAME)-state +PYTHON = python3 + +# S3 bucket suffix — use your AWS account ID on real AWS to ensure global uniqueness +ACCOUNT_ID := $(shell aws sts get-caller-identity --query Account --output text 2>/dev/null || echo "local") + +# Resolve the local API Gateway invoke URL without requiring TF state +LOCAL_API = $(shell awslocal apigatewayv2 get-apis \ + --query "Items[?Name=='$(APP_NAME)'].ApiEndpoint" \ + --output text 2>/dev/null | tr -d '\n') + +.DEFAULT_GOAL := help + +.PHONY: help \ + localstack-start localstack-stop \ + deploy-aws seed-aws test-aws destroy-aws \ + replicate patch-local-lambda \ + deploy-local seed-local setup-aws-dynamo enable-proxy test-local destroy-local \ + pod-save pod-load pod-list + +# ── Help ─────────────────────────────────────────────────────────────────────── + +help: ## Show available targets + @awk 'BEGIN {FS = ":.*##"}; \ + /^##@/ { printf "\n\033[1m%s\033[0m\n", substr($$0, 5) }; \ + /^[a-zA-Z_-]+:.*?## / { printf " \033[36m%-22s\033[0m %s\n", $$1, $$2 }' \ + $(MAKEFILE_LIST) + @echo "" + +##@ LocalStack + +localstack-start: ## Start LocalStack (Pro required for proxy feature) + EXTRA_CORS_ALLOWED_ORIGINS=https://aws-replicator.localhost.localstack.cloud:4566 \ + GATEWAY_SERVER=hypercorn \ + localstack start -d + @echo "Waiting for LocalStack..." + @until localstack status services 2>/dev/null | grep -q running; do sleep 1; done + @echo "LocalStack is ready." + +localstack-stop: ## Stop LocalStack + localstack stop + +##@ Scenario 1 — Replicator (deploy to AWS, then clone into LocalStack) + +deploy-aws: ## Deploy stack to real AWS (DynamoDB + Lambda + S3) + cd infra && terraform init -upgrade + cd infra && terraform apply -auto-approve \ + -var="aws_region=$(AWS_REGION)" \ + -var="app_name=$(APP_NAME)" \ + -var="bucket_suffix=$(ACCOUNT_ID)" + @echo "" + @echo "AWS API URL: $$(cd infra && terraform output -raw api_url)" + @echo "AWS Website URL: $$(cd infra && terraform output -raw website_url_aws)" + +seed-aws: ## Seed AWS DynamoDB with sample products + TABLE_NAME=$(TABLE_NAME) AWS_REGION=$(AWS_REGION) $(PYTHON) scripts/seed.py aws + +test-aws: ## Smoke-test the AWS API endpoint + @API=$$(cd infra && terraform output -raw api_url 2>/dev/null || echo ""); \ + [ -n "$$API" ] || { echo "Run 'make deploy-aws' first."; exit 1; }; \ + $(PYTHON) scripts/test.py "$$API" + +destroy-aws: ## Tear down AWS resources + cd infra && terraform destroy -auto-approve \ + -var="aws_region=$(AWS_REGION)" \ + -var="app_name=$(APP_NAME)" \ + -var="bucket_suffix=$(ACCOUNT_ID)" + +replicate: ## Replicate DynamoDB + Lambda from AWS into LocalStack (run after deploy-aws) + APP_NAME=$(APP_NAME) TABLE_NAME=$(TABLE_NAME) FUNCTION_NAME=$(FUNCTION_NAME) \ + AWS_REGION=$(AWS_REGION) $(PYTHON) scripts/replicate.py + +patch-local-lambda: ## Push local handler.py to LocalStack without re-deploying to AWS + cd lambda && zip -q handler.zip handler.py + awslocal lambda update-function-code \ + --function-name $(FUNCTION_NAME) \ + --zip-file fileb://lambda/handler.zip \ + --query 'LastModified' --output text + @# Ensure the Lambda uses fake creds so it matches the 000000000000 resource namespace + awslocal lambda update-function-configuration \ + --function-name $(FUNCTION_NAME) \ + --environment "Variables={TABLE_NAME=$(TABLE_NAME),AWS_ACCESS_KEY_ID=test,AWS_SECRET_ACCESS_KEY=test}" \ + --query 'LastModified' --output text + +##@ Scenario 2 — Proxy (run locally, forward DynamoDB calls to real AWS) + +deploy-local: ## Deploy full stack to LocalStack + cd infra && tflocal init -upgrade + cd infra && tflocal apply -auto-approve \ + -var="aws_region=$(AWS_REGION)" \ + -var="app_name=$(APP_NAME)" + @# LocalStack injects the host's real AWS credentials into Lambda containers, which + @# puts SDK calls in the real-account namespace (e.g. 411503686428) instead of the + @# 000000000000 namespace that tflocal/awslocal use. Pin to fake creds so the Lambda + @# can find the local DynamoDB table. + awslocal lambda update-function-configuration \ + --function-name $(FUNCTION_NAME) \ + --environment "Variables={TABLE_NAME=$(TABLE_NAME),AWS_ACCESS_KEY_ID=test,AWS_SECRET_ACCESS_KEY=test}" \ + --query 'LastModified' --output text + @echo "" + @echo "Local API URL: $$(cd infra && tflocal output -raw api_url)" + @echo "Local Website URL: $$(cd infra && tflocal output -raw website_url_local)" + +seed-local: ## Seed local DynamoDB with sample products + TABLE_NAME=$(TABLE_NAME) AWS_REGION=$(AWS_REGION) $(PYTHON) scripts/seed.py local + +setup-aws-dynamo: ## Create + seed the DynamoDB table on real AWS (proxy prerequisite) + @echo "==> Creating table '$(TABLE_NAME)' on AWS..." + aws dynamodb create-table \ + --table-name $(TABLE_NAME) \ + --attribute-definitions AttributeName=id,AttributeType=S \ + --key-schema AttributeName=id,KeyType=HASH \ + --billing-mode PAY_PER_REQUEST \ + --region $(AWS_REGION) 2>/dev/null || echo " (table already exists)" + TABLE_NAME=$(TABLE_NAME) AWS_REGION=$(AWS_REGION) $(PYTHON) scripts/seed.py aws + +enable-proxy: ## Forward LocalStack DynamoDB calls to real AWS (open a separate terminal) + @echo "==> Enabling DynamoDB proxy to real AWS. Press Ctrl+C to stop." + @echo " While active, 'make test-local' will return AWS products." + @echo "" + localstack aws proxy -c proxy_config.yml + +test-local: ## Smoke-test the local API (works after 'replicate' or 'deploy-local') + @[ -n "$(LOCAL_API)" ] || { \ + echo "No local API Gateway found."; \ + echo "Run 'make replicate' or 'make deploy-local' first."; \ + exit 1; } + $(PYTHON) scripts/test.py "$(LOCAL_API)" + +destroy-local: ## Tear down LocalStack resources + cd infra && tflocal destroy -auto-approve \ + -var="aws_region=$(AWS_REGION)" \ + -var="app_name=$(APP_NAME)" + +##@ Scenario 3 — Cloud Pods (snapshot and restore full LocalStack state) + +pod-save: ## Snapshot current LocalStack state as a Cloud Pod (requires Pro) + @echo "==> Saving LocalStack state to pod '$(POD_NAME)'..." + localstack pod save $(POD_NAME) + @echo " State saved. Share the pod name to let teammates load it." + @echo " Load with: make pod-load (or: POD_NAME= make pod-load)" + +pod-load: ## Restore a previously saved Cloud Pod into LocalStack (requires Pro) + @echo "==> Loading pod '$(POD_NAME)' into LocalStack..." + localstack pod load $(POD_NAME) + @echo " State restored. Run 'make test-local' to verify." + +pod-list: ## List all available Cloud Pods + localstack pod list diff --git a/samples/aws-replicator-proxy/README.md b/samples/aws-replicator-proxy/README.md new file mode 100644 index 0000000..4d8b25e --- /dev/null +++ b/samples/aws-replicator-proxy/README.md @@ -0,0 +1,317 @@ +# Demo — LocalStack Replicator, AWS Proxy & Cloud Pods + +A minimal e-commerce product catalog that illustrates three powerful LocalStack features: + +- **AWS Replicator** — mirror real AWS resources (DynamoDB table + Lambda) into LocalStack so you can run the same app locally without re-deploying anything +- **AWS Proxy** — run everything locally, then transparently forward DynamoDB calls to real AWS so your local app sees upstream data +- **Cloud Pods** — snapshot the entire LocalStack state (tables, Lambda, S3, API Gateway) into a versioned pod and restore it in seconds on any machine + +## Architecture + +``` +Browser → S3 Static Website + ↓ (API URL) + API Gateway v2 (HTTP API) + ↓ + Lambda fn (product-catalog) + ↓ + DynamoDB (product-catalog-products) +``` + +Resources deployed: + +| Resource | Name | +|---|---| +| DynamoDB table | `product-catalog-products` | +| Lambda function | `product-catalog` | +| API Gateway v2 | HTTP API with GET/POST/PUT/DELETE routes | +| S3 static website | `product-catalog-frontend-*` | + +## Prerequisites + +- [LocalStack Pro](https://localstack.cloud) with a valid `LOCALSTACK_AUTH_TOKEN` +- Docker +- LocalStack CLI — `pip install localstack` +- `localstack-extension-aws-replicator` CLI package — `pip install localstack-extension-aws-replicator` +- [Terraform](https://developer.hashicorp.com/terraform/downloads) + [tflocal](https://github.com/localstack/terraform-local) — `pip install terraform-local` +- [AWS CLI](https://aws.amazon.com/cli/) + [awslocal](https://github.com/localstack/awscli-local) — `pip install awscli-local` +- Python 3.10+ with `boto3` — `pip install boto3` +- AWS credentials configured (used by real-AWS steps and the proxy/replicator) + +## Quick start + +```bash +export LOCALSTACK_AUTH_TOKEN=ls-... # or add to ~/.bashrc / .env +make localstack-start # start LocalStack (auto-installs replicator extension) +make help # show all available targets +``` + +--- + +## Scenario 1 — AWS Replicator + +> Deploy to real AWS first, then replicate the DynamoDB table (with all its items) and the Lambda function into LocalStack. No re-deploy needed locally. + +``` +Real AWS LocalStack +───────────────── ────────────────────────── +DynamoDB (products) ──────────► DynamoDB (replicated) +Lambda function ──────────► Lambda function (replicated) +API Gateway / URL Lambda Function URL (created locally) +``` + +### Step 1 — Deploy to AWS + +```bash +make deploy-aws +``` + +Terraform creates the DynamoDB table, Lambda, Function URL, S3 website, and IAM role on your real AWS account. + +### Step 2 — Seed AWS DynamoDB + +```bash +make seed-aws +``` + +Inserts 4 sample products into the real AWS DynamoDB table. + +### Step 3 — Test on AWS + +```bash +make test-aws +``` + +Calls the real AWS Lambda Function URL and verifies the products are returned. + +### Step 4 — Replicate into LocalStack + +```bash +make replicate +``` + +This script uses boto3 directly (one client for real AWS, one for LocalStack) to: +1. Copy the DynamoDB table schema and all items +2. Download the Lambda zip from AWS and create the function in LocalStack +3. Wire up an API Gateway in LocalStack pointing at the replicated Lambda +4. Upload `index.html` + `config.json` (with the local API URL) to a local S3 bucket + +> **Note:** The `localstack replicator start` CLI does not yet support `AWS::Lambda::Function`, so replication is implemented directly via boto3 here. DynamoDB replication via the CLI also requires AWS credentials inside the LocalStack container, which the direct approach avoids. + +The replicated Lambda automatically connects to the replicated local DynamoDB (same table name, LocalStack intercepts). + +### Step 5 — Test locally + +```bash +make test-local +``` + +Same 4 products appear — running entirely on LocalStack. No code changes, no re-deploy. + +Open the local website printed by `make replicate` and paste in the local API URL to see the frontend. + +### Cleanup + +```bash +make destroy-aws +``` + +--- + +## Scenario 2 — DynamoDB Proxy + +> Deploy the entire stack to LocalStack, add local data, then enable the DynamoDB proxy. From that point, all DynamoDB reads/writes go to real AWS — your local Lambda seamlessly uses upstream data. + +``` +LocalStack +───────────────────────────────────────────────────────── +Browser → S3 → API Gateway → Lambda + │ + ┌─────────────────┘ + ▼ + LocalStack DynamoDB ──[proxy enabled]──► Real AWS DynamoDB +``` + +### Step 1 — Deploy locally + +```bash +make deploy-local +``` + +Deploys the full stack to LocalStack using `tflocal`. + +### Step 2 — Seed local DynamoDB + +```bash +make seed-local +``` + +Inserts 4 sample products into the local DynamoDB. + +### Step 3 — Test locally (local data) + +```bash +make test-local +``` + +Returns the 4 local products. Everything runs offline. + +### Step 4 — Seed real AWS DynamoDB + +In a separate terminal, create and seed the AWS DynamoDB table with different products: + +```bash +make setup-aws-dynamo +``` + +This creates the table on real AWS if it doesn't exist, then seeds it with the same 4 products (or different ones if you edited the seed script). + +### Step 5 — Enable the DynamoDB proxy + +```bash +# Open a NEW terminal for this — it runs in the foreground +make enable-proxy +``` + +This runs `localstack aws proxy -c proxy_config.yml`, which starts a proxy container that intercepts all DynamoDB calls from LocalStack and forwards them to your real AWS account. + +`proxy_config.yml` is configured to proxy **all DynamoDB requests**. You can restrict it to specific tables — see the comments inside. + +### Step 6 — Test locally again (now seeing AWS data) + +```bash +# Back in your original terminal +make test-local +``` + +The same local API endpoint now returns data from real AWS DynamoDB. Your local app is transparently using upstream data with zero code changes. + +Press `Ctrl+C` in the proxy terminal to stop proxying. Subsequent calls return to the local DynamoDB. + +### Cleanup + +```bash +make destroy-local +# optionally also clean up the AWS table created by setup-aws-dynamo: +aws dynamodb delete-table --table-name product-catalog-products +``` + +--- + +## Scenario 3 — Cloud Pods (snapshot and restore) + +> Deploy everything to LocalStack, add products, snapshot the state as a Cloud Pod, then restore it instantly on a fresh instance — no re-deploy needed. Great for sharing a pre-loaded dev environment with your team. + +``` +LocalStack (populated) + DynamoDB ─┐ + Lambda ─┼──[pod save]──► Cloud Pod storage + S3 ─┘ + API GW ─┘ + + ↓ make pod-load + +LocalStack (fresh) ← full state restored +``` + +**Prerequisite:** Requires LocalStack Pro (`LOCALSTACK_AUTH_TOKEN` must be set). + +### Step 1 — Deploy and seed locally + +If you haven't already, deploy the full stack and seed it: + +```bash +make deploy-local +make seed-local +``` + +Use the frontend or `make test-local` to add some extra products via the UI. + +### Step 2 — Save state as a Cloud Pod + +```bash +make pod-save +# or use a custom name: +# POD_NAME=my-demo-state make pod-save +``` + +This snapshots the entire LocalStack instance — DynamoDB table (with all items), Lambda function code and config, S3 buckets, API Gateway, IAM roles — everything — into a versioned Cloud Pod stored in the LocalStack cloud platform. + +### Step 3 — Wipe and restart LocalStack + +```bash +make localstack-stop +make localstack-start +``` + +After restart, LocalStack is completely empty. + +### Step 4 — Restore from the pod + +```bash +make pod-load +``` + +All resources are restored exactly as they were: the DynamoDB table has all items (including anything added via the UI), the Lambda runs the same code, the S3 website is accessible, and the API Gateway routes are live. + +```bash +make test-local # same products as before the restart +``` + +### Listing and sharing pods + +```bash +make pod-list # show all saved pods +``` + +Share the pod name with a teammate. They can load it on their own LocalStack instance with `POD_NAME= make pod-load` and get a fully working pre-seeded environment in seconds. + +### Cleanup + +```bash +localstack pod delete product-catalog-state +make destroy-local +``` + +--- + +## File overview + +``` +. +├── proxy_config.yml DynamoDB proxy configuration +├── Makefile All commands for all three scenarios (run 'make help') +├── infra/ +│ └── main.tf Terraform: DynamoDB + Lambda + API Gateway + S3 + IAM +├── lambda/ +│ └── handler.py Lambda handler (GET/POST/PUT/DELETE /products) +├── frontend/ +│ └── index.html S3 static website — list, add, edit, delete products +└── scripts/ + ├── seed.py Insert sample products into DynamoDB (aws or local) + ├── replicate.py Replicate AWS resources into LocalStack + └── test.py Smoke-test the API +``` + +## How the proxy works internally + +When `localstack aws proxy -c proxy_config.yml` runs: + +1. The LocalStack CLI reads your host AWS credentials and starts a lightweight proxy container +2. The proxy registers itself with the LocalStack server as the handler for DynamoDB requests +3. Your local Lambda calls `http://localhost.localstack.cloud:4566` (the LocalStack DynamoDB endpoint) +4. LocalStack routes matching DynamoDB calls through the proxy container to real AWS +5. Responses flow back through LocalStack to the Lambda — no code change required + +The proxy is service-scoped (`dynamodb: {}`), meaning only DynamoDB is proxied. Lambda, S3, and all other services stay local. + +## How Cloud Pods work internally + +When `localstack pod save ` runs: + +1. LocalStack serializes the in-memory and on-disk state of every active service +2. The snapshot is uploaded to the LocalStack cloud platform as a versioned, named pod +3. `localstack pod load ` downloads the snapshot and injects it into a running instance +4. All resources are re-created in memory — tables, items, Lambda code, S3 objects, routes — exactly as they were + +Cloud Pods are version-controlled: each `pod save` creates a new version, and you can `pod load` any specific version. This makes them useful for golden-state environments, regression baselines, and collaborative demos. diff --git a/samples/aws-replicator-proxy/frontend/index.html b/samples/aws-replicator-proxy/frontend/index.html new file mode 100644 index 0000000..6a3057c --- /dev/null +++ b/samples/aws-replicator-proxy/frontend/index.html @@ -0,0 +1,255 @@ + + + + + + Product Catalog — LocalStack Demo + + + + +
+

Product Catalog

+ LocalStack Demo +
+ +
+ + + +
+ +
Enter an API URL above and click Refresh
+ +
+ +
+ + + + + +
+ + + + + + + diff --git a/samples/aws-replicator-proxy/infra/.terraform.lock.hcl b/samples/aws-replicator-proxy/infra/.terraform.lock.hcl new file mode 100644 index 0000000..5e2b459 --- /dev/null +++ b/samples/aws-replicator-proxy/infra/.terraform.lock.hcl @@ -0,0 +1,45 @@ +# This file is maintained automatically by "terraform init". +# Manual edits may be lost in future updates. + +provider "registry.terraform.io/hashicorp/archive" { + version = "2.8.0" + hashes = [ + "h1:WB6H5ksIZiyq1lQlD/PWeh+tn4FLsbSjVnRW3+4xe2Y=", + "zh:0d14713fdc259fb377d0b899ad3c650a34194bd52194c863303ef22a65a580e2", + "zh:369b56040c7a8085d04e7e8ffac1e2b321a3170e502f788819bc34b868ec016f", + "zh:4d1a3b983ed6af5a52bfe12794674ae55cbadfa6021b37106ade68b433ad216a", + "zh:5c547549e26e083573c78a966ca68ce6d7df6bb8f3948f66a575f07da46b74ea", + "zh:6de093e62a975eb19a5e3017ce38e6e3cb639c17b79648d2000e0a8348f0e997", + "zh:7267936c2cdbc448efeb594d73e6b56a53d6a7ae14fe88cdd2a4133adc3302f0", + "zh:7482f023050ed426b4b45116e1761643bc33b1fd4ce4a6fab207ae2571f35940", + "zh:76bbd93b234e5a2927d98b511d86565700f549b570871a194c35f944b96cefb7", + "zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3", + "zh:c6afc4bc1f002bac9c173007dd4da05fde788cd14c2916089f958c33fedb0dfa", + "zh:d3ba40bd806a3a08e9237dece679193c99afb2085de6b45d7f5d1f673cfcd368", + "zh:e1ad7ded53ecd6f0e5b473a3b44eae2b2e885653a56050ab583d387332be02e4", + "zh:e93e78575ce82be6084cc153c24ba8f385dc8d6880888ee66e918460c870953d", + ] +} + +provider "registry.terraform.io/hashicorp/aws" { + version = "5.100.0" + constraints = "~> 5.0" + hashes = [ + "h1:Ijt7pOlB7Tr7maGQIqtsLFbl7pSMIj06TVdkoSBcYOw=", + "zh:054b8dd49f0549c9a7cc27d159e45327b7b65cf404da5e5a20da154b90b8a644", + "zh:0b97bf8d5e03d15d83cc40b0530a1f84b459354939ba6f135a0086c20ebbe6b2", + "zh:1589a2266af699cbd5d80737a0fe02e54ec9cf2ca54e7e00ac51c7359056f274", + "zh:6330766f1d85f01ae6ea90d1b214b8b74cc8c1badc4696b165b36ddd4cc15f7b", + "zh:7c8c2e30d8e55291b86fcb64bdf6c25489d538688545eb48fd74ad622e5d3862", + "zh:99b1003bd9bd32ee323544da897148f46a527f622dc3971af63ea3e251596342", + "zh:9b12af85486a96aedd8d7984b0ff811a4b42e3d88dad1a3fb4c0b580d04fa425", + "zh:9f8b909d3ec50ade83c8062290378b1ec553edef6a447c56dadc01a99f4eaa93", + "zh:aaef921ff9aabaf8b1869a86d692ebd24fbd4e12c21205034bb679b9caf883a2", + "zh:ac882313207aba00dd5a76dbd572a0ddc818bb9cbf5c9d61b28fe30efaec951e", + "zh:bb64e8aff37becab373a1a0cc1080990785304141af42ed6aa3dd4913b000421", + "zh:dfe495f6621df5540d9c92ad40b8067376350b005c637ea6efac5dc15028add4", + "zh:f0ddf0eaf052766cfe09dea8200a946519f653c384ab4336e2a4a64fdd6310e9", + "zh:f1b7e684f4c7ae1eed272b6de7d2049bb87a0275cb04dbb7cda6636f600699c9", + "zh:ff461571e3f233699bf690db319dfe46aec75e58726636a0d97dd9ac6e32fb70", + ] +} diff --git a/samples/aws-replicator-proxy/infra/main.tf b/samples/aws-replicator-proxy/infra/main.tf new file mode 100644 index 0000000..2e2fc77 --- /dev/null +++ b/samples/aws-replicator-proxy/infra/main.tf @@ -0,0 +1,252 @@ +terraform { + required_providers { + aws = { + source = "hashicorp/aws" + version = "~> 5.0" + } + } +} + +provider "aws" { + region = var.aws_region +} + +variable "aws_region" { + default = "us-east-1" +} + +variable "app_name" { + default = "product-catalog" +} + +# bucket_suffix must be unique across all AWS accounts; use your account ID on real AWS +variable "bucket_suffix" { + default = "local" +} + +# ── DynamoDB ─────────────────────────────────────────────────────────────────── + +resource "aws_dynamodb_table" "products" { + name = "${var.app_name}-products" + billing_mode = "PAY_PER_REQUEST" + hash_key = "id" + + attribute { + name = "id" + type = "S" + } + + tags = { App = var.app_name } +} + +# ── IAM ──────────────────────────────────────────────────────────────────────── + +resource "aws_iam_role" "lambda_exec" { + name = "${var.app_name}-lambda-exec" + + assume_role_policy = jsonencode({ + Version = "2012-10-17" + Statement = [{ + Effect = "Allow" + Principal = { Service = "lambda.amazonaws.com" } + Action = "sts:AssumeRole" + }] + }) +} + +resource "aws_iam_role_policy" "dynamodb" { + name = "dynamodb" + role = aws_iam_role.lambda_exec.id + + policy = jsonencode({ + Version = "2012-10-17" + Statement = [{ + Effect = "Allow" + Action = [ + "dynamodb:Scan", + "dynamodb:PutItem", + "dynamodb:GetItem", + "dynamodb:UpdateItem", + "dynamodb:DeleteItem", + ] + Resource = aws_dynamodb_table.products.arn + }] + }) +} + +resource "aws_iam_role_policy_attachment" "logs" { + role = aws_iam_role.lambda_exec.name + policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole" +} + +# ── Lambda ───────────────────────────────────────────────────────────────────── + +data "archive_file" "lambda" { + type = "zip" + output_path = "${path.module}/../lambda/handler.zip" + source_file = "${path.module}/../lambda/handler.py" +} + +resource "aws_lambda_function" "api" { + function_name = var.app_name + role = aws_iam_role.lambda_exec.arn + runtime = "python3.12" + handler = "handler.handler" + filename = data.archive_file.lambda.output_path + source_code_hash = data.archive_file.lambda.output_base64sha256 + timeout = 30 + + environment { + variables = { + TABLE_NAME = aws_dynamodb_table.products.name + } + } + + tags = { App = var.app_name } +} + +resource "aws_lambda_permission" "apigw" { + action = "lambda:InvokeFunction" + function_name = aws_lambda_function.api.function_name + principal = "apigateway.amazonaws.com" + source_arn = "${aws_apigatewayv2_api.api.execution_arn}/*/*" +} + +# ── API Gateway v2 (HTTP API) ────────────────────────────────────────────────── + +resource "aws_apigatewayv2_api" "api" { + name = var.app_name + protocol_type = "HTTP" + # CORS headers are returned by the Lambda itself so they work identically + # on both AWS and LocalStack without relying on gateway-level injection. +} + +resource "aws_apigatewayv2_integration" "lambda" { + api_id = aws_apigatewayv2_api.api.id + integration_type = "AWS_PROXY" + integration_uri = aws_lambda_function.api.invoke_arn + payload_format_version = "2.0" +} + +resource "aws_apigatewayv2_route" "get_products" { + api_id = aws_apigatewayv2_api.api.id + route_key = "GET /products" + target = "integrations/${aws_apigatewayv2_integration.lambda.id}" +} + +resource "aws_apigatewayv2_route" "post_products" { + api_id = aws_apigatewayv2_api.api.id + route_key = "POST /products" + target = "integrations/${aws_apigatewayv2_integration.lambda.id}" +} + +resource "aws_apigatewayv2_route" "options_products" { + api_id = aws_apigatewayv2_api.api.id + route_key = "OPTIONS /products" + target = "integrations/${aws_apigatewayv2_integration.lambda.id}" +} + +resource "aws_apigatewayv2_route" "put_product" { + api_id = aws_apigatewayv2_api.api.id + route_key = "PUT /products/{id}" + target = "integrations/${aws_apigatewayv2_integration.lambda.id}" +} + +resource "aws_apigatewayv2_route" "delete_product" { + api_id = aws_apigatewayv2_api.api.id + route_key = "DELETE /products/{id}" + target = "integrations/${aws_apigatewayv2_integration.lambda.id}" +} + +resource "aws_apigatewayv2_route" "options_product" { + api_id = aws_apigatewayv2_api.api.id + route_key = "OPTIONS /products/{id}" + target = "integrations/${aws_apigatewayv2_integration.lambda.id}" +} + +resource "aws_apigatewayv2_stage" "default" { + api_id = aws_apigatewayv2_api.api.id + name = "$default" + auto_deploy = true +} + +# ── S3 website ───────────────────────────────────────────────────────────────── + +resource "aws_s3_bucket" "frontend" { + bucket = "${var.app_name}-frontend-${var.bucket_suffix}" + tags = { App = var.app_name } +} + +resource "aws_s3_bucket_website_configuration" "frontend" { + bucket = aws_s3_bucket.frontend.id + index_document { suffix = "index.html" } +} + +resource "aws_s3_bucket_public_access_block" "frontend" { + bucket = aws_s3_bucket.frontend.id + block_public_acls = false + block_public_policy = false + ignore_public_acls = false + restrict_public_buckets = false +} + +resource "aws_s3_bucket_policy" "frontend" { + bucket = aws_s3_bucket.frontend.id + depends_on = [aws_s3_bucket_public_access_block.frontend] + + policy = jsonencode({ + Version = "2012-10-17" + Statement = [{ + Effect = "Allow" + Principal = "*" + Action = "s3:GetObject" + Resource = "${aws_s3_bucket.frontend.arn}/*" + }] + }) +} + +resource "aws_s3_object" "index" { + bucket = aws_s3_bucket.frontend.id + key = "index.html" + source = "${path.module}/../frontend/index.html" + content_type = "text/html" + etag = filemd5("${path.module}/../frontend/index.html") +} + +# config.json is fetched by index.html on load to auto-populate the API URL +resource "aws_s3_object" "config" { + bucket = aws_s3_bucket.frontend.id + key = "config.json" + content = jsonencode({ apiUrl = trim(aws_apigatewayv2_stage.default.invoke_url, "/") }) + content_type = "application/json" + depends_on = [aws_apigatewayv2_stage.default] +} + +# ── Outputs ──────────────────────────────────────────────────────────────────── + +output "api_url" { + value = trim(aws_apigatewayv2_stage.default.invoke_url, "/") + description = "API Gateway HTTP API endpoint" +} + +output "table_name" { + value = aws_dynamodb_table.products.name +} + +output "function_name" { + value = aws_lambda_function.api.function_name +} + +output "frontend_bucket" { + value = aws_s3_bucket.frontend.bucket +} + +output "website_url_aws" { + value = "http://${aws_s3_bucket.frontend.bucket}.s3-website-${var.aws_region}.amazonaws.com" + description = "S3 website URL (AWS)" +} + +output "website_url_local" { + value = "http://${aws_s3_bucket.frontend.bucket}.s3-website.localhost.localstack.cloud:4566" + description = "S3 website URL (LocalStack)" +} diff --git a/samples/aws-replicator-proxy/lambda/handler.py b/samples/aws-replicator-proxy/lambda/handler.py new file mode 100644 index 0000000..c621e7f --- /dev/null +++ b/samples/aws-replicator-proxy/lambda/handler.py @@ -0,0 +1,71 @@ +import json +import os +import boto3 +from decimal import Decimal + +TABLE_NAME = os.environ.get("TABLE_NAME", "product-catalog-products") + +dynamodb = boto3.resource("dynamodb") + + +class DecimalEncoder(json.JSONEncoder): + def default(self, o): # noqa: ANN001 + if isinstance(o, Decimal): + return float(o) + return super().default(o) + + +CORS_HEADERS = { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS", + "Access-Control-Allow-Headers": "content-type", +} + + +def _response(status, body): + return { + "statusCode": status, + "headers": {"Content-Type": "application/json", **CORS_HEADERS}, + "body": json.dumps(body, cls=DecimalEncoder), + } + + +def handler(event, context): + ctx = event.get("requestContext", {}).get("http", {}) + method = ctx.get("method") or event.get("httpMethod", "GET") + path = ctx.get("path") or event.get("path", "/") + + if method == "OPTIONS": + return {"statusCode": 200, "headers": CORS_HEADERS, "body": ""} + + parts = [p for p in path.strip("/").split("/") if p] + is_products = parts[0:1] == ["products"] + item_id = parts[1] if len(parts) > 1 else None + + if not is_products: + return _response(404, {"error": "not found"}) + + table = dynamodb.Table(TABLE_NAME) + + if item_id is None: + if method == "GET": + result = table.scan() + return _response(200, result.get("Items", [])) + + if method == "POST": + body = json.loads(event.get("body") or "{}", parse_float=Decimal) + table.put_item(Item=body) + return _response(201, {"message": "created", "item": body}) + + else: + if method == "PUT": + body = json.loads(event.get("body") or "{}", parse_float=Decimal) + body["id"] = item_id + table.put_item(Item=body) + return _response(200, {"message": "updated", "item": body}) + + if method == "DELETE": + table.delete_item(Key={"id": item_id}) + return _response(200, {"message": "deleted", "id": item_id}) + + return _response(404, {"error": "not found"}) diff --git a/samples/aws-replicator-proxy/proxy_config.yml b/samples/aws-replicator-proxy/proxy_config.yml new file mode 100644 index 0000000..4afdcc0 --- /dev/null +++ b/samples/aws-replicator-proxy/proxy_config.yml @@ -0,0 +1,13 @@ +# Proxy all DynamoDB requests from LocalStack to real AWS. +# When active, your local Lambda's DynamoDB calls are transparently forwarded +# to your AWS account, so the local app reads/writes upstream data. +# +# To scope the proxy to a specific table only, replace the block below with: +# +# services: +# dynamodb: +# resources: +# - '.*:product-catalog-products' +# +services: + dynamodb: {} diff --git a/samples/aws-replicator-proxy/scripts/replicate.py b/samples/aws-replicator-proxy/scripts/replicate.py new file mode 100644 index 0000000..2ca8248 --- /dev/null +++ b/samples/aws-replicator-proxy/scripts/replicate.py @@ -0,0 +1,245 @@ +#!/usr/bin/env python3 +"""Replicate AWS resources (DynamoDB table + Lambda function) into a running LocalStack instance. + +The LocalStack replicator extension does not yet support AWS::Lambda::Function, and its +DynamoDB support requires AWS credentials inside the container. This script implements +replication directly via boto3 instead — one client for reading from real AWS, one for +writing to LocalStack — which is more transparent and reliable for a sample. + +Usage: + python scripts/replicate.py + +Environment variables (all optional): + APP_NAME application name prefix (default: product-catalog) + TABLE_NAME DynamoDB table name (default: -products) + FUNCTION_NAME Lambda function name (default: ) + AWS_REGION AWS region (default: us-east-1) + AWS_ENDPOINT_URL LocalStack endpoint (default: http://localhost:4566) +""" + +import json +import os +import urllib.request +from pathlib import Path + +import boto3 +from botocore.exceptions import ClientError + +APP_NAME = os.environ.get("APP_NAME", "product-catalog") +TABLE_NAME = os.environ.get("TABLE_NAME", f"{APP_NAME}-products") +FUNCTION_NAME = os.environ.get("FUNCTION_NAME", APP_NAME) +AWS_REGION = os.environ.get("AWS_REGION", "us-east-1") +LS_ENDPOINT = os.environ.get("AWS_ENDPOINT_URL", "http://localhost:4566") + +FRONTEND_HTML = Path(__file__).parent.parent / "frontend" / "index.html" +BUCKET = f"{APP_NAME}-frontend-local" + + +def aws_client(service: str): # type: ignore[return] + """Real-AWS boto3 client (uses host credential chain, no endpoint override).""" + return boto3.client(service, region_name=AWS_REGION) + + +def local_client(service: str): # type: ignore[return] + """LocalStack boto3 client — always uses fake credentials so all resources land + in the standard 000000000000 namespace that awslocal / the Lambda env-override use.""" + return boto3.client( + service, + endpoint_url=LS_ENDPOINT, + region_name=AWS_REGION, + aws_access_key_id="test", + aws_secret_access_key="test", + ) + + +# ── DynamoDB ─────────────────────────────────────────────────────────────────── + +def replicate_dynamodb() -> None: + print(f"==> Replicating DynamoDB table '{TABLE_NAME}'...") + aws = aws_client("dynamodb") + local = local_client("dynamodb") + + table = aws.describe_table(TableName=TABLE_NAME)["Table"] + + create_kwargs: dict = { + "TableName": table["TableName"], + "AttributeDefinitions": table["AttributeDefinitions"], + "KeySchema": table["KeySchema"], + "BillingMode": "PAY_PER_REQUEST", + } + if table.get("GlobalSecondaryIndexes"): + create_kwargs["GlobalSecondaryIndexes"] = [ + {k: v for k, v in gsi.items() if k in ("IndexName", "KeySchema", "Projection")} + for gsi in table["GlobalSecondaryIndexes"] + ] + + try: + local.create_table(**create_kwargs) + print(f" Created table '{TABLE_NAME}' in LocalStack") + except ClientError as e: + if e.response["Error"]["Code"] == "ResourceInUseException": + print(f" Table '{TABLE_NAME}' already exists, skipping creation") + else: + raise + + local.get_waiter("table_exists").wait(TableName=TABLE_NAME) + + paginator = aws.get_paginator("scan") + total = 0 + for page in paginator.paginate(TableName=TABLE_NAME): + for item in page.get("Items", []): + local.put_item(TableName=TABLE_NAME, Item=item) + total += 1 + print(f" Copied {total} item(s) to LocalStack") + + +# ── Lambda ───────────────────────────────────────────────────────────────────── + +def replicate_lambda() -> None: + print(f"\n==> Replicating Lambda function '{FUNCTION_NAME}'...") + aws = aws_client("lambda") + local = local_client("lambda") + + fn = aws.get_function(FunctionName=FUNCTION_NAME) + cfg = fn["Configuration"] + + print(" Downloading function code from AWS...") + with urllib.request.urlopen(fn["Code"]["Location"]) as r: + zip_bytes = r.read() + + # Merge in fake LocalStack credentials so the Lambda uses account 000000000000, + # matching the namespace that local_client() and awslocal create resources under. + # Without this, LocalStack injects the host's real AWS credentials into the container, + # causing a cross-account namespace mismatch where the Lambda cannot find local tables. + env_vars = dict(cfg.get("Environment", {}).get("Variables", {})) + env_vars.setdefault("AWS_ACCESS_KEY_ID", "test") + env_vars.setdefault("AWS_SECRET_ACCESS_KEY", "test") + + create_kwargs: dict = { + "FunctionName": FUNCTION_NAME, + "Runtime": cfg["Runtime"], + "Role": cfg["Role"], + "Handler": cfg["Handler"], + "Code": {"ZipFile": zip_bytes}, + "Timeout": cfg.get("Timeout", 30), + "MemorySize": cfg.get("MemorySize", 128), + "Environment": {"Variables": env_vars}, + } + + try: + local.create_function(**create_kwargs) + print(f" Created function '{FUNCTION_NAME}' in LocalStack") + except ClientError as e: + if e.response["Error"]["Code"] == "ResourceConflictException": + local.update_function_code(FunctionName=FUNCTION_NAME, ZipFile=zip_bytes) + local.update_function_configuration( + FunctionName=FUNCTION_NAME, + Handler=cfg["Handler"], + Runtime=cfg["Runtime"], + Timeout=cfg.get("Timeout", 30), + Environment={"Variables": env_vars}, + ) + print(f" Updated existing function '{FUNCTION_NAME}' in LocalStack") + else: + raise + + local.get_waiter("function_active_v2").wait(FunctionName=FUNCTION_NAME) + + +# ── API Gateway ──────────────────────────────────────────────────────────────── + +ROUTES = ( + "GET /products", + "POST /products", + "OPTIONS /products", + "PUT /products/{id}", + "DELETE /products/{id}", + "OPTIONS /products/{id}", +) + + +def create_api_gateway() -> str: + """Create (or update) an HTTP API Gateway in LocalStack pointing at the Lambda.""" + print(f"\n==> Creating API Gateway (HTTP API) in LocalStack...") + apigw = local_client("apigatewayv2") + lam = local_client("lambda") + + existing = [a for a in apigw.get_apis().get("Items", []) if a["Name"] == APP_NAME] + if existing: + api_id = existing[0]["ApiId"] + print(f" API '{APP_NAME}' already exists ({api_id})") + integ_id = apigw.get_integrations(ApiId=api_id)["Items"][0]["IntegrationId"] + else: + api = apigw.create_api(Name=APP_NAME, ProtocolType="HTTP") + api_id = api["ApiId"] + + fn_arn = lam.get_function(FunctionName=FUNCTION_NAME)["Configuration"]["FunctionArn"] + integ = apigw.create_integration( + ApiId = api_id, + IntegrationType = "AWS_PROXY", + IntegrationUri = fn_arn, + PayloadFormatVersion = "2.0", + ) + integ_id = integ["IntegrationId"] + apigw.create_stage(ApiId=api_id, StageName="$default", AutoDeploy=True) + print(f" Created API Gateway '{APP_NAME}' ({api_id})") + + existing_routes = {r["RouteKey"] for r in apigw.get_routes(ApiId=api_id).get("Items", [])} + for route_key in ROUTES: + if route_key not in existing_routes: + apigw.create_route(ApiId=api_id, RouteKey=route_key, + Target=f"integrations/{integ_id}") + print(f" Added route: {route_key}") + + return f"http://{api_id}.execute-api.localhost.localstack.cloud:4566" + + +# ── S3 frontend ──────────────────────────────────────────────────────────────── + +def upload_frontend(local_api: str) -> str: + print(f"\n==> Uploading frontend to LocalStack S3 bucket '{BUCKET}'...") + s3 = local_client("s3") + + try: + s3.create_bucket(Bucket=BUCKET) + except ClientError as e: + if e.response["Error"]["Code"] not in ("BucketAlreadyOwnedByYou", "BucketAlreadyExists"): + raise + + s3.put_bucket_website(Bucket=BUCKET, + WebsiteConfiguration={"IndexDocument": {"Suffix": "index.html"}}) + s3.put_bucket_policy(Bucket=BUCKET, Policy=json.dumps({ + "Version": "2012-10-17", + "Statement": [{"Effect": "Allow", "Principal": "*", + "Action": "s3:GetObject", + "Resource": f"arn:aws:s3:::{BUCKET}/*"}], + })) + s3.put_object(Bucket=BUCKET, Key="index.html", + Body=FRONTEND_HTML.read_bytes(), ContentType="text/html") + s3.put_object(Bucket=BUCKET, Key="config.json", + Body=json.dumps({"apiUrl": local_api}).encode(), + ContentType="application/json") + + return f"http://{BUCKET}.s3-website.localhost.localstack.cloud:4566" + + +# ── Main ─────────────────────────────────────────────────────────────────────── + +def main() -> None: + replicate_dynamodb() + replicate_lambda() + local_api = create_api_gateway() + website = upload_frontend(local_api) + + print("\n" + "=" * 52) + print(" Replication complete!") + print("=" * 52) + print(f"\n Local API: {local_api}/products") + print(f" Local website: {website}") + print(f"\n Quick test:") + print(f" curl '{local_api}/products'") + print(f" python scripts/test.py '{local_api}'") + + +if __name__ == "__main__": + main() diff --git a/samples/aws-replicator-proxy/scripts/seed.py b/samples/aws-replicator-proxy/scripts/seed.py new file mode 100644 index 0000000..e5dd94b --- /dev/null +++ b/samples/aws-replicator-proxy/scripts/seed.py @@ -0,0 +1,48 @@ +#!/usr/bin/env python3 +"""Seed DynamoDB with sample products. + +Usage: + python scripts/seed.py [aws|local] +""" + +import argparse +import os +from decimal import Decimal + +import boto3 + +PRODUCTS = [ + {"id": "prod-001", "name": "Wireless Headphones", "price": Decimal("79.99"), "category": "Electronics", "inStock": True}, + {"id": "prod-002", "name": "Ergonomic Mouse", "price": Decimal("45.00"), "category": "Accessories", "inStock": True}, + {"id": "prod-003", "name": "USB-C Hub (7-port)", "price": Decimal("34.99"), "category": "Accessories", "inStock": False}, + {"id": "prod-004", "name": "4K Webcam", "price": Decimal("129.00"), "category": "Electronics", "inStock": True}, +] + + +def main(mode: str) -> None: + region = os.environ.get("AWS_REGION", "us-east-1") + table_name = os.environ.get("TABLE_NAME", "product-catalog-products") + + kwargs: dict = {"region_name": region} + if mode == "local": + kwargs["endpoint_url"] = os.environ.get("AWS_ENDPOINT_URL", "http://localhost:4566") + kwargs["aws_access_key_id"] = "test" + kwargs["aws_secret_access_key"] = "test" + + dynamodb = boto3.resource("dynamodb", **kwargs) + table = dynamodb.Table(table_name) + + print(f"Seeding '{table_name}' ({mode})...") + for product in PRODUCTS: + table.put_item(Item=product) + print(f" + {product['name']} — ${product['price']}") + + print(f"\nDone. {len(PRODUCTS)} products seeded.") + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument("mode", choices=["aws", "local"], nargs="?", default="local", + help="Target environment (default: local)") + args = parser.parse_args() + main(args.mode) diff --git a/samples/aws-replicator-proxy/scripts/test.py b/samples/aws-replicator-proxy/scripts/test.py new file mode 100644 index 0000000..8776119 --- /dev/null +++ b/samples/aws-replicator-proxy/scripts/test.py @@ -0,0 +1,67 @@ +#!/usr/bin/env python3 +"""Smoke-test the product catalog API (GET + POST + verify). + +Usage: + python scripts/test.py + python scripts/test.py https://xxx.lambda-url.us-east-1.on.aws + python scripts/test.py http://xxx.lambda-url.us-east-1.localhost.localstack.cloud:4566 +""" + +import argparse +import json +import sys +import time +import urllib.error +import urllib.request +from typing import Any + + +def _call(method: str, url: str, data: dict | None = None) -> Any: + body = json.dumps(data).encode() if data else None + headers = {"Content-Type": "application/json"} if body else {} + req = urllib.request.Request(url, data=body, headers=headers, method=method) + try: + with urllib.request.urlopen(req) as r: + return json.loads(r.read()) + except urllib.error.HTTPError as e: + print(f"HTTP {e.code}: {e.read().decode()}", file=sys.stderr) + raise + + +def main(api_url: str) -> None: + base = api_url.rstrip("/") + print(f"Testing API: {base}\n") + + print("--- GET /products ---") + products = _call("GET", f"{base}/products") + print(json.dumps(products, indent=2)) + print(f"\n {len(products)} product(s)") + + print("\n--- POST /products (ephemeral test item) ---") + test_id = f"smoke-{int(time.time())}" + item = { + "id": test_id, + "name": "Smoke Test Item", + "price": 1.0, + "category": "Test", + "inStock": True, + } + created = _call("POST", f"{base}/products", item) + print(json.dumps(created, indent=2)) + + print("\n--- GET /products (verify item present) ---") + after = _call("GET", f"{base}/products") + ids = [p["id"] for p in after] + if test_id not in ids: + print(f"FAIL: test item '{test_id}' missing. Got IDs: {ids}", file=sys.stderr) + sys.exit(1) + + print(f" OK — {len(after)} product(s), test item confirmed.\n") + print("All checks passed.") + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument("api_url", help="Base URL of the Lambda Function URL endpoint") + args = parser.parse_args() + main(args.api_url) From f8575a5be731bc28619f9a0d69a754e339bb32b0 Mon Sep 17 00:00:00 2001 From: Waldemar Hummer Date: Mon, 15 Jun 2026 00:33:30 -0400 Subject: [PATCH 2/3] move localstack-typedb and localstack-wiremock under extensions/ Co-Authored-By: Claude Sonnet 4.6 --- extensions/localstack-typedb/.gitignore | 8 ++++++++ .../localstack-typedb}/Makefile | 0 .../localstack-typedb}/README.md | 0 .../localstack-typedb}/localstack_typedb/__init__.py | 0 .../localstack-typedb}/localstack_typedb/extension.py | 0 .../localstack_typedb/utils/__init__.py | 0 .../localstack-typedb}/localstack_typedb/utils/docker.py | 0 .../localstack_typedb/utils/h2_proxy.py | 0 .../localstack-typedb}/pyproject.toml | 0 .../localstack-typedb}/tests/test_extension.py | 0 .../localstack-wiremock}/.gitignore | 4 ++++ .../localstack-wiremock}/Makefile | 0 .../localstack-wiremock}/README.md | 0 .../localstack-wiremock}/bin/create-stubs.sh | 0 .../localstack-wiremock}/localstack_wiremock/__init__.py | 0 .../localstack-wiremock}/localstack_wiremock/extension.py | 0 .../localstack_wiremock/utils/__init__.py | 0 .../localstack_wiremock/utils/docker.py | 0 .../localstack-wiremock}/pyproject.toml | 0 .../localstack-wiremock}/sample-app/main.tf | 0 .../localstack-wiremock}/sample-app/src/handler.py | 0 .../localstack-wiremock}/sample-app/src/requirements.txt | 0 localstack-typedb/.gitignore | 5 ----- 23 files changed, 12 insertions(+), 5 deletions(-) create mode 100644 extensions/localstack-typedb/.gitignore rename {localstack-typedb => extensions/localstack-typedb}/Makefile (100%) rename {localstack-typedb => extensions/localstack-typedb}/README.md (100%) rename {localstack-typedb => extensions/localstack-typedb}/localstack_typedb/__init__.py (100%) rename {localstack-typedb => extensions/localstack-typedb}/localstack_typedb/extension.py (100%) rename {localstack-typedb => extensions/localstack-typedb}/localstack_typedb/utils/__init__.py (100%) rename {localstack-typedb => extensions/localstack-typedb}/localstack_typedb/utils/docker.py (100%) rename {localstack-typedb => extensions/localstack-typedb}/localstack_typedb/utils/h2_proxy.py (100%) rename {localstack-typedb => extensions/localstack-typedb}/pyproject.toml (100%) rename {localstack-typedb => extensions/localstack-typedb}/tests/test_extension.py (100%) rename {localstack-wiremock => extensions/localstack-wiremock}/.gitignore (65%) rename {localstack-wiremock => extensions/localstack-wiremock}/Makefile (100%) rename {localstack-wiremock => extensions/localstack-wiremock}/README.md (100%) rename {localstack-wiremock => extensions/localstack-wiremock}/bin/create-stubs.sh (100%) rename {localstack-wiremock => extensions/localstack-wiremock}/localstack_wiremock/__init__.py (100%) rename {localstack-wiremock => extensions/localstack-wiremock}/localstack_wiremock/extension.py (100%) rename {localstack-wiremock => extensions/localstack-wiremock}/localstack_wiremock/utils/__init__.py (100%) rename {localstack-wiremock => extensions/localstack-wiremock}/localstack_wiremock/utils/docker.py (100%) rename {localstack-wiremock => extensions/localstack-wiremock}/pyproject.toml (100%) rename {localstack-wiremock => extensions/localstack-wiremock}/sample-app/main.tf (100%) rename {localstack-wiremock => extensions/localstack-wiremock}/sample-app/src/handler.py (100%) rename {localstack-wiremock => extensions/localstack-wiremock}/sample-app/src/requirements.txt (100%) delete mode 100644 localstack-typedb/.gitignore diff --git a/extensions/localstack-typedb/.gitignore b/extensions/localstack-typedb/.gitignore new file mode 100644 index 0000000..9311acd --- /dev/null +++ b/extensions/localstack-typedb/.gitignore @@ -0,0 +1,8 @@ +.venv +dist +build +**/*.egg-info +.eggs +__pycache__ +.pytest_cache +.ruff_cache diff --git a/localstack-typedb/Makefile b/extensions/localstack-typedb/Makefile similarity index 100% rename from localstack-typedb/Makefile rename to extensions/localstack-typedb/Makefile diff --git a/localstack-typedb/README.md b/extensions/localstack-typedb/README.md similarity index 100% rename from localstack-typedb/README.md rename to extensions/localstack-typedb/README.md diff --git a/localstack-typedb/localstack_typedb/__init__.py b/extensions/localstack-typedb/localstack_typedb/__init__.py similarity index 100% rename from localstack-typedb/localstack_typedb/__init__.py rename to extensions/localstack-typedb/localstack_typedb/__init__.py diff --git a/localstack-typedb/localstack_typedb/extension.py b/extensions/localstack-typedb/localstack_typedb/extension.py similarity index 100% rename from localstack-typedb/localstack_typedb/extension.py rename to extensions/localstack-typedb/localstack_typedb/extension.py diff --git a/localstack-typedb/localstack_typedb/utils/__init__.py b/extensions/localstack-typedb/localstack_typedb/utils/__init__.py similarity index 100% rename from localstack-typedb/localstack_typedb/utils/__init__.py rename to extensions/localstack-typedb/localstack_typedb/utils/__init__.py diff --git a/localstack-typedb/localstack_typedb/utils/docker.py b/extensions/localstack-typedb/localstack_typedb/utils/docker.py similarity index 100% rename from localstack-typedb/localstack_typedb/utils/docker.py rename to extensions/localstack-typedb/localstack_typedb/utils/docker.py diff --git a/localstack-typedb/localstack_typedb/utils/h2_proxy.py b/extensions/localstack-typedb/localstack_typedb/utils/h2_proxy.py similarity index 100% rename from localstack-typedb/localstack_typedb/utils/h2_proxy.py rename to extensions/localstack-typedb/localstack_typedb/utils/h2_proxy.py diff --git a/localstack-typedb/pyproject.toml b/extensions/localstack-typedb/pyproject.toml similarity index 100% rename from localstack-typedb/pyproject.toml rename to extensions/localstack-typedb/pyproject.toml diff --git a/localstack-typedb/tests/test_extension.py b/extensions/localstack-typedb/tests/test_extension.py similarity index 100% rename from localstack-typedb/tests/test_extension.py rename to extensions/localstack-typedb/tests/test_extension.py diff --git a/localstack-wiremock/.gitignore b/extensions/localstack-wiremock/.gitignore similarity index 65% rename from localstack-wiremock/.gitignore rename to extensions/localstack-wiremock/.gitignore index 2f3380d..af3e575 100644 --- a/localstack-wiremock/.gitignore +++ b/extensions/localstack-wiremock/.gitignore @@ -6,3 +6,7 @@ build .terraform* terraform.tfstate* *.zip + +__pycache__ +.pytest_cache +.ruff_cache diff --git a/localstack-wiremock/Makefile b/extensions/localstack-wiremock/Makefile similarity index 100% rename from localstack-wiremock/Makefile rename to extensions/localstack-wiremock/Makefile diff --git a/localstack-wiremock/README.md b/extensions/localstack-wiremock/README.md similarity index 100% rename from localstack-wiremock/README.md rename to extensions/localstack-wiremock/README.md diff --git a/localstack-wiremock/bin/create-stubs.sh b/extensions/localstack-wiremock/bin/create-stubs.sh similarity index 100% rename from localstack-wiremock/bin/create-stubs.sh rename to extensions/localstack-wiremock/bin/create-stubs.sh diff --git a/localstack-wiremock/localstack_wiremock/__init__.py b/extensions/localstack-wiremock/localstack_wiremock/__init__.py similarity index 100% rename from localstack-wiremock/localstack_wiremock/__init__.py rename to extensions/localstack-wiremock/localstack_wiremock/__init__.py diff --git a/localstack-wiremock/localstack_wiremock/extension.py b/extensions/localstack-wiremock/localstack_wiremock/extension.py similarity index 100% rename from localstack-wiremock/localstack_wiremock/extension.py rename to extensions/localstack-wiremock/localstack_wiremock/extension.py diff --git a/localstack-wiremock/localstack_wiremock/utils/__init__.py b/extensions/localstack-wiremock/localstack_wiremock/utils/__init__.py similarity index 100% rename from localstack-wiremock/localstack_wiremock/utils/__init__.py rename to extensions/localstack-wiremock/localstack_wiremock/utils/__init__.py diff --git a/localstack-wiremock/localstack_wiremock/utils/docker.py b/extensions/localstack-wiremock/localstack_wiremock/utils/docker.py similarity index 100% rename from localstack-wiremock/localstack_wiremock/utils/docker.py rename to extensions/localstack-wiremock/localstack_wiremock/utils/docker.py diff --git a/localstack-wiremock/pyproject.toml b/extensions/localstack-wiremock/pyproject.toml similarity index 100% rename from localstack-wiremock/pyproject.toml rename to extensions/localstack-wiremock/pyproject.toml diff --git a/localstack-wiremock/sample-app/main.tf b/extensions/localstack-wiremock/sample-app/main.tf similarity index 100% rename from localstack-wiremock/sample-app/main.tf rename to extensions/localstack-wiremock/sample-app/main.tf diff --git a/localstack-wiremock/sample-app/src/handler.py b/extensions/localstack-wiremock/sample-app/src/handler.py similarity index 100% rename from localstack-wiremock/sample-app/src/handler.py rename to extensions/localstack-wiremock/sample-app/src/handler.py diff --git a/localstack-wiremock/sample-app/src/requirements.txt b/extensions/localstack-wiremock/sample-app/src/requirements.txt similarity index 100% rename from localstack-wiremock/sample-app/src/requirements.txt rename to extensions/localstack-wiremock/sample-app/src/requirements.txt diff --git a/localstack-typedb/.gitignore b/localstack-typedb/.gitignore deleted file mode 100644 index 77be714..0000000 --- a/localstack-typedb/.gitignore +++ /dev/null @@ -1,5 +0,0 @@ -.venv -dist -build -**/*.egg-info -.eggs \ No newline at end of file From 5aaa834338442bf19cf5d67d7f4c68f81ae57b0c Mon Sep 17 00:00:00 2001 From: Waldemar Hummer Date: Mon, 15 Jun 2026 00:40:28 -0400 Subject: [PATCH 3/3] fix XSS in delete button, guard missing integration, clean up smoke test items - frontend: use JSON.stringify instead of esc() for deleteProduct args so names with single quotes don't break the onclick attribute - replicate.py: guard against IndexError when existing API has no integrations - test.py: DELETE the smoke test item after verifying it, so repeated runs don't accumulate stale items in the table Co-Authored-By: Claude Sonnet 4.6 --- samples/aws-replicator-proxy/frontend/index.html | 2 +- samples/aws-replicator-proxy/scripts/replicate.py | 7 +++++-- samples/aws-replicator-proxy/scripts/test.py | 9 +++++++-- 3 files changed, 13 insertions(+), 5 deletions(-) diff --git a/samples/aws-replicator-proxy/frontend/index.html b/samples/aws-replicator-proxy/frontend/index.html index 6a3057c..5e4c837 100644 --- a/samples/aws-replicator-proxy/frontend/index.html +++ b/samples/aws-replicator-proxy/frontend/index.html @@ -158,7 +158,7 @@

${esc(p.name)}

${p.inStock ? "In Stock" : "Out of Stock"}
- +
`).join(""); diff --git a/samples/aws-replicator-proxy/scripts/replicate.py b/samples/aws-replicator-proxy/scripts/replicate.py index 2ca8248..143f6c6 100644 --- a/samples/aws-replicator-proxy/scripts/replicate.py +++ b/samples/aws-replicator-proxy/scripts/replicate.py @@ -166,9 +166,12 @@ def create_api_gateway() -> str: existing = [a for a in apigw.get_apis().get("Items", []) if a["Name"] == APP_NAME] if existing: - api_id = existing[0]["ApiId"] + api_id = existing[0]["ApiId"] print(f" API '{APP_NAME}' already exists ({api_id})") - integ_id = apigw.get_integrations(ApiId=api_id)["Items"][0]["IntegrationId"] + integ_items = apigw.get_integrations(ApiId=api_id).get("Items", []) + if not integ_items: + raise RuntimeError(f"API '{api_id}' exists but has no integrations — delete it and re-run") + integ_id = integ_items[0]["IntegrationId"] else: api = apigw.create_api(Name=APP_NAME, ProtocolType="HTTP") api_id = api["ApiId"] diff --git a/samples/aws-replicator-proxy/scripts/test.py b/samples/aws-replicator-proxy/scripts/test.py index 8776119..fab9e11 100644 --- a/samples/aws-replicator-proxy/scripts/test.py +++ b/samples/aws-replicator-proxy/scripts/test.py @@ -56,8 +56,13 @@ def main(api_url: str) -> None: print(f"FAIL: test item '{test_id}' missing. Got IDs: {ids}", file=sys.stderr) sys.exit(1) - print(f" OK — {len(after)} product(s), test item confirmed.\n") - print("All checks passed.") + print(f" OK — {len(after)} product(s), test item confirmed.") + + print(f"\n--- DELETE /products/{test_id} (cleanup) ---") + _call("DELETE", f"{base}/products/{test_id}") + print(f" Removed smoke test item.") + + print("\nAll checks passed.") if __name__ == "__main__":