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 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..5e4c837 --- /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..143f6c6 --- /dev/null +++ b/samples/aws-replicator-proxy/scripts/replicate.py @@ -0,0 +1,248 @@ +#!/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_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"] + + 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..fab9e11 --- /dev/null +++ b/samples/aws-replicator-proxy/scripts/test.py @@ -0,0 +1,72 @@ +#!/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.") + + 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__": + 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)