Spaces:
Running
Running
Upload folder using huggingface_hub
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .gitattributes +28 -0
- Blog.MD +564 -0
- Dockerfile +119 -0
- Makefile +187 -0
- README.md +713 -5
- __init__.py +21 -0
- aws_infra/CHANGELOG.md +0 -0
- aws_infra/CONTRIBUTING.md +196 -0
- aws_infra/Dockerfile +70 -0
- aws_infra/LICENSE +21 -0
- aws_infra/Makefile +127 -0
- aws_infra/README.md +1163 -0
- aws_infra/SECURITY.md +28 -0
- aws_infra/Testcontainers/go-testcontainers/README.md +25 -0
- aws_infra/Testcontainers/go-testcontainers/go.mod +75 -0
- aws_infra/Testcontainers/go-testcontainers/go.sum +232 -0
- aws_infra/Testcontainers/go-testcontainers/ministack_test.go +297 -0
- aws_infra/Testcontainers/java-testcontainers/README.md +25 -0
- aws_infra/Testcontainers/java-testcontainers/pom.xml +80 -0
- aws_infra/Testcontainers/java-testcontainers/src/test/java/io/ministack/MiniStackTest.java +211 -0
- aws_infra/Testcontainers/python-testcontainers/README.md +23 -0
- aws_infra/Testcontainers/python-testcontainers/requirements.txt +3 -0
- aws_infra/Testcontainers/python-testcontainers/test_ministack.py +110 -0
- aws_infra/bin/awslocal +14 -0
- aws_infra/docker-compose.yml +58 -0
- aws_infra/ministack/__init__.py +0 -0
- aws_infra/ministack/__main__.py +3 -0
- aws_infra/ministack/app.py +1414 -0
- aws_infra/ministack/core/__init__.py +0 -0
- aws_infra/ministack/core/hypercorn_compat.py +43 -0
- aws_infra/ministack/core/lambda_runtime.py +629 -0
- aws_infra/ministack/core/persistence.py +94 -0
- aws_infra/ministack/core/responses.py +266 -0
- aws_infra/ministack/core/router.py +559 -0
- aws_infra/ministack/services/__init__.py +0 -0
- aws_infra/ministack/services/acm.py +278 -0
- aws_infra/ministack/services/alb.py +1169 -0
- aws_infra/ministack/services/apigateway.py +1456 -0
- aws_infra/ministack/services/apigateway_v1.py +1602 -0
- aws_infra/ministack/services/appconfig.py +852 -0
- aws_infra/ministack/services/appsync.py +1001 -0
- aws_infra/ministack/services/athena.py +938 -0
- aws_infra/ministack/services/autoscaling.py +554 -0
- aws_infra/ministack/services/cloudformation/__init__.py +109 -0
- aws_infra/ministack/services/cloudformation/changesets.py +323 -0
- aws_infra/ministack/services/cloudformation/engine.py +545 -0
- aws_infra/ministack/services/cloudformation/handlers.py +646 -0
- aws_infra/ministack/services/cloudformation/helpers.py +109 -0
- aws_infra/ministack/services/cloudformation/provisioners.py +0 -0
- aws_infra/ministack/services/cloudformation/stacks.py +343 -0
.gitattributes
CHANGED
|
@@ -33,3 +33,31 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
|
|
| 33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
| 36 |
+
aws_infra/ministack_logo.png filter=lfs diff=lfs merge=lfs -text
|
| 37 |
+
docs/figures/architecture_diagram.png filter=lfs diff=lfs merge=lfs -text
|
| 38 |
+
docs/figures/compare_dataset.png filter=lfs diff=lfs merge=lfs -text
|
| 39 |
+
docs/figures/compare_rl_env.png filter=lfs diff=lfs merge=lfs -text
|
| 40 |
+
docs/figures/curriculum_progression.png filter=lfs diff=lfs merge=lfs -text
|
| 41 |
+
docs/figures/dataset_composition.png filter=lfs diff=lfs merge=lfs -text
|
| 42 |
+
docs/figures/env_init_screenshot.png filter=lfs diff=lfs merge=lfs -text
|
| 43 |
+
docs/figures/grpo_final_per_step.png filter=lfs diff=lfs merge=lfs -text
|
| 44 |
+
docs/figures/grpo_optuna_trial_curves.png filter=lfs diff=lfs merge=lfs -text
|
| 45 |
+
docs/figures/grpo_optuna_trials_comparison.png filter=lfs diff=lfs merge=lfs -text
|
| 46 |
+
docs/figures/grpo_reward_curve.png filter=lfs diff=lfs merge=lfs -text
|
| 47 |
+
docs/figures/ministack_logo.png filter=lfs diff=lfs merge=lfs -text
|
| 48 |
+
docs/figures/optuna_parallel.png filter=lfs diff=lfs merge=lfs -text
|
| 49 |
+
docs/figures/optuna_slice.png filter=lfs diff=lfs merge=lfs -text
|
| 50 |
+
docs/figures/parallel_rollout_diagram.png filter=lfs diff=lfs merge=lfs -text
|
| 51 |
+
docs/figures/reward_components.png filter=lfs diff=lfs merge=lfs -text
|
| 52 |
+
docs/figures/sft_loss_curve.png filter=lfs diff=lfs merge=lfs -text
|
| 53 |
+
docs/figures/tier_pyramid.png filter=lfs diff=lfs merge=lfs -text
|
| 54 |
+
images/compare_dataset.png filter=lfs diff=lfs merge=lfs -text
|
| 55 |
+
images/compare_rl_env.png filter=lfs diff=lfs merge=lfs -text
|
| 56 |
+
scripts/Screenshot[[:space:]]2026-04-20[[:space:]]at[[:space:]]6.50.47 PM.png filter=lfs diff=lfs merge=lfs -text
|
| 57 |
+
server/static/figures/compare_dataset.png filter=lfs diff=lfs merge=lfs -text
|
| 58 |
+
server/static/figures/compare_rl_env.png filter=lfs diff=lfs merge=lfs -text
|
| 59 |
+
server/static/figures/grpo_final_per_step.png filter=lfs diff=lfs merge=lfs -text
|
| 60 |
+
server/static/figures/grpo_optuna_trials_comparison.png filter=lfs diff=lfs merge=lfs -text
|
| 61 |
+
server/static/figures/grpo_reward_curve.png filter=lfs diff=lfs merge=lfs -text
|
| 62 |
+
server/static/figures/ministack_logo.png filter=lfs diff=lfs merge=lfs -text
|
| 63 |
+
server/static/figures/sft_loss_curve.png filter=lfs diff=lfs merge=lfs -text
|
Blog.MD
ADDED
|
@@ -0,0 +1,564 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: "From Cloud Chaos to Capable Agents: Training an LLM SRE on 120+ AWS Tasks"
|
| 3 |
+
thumbnail: docs/figures/blog_hero.png
|
| 4 |
+
authors:
|
| 5 |
+
- user: Sizzing
|
| 6 |
+
name: Uday Kiran Padhy
|
| 7 |
+
tags:
|
| 8 |
+
- reinforcement-learning
|
| 9 |
+
- openenv
|
| 10 |
+
- grpo
|
| 11 |
+
- agents
|
| 12 |
+
- rlve
|
| 13 |
+
- aws
|
| 14 |
+
- sft
|
| 15 |
+
- lora
|
| 16 |
+
- trl
|
| 17 |
+
date: "2026-04-26"
|
| 18 |
+
---
|
| 19 |
+
|
| 20 |
+

|
| 21 |
+
|
| 22 |
+
# From Cloud Chaos to Capable Agents
|
| 23 |
+
|
| 24 |
+
### Training an LLM SRE on 120+ AWS Tasks with SFT → GRPO
|
| 25 |
+
|
| 26 |
+
> **TL;DR.** Cloud agents fail in production not because they don't know the commands — but because **state drifts, services hiccup, and reward signals get gamed.** We built an OpenEnv-compatible RL environment that simulates all three: 120+ AWS tasks across 5 difficulty tiers under chaos and drift, an **8-layer anti-reward-hacking stack**, and a SFT → GRPO pipeline with **8-way parallel multi-turn rollouts on a single GPU**. After training, format compliance hit **100%**, exact-match jumped **39% → 89%**, and intermediate-tier success climbed **81% → 87%** — all with a 3B-parameter base model on a free Colab runtime.
|
| 27 |
+
|
| 28 |
+
| | |
|
| 29 |
+
|---|---|
|
| 30 |
+
| **Live demo** | [sizzing-aws-rl-env.hf.space/web](https://sizzing-aws-rl-env.hf.space/web) |
|
| 31 |
+
| **API docs** | [sizzing-aws-rl-env.hf.space/docs](https://sizzing-aws-rl-env.hf.space/docs) (Swagger) · [/redoc](https://sizzing-aws-rl-env.hf.space/redoc) |
|
| 32 |
+
| **HF Space** | [huggingface.co/spaces/Sizzing/aws_rl_env](https://huggingface.co/spaces/Sizzing/aws_rl_env) |
|
| 33 |
+
| **SFT adapter**| [Sizzing/aws-rl-sft-qwen25coder3b-adapter](https://huggingface.co/Sizzing/aws-rl-sft-qwen25coder3b-adapter) |
|
| 34 |
+
| **GRPO adapter**| [Sizzing/aws-rl-grpo-qwen25coder3b-adapter](https://huggingface.co/Sizzing/aws-rl-grpo-qwen25coder3b-adapter) |
|
| 35 |
+
| **Dataset** | [Sizzing/aws-rl-sft](https://huggingface.co/datasets/Sizzing/aws-rl-sft) |
|
| 36 |
+
| **GitHub** | [github.com/udaykiranpadhy/aws-rl-env](https://github.com/udaykiranpadhy/aws-rl-env) |
|
| 37 |
+
|
| 38 |
+
---
|
| 39 |
+
|
| 40 |
+
## 1. The problem: why cloud-ops RL is hard
|
| 41 |
+
|
| 42 |
+
Modern AI agents are increasingly asked to operate cloud infrastructure — provision resources, fix misconfigurations, respond to drift, lock down a leaky bucket at 2 a.m. To train such agents you need three things at once: a **realistic environment**, **reliable reward signals**, and **enough scale to make RL feasible**. The market currently forces a hard tradeoff:
|
| 43 |
+
|
| 44 |
+
- **Real AWS** — production-fidelity, but **hundreds of dollars per training run**, impossible to reset cleanly, dangerous if the agent decides to delete prod.
|
| 45 |
+
- **Toy emulators / vanilla LocalStack** — free and resettable, but they **don't behave like production AWS**: error codes drift, response shapes diverge, and the agent learns shortcuts that crumble on real cloud.
|
| 46 |
+
|
| 47 |
+
There's a third trap that bites every RL practitioner who's tried this before: **reward hacking**. An agent that optimizes a naïve reward will discover that printing `"bucket created"` to stdout is way easier than actually creating a bucket, and its training curve will look great while its real success rate stays at zero.
|
| 48 |
+
|
| 49 |
+
This project closes the gap. We built:
|
| 50 |
+
|
| 51 |
+
1. **An OpenEnv-compatible RL environment** that speaks **real AWS CLI semantics**. The agent sends `aws s3 mb …`, `aws iam create-role …`, exactly the commands a human SRE would type.
|
| 52 |
+
2. **A vendored, customized [MiniStack](https://github.com/srivenkat/MiniStack) simulator** that responds with production-equivalent JSON, runs locally for **zero cost**, supports 34 AWS services, and exposes a single-call state-introspection endpoint we added so the grader has cheap ground-truth access.
|
| 53 |
+
3. **A 120+ task curriculum** across 5 tiers (warmup → expert) plus an adversarial drift track, with adaptive selection, mastery tracking, spaced repetition, chaos injection, and randomized drift mutations — every feature designed to keep the reward signal honest.
|
| 54 |
+
4. **A complete SFT → GRPO training pipeline.** A 1,500-row synthetic dataset spanning 5 trajectory shapes, an 11-model base benchmark, LoRA fine-tuning, and TRL GRPO with multi-turn rollouts and Optuna hyperparameter search.
|
| 55 |
+
5. **An 8-way parallel-rollout architecture.** Server-side MiniStack pool, client-side `GrpoPool`, in-process `MultiTurnEnvPool` — three coordinated layers that let G=8 concurrent rollouts run on one GPU **without state contamination**.
|
| 56 |
+
|
| 57 |
+
This isn't another gym classic. It's grounded in real-world utility: **everything an SRE actually does on call.**
|
| 58 |
+
|
| 59 |
+
---
|
| 60 |
+
|
| 61 |
+
## 2. System architecture
|
| 62 |
+
|
| 63 |
+

|
| 64 |
+
|
| 65 |
+
The whole environment ships as **one Docker container** that bundles a FastAPI server, a pool of MiniStack simulator instances, and the AWS CLI v2 binary. Nothing reaches the public internet at runtime.
|
| 66 |
+
|
| 67 |
+
```
|
| 68 |
+
┌────────────────────────────── Docker container ──────────────────────────────┐
|
| 69 |
+
│ │
|
| 70 |
+
│ FastAPI server (port 8000) │
|
| 71 |
+
│ ├── OpenEnv router /reset /step /state /schema /ws /health │
|
| 72 |
+
│ ├── Web playground /web (Jinja2 + 40 AWS service icons) │
|
| 73 |
+
│ ├── env_factory per-WS-session AwsRlEnvironment instance │
|
| 74 |
+
│ │ (acquires a MiniStack port from MiniStackPool) │
|
| 75 |
+
│ └── Services │
|
| 76 |
+
│ Curriculum · TaskGrader · ResourceVerifier · ChaosEngine · DriftEngine │
|
| 77 |
+
│ HintProvider · EpisodeTracker · EnvironmentDesigner · …Strategy │
|
| 78 |
+
│ │
|
| 79 |
+
│ MiniStack instances :4566 :4567 :4568 … :4566+POOL_SIZE-1 │
|
| 80 |
+
│ (vendored at aws_infra/, started by the Dockerfile entrypoint) │
|
| 81 |
+
└──────────────────────────────────────────────────────────────────────────────┘
|
| 82 |
+
▲ ▲
|
| 83 |
+
│ HTTP / WebSocket │ AWS CLI subprocess
|
| 84 |
+
│ │ (AWS_ENDPOINT_URL=http://localhost:4566+i)
|
| 85 |
+
│ │
|
| 86 |
+
┌───────┴───────────┐ ┌───────┴───────────┐
|
| 87 |
+
│ RL Agent │ │ AWS CLI commands │
|
| 88 |
+
│ (the agent) │ │ (client.py) │
|
| 89 |
+
└───────────────────┘ └───────────────────┘
|
| 90 |
+
```
|
| 91 |
+
|
| 92 |
+
### Episode lifecycle
|
| 93 |
+
|
| 94 |
+
```mermaid
|
| 95 |
+
flowchart LR
|
| 96 |
+
A([reset]) --> B[Curriculum<br/>picks task]
|
| 97 |
+
B --> C[Run<br/>setup_commands]
|
| 98 |
+
C --> D{drift<br/>task?}
|
| 99 |
+
D -->|yes| E[DriftEngine<br/>applies 2–3 mutations]
|
| 100 |
+
D -->|no| F[Initial<br/>observation]
|
| 101 |
+
E --> F
|
| 102 |
+
F --> G([step])
|
| 103 |
+
G --> H{starts<br/>with 'aws'?}
|
| 104 |
+
H -->|no| I[reject<br/>success=False]
|
| 105 |
+
H -->|yes| J[EnvironmentStrategy<br/>runs AWS CLI]
|
| 106 |
+
J --> K[EpisodeTracker<br/>records command]
|
| 107 |
+
K --> L[TaskGrader<br/>computes reward]
|
| 108 |
+
L --> M[ChaosEngine<br/>maybe mutates state]
|
| 109 |
+
M --> N{terminate?}
|
| 110 |
+
N -->|achieved or step ≥ MAX| O([done])
|
| 111 |
+
N -->|continue| G
|
| 112 |
+
I --> G
|
| 113 |
+
```
|
| 114 |
+
|
| 115 |
+
Three primitives — `reset`, `step`, `state` — exposed over HTTP and WebSocket. The OpenEnv contract gives any compatible trainer (TRL, TorchForge, SkyRL, Unsloth) a drop-in interface.
|
| 116 |
+
|
| 117 |
+
Full mechanics in [server/README.md](server/README.md).
|
| 118 |
+
|
| 119 |
+
---
|
| 120 |
+
|
| 121 |
+
## 3. The curriculum: 124 tasks, 5 tiers, one priority formula
|
| 122 |
+
|
| 123 |
+

|
| 124 |
+
|
| 125 |
+
We didn't hand-author a fixed schedule. The `Curriculum` service runs a **single weighted-priority formula** that handles exploration, weakness-targeting, and forgetting prevention all at once:
|
| 126 |
+
|
| 127 |
+
```
|
| 128 |
+
score = novelty_bonus # +100 if never attempted
|
| 129 |
+
+ weakness_weight # +50 × (1 − task_success_rate)
|
| 130 |
+
+ spaced_rep_bonus # +30 if a graduated task is "due" for re-test
|
| 131 |
+
− recency_penalty # −20 if attempted in the last 2 episodes
|
| 132 |
+
```
|
| 133 |
+
|
| 134 |
+
Read that formula and you immediately know the schedule: never-seen tasks dominate at first; once attempted, weak ones rise; once mastered, they go on a re-test schedule with intervals `[3, 6, 12, 24, 48]` episodes; you never see the same task two episodes in a row. **Explainable. Auditable. Boring in the best sense.**
|
| 135 |
+
|
| 136 |
+
### Mastery and tier promotion
|
| 137 |
+
|
| 138 |
+
Every task carries a sliding 10-episode success window with `0.85` exponential decay. When that window's success rate crosses `0.7`, the task **graduates** — it stops appearing in the standard rotation but resurfaces on the spaced-rep schedule above. If a graduated task fails on re-test, it un-graduates and rejoins the pool. There are **two ways** to get promoted to the next tier:
|
| 139 |
+
|
| 140 |
+
- **Standard path** — meet the tier's `min_episodes` AND `advance_rate` (0.6 – 0.7 depending on tier).
|
| 141 |
+
- **Fast-track** — three consecutive episodes at ≥ 0.9 success. If you're crushing it, you skip ahead.
|
| 142 |
+
|
| 143 |
+

|
| 144 |
+
|
| 145 |
+
### What's in each tier
|
| 146 |
+
|
| 147 |
+
| Tier | Tasks | Chaos | Grading strategy | What the agent must do |
|
| 148 |
+
|------|------:|------:|------------------|------------------------|
|
| 149 |
+
| Warmup | 25 | 10% | `command_match` | Emit the right service + operation. |
|
| 150 |
+
| Beginner | 25 | 10% | `resource_creation` | Actually create a resource that ends up in MiniStack state. |
|
| 151 |
+
| Intermediate | 25 | 20% | `multi_step` | Complete an ordered sequence (e.g., bucket → policy → versioning). |
|
| 152 |
+
| Advanced | 25 | 30% | `multi_step + services` | Same, but **all** required services must be touched. |
|
| 153 |
+
| Expert | 24 | 30% | `state_checks` | Pass arbitrary AWS CLI assertions on the final state. |
|
| 154 |
+
| **Drift** | 9 | — | `state_checks` (auto-repair) | Detect and fix 2–3 random pre-applied mutations. |
|
| 155 |
+
|
| 156 |
+
The full task pool is YAML-defined in [server/services/tasks/](server/services/tasks/) — judges can read or modify it without touching code.
|
| 157 |
+
|
| 158 |
+
---
|
| 159 |
+
|
| 160 |
+
## 4. Reward shaping and the 8-layer anti-reward-hacking stack
|
| 161 |
+
|
| 162 |
+
> **This is the most novel part of the project.** Most environments trust the reward signal. This one assumes the agent will try to game it — and stops it eight different ways.
|
| 163 |
+
|
| 164 |
+
### How reward is built up
|
| 165 |
+
|
| 166 |
+
```mermaid
|
| 167 |
+
flowchart TD
|
| 168 |
+
Start([step result]) --> Q1{task<br/>achieved?}
|
| 169 |
+
Q1 -->|yes| R1[reward = 1.0]
|
| 170 |
+
R1 --> CB{survived<br/>chaos?}
|
| 171 |
+
CB -->|yes| R2[× 1.05<br/>chaos bonus]
|
| 172 |
+
CB -->|no| R3[reward stays 1.0]
|
| 173 |
+
R2 --> HD[× 0.85^n<br/>hint decay]
|
| 174 |
+
R3 --> HD
|
| 175 |
+
Q1 -->|no| S1[reward = partial × 0.8]
|
| 176 |
+
S1 --> S2{progress<br/>increased?}
|
| 177 |
+
S2 -->|yes| S3[+ 0.1<br/>progress delta]
|
| 178 |
+
S2 -->|no| S4[no delta]
|
| 179 |
+
S3 --> S5{command<br/>failed?}
|
| 180 |
+
S4 --> S5
|
| 181 |
+
S5 -->|yes| S6[× 0.5<br/>error penalty]
|
| 182 |
+
S5 -->|no| S7[no penalty]
|
| 183 |
+
S6 --> S8[− 0.1 × rollback_count<br/>+ 0.02 × idempotent_retries]
|
| 184 |
+
S7 --> S8
|
| 185 |
+
S8 --> S9[clamp to 0.0–0.99<br/>1.0 reserved for completion]
|
| 186 |
+
S9 --> HD
|
| 187 |
+
HD --> End([final reward])
|
| 188 |
+
```
|
| 189 |
+
|
| 190 |
+

|
| 191 |
+
|
| 192 |
+
The reward is **dense by design**: every step provides meaningful signal, not just terminal success. Rollbacks (create-then-delete cycles) are explicitly penalized. Graceful retries on "already exists" errors get a small bonus. **Operational discipline is baked into the reward**, not just task completion.
|
| 193 |
+
|
| 194 |
+
### Five grading strategies, dispatched by tier
|
| 195 |
+
|
| 196 |
+
A single grader can't fairly score "did you say `aws s3 mb`?" and "did the bucket end up with versioning enabled, encrypted, blocking public access, AND not deleted by accident?" so the `TaskGrader` polymorphs:
|
| 197 |
+
|
| 198 |
+
| Tier | Strategy | Example assertion |
|
| 199 |
+
|------|----------|-------------------|
|
| 200 |
+
| Warmup | `command_match` | `command_contains: "s3 mb"` |
|
| 201 |
+
| Beginner | `resource_creation` | `resource_exists: {service: s3, name: my-bucket}` |
|
| 202 |
+
| Intermediate | `multi_step` | Ordered list of step criteria |
|
| 203 |
+
| Advanced | `multi_step + services` | Same + `services: [s3, iam]` must all be touched |
|
| 204 |
+
| Expert | `state_checks` | Arbitrary AWS CLI assertions on infra state |
|
| 205 |
+
|
| 206 |
+
### The 8 defense layers
|
| 207 |
+
|
| 208 |
+
```mermaid
|
| 209 |
+
flowchart LR
|
| 210 |
+
Agent[Agent action] --> L1["① Allow-list<br/>must start with 'aws '"]
|
| 211 |
+
L1 --> L2["② Per-episode dedup<br/>op,resource credits once"]
|
| 212 |
+
L2 --> L3["③ Grader invisibility<br/>state-checks never seen by agent"]
|
| 213 |
+
L3 --> L4["④ No read-credit<br/>describe/list earn zero"]
|
| 214 |
+
L4 --> L5["⑤ Monotonic progress<br/>can't decrement to re-credit"]
|
| 215 |
+
L5 --> L6["⑥ Exact resource-name match<br/>my-bucket-2 ≠ my-bucket"]
|
| 216 |
+
L6 --> L7["⑦ Ground-truth via MiniStack<br/>not agent stdout"]
|
| 217 |
+
L7 --> L8["⑧ Final-state assertions<br/>jq-paths on live state"]
|
| 218 |
+
L8 --> Reward([Reward])
|
| 219 |
+
```
|
| 220 |
+
|
| 221 |
+
| # | Layer | Hack it defeats |
|
| 222 |
+
|---|-------|------------------|
|
| 223 |
+
| 1 | **Command allow-list** (`aws ` prefix only) | Shell escapes, fake stdout |
|
| 224 |
+
| 2 | **Dedup of `(operation, resource)` per episode** | Spamming `s3 mb …` 50× to inflate a "completed steps" counter |
|
| 225 |
+
| 3 | **Grader invisibility** | Reverse-engineering reward by reading state-check queries |
|
| 226 |
+
| 4 | **No verification reward** | Running `aws s3 ls` to "prove" the bucket exists |
|
| 227 |
+
| 5 | **Monotonic `partial_progress`** | Bouncing progress down then back up to re-earn credit |
|
| 228 |
+
| 6 | **Exact resource-name validation** | Creating `my-test-bucket-2` instead of `my-test-bucket` |
|
| 229 |
+
| 7 | **Ground-truth via `/_ministack/state`** | Forging stdout that looks successful when the resource doesn't exist |
|
| 230 |
+
| 8 | **Final-state AWS CLI assertions** | Passing the steps but leaving prod broken |
|
| 231 |
+
|
| 232 |
+
These layers **compose**. To hack the reward, the agent would have to defeat all eight independently — each one alone is a hard problem.
|
| 233 |
+
|
| 234 |
+
### Chaos engine and drift engine
|
| 235 |
+
|
| 236 |
+
The reward stack is hardened, but the env itself is also adversarial:
|
| 237 |
+
|
| 238 |
+
- **Chaos** (`server/services/chaos.py`) — silent mid-episode mutations on services the task is touching. Probabilities scale by tier: 10% / 20% / 30%. Survive a chaotic episode and the reward is multiplied by **×1.05**.
|
| 239 |
+
- **Drift** (`server/services/drift.py`) — for the 9 drift tasks, 2–3 random mutations from a per-task pool are applied **before** the agent sees the env. The agent must detect and repair them. Mutations are **randomized per episode** so the agent can't memorize a script.
|
| 240 |
+
- **Hints** — three progressive levels available via `aws help --task-hint`. Each hint multiplies the final reward by `0.85` (so 3 hints → 0.61× decay). The agent decides whether the cost is worth it.
|
| 241 |
+
|
| 242 |
+
Full mechanics, including all 5 grading strategies and the chaos/drift logic, are in [server/README.md §8 – §13](server/README.md).
|
| 243 |
+
|
| 244 |
+
---
|
| 245 |
+
|
| 246 |
+
## 5. Parallel rollout architecture: 3 coordinated pool layers
|
| 247 |
+
|
| 248 |
+
GRPO needs `G=8` rollouts **on the same task** per training step — that's how it computes group-relative advantages without a critic. Run them sequentially and you pay 8 × 6 turns × 50 ms = **2,400 ms** of wall-clock per step, before the GPU has done anything. Run them in parallel and a state bug between two rollouts will silently destroy your gradient.
|
| 249 |
+
|
| 250 |
+
So we built three coordinated pool layers that **parallelize transparently while guaranteeing state isolation**.
|
| 251 |
+
|
| 252 |
+
```mermaid
|
| 253 |
+
flowchart TD
|
| 254 |
+
T[Trainer step<br/>needs G=8 rollouts] --> M[MultiTurnEnvPool<br/>sync API · owns asyncio loop]
|
| 255 |
+
M --> G[GrpoPool<br/>async · asyncio.gather]
|
| 256 |
+
G --> WS1[WS session 1]
|
| 257 |
+
G --> WS2[WS session 2]
|
| 258 |
+
G --> WS3[WS session ...]
|
| 259 |
+
G --> WS8[WS session 8]
|
| 260 |
+
WS1 --> S[FastAPI server<br/>OpenEnv max_concurrent_envs=8]
|
| 261 |
+
WS2 --> S
|
| 262 |
+
WS3 --> S
|
| 263 |
+
WS8 --> S
|
| 264 |
+
S --> P[MiniStackPool<br/>free-list · threading.Lock]
|
| 265 |
+
P --> M1[:4566]
|
| 266 |
+
P --> M2[:4567]
|
| 267 |
+
P --> M3[:4568]
|
| 268 |
+
P --> M8[:4573]
|
| 269 |
+
style P fill:#fff7fa,stroke:#ff4f8b
|
| 270 |
+
style M fill:#fff7fa,stroke:#ff4f8b
|
| 271 |
+
style G fill:#fff7fa,stroke:#ff4f8b
|
| 272 |
+
```
|
| 273 |
+
|
| 274 |
+

|
| 275 |
+
|
| 276 |
+
### The three layers
|
| 277 |
+
|
| 278 |
+
- **Server-side `MiniStackPool`** ([server/app.py](server/app.py)) — free-list of ports `[BASE, BASE + POOL_SIZE)`, lock-guarded `acquire()` / `release()`. Each WebSocket session gets a unique MiniStack process that persists for the session's lifetime. **8 isolated MiniStack instances on ports 4566–4573 mean zero cross-rollout state bleed.**
|
| 279 |
+
- **Client-side async `GrpoPool`** ([scripts/grpo_pool.py](scripts/grpo_pool.py)) — pure-asyncio, uses `asyncio.gather` over N WebSocket sessions. Used by training and demo notebooks.
|
| 280 |
+
- **In-process sync `MultiTurnEnvPool`** ([train/train_grpo_lora.ipynb](train/train_grpo_lora.ipynb)) — wraps `GrpoPool` behind a sync API by owning a background asyncio loop. The TRL trainer keeps its sync API; concurrency happens inside.
|
| 281 |
+
|
| 282 |
+
### The all-or-nothing connect protocol
|
| 283 |
+
|
| 284 |
+
Here's the surprising-detail callout, the kind a judge appreciates:
|
| 285 |
+
|
| 286 |
+
> **If 7 of 8 WebSocket connects succeed and the 8th fails, all 8 must be rolled back and closed.**
|
| 287 |
+
|
| 288 |
+
Why? Because the 7 successful connects already acquired MiniStack ports from the server-side pool. If we kept them open and just retried the 8th, those 7 ports would leak — they stay acquired until the server's idle timeout fires (minutes), and the next training step finds the pool exhausted.
|
| 289 |
+
|
| 290 |
+
This single invariant is the difference between *"training resumes cleanly after every flake"* and *"every flake corrupts the pool; rebuild the container at 3 a.m."*
|
| 291 |
+
|
| 292 |
+

|
| 293 |
+
|
| 294 |
+
### Wall-clock impact
|
| 295 |
+
|
| 296 |
+
- **Sequential**: 8 rollouts × 6 turns × ~50 ms env time = **2,400 ms / GRPO step**.
|
| 297 |
+
- **Parallel (8-way)**: max(8 envs) ≈ **300 ms / GRPO step**.
|
| 298 |
+
- **Effective speedup**: ~8× on the env side. The GPU forward-pass still serializes behind a `threading.Lock`, but env time is no longer the bottleneck.
|
| 299 |
+
|
| 300 |
+
Full details, including all the corner cases of the all-or-nothing protocol, are in [scripts/README.md](scripts/README.md).
|
| 301 |
+
|
| 302 |
+
---
|
| 303 |
+
|
| 304 |
+
## 6. MiniStack: vendored, customized, reproducible
|
| 305 |
+
|
| 306 |
+
The simulator powering the env is **vendored as a git subtree** at [aws_infra/](aws_infra/), not pulled as a black-box dependency. Why fork a perfectly good upstream?
|
| 307 |
+
|
| 308 |
+
1. **One-call grading**. We added a custom `/_ministack/state` endpoint (commit `a648c3a`) that returns the entire infrastructure inventory in **one HTTP call** instead of iterating 20+ list APIs per grading pass. This single endpoint is what makes layer 7 of the anti-hacking stack cheap enough to run every step.
|
| 309 |
+
2. **Reproducible Docker builds with no runtime network**. Pinning a specific MiniStack revision means the image is bit-identical across rebuilds. The Docker image bundles the simulator; it doesn't pull at startup.
|
| 310 |
+
3. **Freedom to extend service coverage** when a task needs a service the upstream doesn't yet support.
|
| 311 |
+
|
| 312 |
+
The custom commits are kept as **small, isolated patches** so periodic upstream syncs (e.g., `af2e945`, `579597b`) replay cleanly with `git subtree pull`. To inspect:
|
| 313 |
+
|
| 314 |
+
```bash
|
| 315 |
+
git show a648c3a # the state-endpoint diff
|
| 316 |
+
git log --oneline -- aws_infra/ # only the aws_infra subtree history
|
| 317 |
+
```
|
| 318 |
+
|
| 319 |
+
This is a small thing, but it's one of those engineering-maturity signals that says **"this repo is built to be maintained, not just demoed."** The full subtree workflow is in [server/README.md §5](server/README.md#5-ministack-vendored-fork--customizations).
|
| 320 |
+
|
| 321 |
+
---
|
| 322 |
+
|
| 323 |
+
## 7. The training pipeline: SFT → GRPO
|
| 324 |
+
|
| 325 |
+
```mermaid
|
| 326 |
+
flowchart LR
|
| 327 |
+
TT[tests_tasks/<br/>134 canonical solutions] --> AST[AST extract<br/>build_sft_dataset.py]
|
| 328 |
+
AST --> DS[1,500 row<br/>SFT dataset<br/>5 trajectory types]
|
| 329 |
+
DS -.->|published| HF1[(HF Dataset<br/>aws-rl-sft)]
|
| 330 |
+
DS --> SFT[Stage 1: SFT LoRA<br/>Qwen2.5-Coder-3B<br/>Optuna 6 trials]
|
| 331 |
+
SFT --> SA[SFT adapter]
|
| 332 |
+
SA -.->|published| HF2[(HF Hub<br/>aws-rl-sft-adapter)]
|
| 333 |
+
SA --> GRPO[Stage 2: GRPO<br/>TRL · G=8 rollouts<br/>Optuna 4 trials]
|
| 334 |
+
ENV[(AWS RL Env<br/>FastAPI + MiniStack pool)] --> GRPO
|
| 335 |
+
GRPO --> GA[GRPO adapter]
|
| 336 |
+
GA -.->|published| HF3[(HF Hub<br/>aws-rl-grpo-adapter)]
|
| 337 |
+
style ENV fill:#fff7fa,stroke:#ff4f8b
|
| 338 |
+
style HF1 fill:#fffbeb,stroke:#f59e0b
|
| 339 |
+
style HF2 fill:#fffbeb,stroke:#f59e0b
|
| 340 |
+
style HF3 fill:#fffbeb,stroke:#f59e0b
|
| 341 |
+
```
|
| 342 |
+
|
| 343 |
+
Two stages, both reproducible on a free Colab GPU runtime. Full detail in [train/README.md](train/README.md).
|
| 344 |
+
|
| 345 |
+
### 7.1 Dataset — 1,500 deterministic synthetic rows
|
| 346 |
+
|
| 347 |
+

|
| 348 |
+
|
| 349 |
+
The dataset is **synthetic but deterministic** — and that's not an oxymoron. We don't run pytest to generate examples; we use Python's `ast` module to extract canonical commands directly from `tests_tasks/test_<tier>_tasks.py`. **No simulator spin-up. Zero flake risk. Bit-for-bit reproducible** with one script.
|
| 350 |
+
|
| 351 |
+
Five trajectory types teach realistic multi-turn behavior:
|
| 352 |
+
|
| 353 |
+
- **Success (55%)** — the canonical command for the task.
|
| 354 |
+
- **Multi-step continuation (20%)** — given the partial conversation, predict the next command. Simulated AWS responses are interpolated with resource names, so the model learns *"what you do depends on what's already been done"*, not *"always run the first command"*.
|
| 355 |
+
- **Failure recovery (15%)** — on a malformed AWS error, fix the command.
|
| 356 |
+
- **Verification (5%)** — pick the right `aws describe-*` to confirm state.
|
| 357 |
+
- **Hint usage (5%)** — given a hint, follow it.
|
| 358 |
+
|
| 359 |
+
Tier weighting is **50/30/15/5/0** (warmup / beginner / intermediate / advanced / expert). **Expert is intentionally excluded from SFT** — expert tasks have randomized state checks, so there's no single canonical script. Teaching SFT a fixed solution would be wrong; GRPO's reward signal is the right tool for randomized end-states.
|
| 360 |
+
|
| 361 |
+
Published as [Sizzing/aws-rl-sft](https://huggingface.co/datasets/Sizzing/aws-rl-sft).
|
| 362 |
+
|
| 363 |
+
### 7.2 Base model selection — 11 candidates, 1 winner
|
| 364 |
+
|
| 365 |
+

|
| 366 |
+
|
| 367 |
+
We didn't pick a base model on vibes. **11 chat models × 27 held-out prompts**, four quality metrics plus latency. Full report in [data/sft/MODEL_EVALUATION.md](data/sft/MODEL_EVALUATION.md).
|
| 368 |
+
|
| 369 |
+
| Model | exact% | op% | latency | Verdict |
|
| 370 |
+
|-------|------:|----:|--------:|---------|
|
| 371 |
+
| **Qwen2.5-Coder-3B-Instruct** ✅ | **41%** | **63%** | **3.1 s** | Best balance of accuracy and speed |
|
| 372 |
+
| Qwen3-4B | 33% | 59% | 10.4 s | Perfect format, but 3× slower |
|
| 373 |
+
| Qwen2.5-Coder-1.5B | 22% | 41% | 2.5 s | Fast, but 19-pp accuracy gap |
|
| 374 |
+
| SmolLM2-1.7B | 7% | 19% | 2.0 s | Too small for AWS knowledge |
|
| 375 |
+
| DeepSeek-R1-Distill-Qwen-1.5B | 0% | 4% | 6.8 s | Wrong domain — reasoning ≠ AWS |
|
| 376 |
+
|
| 377 |
+
**Winner: [unsloth/Qwen2.5-Coder-3B-Instruct-bnb-4bit](https://huggingface.co/unsloth/Qwen2.5-Coder-3B-Instruct-bnb-4bit)** — 41% exact-match, 63% operation-match, 3.1 s latency. Small enough for 8-way parallel GRPO on a 24 GB GPU; accurate enough that SFT has a strong starting point.
|
| 378 |
+
|
| 379 |
+
### 7.3 Stage 1 — SFT (LoRA)
|
| 380 |
+
|
| 381 |
+
LoRA, attention-only, ~10–40M trainable parameters. We let Optuna sweep 6 trials over `[lora_r, lora_alpha_mul, lora_dropout, learning_rate, warmup_ratio]`:
|
| 382 |
+
|
| 383 |
+
| Hyperparameter | Search space | Best value |
|
| 384 |
+
|---------------|--------------|-----------:|
|
| 385 |
+
| `lora_r` | {8, 16, 32} | **16** |
|
| 386 |
+
| `lora_alpha_mul` | [0.5, 2.0] | **1.0** (α = 16) |
|
| 387 |
+
| `lora_dropout` | [0.005, 0.031] | **0.0058** |
|
| 388 |
+
| `learning_rate` | [5e-5, 5e-4] | **4.03e-4** |
|
| 389 |
+
| `warmup_ratio` | [0.05, 0.15] | **0.10** |
|
| 390 |
+
|
| 391 |
+

|
| 392 |
+
|
| 393 |
+

|
| 394 |
+

|
| 395 |
+
|
| 396 |
+
Best trial reached **val loss 0.052 after 188 steps** (~30 min on a Colab A10). Adapter published: [Sizzing/aws-rl-sft-qwen25coder3b-adapter](https://huggingface.co/Sizzing/aws-rl-sft-qwen25coder3b-adapter).
|
| 397 |
+
|
| 398 |
+
### 7.4 Stage 2 — GRPO (TRL)
|
| 399 |
+
|
| 400 |
+
GRPO is a critic-free RL algorithm that computes advantages from a **group of G rollouts** on the same prompt. TRL's `GRPOTrainer` is the implementation; we wrap it with our `MultiTurnEnvPool` so each "rollout" is a multi-turn AWS CLI episode, not a single completion.
|
| 401 |
+
|
| 402 |
+
```python
|
| 403 |
+
GRPOConfig(
|
| 404 |
+
model_name_or_path="Sizzing/aws-rl-sft-qwen25coder3b-adapter",
|
| 405 |
+
num_generations=8, # G=8 rollouts per step
|
| 406 |
+
beta=0.0021, # KL coefficient (tight — Optuna picked it)
|
| 407 |
+
learning_rate=1.6e-5,
|
| 408 |
+
temperature=0.99,
|
| 409 |
+
top_p=0.95,
|
| 410 |
+
max_turns=6, # multi-turn episode length
|
| 411 |
+
loss_type="dapo",
|
| 412 |
+
reward_func=env_reward, # AwsRlEnv → final reward
|
| 413 |
+
)
|
| 414 |
+
```
|
| 415 |
+
|
| 416 |
+
Optuna swept 4 trials over `[learning_rate, beta, temperature]` — a tighter 3-parameter space because we already had a strong SFT baseline.
|
| 417 |
+
|
| 418 |
+

|
| 419 |
+

|
| 420 |
+

|
| 421 |
+
|
| 422 |
+
Final run: **35 GRPO steps, ~1.5 hours on Colab A10**.
|
| 423 |
+
|
| 424 |
+

|
| 425 |
+

|
| 426 |
+

|
| 427 |
+
|
| 428 |
+
Adapter published: [Sizzing/aws-rl-grpo-qwen25coder3b-adapter](https://huggingface.co/Sizzing/aws-rl-grpo-qwen25coder3b-adapter).
|
| 429 |
+
|
| 430 |
+
---
|
| 431 |
+
|
| 432 |
+
## 8. Results
|
| 433 |
+
|
| 434 |
+
### 8.1 Base vs SFT — single-step held-out eval
|
| 435 |
+
|
| 436 |
+
After running the SFT pipeline end-to-end, the eval delta on the same held-out prompts is striking:
|
| 437 |
+
|
| 438 |
+
| Metric | Base | Post-SFT | Δ |
|
| 439 |
+
|-----------------|-------:|---------:|:------------:|
|
| 440 |
+
| `format_pct` | 33.3% | **100.0%** | **+66.7 pp** |
|
| 441 |
+
| `exact_pct` | 38.9% | **88.9%** | **+50.0 pp** |
|
| 442 |
+
| `service_pct` | 77.8% | **88.9%** | +11.1 pp |
|
| 443 |
+
| `operation_pct` | 61.1% | **88.9%** | +27.8 pp |
|
| 444 |
+
| `avg_len` | 85.8 | 74.7 | −11 chars (tighter) |
|
| 445 |
+
|
| 446 |
+

|
| 447 |
+

|
| 448 |
+

|
| 449 |
+
|
| 450 |
+
Every target from [data/sft/MODEL_EVALUATION.md §11](data/sft/MODEL_EVALUATION.md#11-target-metrics-for-sft) is met or exceeded. **Format compliance is now perfect**; the model never wraps commands in fences or quotes after SFT. **Exact-match jumped from 39% to 89%** — the agent now emits the canonical command for ~9 of every 10 prompts.
|
| 451 |
+
|
| 452 |
+
### 8.2 SFT vs GRPO — multi-step live env eval (100+ episodes)
|
| 453 |
+
|
| 454 |
+
This is the harder benchmark. We let the SFT and GRPO adapters loose on the live RL environment for 100+ episodes each:
|
| 455 |
+
|
| 456 |
+
| Metric | SFT | SFT + GRPO | Δ |
|
| 457 |
+
|-------------------------------:|:-------:|:----------:|:------------:|
|
| 458 |
+
| Overall success rate | 86.8% | 86.2% | −0.5 pp |
|
| 459 |
+
| Overall mean reward | 0.883 | 0.877 | −0.006 |
|
| 460 |
+
| Beginner success | 96.2% | **100.0%** | **+3.8 pp** |
|
| 461 |
+
| **Intermediate success** | 81.0% | **87.0%** | **+6.0 pp** |
|
| 462 |
+
| Warmup success | 96.0% | 90.2% | −5.8 pp |
|
| 463 |
+
| Expert success | 22.2% | 22.2% | flat |
|
| 464 |
+
| Drift repair rate | 22.2% | 22.2% | flat |
|
| 465 |
+
| Destructive-action fail rate | 15.1% | 14.7% | −0.4 pp |
|
| 466 |
+
| Steps to solve | 1.45 | 1.55 | +0.10 |
|
| 467 |
+
|
| 468 |
+

|
| 469 |
+

|
| 470 |
+

|
| 471 |
+

|
| 472 |
+
|
| 473 |
+
> **Honest reading.** GRPO **preserves the SFT gains** and **modestly improves the middle tiers** (beginner +3.8 pp, intermediate +6.0 pp). It does **not crack the expert-tier bottleneck** — 22% on SRE / drift / security-posture tasks, flat from SFT. With longer GRPO runs and an expert-weighted curriculum, this is the next gain to chase. We're calling this out directly because credibility matters more than a clean win-bar.
|
| 474 |
+
|
| 475 |
+
### 8.3 Qualitative rollouts
|
| 476 |
+
|
| 477 |
+
One sample episode per tier, post-GRPO:
|
| 478 |
+
|
| 479 |
+

|
| 480 |
+
|
| 481 |
+
The full notebook with side-by-side base / SFT / GRPO transcripts is at [compare/compare_base_vs_sft.ipynb](compare/compare_base_vs_sft.ipynb).
|
| 482 |
+
|
| 483 |
+
---
|
| 484 |
+
|
| 485 |
+
## 9. Reproducibility
|
| 486 |
+
|
| 487 |
+
Everything in this blog runs from three Colab notebooks. **No private dependencies, no purchased compute, no leaked state.**
|
| 488 |
+
|
| 489 |
+
| Notebook | What it does | Open |
|
| 490 |
+
|---|---|---|
|
| 491 |
+
| [train/train_sft_lora.ipynb](train/train_sft_lora.ipynb) | Stage 1 — SFT LoRA fine-tune | [Colab](https://colab.research.google.com/drive/1dm9sDaLxHX6s9zEG_SC0FQcKWKkc3TfL?usp=sharing) |
|
| 492 |
+
| [train/train_grpo_lora.ipynb](train/train_grpo_lora.ipynb) | Stage 2 — GRPO multi-turn rollouts | [Colab](https://colab.research.google.com/drive/1NwiOM0h_JpXXGRxfY_xZtDiaigvIaKjx?usp=sharing) |
|
| 493 |
+
| [compare/compare_base_vs_sft.ipynb](compare/compare_base_vs_sft.ipynb) | Side-by-side base vs SFT (dataset + RL env) | [Colab](https://colab.research.google.com/drive/17406aiad8h4nAphV42vVNZ-a5SzZMIre?usp=sharing) |
|
| 494 |
+
|
| 495 |
+
**Local dev** is one command:
|
| 496 |
+
|
| 497 |
+
```bash
|
| 498 |
+
make docker-run # FastAPI + MiniStack on :8000
|
| 499 |
+
|
| 500 |
+
# 8-way parallel rollouts for training:
|
| 501 |
+
AWS_RL_ENV_POOL_SIZE=8 make run
|
| 502 |
+
```
|
| 503 |
+
|
| 504 |
+
**The test suite** is also the canonical-solution source. 10 unit tests + 134 tier-integration tests, where each integration test is an AST-extractable solution for the SFT dataset:
|
| 505 |
+
|
| 506 |
+
```bash
|
| 507 |
+
pytest tests/ tests_tasks/ -v
|
| 508 |
+
```
|
| 509 |
+
|
| 510 |
+
| Path | What it covers |
|
| 511 |
+
|------|----------------|
|
| 512 |
+
| [tests/test_task_grader.py](tests/test_task_grader.py) | All 5 grading strategies + every penalty/bonus |
|
| 513 |
+
| [tests/test_resource_verifier.py](tests/test_resource_verifier.py) | Per-service ground-truth verification (20+ services) |
|
| 514 |
+
| [tests/test_pool.py](tests/test_pool.py) · [test_grpo_pool.py](tests/test_grpo_pool.py) | All-or-nothing connect protocol |
|
| 515 |
+
| [tests/test_drift_engine.py](tests/test_drift_engine.py) | Random drift selection + mutation application |
|
| 516 |
+
| [tests_tasks/test_*_tasks.py](tests_tasks/) | 134 tasks exercised end-to-end against MiniStack |
|
| 517 |
+
|
| 518 |
+
All artifacts are on the Hub (dataset, SFT adapter, GRPO adapter, Space). A judge can fork this repo and re-run the entire pipeline in a few hours.
|
| 519 |
+
|
| 520 |
+
---
|
| 521 |
+
|
| 522 |
+
## 10. What's next
|
| 523 |
+
|
| 524 |
+
The expert-tier bottleneck (22% success on state-check / drift / security-posture tasks) is the single biggest target:
|
| 525 |
+
|
| 526 |
+
- **Longer GRPO runs** — 35 steps is short by RL standards. We'd expect compounded improvements from 200–500 steps with the same config.
|
| 527 |
+
- **Expert-weighted curriculum** — currently the priority formula doesn't preferentially upweight expert tasks; with a small bias term we'd see more expert exposure per step.
|
| 528 |
+
- **DPO on expert trajectories** — preference pairs (good vs bad expert solves) might shape multi-step expert behavior more efficiently than scalar reward.
|
| 529 |
+
- **Real-AWS strategy backend** — `BACKEND_TYPE=aws` is wired and ready. Cost-budgeted eval runs against a sandboxed real account would close the sim-to-real gap once and for all.
|
| 530 |
+
|
| 531 |
+
PRs welcome at [github.com/udaykiranpadhy/aws-rl-env](https://github.com/udaykiranpadhy/aws-rl-env). The env is OpenEnv-compliant, so any TRL / TorchForge / SkyRL / Unsloth user can plug in tomorrow.
|
| 532 |
+
|
| 533 |
+
---
|
| 534 |
+
|
| 535 |
+
## 11. Acknowledgments
|
| 536 |
+
|
| 537 |
+
Thank you to:
|
| 538 |
+
|
| 539 |
+
- **MiniStack** — vendored at [aws_infra/](aws_infra/), upstream license preserved. Custom modifications are commits `a648c3a`, `a00e981`; periodic upstream syncs `af2e945`, `579597b`.
|
| 540 |
+
- **OpenEnv** — environment protocol and Python client framework that this entire project plugs into.
|
| 541 |
+
- **TRL** (Hugging Face) — `GRPOTrainer` implementation and the rest of the post-training stack.
|
| 542 |
+
- **Unsloth** — 4-bit quantized model loaders and fused training kernels that fit a 3B model + 8 rollouts on 24 GB.
|
| 543 |
+
- **Optuna** — TPE sampler that found the SFT and GRPO hyperparameters without us having to.
|
| 544 |
+
- **Google Colab** — free GPU runtime for the full training pipeline.
|
| 545 |
+
- **AWS service icons** in [server/static/img/aws/](server/static/img/aws/) — used in the web playground.
|
| 546 |
+
|
| 547 |
+
---
|
| 548 |
+
|
| 549 |
+
### Sub-README index — for the deeper dives
|
| 550 |
+
|
| 551 |
+
| Path | What it covers |
|
| 552 |
+
|------|----------------|
|
| 553 |
+
| [server/README.md](server/README.md) | Environment internals — curriculum, reward shaping, anti-hacking, chaos, drift, MiniStack-fork detail |
|
| 554 |
+
| [train/README.md](train/README.md) | SFT + GRPO pipeline — LoRA config, Optuna search, multi-turn rollouts |
|
| 555 |
+
| [scripts/README.md](scripts/README.md) | Parallel-rollout architecture — 3 pool layers, all-or-nothing connect, concurrency safety |
|
| 556 |
+
| [data/README.md](data/README.md) | Dataset generation — 5 trajectory types, AST extraction, base-model selection summary |
|
| 557 |
+
| [data/sft/MODEL_EVALUATION.md](data/sft/MODEL_EVALUATION.md) | Full 11-model benchmark report — methodology, per-model verdicts |
|
| 558 |
+
| [compare/README.md](compare/README.md) | Base vs SFT comparison harness |
|
| 559 |
+
| [aws_infra/README.md](aws_infra/README.md) | Vendored MiniStack upstream documentation |
|
| 560 |
+
|
| 561 |
+
---
|
| 562 |
+
|
| 563 |
+
### Small Explanation Video
|
| 564 |
+
- [Recorded Video](https://share.zight.com/NQu0pLvQ)
|
Dockerfile
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Copyright (c) Meta Platforms, Inc. and affiliates.
|
| 2 |
+
# All rights reserved.
|
| 3 |
+
#
|
| 4 |
+
# This source code is licensed under the BSD-style license found in the
|
| 5 |
+
# LICENSE file in the root directory of this source tree.
|
| 6 |
+
|
| 7 |
+
# Multi-stage build using openenv-base
|
| 8 |
+
# This Dockerfile is flexible and works for both:
|
| 9 |
+
# - In-repo environments (with local OpenEnv sources)
|
| 10 |
+
# - Standalone environments (with openenv from PyPI/Git)
|
| 11 |
+
# The build script (openenv build) handles context detection and sets appropriate build args.
|
| 12 |
+
|
| 13 |
+
ARG BASE_IMAGE=ghcr.io/meta-pytorch/openenv-base:latest
|
| 14 |
+
FROM ${BASE_IMAGE} AS builder
|
| 15 |
+
|
| 16 |
+
WORKDIR /app
|
| 17 |
+
|
| 18 |
+
# Ensure git is available (required for installing dependencies from VCS)
|
| 19 |
+
RUN apt-get update && \
|
| 20 |
+
apt-get install -y --no-install-recommends git && \
|
| 21 |
+
rm -rf /var/lib/apt/lists/*
|
| 22 |
+
|
| 23 |
+
# Build argument to control whether we're building standalone or in-repo
|
| 24 |
+
ARG BUILD_MODE=in-repo
|
| 25 |
+
ARG ENV_NAME=aws_rl_env
|
| 26 |
+
|
| 27 |
+
# Copy environment code (always at root of build context)
|
| 28 |
+
COPY . /app/env
|
| 29 |
+
|
| 30 |
+
# For in-repo builds, openenv is already vendored in the build context
|
| 31 |
+
# For standalone builds, openenv will be installed via pyproject.toml
|
| 32 |
+
WORKDIR /app/env
|
| 33 |
+
|
| 34 |
+
# Ensure uv is available (for local builds where base image lacks it)
|
| 35 |
+
RUN if ! command -v uv >/dev/null 2>&1; then \
|
| 36 |
+
curl -LsSf https://astral.sh/uv/install.sh | sh && \
|
| 37 |
+
mv /root/.local/bin/uv /usr/local/bin/uv && \
|
| 38 |
+
mv /root/.local/bin/uvx /usr/local/bin/uvx; \
|
| 39 |
+
fi
|
| 40 |
+
|
| 41 |
+
# Install dependencies using uv sync
|
| 42 |
+
# If uv.lock exists, use it; otherwise resolve on the fly
|
| 43 |
+
RUN --mount=type=cache,target=/root/.cache/uv \
|
| 44 |
+
if [ -f uv.lock ]; then \
|
| 45 |
+
uv sync --frozen --extra dev --no-install-project --no-editable; \
|
| 46 |
+
else \
|
| 47 |
+
uv sync --extra dev --no-install-project --no-editable; \
|
| 48 |
+
fi
|
| 49 |
+
|
| 50 |
+
RUN --mount=type=cache,target=/root/.cache/uv \
|
| 51 |
+
if [ -f uv.lock ]; then \
|
| 52 |
+
uv sync --frozen --extra dev --no-editable; \
|
| 53 |
+
else \
|
| 54 |
+
uv sync --extra dev --no-editable; \
|
| 55 |
+
fi
|
| 56 |
+
|
| 57 |
+
# Final runtime stage
|
| 58 |
+
FROM ${BASE_IMAGE}
|
| 59 |
+
|
| 60 |
+
WORKDIR /app
|
| 61 |
+
|
| 62 |
+
# Copy the uv-managed Python interpreter from builder
|
| 63 |
+
COPY --from=builder /root/.local/share/uv/python /root/.local/share/uv/python
|
| 64 |
+
|
| 65 |
+
# Copy the virtual environment from builder
|
| 66 |
+
COPY --from=builder /app/env/.venv /app/.venv
|
| 67 |
+
|
| 68 |
+
# Copy the environment code
|
| 69 |
+
COPY --from=builder /app/env /app/env
|
| 70 |
+
|
| 71 |
+
# Install AWS CLI
|
| 72 |
+
RUN apt-get update && \
|
| 73 |
+
apt-get install -y --no-install-recommends awscli && \
|
| 74 |
+
rm -rf /var/lib/apt/lists/*
|
| 75 |
+
|
| 76 |
+
# Configure AWS CLI to point to MiniStack (vendored at aws_infra/) and use dummy credentials
|
| 77 |
+
RUN mkdir -p /root/.aws && \
|
| 78 |
+
printf '[default]\nregion = us-east-1\noutput = json\n' > /root/.aws/config && \
|
| 79 |
+
printf '[default]\naws_access_key_id = test\naws_secret_access_key = test\n' > /root/.aws/credentials
|
| 80 |
+
ENV AWS_ENDPOINT_URL=http://localhost:4566
|
| 81 |
+
|
| 82 |
+
# Enable the web interface for OpenEnv (if applicable)
|
| 83 |
+
ENV ENABLE_WEB_INTERFACE=true
|
| 84 |
+
|
| 85 |
+
# Set PATH to use the virtual environment
|
| 86 |
+
ENV PATH="/app/.venv/bin:$PATH"
|
| 87 |
+
|
| 88 |
+
# Set PYTHONPATH so imports work correctly
|
| 89 |
+
ENV PYTHONPATH="/app/env:$PYTHONPATH"
|
| 90 |
+
|
| 91 |
+
ENV AWS_RL_ENV_POOL_SIZE=8
|
| 92 |
+
ENV AWS_RL_ENV_MINISTACK_BASE_PORT=4566
|
| 93 |
+
# Dedicated port for the web playground's lazily-spawned MiniStack.
|
| 94 |
+
# Kept outside the pool's range so a WebSocket session can never claim it.
|
| 95 |
+
ENV AWS_RL_ENV_WEB_MINISTACK_PORT=4565
|
| 96 |
+
|
| 97 |
+
# DEV_MODE=1 enables live reload via --reload flag
|
| 98 |
+
ENV DEV_MODE=0
|
| 99 |
+
|
| 100 |
+
ENV API_BASE_URL=https://router.huggingface.co/v1
|
| 101 |
+
ENV MODEL_NAME=Qwen/Qwen2.5-72B-Instruct
|
| 102 |
+
|
| 103 |
+
# Entrypoint: start N MiniStack instances (AWS_RL_ENV_POOL_SIZE, default 1),
|
| 104 |
+
# then run the FastAPI server. Each MiniStack listens on a distinct port
|
| 105 |
+
# starting at AWS_RL_ENV_MINISTACK_BASE_PORT (default 4566).
|
| 106 |
+
# The web playground's MiniStack on AWS_RL_ENV_WEB_MINISTACK_PORT is NOT
|
| 107 |
+
# started here — the FastAPI server spawns it lazily on the first /web/*
|
| 108 |
+
# request so training-only deployments pay zero cost.
|
| 109 |
+
# cloudflared tunnel --url localhost:8000
|
| 110 |
+
CMD ["sh", "-c", "\
|
| 111 |
+
POOL_SIZE=\"${AWS_RL_ENV_POOL_SIZE:-1}\"; \
|
| 112 |
+
BASE_PORT=\"${AWS_RL_ENV_MINISTACK_BASE_PORT:-4566}\"; \
|
| 113 |
+
i=0; while [ \"$i\" -lt \"$POOL_SIZE\" ]; do \
|
| 114 |
+
GATEWAY_PORT=$((BASE_PORT + i)) ministack -d; \
|
| 115 |
+
i=$((i + 1)); \
|
| 116 |
+
done; \
|
| 117 |
+
sleep 3; \
|
| 118 |
+
uvicorn server.app:app --host 0.0.0.0 --port 8000 $([ \"$DEV_MODE\" = '1' ] && echo '--reload --reload-dir /app/env') \
|
| 119 |
+
"]
|
Makefile
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Load .env if present so Make sees every KEY=value in it, both as $(VAR)
|
| 2 |
+
# inside the Makefile and as an environment variable exported to recipes.
|
| 3 |
+
# Precedence (highest → lowest):
|
| 4 |
+
# 1. CLI: make run POOL_SIZE=16
|
| 5 |
+
# 2. Shell env: POOL_SIZE=16 make run
|
| 6 |
+
# 3. .env file: AWS_RL_ENV_POOL_SIZE=16 in ./.env
|
| 7 |
+
# 4. Makefile default (via ?=)
|
| 8 |
+
# Create one by copying the template: cp .env.example .env
|
| 9 |
+
ifneq (,$(wildcard ./.env))
|
| 10 |
+
include .env
|
| 11 |
+
export
|
| 12 |
+
endif
|
| 13 |
+
|
| 14 |
+
# Project settings
|
| 15 |
+
PROJECT_NAME := openenv-aws_rl_env
|
| 16 |
+
PYTHON := python3
|
| 17 |
+
UV := uv
|
| 18 |
+
DOCKER_IMAGE := aws-rl-env
|
| 19 |
+
DOCKER_TAG := latest
|
| 20 |
+
SERVER_HOST := 0.0.0.0
|
| 21 |
+
SERVER_PORT := 8000
|
| 22 |
+
|
| 23 |
+
# Parallel MiniStack pool (used by `make run`).
|
| 24 |
+
# POOL_SIZE=1 → single MiniStack on MINISTACK_BASE_PORT (legacy behavior)
|
| 25 |
+
# POOL_SIZE>1 → N MiniStacks on MINISTACK_BASE_PORT..BASE_PORT+N-1,
|
| 26 |
+
# server exposes N concurrent WebSocket sessions
|
| 27 |
+
# Override from CLI: `make run POOL_SIZE=8` or `POOL_SIZE=8 make run`.
|
| 28 |
+
POOL_SIZE ?= $(or $(AWS_RL_ENV_POOL_SIZE),1)
|
| 29 |
+
MINISTACK_BASE_PORT ?= $(or $(AWS_RL_ENV_MINISTACK_BASE_PORT),4566)
|
| 30 |
+
|
| 31 |
+
.DEFAULT_GOAL := help
|
| 32 |
+
|
| 33 |
+
# ──────────────────────────────────────────────
|
| 34 |
+
# Setup & Dependencies
|
| 35 |
+
# ──────────────────────────────────────────────
|
| 36 |
+
|
| 37 |
+
.PHONY: install
|
| 38 |
+
install: ## Install project dependencies
|
| 39 |
+
$(UV) sync --frozen
|
| 40 |
+
|
| 41 |
+
.PHONY: install-dev
|
| 42 |
+
install-dev: ## Install project with dev dependencies
|
| 43 |
+
$(UV) sync --frozen --extra dev
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
.PHONY: install-all
|
| 47 |
+
install-all: ## Install project with all dependencies (dev + training)
|
| 48 |
+
$(UV) sync --frozen --all-extras
|
| 49 |
+
|
| 50 |
+
.PHONY: lock
|
| 51 |
+
lock: ## Update the lockfile
|
| 52 |
+
$(UV) lock
|
| 53 |
+
|
| 54 |
+
# ──────────────────────────────────────────────
|
| 55 |
+
# Development
|
| 56 |
+
# ──────────────────────────────────────────────
|
| 57 |
+
|
| 58 |
+
.PHONY: run
|
| 59 |
+
run: ## Run MiniStack pool + FastAPI server. Env: POOL_SIZE (default 1), MINISTACK_BASE_PORT (default 4566)
|
| 60 |
+
@echo "==> Starting $(POOL_SIZE) MiniStack(s) on ports $(MINISTACK_BASE_PORT)..$$(($(MINISTACK_BASE_PORT) + $(POOL_SIZE) - 1))"
|
| 61 |
+
@for i in $$(seq 0 $$(($(POOL_SIZE) - 1))); do \
|
| 62 |
+
port=$$(($(MINISTACK_BASE_PORT) + $$i)); \
|
| 63 |
+
echo " MiniStack :$$port"; \
|
| 64 |
+
GATEWAY_PORT=$$port ministack -d; \
|
| 65 |
+
done
|
| 66 |
+
@sleep 2
|
| 67 |
+
@echo "==> FastAPI server on $(SERVER_HOST):$(SERVER_PORT) (POOL_SIZE=$(POOL_SIZE))"
|
| 68 |
+
AWS_RL_ENV_POOL_SIZE=$(POOL_SIZE) \
|
| 69 |
+
AWS_RL_ENV_MINISTACK_BASE_PORT=$(MINISTACK_BASE_PORT) \
|
| 70 |
+
$(UV) run uvicorn server.app:app --host $(SERVER_HOST) --port $(SERVER_PORT) --reload
|
| 71 |
+
|
| 72 |
+
.PHONY: run-stop
|
| 73 |
+
run-stop: ## Stop every MiniStack started by `make run` (uses current POOL_SIZE + MINISTACK_BASE_PORT)
|
| 74 |
+
@for i in $$(seq 0 $$(($(POOL_SIZE) - 1))); do \
|
| 75 |
+
port=$$(($(MINISTACK_BASE_PORT) + $$i)); \
|
| 76 |
+
echo " stopping MiniStack :$$port"; \
|
| 77 |
+
GATEWAY_PORT=$$port ministack --stop || true; \
|
| 78 |
+
done
|
| 79 |
+
|
| 80 |
+
# ──────────────────────────────────────────────
|
| 81 |
+
# Code Quality
|
| 82 |
+
# ──────────────────────────────────────────────
|
| 83 |
+
|
| 84 |
+
.PHONY: format
|
| 85 |
+
format: ## Format code with ruff
|
| 86 |
+
$(UV) run ruff format .
|
| 87 |
+
|
| 88 |
+
.PHONY: lint
|
| 89 |
+
lint: ## Lint code with ruff
|
| 90 |
+
$(UV) run ruff check .
|
| 91 |
+
|
| 92 |
+
.PHONY: lint-fix
|
| 93 |
+
lint-fix: ## Lint and auto-fix code with ruff
|
| 94 |
+
$(UV) run ruff check --fix .
|
| 95 |
+
|
| 96 |
+
.PHONY: typecheck
|
| 97 |
+
typecheck: ## Run type checking with mypy
|
| 98 |
+
$(UV) run mypy
|
| 99 |
+
|
| 100 |
+
.PHONY: check
|
| 101 |
+
check: lint typecheck
|
| 102 |
+
|
| 103 |
+
# ──────────────────────────────────────────────
|
| 104 |
+
# Docker
|
| 105 |
+
# ──────────────────────────────────────────────
|
| 106 |
+
|
| 107 |
+
.PHONY: docker-build
|
| 108 |
+
docker-build: ## Build Docker image
|
| 109 |
+
docker build -t $(DOCKER_IMAGE):$(DOCKER_TAG) .
|
| 110 |
+
|
| 111 |
+
.PHONY: docker-run
|
| 112 |
+
docker-run: ## Run Docker container
|
| 113 |
+
docker run --rm --name $(DOCKER_IMAGE) -p $(SERVER_PORT):8000 $(DOCKER_IMAGE):$(DOCKER_TAG)
|
| 114 |
+
|
| 115 |
+
.PHONY: docker-run-dev
|
| 116 |
+
docker-run-dev: ## Run Docker container in dev mode with live reload
|
| 117 |
+
docker run --rm --name $(DOCKER_IMAGE) -p $(SERVER_PORT):8000 -v $(PWD):/app/env -v /app/env/.venv -e DEV_MODE=1 $(DOCKER_IMAGE):$(DOCKER_TAG)
|
| 118 |
+
|
| 119 |
+
.PHONY: docker-run-detach
|
| 120 |
+
docker-run-detach: ## Run Docker container in background
|
| 121 |
+
docker run -d --rm --name $(DOCKER_IMAGE) -p $(SERVER_PORT):8000 -v $(PWD):/app/env -v /app/env/.venv -e DEV_MODE=1 $(DOCKER_IMAGE):$(DOCKER_TAG)
|
| 122 |
+
|
| 123 |
+
.PHONY: docker-stop
|
| 124 |
+
docker-stop: ## Stop the running Docker container
|
| 125 |
+
docker stop $(DOCKER_IMAGE)
|
| 126 |
+
|
| 127 |
+
.PHONY: docker-logs
|
| 128 |
+
docker-logs: ## Tail logs from the running Docker container
|
| 129 |
+
docker logs -f $(DOCKER_IMAGE)
|
| 130 |
+
|
| 131 |
+
.PHONY: docker-shell
|
| 132 |
+
docker-shell: ## Open a shell in the running Docker container
|
| 133 |
+
docker exec -it $(DOCKER_IMAGE) /bin/bash
|
| 134 |
+
|
| 135 |
+
.PHONY: docker-clean
|
| 136 |
+
docker-clean: ## Stop and remove all running containers for this image
|
| 137 |
+
@docker ps -q --filter ancestor=$(DOCKER_IMAGE):$(DOCKER_TAG) | xargs -r docker rm -f
|
| 138 |
+
|
| 139 |
+
.PHONY: docker-test
|
| 140 |
+
docker-test: ## Run tests inside the running Docker container
|
| 141 |
+
docker exec $(DOCKER_IMAGE) python -m pytest env/tests -v
|
| 142 |
+
|
| 143 |
+
.PHONY: docker-health
|
| 144 |
+
docker-health: ## Check health of the running container
|
| 145 |
+
@curl -sf http://localhost:$(SERVER_PORT)/health && echo " OK" || echo " FAIL"
|
| 146 |
+
|
| 147 |
+
# ──────────────────────────────────────────────
|
| 148 |
+
# OpenEnv
|
| 149 |
+
# ──────────────────────────────────────────────
|
| 150 |
+
|
| 151 |
+
.PHONY: openenv-validate
|
| 152 |
+
openenv-validate: ## Validate the OpenEnv configuration
|
| 153 |
+
openenv validate
|
| 154 |
+
|
| 155 |
+
.PHONY: openenv-build
|
| 156 |
+
openenv-build: ## Build the environment using OpenEnv CLI
|
| 157 |
+
openenv build
|
| 158 |
+
|
| 159 |
+
.PHONY: openenv-push
|
| 160 |
+
openenv-push: ## Push the environment to Hugging Face Spaces
|
| 161 |
+
openenv push
|
| 162 |
+
|
| 163 |
+
# ──────────────────────────────────────────────
|
| 164 |
+
# Cleanup
|
| 165 |
+
# ──────────────────────────────────────────────
|
| 166 |
+
|
| 167 |
+
.PHONY: clean
|
| 168 |
+
clean: ## Remove build artifacts and caches
|
| 169 |
+
rm -rf build/ dist/ *.egg-info .eggs/
|
| 170 |
+
rm -rf aws_infra/*.egg-info aws_infra/build/ aws_infra/dist/
|
| 171 |
+
rm -rf .pytest_cache/ .mypy_cache/ .ruff_cache/
|
| 172 |
+
rm -rf htmlcov/ .coverage coverage.xml
|
| 173 |
+
find . -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true
|
| 174 |
+
find . -type f -name '*.pyc' -delete 2>/dev/null || true
|
| 175 |
+
|
| 176 |
+
.PHONY: clean-all
|
| 177 |
+
clean-all: clean ## Remove all artifacts including venv
|
| 178 |
+
rm -rf .venv/
|
| 179 |
+
|
| 180 |
+
# ──────────────────────────────────────────────
|
| 181 |
+
# Help
|
| 182 |
+
# ──────────────────────────────────────────────
|
| 183 |
+
|
| 184 |
+
.PHONY: help
|
| 185 |
+
help: ## Show this help message
|
| 186 |
+
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | \
|
| 187 |
+
awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-20s\033[0m %s\n", $$1, $$2}'
|
README.md
CHANGED
|
@@ -1,10 +1,718 @@
|
|
| 1 |
---
|
| 2 |
-
title:
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
sdk: docker
|
| 7 |
pinned: false
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
---
|
| 9 |
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
+
title: AWS RL Environment Server
|
| 3 |
+
emoji: 🥇
|
| 4 |
+
colorFrom: pink
|
| 5 |
+
colorTo: pink
|
| 6 |
sdk: docker
|
| 7 |
pinned: false
|
| 8 |
+
app_port: 8000
|
| 9 |
+
base_path: /web
|
| 10 |
+
tags:
|
| 11 |
+
- openenv
|
| 12 |
---
|
| 13 |
|
| 14 |
+
|
| 15 |
+
# AWS Cloud Operations — RL Environment & Training Pipeline
|
| 16 |
+
|
| 17 |
+
> Cloud agents fail in production not because they don’t know the commands — but because state drifts, services hiccup, and reward signals get gamed. We built an environment that simulates all three: 120+ AWS tasks under chaos and drift, an 8-layer anti-reward-hacking stack, and an adversarial curriculum that targets the agent’s own weak spots. After SFT → GRPO on a single GPU with 8 parallel rollouts, format compliance hit 100%, exact-match jumped 39% → 89%, and intermediate-tier success climbed 81% → 87%.
|
| 18 |
+
|
| 19 |
+
| | |
|
| 20 |
+
|---|---|
|
| 21 |
+
| **Live demo** | [sizzing-aws-rl-env.hf.space/web](https://sizzing-aws-rl-env.hf.space/web) — try the playground in a browser |
|
| 22 |
+
| **API docs** | [sizzing-aws-rl-env.hf.space/docs](https://sizzing-aws-rl-env.hf.space/docs) (Swagger), [/redoc](https://sizzing-aws-rl-env.hf.space/redoc) |
|
| 23 |
+
| **HF Space** | [huggingface.co/spaces/Sizzing/aws_rl_env](https://huggingface.co/spaces/Sizzing/aws_rl_env) |
|
| 24 |
+
| **SFT adapter**| [Sizzing/aws-rl-sft-qwen25coder3b-adapter](https://huggingface.co/Sizzing/aws-rl-sft-qwen25coder3b-adapter) |
|
| 25 |
+
| **Dataset** | [Sizzing/aws-rl-sft](https://huggingface.co/datasets/Sizzing/aws-rl-sft) |
|
| 26 |
+
|
| 27 |
+
---
|
| 28 |
+
|
| 29 |
+
## Table of contents
|
| 30 |
+
|
| 31 |
+
1. [What this is & why it matters](#1-what-this-is--why-it-matters)
|
| 32 |
+
2. [Highlights — full feature inventory](#2-highlights--full-feature-inventory)
|
| 33 |
+
3. [Architecture](#3-architecture)
|
| 34 |
+
4. [Live demo & Quick Start](#4-live-demo--quick-start)
|
| 35 |
+
5. [Run on Colab](#5-run-on-colab)
|
| 36 |
+
6. [Action / Observation spec](#6-action--observation-spec)
|
| 37 |
+
7. [Curriculum & Reward (overview)](#7-curriculum--reward-overview)
|
| 38 |
+
8. [Training pipeline (SFT → GRPO)](#8-training-pipeline-sft--grpo)
|
| 39 |
+
9. [Parallel rollout architecture](#9-parallel-rollout-architecture)
|
| 40 |
+
10. [MiniStack: vendored & customized](#10-ministack-vendored--customized)
|
| 41 |
+
11. [Results & Benchmarks](#11-results--benchmarks)
|
| 42 |
+
12. [Repository map](#12-repository-map)
|
| 43 |
+
13. [Configuration & Running](#13-configuration--running)
|
| 44 |
+
14. [Testing](#14-testing)
|
| 45 |
+
15. [Tech stack](#15-tech-stack)
|
| 46 |
+
16. [Links](#16-links)
|
| 47 |
+
17. [Acknowledgments](#17-acknowledgments)
|
| 48 |
+
|
| 49 |
+
---
|
| 50 |
+
|
| 51 |
+
## 1. What this is & why it matters
|
| 52 |
+
|
| 53 |
+
Modern AI agents are increasingly asked to operate cloud infrastructure — provisioning resources, fixing misconfigurations, responding to drift. Training such agents needs (a) a realistic environment, (b) reliable reward signals, and (c) enough scale to make RL feasible. Existing options force a hard tradeoff: real AWS costs hundreds of dollars per training run and is impossible to reset; toy emulators don't behave like production AWS.
|
| 54 |
+
|
| 55 |
+
**This project closes that gap.** We built:
|
| 56 |
+
|
| 57 |
+
1. **An OpenEnv-compatible RL environment** that speaks real AWS CLI semantics. The agent sends `aws s3 mb …`, `aws iam create-role …`, and so on — the exact same commands a human SRE would type.
|
| 58 |
+
2. **A vendored, customized MiniStack simulator** that responds with production-equivalent JSON, runs locally for zero cost, supports 34 AWS services, and exposes a single-call state-introspection endpoint we added so the grader has cheap ground-truth access.
|
| 59 |
+
3. **A 120+ task curriculum** across 5 tiers (warmup → expert) with adaptive selection, mastery tracking, spaced repetition, chaos injection, and drift-detection scenarios — every feature designed to keep the reward signal honest and prevent the agent from gaming it.
|
| 60 |
+
4. **A complete SFT → GRPO training pipeline.** A 1,500-row synthetic dataset spanning 5 trajectory shapes, an 11-model base benchmark, LoRA fine-tuning, and TRL GRPO with multi-turn rollouts and Optuna hyperparameter search.
|
| 61 |
+
5. **An 8-way parallel-rollout architecture.** Server-side MiniStack pool, client-side `GrpoPool`, in-process `MultiTurnEnvPool` — three coordinated layers that let G=8 concurrent rollouts run on one GPU without state contamination.
|
| 62 |
+
|
| 63 |
+
Everything is reproducible: the dataset is generated by a deterministic script, the model selection is documented end-to-end, training entry points run on Colab, and the env runs locally in a single Docker container with no external network requirement.
|
| 64 |
+
|
| 65 |
+
---
|
| 66 |
+
|
| 67 |
+
## 2. Highlights — full feature inventory
|
| 68 |
+
|
| 69 |
+
This is the complete surface area of the project. Each entry links to deeper documentation in the corresponding sub-README.
|
| 70 |
+
|
| 71 |
+
### Environment & Curriculum
|
| 72 |
+
- **[120+ tasks across 5 tiers](server/services/tasks/)** — warmup (25), beginner (25), intermediate (25), advanced (25), expert (24), drift (9). YAML-defined task spec per tier.
|
| 73 |
+
- **[Curriculum learning with priority scoring](server/README.md#7-curriculum-manager)** — `score = novelty + weakness − recency + spaced_rep_bonus` drives task selection.
|
| 74 |
+
- **[Mastery tracking](server/README.md#7-curriculum-manager)** — sliding 10-episode window, 0.7 threshold, 0.85 exponential decay, supports un-graduation.
|
| 75 |
+
- **[Spaced repetition](server/README.md#7-curriculum-manager)** — graduated tasks resurface at intervals `[3, 6, 12, 24, 48]` to prevent forgetting.
|
| 76 |
+
- **[Tier promotion](server/README.md#7-curriculum-manager)** — standard (min episodes + success rate) + fast-track (3 consecutive ≥90% episodes).
|
| 77 |
+
- **[Strategy pattern: simulator vs real AWS](server/README.md#4-strategy-pattern-simulator-vs-real-aws)** — `BACKEND_TYPE=simulator` (default) or `aws`, no code fork.
|
| 78 |
+
|
| 79 |
+
### Reward shaping
|
| 80 |
+
- **[Five grading strategies](server/README.md#8-reward-shaping--taskgrader)** — command-match (warmup), resource-creation (beginner), multi-step (intermediate), multi-step+services (advanced), state-checks (expert).
|
| 81 |
+
- **[Dense partial-progress signal](server/README.md#8-reward-shaping--taskgrader)** — clamped to `[0.0, 0.99]`, `1.0` reserved for verified completion.
|
| 82 |
+
- **[Rollback penalty](server/README.md#8-reward-shaping--taskgrader)** — `−0.1` per `(create-X, …, delete-X)` pair.
|
| 83 |
+
- **[Idempotency bonus](server/README.md#8-reward-shaping--taskgrader)** — `+0.02` for graceful "already exists" retry.
|
| 84 |
+
- **[Hint decay](server/README.md#13-hint-provider)** — three-level progressive hints with `0.85^n` reward multiplier.
|
| 85 |
+
- **[Chaos survival bonus](server/README.md#11-chaos-engine)** — `×1.05` if the agent completes a chaotic task.
|
| 86 |
+
|
| 87 |
+
### Resilience & adversarial features
|
| 88 |
+
- **[Chaos injection](server/README.md#11-chaos-engine)** — silent mid-episode mutations, tier-scaled probabilities (10/20/30%) on services the task is touching.
|
| 89 |
+
- **[Drift detection](server/README.md#12-drift-engine)** — 6 expert tasks, 2–3 random mutations from a per-task pool, randomized per episode (no memorization).
|
| 90 |
+
- **[Security-posture audit tasks](server/README.md#17-security-posture-audit-examples)** — S3 public bucket lockdown, IAM least-privilege, Lambda secret rotation.
|
| 91 |
+
- **[8-layer anti-reward-hacking](server/README.md#9-anti-reward-hacking--8-defense-layers)** — ground-truth verification, dedup, grader invisibility, command allow-list, no-credit-for-reads, monotonic progress, exact resource-name validation, final state checks.
|
| 92 |
+
|
| 93 |
+
### Training pipeline
|
| 94 |
+
- **[Synthetic SFT dataset (1,500 rows)](data/README.md)** — 5 trajectory types: success / multi-step continuation / failure recovery / verification / hint usage.
|
| 95 |
+
- **[Rigorous base-model selection](data/sft/MODEL_EVALUATION.md)** — 11 models × 27 prompts, [Qwen2.5-Coder-3B-Instruct](https://huggingface.co/unsloth/Qwen2.5-Coder-3B-Instruct-bnb-4bit) wins.
|
| 96 |
+
- **[LoRA SFT](train/README.md#1-sft-stage--supervised-lora)** — `r ∈ {8,16,32}`, `lora_alpha = r × multiplier`, attention-only adaptation.
|
| 97 |
+
- **[GRPO RL via TRL](train/README.md#2-grpo-stage--reinforcement-learning)** — group-relative advantages, KL to SFT reference, `dapo` loss, no critic.
|
| 98 |
+
- **[Multi-turn rollouts](train/README.md#4-multi-turn-rollouts--parallel-envs)** — up to `MAX_TURNS=6`, observation fed back as next-turn user message.
|
| 99 |
+
- **[Optuna hyperparameter search](train/README.md#3-optuna-hyperparameter-search)** — TPE sampler over 8-dim space, frozen held-out validation set.
|
| 100 |
+
- **[HuggingFace integration](data/README.md#7-huggingface-publishing)** — adapter + dataset published to Hub, OpenEnv Space deployment.
|
| 101 |
+
|
| 102 |
+
### Parallel rollout architecture
|
| 103 |
+
- **[Server-side MiniStack pool](server/README.md#6-server-side-ministack-pool-parallel-rollouts)** — `MiniStackPool` ([server/app.py](server/app.py)), free-list of ports, lock-guarded acquire/release.
|
| 104 |
+
- **[Client-side GrpoPool](scripts/README.md#2-three-coordinated-pool-layers)** — async-native, all-or-nothing connect, asyncio.gather for concurrent rollouts.
|
| 105 |
+
- **[In-process MultiTurnEnvPool](train/README.md#4-multi-turn-rollouts--parallel-envs)** — sync API, owns a background asyncio loop, used by the trainer.
|
| 106 |
+
- **[8 isolated rollouts on one server](scripts/README.md#7-running-the-multi-connection-demo)** — proof in [scripts/TestMultipleConnects.ipynb](scripts/TestMultipleConnects.ipynb).
|
| 107 |
+
|
| 108 |
+
### Vendored simulator
|
| 109 |
+
- **[MiniStack as git subtree](server/README.md#5-ministack-vendored-fork--customizations)** — vendored at [aws_infra/](aws_infra/) (commit `2c38c0b`). 34 AWS services. MIT.
|
| 110 |
+
- **[Custom `/_ministack/state` endpoint](server/README.md#5-ministack-vendored-fork--customizations)** — added in commit `a648c3a`; returns full infra inventory in one call.
|
| 111 |
+
- **[Upstream sync workflow](server/README.md#5-ministack-vendored-fork--customizations)** — periodic `git subtree pull`; isolated patches keep conflicts minimal.
|
| 112 |
+
|
| 113 |
+
### Operations & deployment
|
| 114 |
+
- **[OpenEnv-compliant](https://github.com/openai/openenv)** — `/reset`, `/step`, `/state`, `/schema`, `/ws` HTTP+WebSocket endpoints.
|
| 115 |
+
- **[Web playground UI](server/README.md#19-web-playground)** — `/web` route, 40 AWS service icons, Jinja2 + JS frontend.
|
| 116 |
+
- **[Docker-first deployment](Dockerfile)** — multi-stage build, container ships server + N MiniStack instances + AWS CLI.
|
| 117 |
+
- **[Comprehensive test suite](#14-testing)** — 10 unit tests + 6 tier-integration suites covering 134 tasks.
|
| 118 |
+
|
| 119 |
+
---
|
| 120 |
+
|
| 121 |
+
## 3. Architecture
|
| 122 |
+
|
| 123 |
+
> 
|
| 124 |
+
|
| 125 |
+
```
|
| 126 |
+
┌────────────────────────────────── Docker container ──────────────────────────────────┐
|
| 127 |
+
│ │
|
| 128 |
+
│ FastAPI server (port 8000) │
|
| 129 |
+
│ ├── OpenEnv router /reset /step /state /schema /ws /health │
|
| 130 |
+
│ ├── Web playground /web (Jinja2 + 40 AWS icon SVGs) │
|
| 131 |
+
│ ├── env_factory per-WS-session AwsRlEnvironment instance │
|
| 132 |
+
│ │ (acquires a MiniStack port from MiniStackPool) │
|
| 133 |
+
│ └── Services │
|
| 134 |
+
│ Curriculum · TaskGrader · ResourceVerifier · ChaosEngine · DriftEngine │
|
| 135 |
+
│ HintProvider · EpisodeTracker · EnvironmentDesigner · EnvironmentStrategy │
|
| 136 |
+
│ │
|
| 137 |
+
│ │
|
| 138 |
+
│ MiniStack instances :4566 :4567 :4568 … :4566+POOL_SIZE-1 │
|
| 139 |
+
│ (vendored at aws_infra/, started by the Dockerfile entrypoint) │
|
| 140 |
+
│ │
|
| 141 |
+
└──────────────────────────────────────────────────────────────────────────────────────┘
|
| 142 |
+
▲ ▲
|
| 143 |
+
│ HTTP/WS │ AWS CLI subprocess
|
| 144 |
+
│ │ (AWS_ENDPOINT_URL=http://localhost:4566+i)
|
| 145 |
+
│ │
|
| 146 |
+
┌───────┴───────────┐ ┌───────┴───────────┐
|
| 147 |
+
│ RL Agent │ │ AWS CLI commands │
|
| 148 |
+
│ the agent emits │ │ (client.py) │
|
| 149 |
+
└───────────────────┘ └───────────────────┘
|
| 150 |
+
```
|
| 151 |
+
|
| 152 |
+
### Episode lifecycle
|
| 153 |
+
|
| 154 |
+
1. **`reset()`** — wipes simulator state, picks next task from the curriculum, runs `setup_commands`, applies drift if applicable, returns initial observation.
|
| 155 |
+
2. **`step(action)`** — validates the command (must start with `aws `), intercepts hint requests, executes via the strategy, records in tracker, grades with shaped reward, optionally injects chaos, returns observation.
|
| 156 |
+
3. **Hint** — agent sends `aws help --task-hint`; intercepted before reaching MiniStack; returns next-level hint, increments `hints_used` (which decays final reward by `0.85^n`).
|
| 157 |
+
4. **Termination** — `task_achieved=True` or `step_count >= MAX_STEPS` (default 15).
|
| 158 |
+
|
| 159 |
+
Full mechanics in [At server/README.md file](server/README.md).
|
| 160 |
+
|
| 161 |
+
---
|
| 162 |
+
|
| 163 |
+
## 4. Live demo & Quick Start
|
| 164 |
+
|
| 165 |
+
### Try it in a browser
|
| 166 |
+
|
| 167 |
+
The hosted playground lets you click around any task without writing code:
|
| 168 |
+
|
| 169 |
+
> **[Hugging Face Spaces Playground](https://sizzing-aws-rl-env.hf.space/web#playground)**
|
| 170 |
+
|
| 171 |
+
### Python client
|
| 172 |
+
|
| 173 |
+
```python
|
| 174 |
+
from aws_rl_env import AwsRlAction, AwsRlEnv
|
| 175 |
+
|
| 176 |
+
with AwsRlEnv.from_docker_image("aws-rl-env:latest") as env:
|
| 177 |
+
result = env.reset()
|
| 178 |
+
print(f"Task: {result.observation.task.description}")
|
| 179 |
+
|
| 180 |
+
result = env.step(AwsRlAction(command="aws s3 mb s3://my-bucket"))
|
| 181 |
+
print(f"Reward: {result.reward}, Done: {result.done}")
|
| 182 |
+
```
|
| 183 |
+
|
| 184 |
+
Or against a running server:
|
| 185 |
+
|
| 186 |
+
```python
|
| 187 |
+
env = AwsRlEnv(base_url="http://localhost:8000")
|
| 188 |
+
result = env.reset()
|
| 189 |
+
result = env.step(AwsRlAction(command="aws s3 ls"))
|
| 190 |
+
```
|
| 191 |
+
|
| 192 |
+
### WebSocket API
|
| 193 |
+
|
| 194 |
+
```python
|
| 195 |
+
import websockets, json
|
| 196 |
+
|
| 197 |
+
async with websockets.connect("wss://sizzing-aws-rl-env.hf.space/ws") as ws:
|
| 198 |
+
await ws.send(json.dumps({"type": "reset"}))
|
| 199 |
+
obs = json.loads(await ws.recv())
|
| 200 |
+
|
| 201 |
+
await ws.send(json.dumps({"type": "step", "data": {"command": "aws s3 ls"}}))
|
| 202 |
+
obs = json.loads(await ws.recv())
|
| 203 |
+
```
|
| 204 |
+
|
| 205 |
+
### Local Docker
|
| 206 |
+
|
| 207 |
+
```bash
|
| 208 |
+
make docker-build # build the image
|
| 209 |
+
make docker-run # foreground; serves on :8000
|
| 210 |
+
make docker-run-detach # background
|
| 211 |
+
make docker-health # liveness probe
|
| 212 |
+
```
|
| 213 |
+
|
| 214 |
+
For training (8-way parallel rollouts):
|
| 215 |
+
|
| 216 |
+
```bash
|
| 217 |
+
AWS_RL_ENV_POOL_SIZE=8 make run
|
| 218 |
+
```
|
| 219 |
+
|
| 220 |
+
---
|
| 221 |
+
|
| 222 |
+
## 5. Run on Colab
|
| 223 |
+
|
| 224 |
+
The full pipeline is reproducible on a Colab GPU runtime. Drop your token into Colab Secrets, set `ENV_BASE_URL` to your HF Space (or local with ngrok), and run.
|
| 225 |
+
|
| 226 |
+
| Notebook | What it does | Open in Colab |
|
| 227 |
+
|-------------------------------------------------------------------------------------|-------------------------------------------------------|----------------------------------------------|
|
| 228 |
+
| [train/train_sft_lora.ipynb](train/train_sft_lora.ipynb) | Stage 1 — SFT LoRA fine-tuning of Qwen2.5-Coder-3B | https://colab.research.google.com/drive/1dm9sDaLxHX6s9zEG_SC0FQcKWKkc3TfL?usp=sharing|
|
| 229 |
+
| [train/train_grpo_lora.ipynb](train/train_grpo_lora.ipynb) | Stage 2 — GRPO RL training with multi-turn rollouts | https://colab.research.google.com/drive/1NwiOM0h_JpXXGRxfY_xZtDiaigvIaKjx?usp=sharing |
|
| 230 |
+
| [compare/compare_base_vs_sft.ipynb](compare/compare_base_vs_sft.ipynb) | Side-by-side: base model vs SFT adapter (dataset + RL env) | https://colab.research.google.com/drive/17406aiad8h4nAphV42vVNZ-a5SzZMIre?usp=sharing |
|
| 231 |
+
|
| 232 |
+
Replace each `<!-- TODO -->` with the Colab badge URL once published.
|
| 233 |
+
|
| 234 |
+
---
|
| 235 |
+
|
| 236 |
+
## 6. Action / Observation spec
|
| 237 |
+
|
| 238 |
+
The full Pydantic data models — kept inline so any reader can wire up an agent without leaving this page. Source: [models.py](models.py).
|
| 239 |
+
|
| 240 |
+
### Action
|
| 241 |
+
|
| 242 |
+
```python
|
| 243 |
+
class AwsRlAction(Action):
|
| 244 |
+
command: str # AWS CLI command, e.g. "aws s3 ls"
|
| 245 |
+
```
|
| 246 |
+
|
| 247 |
+
The environment validates that `command` starts with `aws `; anything else is rejected with `success=False`.
|
| 248 |
+
|
| 249 |
+
### Observation
|
| 250 |
+
|
| 251 |
+
```python
|
| 252 |
+
class AwsRlObservation(Observation):
|
| 253 |
+
episode_id: EpisodeID
|
| 254 |
+
step_count: StepCount
|
| 255 |
+
command_success: bool # exit code == 0
|
| 256 |
+
command_output: str # stdout from the AWS CLI invocation
|
| 257 |
+
error: str # stderr (empty if success)
|
| 258 |
+
task: TaskInfo | None # masked task definition (no success criteria)
|
| 259 |
+
task_achieved: bool
|
| 260 |
+
partial_progress: float # current task progress in [0.0, 1.0]
|
| 261 |
+
hints_used: int # cumulative hint count this episode
|
| 262 |
+
hint_text: str # most recent hint text (if any)
|
| 263 |
+
```
|
| 264 |
+
|
| 265 |
+
### State
|
| 266 |
+
|
| 267 |
+
```python
|
| 268 |
+
class AwsRlState(State):
|
| 269 |
+
current_task: Task | None # full task assigned for the episode
|
| 270 |
+
tracker: TrackerState # episode tracker snapshot
|
| 271 |
+
infra_state: dict # AWS infrastructure state keyed by service name
|
| 272 |
+
chaos_occurred: bool # whether chaos was injected this episode
|
| 273 |
+
current_tier: str # agent's current difficulty tier
|
| 274 |
+
|
| 275 |
+
class TrackerState:
|
| 276 |
+
step_count: int # steps taken this episode
|
| 277 |
+
hints_used: int # hints requested this episode
|
| 278 |
+
progress: float # current partial progress [0.0, 1.0]
|
| 279 |
+
commands_executed: list[str] # commands executed this episode
|
| 280 |
+
credited_operations: list[str] # (operation, resource) pairs that earned credit
|
| 281 |
+
```
|
| 282 |
+
|
| 283 |
+
### Task definitions
|
| 284 |
+
|
| 285 |
+
```python
|
| 286 |
+
class Task:
|
| 287 |
+
task_id: TaskID
|
| 288 |
+
difficulty: TaskDifficulty # warmup | beginner | intermediate | advanced | expert
|
| 289 |
+
description: str # human-readable goal
|
| 290 |
+
success_criteria: SuccessCriteria
|
| 291 |
+
setup_commands: list[SetupCommand] # pre-provision for SRE tasks
|
| 292 |
+
desired_state_spec: str | None # natural-language desired end state (drift tasks)
|
| 293 |
+
possible_drifts: list[SetupCommand] # pool of mutations for DriftEngine
|
| 294 |
+
|
| 295 |
+
class TaskInfo:
|
| 296 |
+
"""Agent-visible subset of Task — masks success_criteria, setup_commands, and possible_drifts."""
|
| 297 |
+
task_id: TaskID
|
| 298 |
+
difficulty: TaskDifficulty
|
| 299 |
+
description: str
|
| 300 |
+
desired_state_spec: str | None
|
| 301 |
+
|
| 302 |
+
class SuccessCriteria:
|
| 303 |
+
command_contains: str | None # warmup/beginner
|
| 304 |
+
operation: str | None # warmup/beginner
|
| 305 |
+
resource_exists: ResourceExistsCheck | None # beginner
|
| 306 |
+
steps: list[StepCriteria] # intermediate/advanced/expert
|
| 307 |
+
services: list[AwsService] # advanced/expert
|
| 308 |
+
state_checks: list[StateCheck] # expert (ground truth)
|
| 309 |
+
```
|
| 310 |
+
|
| 311 |
+
### Curriculum config
|
| 312 |
+
|
| 313 |
+
```python
|
| 314 |
+
class TierConfig:
|
| 315 |
+
min_episodes: int # minimum episodes before promotion
|
| 316 |
+
advance_rate: float # tier success rate threshold (0.6 - 1.0)
|
| 317 |
+
mastery_window: int # sliding window size (default: 10)
|
| 318 |
+
mastery_threshold: float # per-task graduation threshold (default: 0.7)
|
| 319 |
+
fast_track_rate: float # early promotion threshold (default: 0.9)
|
| 320 |
+
chaos_probability: float # probability of chaos injection per step
|
| 321 |
+
|
| 322 |
+
class SpacedRepState:
|
| 323 |
+
interval: int # episodes until next re-test (3 → 48)
|
| 324 |
+
last_graduated_episode: int # when last graduated
|
| 325 |
+
```
|
| 326 |
+
|
| 327 |
+
---
|
| 328 |
+
|
| 329 |
+
## 7. Curriculum & Reward (overview)
|
| 330 |
+
|
| 331 |
+
The curriculum and reward stack is the heart of the project. This section is the elevator pitch; **the full mechanics — priority scoring math, anti-reward-hacking layers, chaos engine, drift engine — live in [server/README.md](server/README.md)**.
|
| 332 |
+
|
| 333 |
+
### Priority scoring (one-formula task selection)
|
| 334 |
+
|
| 335 |
+
```
|
| 336 |
+
score = novelty_bonus # +100 if never attempted
|
| 337 |
+
+ weakness_weight # +50 × (1 − task_success_rate)
|
| 338 |
+
+ spaced_rep_bonus # +30 if a graduated task is "due" for re-test
|
| 339 |
+
− recency_penalty # −20 if attempted in the last 2 episodes
|
| 340 |
+
```
|
| 341 |
+
|
| 342 |
+
Exploration, weakness-targeting, anti-forgetting, and variety — all balanced by one weighted sum.
|
| 343 |
+
|
| 344 |
+
### Reward shaping
|
| 345 |
+
|
| 346 |
+
```
|
| 347 |
+
if task_achieved:
|
| 348 |
+
reward = 1.0
|
| 349 |
+
if survived_chaos: reward *= 1.05 # chaos survival bonus
|
| 350 |
+
else:
|
| 351 |
+
reward = partial_progress * 0.8 # ≤ 0.8 from steps alone
|
| 352 |
+
if progress_increased: reward += 0.1 # dense progress signal
|
| 353 |
+
if command_failed: reward *= 0.5 # error penalty
|
| 354 |
+
reward -= 0.1 * rollback_count # waste penalty
|
| 355 |
+
reward += 0.02 * idempotent_retries # graceful retry bonus
|
| 356 |
+
reward = clamp(reward, 0.0, 0.99) # 1.0 reserved for completion
|
| 357 |
+
|
| 358 |
+
reward *= 0.85 ** hints_used # hint decay applied last
|
| 359 |
+
```
|
| 360 |
+
|
| 361 |
+
The agent's loss surface is intentionally narrow: only doing the task earns full reward, and every reward-hacking shortcut we identified during design has a defense layer (full list in [Server's Readme file section §9](server/README.md#9-anti-reward-hacking--8-defense-layers)).
|
| 362 |
+
|
| 363 |
+
> 
|
| 364 |
+
|
| 365 |
+
---
|
| 366 |
+
|
| 367 |
+
## 8. Training pipeline (SFT → GRPO)
|
| 368 |
+
|
| 369 |
+
The training pipeline runs in two stages, both reproducible on Colab. Full detail in **[train/README.md](train/README.md)**.
|
| 370 |
+
|
| 371 |
+
```
|
| 372 |
+
┌────────── data/sft/ ──────────┐
|
| 373 |
+
│ 1,500 train · 150 val rows │
|
| 374 |
+
│ 5 trajectory types │
|
| 375 |
+
└───────────────┬───────────────┘
|
| 376 |
+
▼
|
| 377 |
+
STAGE 1 — Supervised Fine-Tuning train/train_sft_lora.ipynb
|
| 378 |
+
Qwen2.5-Coder-3B-Instruct + LoRA r=8/16/32 (Optuna) → SFT adapter
|
| 379 |
+
│
|
| 380 |
+
│ Sizzing/aws-rl-sft-qwen25coder3b-adapter
|
| 381 |
+
▼
|
| 382 |
+
STAGE 2 — GRPO RL train/train_grpo_lora.ipynb
|
| 383 |
+
G=8 parallel rollouts · multi-turn · reward = env return
|
| 384 |
+
Optuna over (lr, β, G, T, top_p, lora_r, max_turns)
|
| 385 |
+
```
|
| 386 |
+
|
| 387 |
+
### Numbers worth knowing
|
| 388 |
+
|
| 389 |
+
| | |
|
| 390 |
+
|---|---|
|
| 391 |
+
| **Base model** | `unsloth/Qwen2.5-Coder-3B-Instruct-bnb-4bit` — picked via [Through model evaluation](data/sft/MODEL_EVALUATION.md) |
|
| 392 |
+
| **SFT LoRA** | `r ∈ {8,16,32}`, `lora_alpha = r × multiplier`, target = attention only, dropout `[0.005, 0.031]` |
|
| 393 |
+
| **GRPO config** | `G=8`, `β=0.04`, `lr=5e-6`, `T=0.9`, `top_p=0.95`, `max_turns=6`, loss=`dapo` |
|
| 394 |
+
| **Optuna search** | TPE sampler, 6 trials × 30 GRPO steps, frozen 10-task held-out val set |
|
| 395 |
+
| **Final training** | 200 GRPO steps with best config |
|
| 396 |
+
|
| 397 |
+
### Training graphs
|
| 398 |
+
|
| 399 |
+
> Embed once notebook is executed:
|
| 400 |
+
> 
|
| 401 |
+
> 
|
| 402 |
+
> 
|
| 403 |
+
> 
|
| 404 |
+
|
| 405 |
+
---
|
| 406 |
+
|
| 407 |
+
## 9. Parallel rollout architecture
|
| 408 |
+
|
| 409 |
+
GRPO needs `G` rollouts on the same task per training step. We run all G in parallel with **state isolation guaranteed**. Three coordinated pool layers make it work:
|
| 410 |
+
|
| 411 |
+
```
|
| 412 |
+
Trainer (G=8 generations needed per step)
|
| 413 |
+
│
|
| 414 |
+
┌────────────────────┼────────────────────┐
|
| 415 |
+
▼ ▼ ▼
|
| 416 |
+
MultiTurnEnvPool GrpoPool (in-process)
|
| 417 |
+
(train_grpo.py) (scripts/grpo_pool.py)
|
| 418 |
+
sync API async API
|
| 419 |
+
│ │
|
| 420 |
+
└─────── 8 WebSocket connections ────────┘
|
| 421 |
+
│
|
| 422 |
+
▼
|
| 423 |
+
FastAPI server :8000
|
| 424 |
+
+ OpenEnv max_concurrent_envs=8
|
| 425 |
+
│
|
| 426 |
+
▼
|
| 427 |
+
MiniStackPool (free-list, lock-guarded)
|
| 428 |
+
acquire(port) on connect, release on disconnect
|
| 429 |
+
│
|
| 430 |
+
▼
|
| 431 |
+
8 isolated MiniStack instances :4566..:4573
|
| 432 |
+
```
|
| 433 |
+
|
| 434 |
+
Wall-clock impact: an 8-rollout × 6-turn episode runs in ~300 ms of env time vs ~2.4 s sequential. Full mechanics, including the **all-or-nothing connect protocol** that prevents pool-slot leakage on flake, are in **[Scripts README file](scripts/README.md)**.
|
| 435 |
+
|
| 436 |
+
> 
|
| 437 |
+
|
| 438 |
+
---
|
| 439 |
+
|
| 440 |
+
## 10. MiniStack: vendored & customized
|
| 441 |
+
|
| 442 |
+
The simulator powering the env is **vendored** as a git subtree at [aws_infra/](aws_infra/), not pulled as a black-box dependency. We forked it because we needed:
|
| 443 |
+
|
| 444 |
+
1. A custom `/_ministack/state` JSON endpoint so the grader can read the entire infra inventory in **one HTTP call** instead of iterating 20+ list APIs per grading pass. Added in commit `a648c3a "feat: Add support for service state retrieval and action listing across multiple AWS services"`.
|
| 445 |
+
2. A reproducible build with no runtime network requirement — the Docker image bundles a specific MiniStack revision.
|
| 446 |
+
3. The freedom to extend service coverage on demand.
|
| 447 |
+
|
| 448 |
+
Custom commits live as small, isolated patches so periodic upstream syncs (`af2e945`, `579597b`) replay cleanly. To inspect:
|
| 449 |
+
|
| 450 |
+
```bash
|
| 451 |
+
git show a648c3a # the state-endpoint diff
|
| 452 |
+
git log --oneline -- aws_infra/ # only the aws_infra subtree history
|
| 453 |
+
```
|
| 454 |
+
|
| 455 |
+
Full subtree workflow + commit-by-commit detail in [server/README.md §5](server/README.md#5-ministack-vendored-fork--customizations). Upstream MiniStack docs (81 KB) are preserved at [aws_infra/README.md](aws_infra/README.md).
|
| 456 |
+
|
| 457 |
+
---
|
| 458 |
+
|
| 459 |
+
## 11. Results & Benchmarks
|
| 460 |
+
|
| 461 |
+
### Base-model selection
|
| 462 |
+
|
| 463 |
+
We evaluated 11 chat models on 27 held-out prompts. **Qwen2.5-Coder-3B-Instruct** wins on every metric that matters: 41% exact match (highest), 63% operation match (highest), 3.1 s/call (3× faster than the 4B runner-up). Full report:
|
| 464 |
+
|
| 465 |
+
> **[data/sft/MODEL_EVALUATION.md](data/sft/MODEL_EVALUATION.md)** — 270-line writeup, per-model verdicts, methodology
|
| 466 |
+
|
| 467 |
+
> 
|
| 468 |
+
|
| 469 |
+
### Base vs SFT — actual results
|
| 470 |
+
|
| 471 |
+
After running the SFT pipeline end-to-end, the eval delta on the same held-out prompts is striking:
|
| 472 |
+
|
| 473 |
+
| Metric | Base | Post-SFT | Delta |
|
| 474 |
+
|-----------------|:------:|:--------:|:-----------:|
|
| 475 |
+
| `format_pct` | 33.3% | **100.0%** | **+66.7 pp** |
|
| 476 |
+
| `exact_pct` | 38.9% | **88.9%** | **+50.0 pp** |
|
| 477 |
+
| `service_pct` | 77.8% | **88.9%** | +11.1 pp |
|
| 478 |
+
| `operation_pct` | 61.1% | **88.9%** | +27.8 pp |
|
| 479 |
+
| `avg_len` | 85.8 | 74.7 | −11 chars (tighter) |
|
| 480 |
+
|
| 481 |
+
> 
|
| 482 |
+
|
| 483 |
+
Every target from [data/sft/MODEL_EVALUATION.md §11](data/sft/MODEL_EVALUATION.md) is met or exceeded. Format compliance is now perfect; the model never wraps commands in fences or quotes after SFT. Exact-match jumped from 39% to 89% — the agent now emits the canonical command for ~9 of every 10 prompts.
|
| 484 |
+
|
| 485 |
+
The richer two-mode benchmark (dataset eval + live RL env eval) is in [compare/compare_base_vs_sft.ipynb](compare/compare_base_vs_sft.ipynb); methodology in [compare/README.md](compare/README.md).
|
| 486 |
+
|
| 487 |
+
> 
|
| 488 |
+
> 
|
| 489 |
+
|
| 490 |
+
### SFT training curves
|
| 491 |
+
|
| 492 |
+
> 
|
| 493 |
+
|
| 494 |
+
### Optuna SFT search
|
| 495 |
+
|
| 496 |
+
The best SFT trial (out of 6) used `lora_r=16, lora_alpha=16, dropout=0.0058, lr=4.03e-4, warmup=0.1` — see [train/README.md §3](train/README.md#3-optuna-hyperparameter-search) for the full Optuna study table.
|
| 497 |
+
|
| 498 |
+
> 
|
| 499 |
+
> 
|
| 500 |
+
|
| 501 |
+
### GRPO results (live multi-step env eval)
|
| 502 |
+
|
| 503 |
+
After 35 GRPO steps on top of the SFT adapter (best Optuna config: `lr=1.6e-5, β=0.0021, T=0.99`), we re-evaluated end-to-end on 100+ episodes:
|
| 504 |
+
|
| 505 |
+
| Metric | Base + SFT | Base + SFT + GRPO | Δ |
|
| 506 |
+
|-------------------------------|:---------:|:-----------------:|:------------:|
|
| 507 |
+
| Overall success rate | 86.8% | 86.2% | −0.5 pp |
|
| 508 |
+
| Overall mean reward | 0.883 | 0.877 | −0.006 |
|
| 509 |
+
| Beginner success | 96.2% | **100.0%** | **+3.8 pp** |
|
| 510 |
+
| Intermediate success | 81.0% | **87.0%** | **+6.0 pp** |
|
| 511 |
+
| Warmup success | 96.0% | 90.2% | −5.8 pp |
|
| 512 |
+
| Expert success | 22.2% | 22.2% | flat |
|
| 513 |
+
| Drift repair rate | 22.2% | 22.2% | flat |
|
| 514 |
+
| Destructive-action fail rate | 15.1% | 14.7% | −0.4 pp |
|
| 515 |
+
| Steps to solve | 1.45 | 1.55 | +0.10 |
|
| 516 |
+
|
| 517 |
+
> 
|
| 518 |
+
> 
|
| 519 |
+
|
| 520 |
+
**Honest reading:** the 35-step GRPO run preserves the SFT gains and modestly improves the middle tiers (beginner +3.8 pp, intermediate +6.0 pp) — but does not crack the **expert-tier bottleneck** (22% success on SRE / drift / security-posture tasks). With longer GRPO runs and more curriculum exposure to expert tasks, this is the next gain to chase.
|
| 521 |
+
|
| 522 |
+
### GRPO training curves
|
| 523 |
+
|
| 524 |
+
Per-step training signals from the final 35-step GRPO run:
|
| 525 |
+
|
| 526 |
+
> 
|
| 527 |
+
> 
|
| 528 |
+
|
| 529 |
+
Optuna search across 4 trials picked the final config:
|
| 530 |
+
|
| 531 |
+
> 
|
| 532 |
+
> 
|
| 533 |
+
> 
|
| 534 |
+
|
| 535 |
+
### Qualitative rollouts (post-GRPO)
|
| 536 |
+
|
| 537 |
+
One sample episode per tier:
|
| 538 |
+
|
| 539 |
+
> 
|
| 540 |
+
|
| 541 |
+
---
|
| 542 |
+
|
| 543 |
+
## 12. Repository map
|
| 544 |
+
|
| 545 |
+
| Path | Purpose | Sub-README |
|
| 546 |
+
|--------------------------------|--------------------------------------------------------------------|-----------------------------------------|
|
| 547 |
+
| [server/](server/) | OpenEnv FastAPI server, env logic, services, web playground | [server/README.md](server/README.md) |
|
| 548 |
+
| [train/](train/) | SFT and GRPO training notebooks | [train/README.md](train/README.md) |
|
| 549 |
+
| [data/](data/) | SFT dataset, base-model selection, eval harness | [data/README.md](data/README.md) · [MODEL_EVALUATION.md](data/sft/MODEL_EVALUATION.md) |
|
| 550 |
+
| [compare/](compare/) | Base vs SFT side-by-side benchmark | [compare/README.md](compare/README.md) |
|
| 551 |
+
| [scripts/](scripts/) | Parallel-rollout architecture + multi-connection demo | [scripts/README.md](scripts/README.md) |
|
| 552 |
+
| [aws_infra/](aws_infra/) | Vendored MiniStack simulator (git subtree) | [aws_infra/README.md](aws_infra/README.md) |
|
| 553 |
+
| [tests/](tests/), [tests_tasks/](tests_tasks/) | Unit + tier-integration test suites | (see [§14](#14-testing)) |
|
| 554 |
+
| [models.py](models.py) | Pydantic data models for action/observation/task | (inline §6) |
|
| 555 |
+
| [client.py](client.py) | OpenEnv HTTP/WebSocket client wrapper | — |
|
| 556 |
+
| [inference.py](inference.py) | Single-model agent loop (matches RL eval mode of `compare/`) | — |
|
| 557 |
+
| [train_grpo.py](train_grpo.py) | GRPO trainer (1,283 LOC) — `MultiTurnEnvPool`, Optuna, plotting | (see [train/README.md](train/README.md)) |
|
| 558 |
+
| [aws_rl_env_colab.ipynb](aws_rl_env_colab.ipynb) | Colab driver for the full training pipeline | — |
|
| 559 |
+
| [docs/figures/](docs/figures/) | All README graphs and screenshots | — |
|
| 560 |
+
|
| 561 |
+
---
|
| 562 |
+
|
| 563 |
+
## 13. Configuration & Running
|
| 564 |
+
|
| 565 |
+
### Docker (recommended)
|
| 566 |
+
|
| 567 |
+
```bash
|
| 568 |
+
make docker-build # build the image
|
| 569 |
+
make docker-run # foreground on :8000
|
| 570 |
+
make docker-run-detach # background
|
| 571 |
+
make docker-health # liveness probe
|
| 572 |
+
```
|
| 573 |
+
|
| 574 |
+
|
| 575 |
+
### OpenEnv deployment
|
| 576 |
+
|
| 577 |
+
```bash
|
| 578 |
+
make openenv-validate # validate config
|
| 579 |
+
make openenv-build # build environment
|
| 580 |
+
make openenv-push # push to HuggingFace Spaces
|
| 581 |
+
```
|
| 582 |
+
|
| 583 |
+
### Environment variables
|
| 584 |
+
|
| 585 |
+
| Variable | Default | Description |
|
| 586 |
+
|-------------------------------------|--------------------------|-------------------------------------------------------------------|
|
| 587 |
+
| `AWS_INFRA_URL` | `http://localhost:4566` | MiniStack endpoint (used when `POOL_SIZE=1`) |
|
| 588 |
+
| `AWS_RL_ENV_POOL_SIZE` | `1` | **Server-side MiniStack pool size; set to 8 for GRPO training** |
|
| 589 |
+
| `AWS_RL_ENV_MINISTACK_BASE_PORT` | `4566` | First MiniStack port; pool covers `[BASE, BASE + POOL_SIZE)` |
|
| 590 |
+
| `BACKEND_TYPE` | `simulator` | `simulator` (MiniStack) or `aws` (real AWS, no pool) |
|
| 591 |
+
| `AWS_ACCESS_KEY_ID` | `test` | AWS credentials (any value works for the simulator) |
|
| 592 |
+
| `AWS_SECRET_ACCESS_KEY` | `test` | AWS credentials (any value works for the simulator) |
|
| 593 |
+
| `AWS_DEFAULT_REGION` | `us-east-1` | AWS region |
|
| 594 |
+
| `MAX_STEPS` | `15` | Max steps per episode |
|
| 595 |
+
| `API_BASE_URL` | — | LLM API endpoint for [inference.py](inference.py) |
|
| 596 |
+
| `MODEL_NAME` | — | LLM model name for [inference.py](inference.py) |
|
| 597 |
+
| `HF_TOKEN` | — | HuggingFace token (dataset/adapter access, push) |
|
| 598 |
+
| `TEMPERATURE` | `0.7` | LLM sampling temperature |
|
| 599 |
+
|
| 600 |
+
### Curriculum stats API
|
| 601 |
+
|
| 602 |
+
```python
|
| 603 |
+
curriculum.get_stats()
|
| 604 |
+
# {
|
| 605 |
+
# "episode_count": 42,
|
| 606 |
+
# "tier": "intermediate",
|
| 607 |
+
# "tier_episodes": 12,
|
| 608 |
+
# "tier_success_rate": 0.75,
|
| 609 |
+
# "graduated_tasks": [0, 2, 4],
|
| 610 |
+
# "weak_spots": [11, 12],
|
| 611 |
+
# "skill_profile": {0: 0.95, 1: 0.8, ...},
|
| 612 |
+
# "spaced_rep_due": [0, 2],
|
| 613 |
+
# "avg_reward_last_10": 0.65
|
| 614 |
+
# }
|
| 615 |
+
```
|
| 616 |
+
|
| 617 |
+
---
|
| 618 |
+
|
| 619 |
+
## 14. Testing
|
| 620 |
+
|
| 621 |
+
The test suite covers both isolated unit logic and end-to-end task execution against MiniStack.
|
| 622 |
+
|
| 623 |
+
### Unit tests — [tests/](tests/)
|
| 624 |
+
|
| 625 |
+
```bash
|
| 626 |
+
pytest tests/ -v
|
| 627 |
+
```
|
| 628 |
+
|
| 629 |
+
| File | Covers |
|
| 630 |
+
|----------------------------------------------------------------------------------------------|-----------------------------------------------------------------|
|
| 631 |
+
| [test_aws_rl_env_environment.py](tests/test_aws_rl_env_environment.py) | Environment lifecycle, reset/step semantics, reward integration |
|
| 632 |
+
| [test_task_grader.py](tests/test_task_grader.py) | All 5 grading strategies, partial progress, penalties, bonuses |
|
| 633 |
+
| [test_resource_verifier.py](tests/test_resource_verifier.py) | Per-service ground-truth verification (20+ services) |
|
| 634 |
+
| [test_episode_tracker.py](tests/test_episode_tracker.py) | Command parsing, dedup, monotonic progress, rollback detection |
|
| 635 |
+
| [test_episode_context.py](tests/test_episode_context.py) | Per-episode context lifecycle |
|
| 636 |
+
| [test_drift_engine.py](tests/test_drift_engine.py) | Random drift selection, mutation application |
|
| 637 |
+
| [test_hint_provider.py](tests/test_hint_provider.py) | Three-level progressive hints, decay computation |
|
| 638 |
+
| [test_environment_designer.py](tests/test_environment_designer.py) | Setup-command provisioning |
|
| 639 |
+
| [test_pool.py](tests/test_pool.py) | Server-side `MiniStackPool` acquire/release, exhaustion |
|
| 640 |
+
| [test_grpo_pool.py](tests/test_grpo_pool.py) | Client-side `GrpoPool` connect/close, all-or-nothing rollback |
|
| 641 |
+
|
| 642 |
+
### Tier integration tests — [tests_tasks/](tests_tasks/)
|
| 643 |
+
|
| 644 |
+
```bash
|
| 645 |
+
pytest tests_tasks/ -v
|
| 646 |
+
```
|
| 647 |
+
|
| 648 |
+
134 tasks exercised end-to-end:
|
| 649 |
+
|
| 650 |
+
| File | Tasks |
|
| 651 |
+
|-----------------------------------------------------------------------------------------------------|------:|
|
| 652 |
+
| [test_warmup_tasks.py](tests_tasks/test_warmup_tasks.py) | 25 |
|
| 653 |
+
| [test_beginner_tasks.py](tests_tasks/test_beginner_tasks.py) | 25 |
|
| 654 |
+
| [test_intermediate_tasks.py](tests_tasks/test_intermediate_tasks.py) | 25 |
|
| 655 |
+
| [test_advanced_tasks.py](tests_tasks/test_advanced_tasks.py) | 25 |
|
| 656 |
+
| [test_expert_tasks.py](tests_tasks/test_expert_tasks.py) | 24 |
|
| 657 |
+
| [test_drift_tasks.py](tests_tasks/test_drift_tasks.py) | 9 |
|
| 658 |
+
| **Total** | **133** |
|
| 659 |
+
|
| 660 |
+
These tests double as the source of truth for canonical solutions used by the SFT dataset generator (extracted via AST — see [data/README.md §1](data/README.md#1-sft-dataset-generation)).
|
| 661 |
+
|
| 662 |
+
---
|
| 663 |
+
|
| 664 |
+
## 15. Tech stack
|
| 665 |
+
|
| 666 |
+
- **Python 3.12**, [`uv`](https://github.com/astral-sh/uv) for dependency management, multi-stage Docker
|
| 667 |
+
- **FastAPI**, **OpenEnv** (HTTP + WebSocket env protocol), **uvicorn**
|
| 668 |
+
- **TRL ≥ 0.21** (`GRPOTrainer`, `GRPOConfig`)
|
| 669 |
+
- **PEFT** (LoRA), **Unsloth** (4-bit quantized base, fused training kernels)
|
| 670 |
+
- **Transformers ≥ 4.45**, **datasets ≥ 2.20**, **HuggingFace Hub ≥ 0.24**
|
| 671 |
+
- **Optuna ≥ 3.6** (TPE sampler, SQLite study storage)
|
| 672 |
+
- **asyncio** + **websockets** + **httpx** (parallel rollout orchestration)
|
| 673 |
+
- **MiniStack** (vendored at [aws_infra/](aws_infra/), 34 AWS services)
|
| 674 |
+
- **AWS CLI v2** (subprocess invocation against MiniStack endpoint)
|
| 675 |
+
- **matplotlib**, **plotly** (training curves, Optuna visualizations)
|
| 676 |
+
- **pytest** (16 test files, ~250 KB of test code)
|
| 677 |
+
|
| 678 |
+
---
|
| 679 |
+
|
| 680 |
+
## 16. Links
|
| 681 |
+
|
| 682 |
+
- **Live demo**: [sizzing-aws-rl-env.hf.space/web](https://sizzing-aws-rl-env.hf.space/web)
|
| 683 |
+
- **HF Space**: [huggingface.co/spaces/Sizzing/aws_rl_env](https://huggingface.co/spaces/Sizzing/aws_rl_env)
|
| 684 |
+
- **API docs**: [/docs](https://sizzing-aws-rl-env.hf.space/docs) · [/redoc](https://sizzing-aws-rl-env.hf.space/redoc)
|
| 685 |
+
- **SFT adapter**: [Sizzing/aws-rl-sft-qwen25coder3b-adapter](https://huggingface.co/Sizzing/aws-rl-sft-qwen25coder3b-adapter)
|
| 686 |
+
- **GRPO adapter**: [Sizzing/aws-rl-grpo-qwen25coder3b-adapter](https://huggingface.co/Sizzing/aws-rl-grpo-qwen25coder3b-adapter)
|
| 687 |
+
- **Dataset**: [Sizzing/aws-rl-sft](https://huggingface.co/datasets/Sizzing/aws-rl-sft)
|
| 688 |
+
- **GitHub**: [github.com/udaykiranpadhy/aws-rl-env](https://github.com/udaykiranpadhy/aws-rl-env)
|
| 689 |
+
|
| 690 |
+
---
|
| 691 |
+
|
| 692 |
+
## 17. Acknowledgments
|
| 693 |
+
|
| 694 |
+
- **MiniStack** — vendored at [aws_infra/](aws_infra/). Upstream license preserved. Custom modifications attributable to commits `a648c3a`, `a00e981`; periodic upstream syncs `af2e945`, `579597b`.
|
| 695 |
+
- **OpenEnv** — environment protocol and Python client framework.
|
| 696 |
+
- **TRL** (HuggingFace) — `GRPOTrainer` implementation.
|
| 697 |
+
- **Unsloth** — 4-bit quantized model loaders + fused training kernels.
|
| 698 |
+
- **Google Colab** for providing their infrastructure to train models.
|
| 699 |
+
- **AWS service icons** in [server/static/img/aws/](server/static/img/aws/) — used in the web playground.
|
| 700 |
+
|
| 701 |
+
---
|
| 702 |
+
|
| 703 |
+
## Sub-README index
|
| 704 |
+
|
| 705 |
+
For deep technical detail on any subsystem:
|
| 706 |
+
|
| 707 |
+
- [server/README.md](server/README.md) — environment internals (curriculum, reward shaping, anti-hacking, chaos, drift, MiniStack-fork detail)
|
| 708 |
+
- [train/README.md](train/README.md) — SFT + GRPO training pipeline (LoRA config, Optuna search, multi-turn rollouts)
|
| 709 |
+
- [scripts/README.md](scripts/README.md) — parallel-rollout architecture (3 pool layers, all-or-nothing connect, concurrency safety)
|
| 710 |
+
- [data/README.md](data/README.md) — dataset generation (5 trajectory types, AST extraction) + base-model selection summary
|
| 711 |
+
- [data/sft/MODEL_EVALUATION.md](data/sft/MODEL_EVALUATION.md) — full 11-model benchmark report
|
| 712 |
+
- [compare/README.md](compare/README.md) — base vs SFT comparison harness
|
| 713 |
+
- [aws_infra/README.md](aws_infra/README.md) — vendored MiniStack upstream documentation (81 KB)
|
| 714 |
+
|
| 715 |
+
|
| 716 |
+
## Small Video Explanation
|
| 717 |
+
|
| 718 |
+
- [Recorded Video explaining core functionality](https://share.zight.com/NQu0pLvQ)
|
__init__.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Copyright (c) Meta Platforms, Inc. and affiliates.
|
| 2 |
+
# All rights reserved.
|
| 3 |
+
#
|
| 4 |
+
# This source code is licensed under the BSD-style license found in the
|
| 5 |
+
# LICENSE file in the root directory of this source tree.
|
| 6 |
+
|
| 7 |
+
"""Aws Rl Env Environment."""
|
| 8 |
+
|
| 9 |
+
try:
|
| 10 |
+
from .client import AwsRlEnv
|
| 11 |
+
from .models import AwsRlAction, AwsRlObservation
|
| 12 |
+
except ImportError:
|
| 13 |
+
# When imported directly (e.g. by pytest from rootdir) rather than as
|
| 14 |
+
# part of the aws_rl_env package, relative imports are unavailable.
|
| 15 |
+
pass
|
| 16 |
+
|
| 17 |
+
__all__ = [
|
| 18 |
+
"AwsRlAction",
|
| 19 |
+
"AwsRlObservation",
|
| 20 |
+
"AwsRlEnv",
|
| 21 |
+
]
|
aws_infra/CHANGELOG.md
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
aws_infra/CONTRIBUTING.md
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Contributing to MiniStack
|
| 2 |
+
|
| 3 |
+
Thanks for wanting to contribute. The codebase is intentionally simple — each AWS service is a single self-contained Python file inside `ministack/services/`. Adding a new service or fixing a bug should take minutes, not hours.
|
| 4 |
+
|
| 5 |
+
## Project Structure
|
| 6 |
+
|
| 7 |
+
```
|
| 8 |
+
ministack/
|
| 9 |
+
├── ministack/
|
| 10 |
+
│ ├── app.py # ASGI entry point, service routing, reset endpoint
|
| 11 |
+
│ ├── core/
|
| 12 |
+
│ │ ├── responses.py # json_response, error_response_json, new_uuid
|
| 13 |
+
│ │ ├── router.py # detect_service(), SERVICE_PATTERNS
|
| 14 |
+
│ │ ├── lambda_runtime.py
|
| 15 |
+
│ │ └── persistence.py
|
| 16 |
+
│ └── services/
|
| 17 |
+
│ ├── s3.py, sqs.py, sns.py, dynamodb.py, ...
|
| 18 |
+
│ └── cognito.py # example of a two-client service file
|
| 19 |
+
├── tests/
|
| 20 |
+
│ ├── conftest.py # pytest fixtures (boto3 clients)
|
| 21 |
+
│ └── test_services.py # all integration tests
|
| 22 |
+
├── Dockerfile
|
| 23 |
+
├── pyproject.toml
|
| 24 |
+
└── CHANGELOG.md
|
| 25 |
+
```
|
| 26 |
+
|
| 27 |
+
## Adding a New Service
|
| 28 |
+
|
| 29 |
+
Every service follows the same 4-step pattern:
|
| 30 |
+
|
| 31 |
+
### 1. Create `ministack/services/myservice.py`
|
| 32 |
+
|
| 33 |
+
```python
|
| 34 |
+
"""
|
| 35 |
+
MyService Emulator.
|
| 36 |
+
JSON-based API via X-Amz-Target.
|
| 37 |
+
Supports: OperationOne, OperationTwo, ...
|
| 38 |
+
"""
|
| 39 |
+
|
| 40 |
+
import json
|
| 41 |
+
import logging
|
| 42 |
+
from ministack.core.responses import json_response, error_response_json, new_uuid
|
| 43 |
+
|
| 44 |
+
logger = logging.getLogger("myservice")
|
| 45 |
+
|
| 46 |
+
ACCOUNT_ID = "000000000000"
|
| 47 |
+
REGION = "us-east-1"
|
| 48 |
+
|
| 49 |
+
_state: dict = {} # in-memory storage
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
async def handle_request(method, path, headers, body, query_params):
|
| 53 |
+
target = headers.get("x-amz-target", "")
|
| 54 |
+
action = target.split(".")[-1] if "." in target else ""
|
| 55 |
+
|
| 56 |
+
try:
|
| 57 |
+
data = json.loads(body) if body else {}
|
| 58 |
+
except json.JSONDecodeError:
|
| 59 |
+
return error_response_json("SerializationException", "Invalid JSON", 400)
|
| 60 |
+
|
| 61 |
+
handlers = {
|
| 62 |
+
"OperationOne": _operation_one,
|
| 63 |
+
"OperationTwo": _operation_two,
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
handler = handlers.get(action)
|
| 67 |
+
if not handler:
|
| 68 |
+
return error_response_json("InvalidAction", f"Unknown action: {action}", 400)
|
| 69 |
+
return handler(data)
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
def _operation_one(data):
|
| 73 |
+
return json_response({"result": "ok"})
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
def _operation_two(data):
|
| 77 |
+
return json_response({})
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
def reset():
|
| 81 |
+
_state.clear()
|
| 82 |
+
```
|
| 83 |
+
|
| 84 |
+
**Protocol guide:**
|
| 85 |
+
|
| 86 |
+
- JSON services (DynamoDB, SecretsManager, Glue, Athena, Cognito, etc.) — use `json_response` / `error_response_json`, route via `X-Amz-Target`
|
| 87 |
+
- XML/Query services (S3, SQS, SNS, IAM, STS, RDS, ElastiCache, EC2) — build XML responses, route via `Action` query param; use `_xml(status, root_tag, inner)` pattern; verify field names against botocore shapes via `Loader().load_service_model()`
|
| 88 |
+
- REST services (Lambda, ECS, Route53) — route via URL path
|
| 89 |
+
|
| 90 |
+
### 2. Register in `ministack/app.py`
|
| 91 |
+
|
| 92 |
+
```python
|
| 93 |
+
from ministack.services import myservice
|
| 94 |
+
|
| 95 |
+
SERVICE_REGISTRY = {
|
| 96 |
+
# ... existing ...
|
| 97 |
+
"myservice": {"module": "myservice"},
|
| 98 |
+
}
|
| 99 |
+
```
|
| 100 |
+
|
| 101 |
+
If the service needs aliases, add them in the registry entry.
|
| 102 |
+
|
| 103 |
+
### 3. Add detection to `ministack/core/router.py`
|
| 104 |
+
|
| 105 |
+
```python
|
| 106 |
+
SERVICE_PATTERNS = {
|
| 107 |
+
# ... existing ...
|
| 108 |
+
"myservice": {
|
| 109 |
+
"target_prefixes": ["AWSMyService"], # for X-Amz-Target routing
|
| 110 |
+
"host_patterns": [r"myservice\."], # for host-based routing
|
| 111 |
+
},
|
| 112 |
+
}
|
| 113 |
+
```
|
| 114 |
+
|
| 115 |
+
Add any credential scope or `Action`-based routing as needed.
|
| 116 |
+
|
| 117 |
+
### 4. Add a fixture to `tests/conftest.py`
|
| 118 |
+
|
| 119 |
+
```python
|
| 120 |
+
@pytest.fixture(scope="session")
|
| 121 |
+
def mysvc():
|
| 122 |
+
return make_client("myservice")
|
| 123 |
+
```
|
| 124 |
+
|
| 125 |
+
### 5. Add tests to `tests/test_services.py`
|
| 126 |
+
|
| 127 |
+
```python
|
| 128 |
+
def test_myservice_operation_one(mysvc):
|
| 129 |
+
resp = mysvc.operation_one(Param="value")
|
| 130 |
+
assert resp["result"] == "ok"
|
| 131 |
+
```
|
| 132 |
+
|
| 133 |
+
---
|
| 134 |
+
|
| 135 |
+
## Running Tests Locally
|
| 136 |
+
|
| 137 |
+
```bash
|
| 138 |
+
# Start the stack
|
| 139 |
+
docker compose up -d
|
| 140 |
+
|
| 141 |
+
# Install test dependencies
|
| 142 |
+
pip install boto3 pytest pytest-xdist duckdb docker cbor2
|
| 143 |
+
|
| 144 |
+
# Parallel-safe phase: run tests that are safe to run concurrently
|
| 145 |
+
pytest tests/ -v -n 4 --dist=loadfile -m "not serial"
|
| 146 |
+
|
| 147 |
+
# Serial/global-state phase: run tests that mutate runtime state or require isolation
|
| 148 |
+
pytest tests/ -v -m serial
|
| 149 |
+
|
| 150 |
+
# Run a specific service
|
| 151 |
+
pytest tests/ -v -k "cognito"
|
| 152 |
+
```
|
| 153 |
+
|
| 154 |
+
---
|
| 155 |
+
|
| 156 |
+
## Code Conventions
|
| 157 |
+
|
| 158 |
+
- **One file per service** — keep everything for a service in `ministack/services/myservice.py`
|
| 159 |
+
- **Imports** — always `from ministack.core.responses import ...`, never `from core.responses import ...`
|
| 160 |
+
- **In-memory state** — use module-level dicts (`_things: dict = {}`)
|
| 161 |
+
- **reset()** — every service must expose a `reset()` that clears all module-level state; it's called by `/_ministack/reset`
|
| 162 |
+
- **No external AWS deps** — no `boto3`, `botocore`, or `aws-sdk` in service code
|
| 163 |
+
- **Minimal dependencies** — `duckdb` and `docker` are optional; guard with `try/except ImportError`
|
| 164 |
+
- **Error responses** — match real AWS error codes and HTTP status codes as closely as possible
|
| 165 |
+
- **Logging** — `logger = logging.getLogger("servicename")`; DEBUG for request details, INFO for significant events
|
| 166 |
+
|
| 167 |
+
---
|
| 168 |
+
|
| 169 |
+
## Pull Request Checklist
|
| 170 |
+
|
| 171 |
+
- [ ] New service file in `ministack/services/`
|
| 172 |
+
- [ ] Registered in `ministack/app.py` SERVICE_REGISTRY
|
| 173 |
+
- [ ] Detection patterns added to `ministack/core/router.py`
|
| 174 |
+
- [ ] Fixture added to `tests/conftest.py`
|
| 175 |
+
- [ ] Tests added and passing (`pytest tests/ -v`)
|
| 176 |
+
- [ ] Linting passes (`ruff check ministack/`)
|
| 177 |
+
- [ ] Service added to the table in `README.md`
|
| 178 |
+
- [ ] Entry added to `CHANGELOG.md`
|
| 179 |
+
|
| 180 |
+
---
|
| 181 |
+
|
| 182 |
+
## What We're Looking For
|
| 183 |
+
|
| 184 |
+
High-value contributions right now:
|
| 185 |
+
|
| 186 |
+
- **CloudFront** — distribution CRUD, invalidations, origin configuration
|
| 187 |
+
- **CodeBuild / CodePipeline** — CI/CD pipeline stubs
|
| 188 |
+
- **AppSync** — GraphQL API CRUD
|
| 189 |
+
- **SQS FIFO** — message group / deduplication support
|
| 190 |
+
- **More Cognito flows** — hosted UI, federated identity providers, custom auth triggers
|
| 191 |
+
|
| 192 |
+
---
|
| 193 |
+
|
| 194 |
+
## Questions?
|
| 195 |
+
|
| 196 |
+
Open a GitHub Discussion or file an issue with the `question` label.
|
aws_infra/Dockerfile
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.12-alpine AS builder
|
| 2 |
+
|
| 3 |
+
RUN pip install --no-cache-dir --no-compile \
|
| 4 |
+
hypercorn==0.18.0 \
|
| 5 |
+
"cbor2>=5.4.0" \
|
| 6 |
+
"defusedxml>=0.7" \
|
| 7 |
+
"docker>=7.0.0" \
|
| 8 |
+
"pyyaml>=6.0" \
|
| 9 |
+
"cryptography>=41.0" \
|
| 10 |
+
"pymysql>=1.1" \
|
| 11 |
+
"boto3>=1.34" \
|
| 12 |
+
"awscli"
|
| 13 |
+
|
| 14 |
+
# Strip awscli help examples (~25 MB) and Python cache files (~15 MB).
|
| 15 |
+
RUN rm -rf /usr/local/lib/python3.12/site-packages/awscli/examples \
|
| 16 |
+
&& find /usr/local/lib/python3.12/site-packages -type d -name '__pycache__' -exec rm -rf {} + 2>/dev/null \
|
| 17 |
+
&& rm -rf /usr/local/lib/python3.12/site-packages/pip*.dist-info \
|
| 18 |
+
&& rm -rf /usr/local/lib/python3.12/site-packages/pip*
|
| 19 |
+
|
| 20 |
+
FROM python:3.12-alpine
|
| 21 |
+
|
| 22 |
+
LABEL maintainer="MiniStack" \
|
| 23 |
+
description="Local AWS Service Emulator — drop-in LocalStack replacement"
|
| 24 |
+
|
| 25 |
+
# Upgrade base packages to pick up latest security patches.
|
| 26 |
+
RUN apk upgrade --no-cache && apk add --no-cache nodejs bash && rm -f /usr/bin/wget /bin/wget \
|
| 27 |
+
&& rm -rf /usr/local/lib/python3.12/site-packages/pip* \
|
| 28 |
+
/usr/local/bin/pip*
|
| 29 |
+
|
| 30 |
+
WORKDIR /opt/ministack
|
| 31 |
+
|
| 32 |
+
# Copy cleaned Python packages and CLI entrypoints from builder.
|
| 33 |
+
COPY --from=builder /usr/local/lib/python3.12/site-packages /usr/local/lib/python3.12/site-packages
|
| 34 |
+
COPY --from=builder /usr/local/bin/aws /usr/local/bin/aws
|
| 35 |
+
COPY --from=builder /usr/local/bin/aws_completer /usr/local/bin/aws_completer
|
| 36 |
+
COPY --from=builder /usr/local/bin/hypercorn /usr/local/bin/hypercorn
|
| 37 |
+
|
| 38 |
+
COPY bin/awslocal /usr/local/bin/awslocal
|
| 39 |
+
RUN chmod +x /usr/local/bin/awslocal
|
| 40 |
+
|
| 41 |
+
COPY ministack/ ministack/
|
| 42 |
+
|
| 43 |
+
RUN addgroup -S ministack && adduser -S ministack -G ministack
|
| 44 |
+
RUN mkdir -p /tmp/ministack-data/s3 && chown -R ministack:ministack /tmp/ministack-data
|
| 45 |
+
RUN mkdir -p /docker-entrypoint-initaws.d/ready.d \
|
| 46 |
+
/etc/localstack/init/boot.d \
|
| 47 |
+
/etc/localstack/init/ready.d && \
|
| 48 |
+
chown -R ministack:ministack /docker-entrypoint-initaws.d /etc/localstack
|
| 49 |
+
VOLUME /docker-entrypoint-initaws.d
|
| 50 |
+
VOLUME /etc/localstack/init
|
| 51 |
+
|
| 52 |
+
ENV GATEWAY_PORT=4566 \
|
| 53 |
+
LOG_LEVEL=INFO \
|
| 54 |
+
S3_PERSIST=0 \
|
| 55 |
+
S3_DATA_DIR=/tmp/ministack-data/s3 \
|
| 56 |
+
REDIS_HOST=redis \
|
| 57 |
+
REDIS_PORT=6379 \
|
| 58 |
+
RDS_BASE_PORT=15432 \
|
| 59 |
+
RDS_PERSIST=0 \
|
| 60 |
+
ELASTICACHE_BASE_PORT=16379 \
|
| 61 |
+
LAMBDA_EXECUTOR=local \
|
| 62 |
+
PYTHONUNBUFFERED=1
|
| 63 |
+
|
| 64 |
+
EXPOSE 4566
|
| 65 |
+
|
| 66 |
+
# Pure Python healthcheck — no curl dependency
|
| 67 |
+
HEALTHCHECK --interval=10s --timeout=3s --start-period=5s --retries=3 \
|
| 68 |
+
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:4566/_ministack/health')" || exit 1
|
| 69 |
+
|
| 70 |
+
ENTRYPOINT ["python", "-m", "hypercorn", "ministack.app:app", "--bind", "0.0.0.0:4566", "--keep-alive", "75"]
|
aws_infra/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
MIT License
|
| 2 |
+
|
| 3 |
+
Copyright (c) 2026 MiniStack Contributors
|
| 4 |
+
|
| 5 |
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
| 6 |
+
of this software and associated documentation files (the "Software"), to deal
|
| 7 |
+
in the Software without restriction, including without limitation the rights
|
| 8 |
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
| 9 |
+
copies of the Software, and to permit persons to whom the Software is
|
| 10 |
+
furnished to do so, subject to the following conditions:
|
| 11 |
+
|
| 12 |
+
The above copyright notice and this permission notice shall be included in all
|
| 13 |
+
copies or substantial portions of the Software.
|
| 14 |
+
|
| 15 |
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
| 16 |
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
| 17 |
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
| 18 |
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
| 19 |
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
| 20 |
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
| 21 |
+
SOFTWARE.
|
aws_infra/Makefile
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
.PHONY: build run stop test logs health clean
|
| 2 |
+
|
| 3 |
+
IMAGE_NAME := ministack
|
| 4 |
+
CONTAINER_NAME := ministack
|
| 5 |
+
PORT := 4566
|
| 6 |
+
|
| 7 |
+
# Override any shell AWS credentials so make test is self-contained
|
| 8 |
+
export AWS_ACCESS_KEY_ID := test
|
| 9 |
+
export AWS_SECRET_ACCESS_KEY := test
|
| 10 |
+
export AWS_DEFAULT_REGION := us-east-1
|
| 11 |
+
unexport AWS_PROFILE
|
| 12 |
+
|
| 13 |
+
build:
|
| 14 |
+
docker build -t $(IMAGE_NAME) .
|
| 15 |
+
|
| 16 |
+
run: build
|
| 17 |
+
docker run -d --name $(CONTAINER_NAME) -p $(PORT):4566 \
|
| 18 |
+
-e LOG_LEVEL=INFO \
|
| 19 |
+
-v /var/run/docker.sock:/var/run/docker.sock \
|
| 20 |
+
$(IMAGE_NAME)
|
| 21 |
+
@echo "MiniStack running on http://localhost:$(PORT)"
|
| 22 |
+
@echo "Health: http://localhost:$(PORT)/_ministack/health"
|
| 23 |
+
|
| 24 |
+
run-compose:
|
| 25 |
+
docker compose up -d --build
|
| 26 |
+
@echo "MiniStack running on http://localhost:$(PORT)"
|
| 27 |
+
|
| 28 |
+
stop:
|
| 29 |
+
docker stop $(CONTAINER_NAME) 2>/dev/null || true
|
| 30 |
+
docker rm $(CONTAINER_NAME) 2>/dev/null || true
|
| 31 |
+
|
| 32 |
+
stop-compose:
|
| 33 |
+
docker compose down
|
| 34 |
+
|
| 35 |
+
logs:
|
| 36 |
+
docker logs -f $(CONTAINER_NAME)
|
| 37 |
+
|
| 38 |
+
health:
|
| 39 |
+
@curl -s http://localhost:$(PORT)/_ministack/health | python3 -m json.tool
|
| 40 |
+
|
| 41 |
+
test: stop run
|
| 42 |
+
@echo "Waiting for ministack to be ready..."
|
| 43 |
+
@READY=0; \
|
| 44 |
+
for i in $$(seq 1 30); do \
|
| 45 |
+
if curl -sf http://localhost:$(PORT)/_ministack/health > /dev/null 2>&1; then \
|
| 46 |
+
echo "Ready after $$i second(s)."; READY=1; break; \
|
| 47 |
+
fi; \
|
| 48 |
+
sleep 1; \
|
| 49 |
+
done; \
|
| 50 |
+
if [ "$$READY" = "0" ]; then echo "ERROR: ministack did not start within 30s" >&2; exit 1; fi
|
| 51 |
+
@echo "=== S3 ==="
|
| 52 |
+
aws --endpoint-url=http://localhost:$(PORT) s3 mb s3://test-bucket
|
| 53 |
+
echo "hello" | aws --endpoint-url=http://localhost:$(PORT) s3 cp - s3://test-bucket/hello.txt
|
| 54 |
+
aws --endpoint-url=http://localhost:$(PORT) s3 ls s3://test-bucket
|
| 55 |
+
aws --endpoint-url=http://localhost:$(PORT) s3 cp s3://test-bucket/hello.txt -
|
| 56 |
+
@echo ""
|
| 57 |
+
@echo "=== SQS ==="
|
| 58 |
+
aws --endpoint-url=http://localhost:$(PORT) sqs create-queue --queue-name test-queue
|
| 59 |
+
aws --endpoint-url=http://localhost:$(PORT) sqs send-message --queue-url http://localhost:$(PORT)/000000000000/test-queue --message-body "hello sqs"
|
| 60 |
+
aws --endpoint-url=http://localhost:$(PORT) sqs receive-message --queue-url http://localhost:$(PORT)/000000000000/test-queue
|
| 61 |
+
@echo ""
|
| 62 |
+
@echo "=== DynamoDB ==="
|
| 63 |
+
aws --endpoint-url=http://localhost:$(PORT) dynamodb create-table \
|
| 64 |
+
--table-name TestTable \
|
| 65 |
+
--attribute-definitions AttributeName=pk,AttributeType=S \
|
| 66 |
+
--key-schema AttributeName=pk,KeyType=HASH \
|
| 67 |
+
--billing-mode PAY_PER_REQUEST
|
| 68 |
+
aws --endpoint-url=http://localhost:$(PORT) dynamodb put-item \
|
| 69 |
+
--table-name TestTable \
|
| 70 |
+
--item '{"pk":{"S":"key1"},"data":{"S":"value1"}}'
|
| 71 |
+
aws --endpoint-url=http://localhost:$(PORT) dynamodb get-item \
|
| 72 |
+
--table-name TestTable \
|
| 73 |
+
--key '{"pk":{"S":"key1"}}'
|
| 74 |
+
@echo ""
|
| 75 |
+
@echo "=== SNS ==="
|
| 76 |
+
aws --endpoint-url=http://localhost:$(PORT) sns create-topic --name test-topic
|
| 77 |
+
aws --endpoint-url=http://localhost:$(PORT) sns list-topics
|
| 78 |
+
@echo ""
|
| 79 |
+
@echo "=== STS ==="
|
| 80 |
+
aws --endpoint-url=http://localhost:$(PORT) sts get-caller-identity
|
| 81 |
+
@echo ""
|
| 82 |
+
@echo "=== SecretsManager ==="
|
| 83 |
+
aws --endpoint-url=http://localhost:$(PORT) secretsmanager create-secret --name test-secret --secret-string '{"user":"admin","pass":"s3cr3t"}'
|
| 84 |
+
aws --endpoint-url=http://localhost:$(PORT) secretsmanager get-secret-value --secret-id test-secret
|
| 85 |
+
@echo ""
|
| 86 |
+
@echo "=== Lambda ==="
|
| 87 |
+
aws --endpoint-url=http://localhost:$(PORT) lambda list-functions
|
| 88 |
+
@echo ""
|
| 89 |
+
@echo "=== ALB/ELBv2 ==="
|
| 90 |
+
@python3 -c "\
|
| 91 |
+
import zipfile; \
|
| 92 |
+
z = zipfile.ZipFile('/tmp/ms-alb-test.zip', 'w'); \
|
| 93 |
+
z.writestr('index.py', 'import json\ndef handler(event, context):\n return {\"statusCode\": 200, \"headers\": {\"Content-Type\": \"application/json\"}, \"body\": json.dumps({\"ok\": True, \"path\": event[\"path\"]})}\n'); \
|
| 94 |
+
z.close(); \
|
| 95 |
+
print('Lambda zip created')"
|
| 96 |
+
aws --endpoint-url=http://localhost:$(PORT) lambda create-function \
|
| 97 |
+
--function-name alb-test-fn --runtime python3.12 \
|
| 98 |
+
--handler index.handler \
|
| 99 |
+
--role arn:aws:iam::000000000000:role/role \
|
| 100 |
+
--zip-file fileb:///tmp/ms-alb-test.zip
|
| 101 |
+
@LB_ARN=$$(aws --endpoint-url=http://localhost:$(PORT) elbv2 create-load-balancer \
|
| 102 |
+
--name test-alb --query 'LoadBalancers[0].LoadBalancerArn' --output text) && \
|
| 103 |
+
TG_ARN=$$(aws --endpoint-url=http://localhost:$(PORT) elbv2 create-target-group \
|
| 104 |
+
--name test-tg --target-type lambda --protocol HTTP --port 80 \
|
| 105 |
+
--vpc-id vpc-00000001 --query 'TargetGroups[0].TargetGroupArn' --output text) && \
|
| 106 |
+
FN_ARN=$$(aws --endpoint-url=http://localhost:$(PORT) lambda get-function \
|
| 107 |
+
--function-name alb-test-fn --query 'Configuration.FunctionArn' --output text) && \
|
| 108 |
+
aws --endpoint-url=http://localhost:$(PORT) elbv2 register-targets \
|
| 109 |
+
--target-group-arn $$TG_ARN --targets Id=$$FN_ARN && \
|
| 110 |
+
aws --endpoint-url=http://localhost:$(PORT) elbv2 create-listener \
|
| 111 |
+
--load-balancer-arn $$LB_ARN --protocol HTTP --port 80 \
|
| 112 |
+
--default-actions Type=forward,TargetGroupArn=$$TG_ARN && \
|
| 113 |
+
RESULT=$$(curl -sf http://localhost:$(PORT)/_alb/test-alb/ping) && \
|
| 114 |
+
echo "ALB response: $$RESULT" && \
|
| 115 |
+
echo "$$RESULT" | python3 -c "import sys,json; d=json.load(sys.stdin); assert d['ok'] and d['path']=='/ping', f'Unexpected response: {d}'" && \
|
| 116 |
+
echo "ALB -> Lambda routing: OK"
|
| 117 |
+
@echo ""
|
| 118 |
+
@echo "=== All tests passed ==="
|
| 119 |
+
|
| 120 |
+
clean: stop
|
| 121 |
+
docker rmi $(IMAGE_NAME) 2>/dev/null || true
|
| 122 |
+
|
| 123 |
+
purge: stop-compose
|
| 124 |
+
docker rm -f $$(docker ps -aq --filter "label=ministack") 2>/dev/null || true
|
| 125 |
+
docker volume prune -f
|
| 126 |
+
rm -rf ./data/s3/*
|
| 127 |
+
@echo "Orphaned ministack containers, dangling volumes, and S3 data cleared"
|
aws_infra/README.md
ADDED
|
@@ -0,0 +1,1163 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<p align="center">
|
| 2 |
+
<img src="ministack_logo.png" alt="MiniStack — Free Open-Source AWS Emulator" width="400"/>
|
| 3 |
+
</p>
|
| 4 |
+
|
| 5 |
+
<h1 align="center">MiniStack</h1>
|
| 6 |
+
<p align="center"><strong>Free, open-source local AWS emulator. Free forever.</strong></p>
|
| 7 |
+
<p align="center">40+ AWS services on a single port · Terraform compatible · Real databases · MIT licensed</p>
|
| 8 |
+
|
| 9 |
+
<p align="center">
|
| 10 |
+
<a href="https://github.com/ministackorg/ministack/releases"><img src="https://img.shields.io/github/v/release/ministackorg/ministack" alt="GitHub release"></a>
|
| 11 |
+
<a href="https://github.com/ministackorg/ministack/actions"><img src="https://img.shields.io/github/actions/workflow/status/ministackorg/ministack/ci.yml?branch=master" alt="Build"></a>
|
| 12 |
+
<a href="https://hub.docker.com/r/ministackorg/ministack"><img src="https://img.shields.io/docker/pulls/ministackorg/ministack" alt="Docker Pulls"></a>
|
| 13 |
+
<a href="https://hub.docker.com/r/ministackorg/ministack"><img src="https://img.shields.io/docker/image-size/ministackorg/ministack/latest" alt="Docker Image Size"></a>
|
| 14 |
+
<a href="https://github.com/ministackorg/ministack/blob/master/LICENSE"><img src="https://img.shields.io/github/license/ministackorg/ministack" alt="License"></a>
|
| 15 |
+
<img src="https://img.shields.io/badge/python-3.12-blue" alt="Python">
|
| 16 |
+
<a href="https://github.com/ministackorg/ministack/stargazers"><img src="https://img.shields.io/github/stars/ministackorg/ministack" alt="GitHub stars"></a>
|
| 17 |
+
</p>
|
| 18 |
+
|
| 19 |
+
<p align="center">
|
| 20 |
+
<a href="https://ministack.org">Website</a> · <a href="https://hub.docker.com/r/ministackorg/ministack">Docker Hub</a> · <a href="https://www.linkedin.com/company/ministackorg/">LinkedIn</a> · <a href="https://www.producthunt.com/products/ministack">Product Hunt</a>
|
| 21 |
+
</p>
|
| 22 |
+
|
| 23 |
+
---
|
| 24 |
+
|
| 25 |
+
## Why MiniStack?
|
| 26 |
+
|
| 27 |
+
LocalStack recently moved its core services behind a paid plan. If you relied on LocalStack Community for local development and CI/CD pipelines, MiniStack is your free alternative.
|
| 28 |
+
|
| 29 |
+
- **40+ AWS services** emulated on a single port (4566)
|
| 30 |
+
- **Drop-in compatible** — works with `boto3`, AWS CLI, Terraform, CDK, Pulumi, any SDK
|
| 31 |
+
- **Real infrastructure** — RDS spins up actual Postgres/MySQL containers, ElastiCache spins up real Redis, Athena runs real SQL via DuckDB, ECS runs real Docker containers
|
| 32 |
+
- **Tiny footprint** — ~270MB image, ~21MB RAM at idle vs LocalStack's ~1GB image and ~500MB RAM
|
| 33 |
+
- **Fast startup** — under 2 seconds, HTTP/2 (h2c) supported
|
| 34 |
+
- **MIT licensed** — use it, fork it, contribute to it
|
| 35 |
+
|
| 36 |
+
---
|
| 37 |
+
|
| 38 |
+
## Quick Start
|
| 39 |
+
|
| 40 |
+
```bash
|
| 41 |
+
# Option 1: PyPI (simplest)
|
| 42 |
+
pip install ministack
|
| 43 |
+
ministack
|
| 44 |
+
# Runs on http://localhost:4566 — use GATEWAY_PORT=XXXX to change
|
| 45 |
+
|
| 46 |
+
# Option 2: Docker Hub
|
| 47 |
+
docker run -p 4566:4566 ministackorg/ministack
|
| 48 |
+
|
| 49 |
+
# Option 2b: Docker Hub with real infrastructure (RDS, ECS, Lambda containers)
|
| 50 |
+
docker run -p 4566:4566 -v /var/run/docker.sock:/var/run/docker.sock ministackorg/ministack
|
| 51 |
+
|
| 52 |
+
# Option 3: Clone and build
|
| 53 |
+
git clone https://github.com/ministackorg/ministack
|
| 54 |
+
cd ministack
|
| 55 |
+
docker compose up -d
|
| 56 |
+
|
| 57 |
+
# Verify (any option)
|
| 58 |
+
curl http://localhost:4566/_ministack/health
|
| 59 |
+
```
|
| 60 |
+
|
| 61 |
+
That's it. No account, no API key, no sign-up.
|
| 62 |
+
|
| 63 |
+
---
|
| 64 |
+
|
| 65 |
+
## Internal API
|
| 66 |
+
|
| 67 |
+
MiniStack exposes internal endpoints for test automation:
|
| 68 |
+
|
| 69 |
+
```bash
|
| 70 |
+
# Health check — returns service status
|
| 71 |
+
curl http://localhost:4566/_ministack/health
|
| 72 |
+
|
| 73 |
+
# Reset all state — wipe every service back to empty (useful between test runs)
|
| 74 |
+
curl -X POST http://localhost:4566/_ministack/reset
|
| 75 |
+
|
| 76 |
+
# Reset and re-run init scripts (boot.d + ready.d)
|
| 77 |
+
curl -X POST http://localhost:4566/_ministack/reset?init=1
|
| 78 |
+
|
| 79 |
+
# Runtime config — change service-level settings without restart
|
| 80 |
+
curl -X POST http://localhost:4566/_ministack/config \
|
| 81 |
+
-H "Content-Type: application/json" \
|
| 82 |
+
-d '{"lambda_svc.LAMBDA_EXECUTOR": "docker"}'
|
| 83 |
+
```
|
| 84 |
+
|
| 85 |
+
The reset endpoint is especially useful in CI pipelines and test suites — call it in `setUp`/`beforeEach` to get a clean environment for every test without restarting the container. Add `?init=1` to re-run your init scripts after the reset, restoring any resources they create (VPCs, queues, seed data, etc.).
|
| 86 |
+
|
| 87 |
+
The config endpoint supports these keys:
|
| 88 |
+
|
| 89 |
+
| Key | Description |
|
| 90 |
+
|-----|-------------|
|
| 91 |
+
| `lambda_svc.LAMBDA_EXECUTOR` | Lambda execution mode (`local` or `docker`) |
|
| 92 |
+
| `athena.ATHENA_ENGINE` | Athena query engine (`duckdb` or `mock`) |
|
| 93 |
+
| `athena.ATHENA_DATA_DIR` | Directory for Athena DuckDB data files |
|
| 94 |
+
| `stepfunctions._sfn_mock_config` | SFN mock config (AWS SFN Local compatible) |
|
| 95 |
+
| `stepfunctions._SFN_WAIT_SCALE` | Scale factor for Wait state durations and retry sleeps (`0` = skip all waits) |
|
| 96 |
+
|
| 97 |
+
To set region or account ID, use environment variables at startup:
|
| 98 |
+
|
| 99 |
+
```bash
|
| 100 |
+
docker run -p 4566:4566 \
|
| 101 |
+
-e MINISTACK_REGION=eu-west-1 \
|
| 102 |
+
-e MINISTACK_ACCOUNT_ID=123456789012 \
|
| 103 |
+
ministackorg/ministack
|
| 104 |
+
```
|
| 105 |
+
|
| 106 |
+
Or use the multi-tenancy feature — a 12-digit access key automatically becomes the account ID (see [Multi-Tenancy](#multi-tenancy) below).
|
| 107 |
+
|
| 108 |
+
Also compatible with LocalStack's health endpoint:
|
| 109 |
+
|
| 110 |
+
```bash
|
| 111 |
+
curl http://localhost:4566/_localstack/health
|
| 112 |
+
curl http://localhost:4566/health
|
| 113 |
+
```
|
| 114 |
+
|
| 115 |
+
---
|
| 116 |
+
|
| 117 |
+
## Multi-Tenancy
|
| 118 |
+
|
| 119 |
+
MiniStack supports lightweight multi-tenancy without any configuration. If the `AWS_ACCESS_KEY_ID` is a **12-digit number**, it is used as the **Account ID** for all ARN generation. Non-numeric keys (like `test`) fall back to the `MINISTACK_ACCOUNT_ID` env var or `000000000000`.
|
| 120 |
+
|
| 121 |
+
```bash
|
| 122 |
+
# Team A — gets account 111111111111
|
| 123 |
+
export AWS_ACCESS_KEY_ID=111111111111
|
| 124 |
+
export AWS_SECRET_ACCESS_KEY=anything
|
| 125 |
+
aws --endpoint-url=http://localhost:4566 sts get-caller-identity
|
| 126 |
+
# → { "Account": "111111111111", ... }
|
| 127 |
+
|
| 128 |
+
# Team B — gets account 222222222222
|
| 129 |
+
export AWS_ACCESS_KEY_ID=222222222222
|
| 130 |
+
export AWS_SECRET_ACCESS_KEY=anything
|
| 131 |
+
aws --endpoint-url=http://localhost:4566 sts get-caller-identity
|
| 132 |
+
# → { "Account": "222222222222", ... }
|
| 133 |
+
```
|
| 134 |
+
|
| 135 |
+
All ARNs and resource state (SQS queues, Lambda functions, IAM roles, S3 buckets, DynamoDB tables, etc.) are fully isolated per account. Resources with the same name in different accounts never collide. This allows multiple developers or CI pipelines to share a single MiniStack endpoint with complete tenant isolation — no extra setup needed.
|
| 136 |
+
|
| 137 |
+
| Access Key | Account ID Used |
|
| 138 |
+
|---|---|
|
| 139 |
+
| `111111111111` | `111111111111` |
|
| 140 |
+
| `048408301323` | `048408301323` |
|
| 141 |
+
| `test` | `000000000000` (default) |
|
| 142 |
+
| `AKIAIOSFODNN7EXAMPLE` | `000000000000` (default) |
|
| 143 |
+
|
| 144 |
+
**Terraform** — set `access_key` in your provider block:
|
| 145 |
+
```hcl
|
| 146 |
+
provider "aws" {
|
| 147 |
+
access_key = "048408301323"
|
| 148 |
+
secret_key = "test"
|
| 149 |
+
region = "us-east-1"
|
| 150 |
+
endpoints { ... }
|
| 151 |
+
}
|
| 152 |
+
```
|
| 153 |
+
|
| 154 |
+
**boto3** — pass `aws_access_key_id`:
|
| 155 |
+
```python
|
| 156 |
+
boto3.client("s3",
|
| 157 |
+
endpoint_url="http://localhost:4566",
|
| 158 |
+
aws_access_key_id="048408301323",
|
| 159 |
+
aws_secret_access_key="test",
|
| 160 |
+
)
|
| 161 |
+
```
|
| 162 |
+
|
| 163 |
+
---
|
| 164 |
+
|
| 165 |
+
## Using with AWS CLI
|
| 166 |
+
|
| 167 |
+
```bash
|
| 168 |
+
# Option A — environment variables (no profile needed)
|
| 169 |
+
export AWS_ACCESS_KEY_ID=test
|
| 170 |
+
export AWS_SECRET_ACCESS_KEY=test
|
| 171 |
+
export AWS_DEFAULT_REGION=us-east-1
|
| 172 |
+
|
| 173 |
+
aws --endpoint-url=http://localhost:4566 s3 mb s3://my-bucket
|
| 174 |
+
aws --endpoint-url=http://localhost:4566 sqs create-queue --queue-name my-queue
|
| 175 |
+
aws --endpoint-url=http://localhost:4566 dynamodb list-tables
|
| 176 |
+
aws --endpoint-url=http://localhost:4566 sts get-caller-identity
|
| 177 |
+
|
| 178 |
+
# Option B — named profile (must pass --profile on every command)
|
| 179 |
+
aws configure --profile local
|
| 180 |
+
# AWS Access Key ID: test
|
| 181 |
+
# AWS Secret Access Key: test
|
| 182 |
+
# Default region: us-east-1
|
| 183 |
+
# Default output format: json
|
| 184 |
+
|
| 185 |
+
aws --profile local --endpoint-url=http://localhost:4566 s3 mb s3://my-bucket
|
| 186 |
+
aws --profile local --endpoint-url=http://localhost:4566 s3 cp ./file.txt s3://my-bucket/
|
| 187 |
+
aws --profile local --endpoint-url=http://localhost:4566 sqs create-queue --queue-name my-queue
|
| 188 |
+
aws --profile local --endpoint-url=http://localhost:4566 dynamodb list-tables
|
| 189 |
+
aws --profile local --endpoint-url=http://localhost:4566 sts get-caller-identity
|
| 190 |
+
```
|
| 191 |
+
|
| 192 |
+
### awslocal wrapper
|
| 193 |
+
|
| 194 |
+
```bash
|
| 195 |
+
chmod +x bin/awslocal
|
| 196 |
+
./bin/awslocal s3 ls
|
| 197 |
+
./bin/awslocal dynamodb list-tables
|
| 198 |
+
```
|
| 199 |
+
|
| 200 |
+
---
|
| 201 |
+
|
| 202 |
+
## Using with boto3
|
| 203 |
+
|
| 204 |
+
```python
|
| 205 |
+
import boto3
|
| 206 |
+
|
| 207 |
+
# All clients use the same endpoint
|
| 208 |
+
def client(service):
|
| 209 |
+
return boto3.client(
|
| 210 |
+
service,
|
| 211 |
+
endpoint_url="http://localhost:4566",
|
| 212 |
+
aws_access_key_id="test",
|
| 213 |
+
aws_secret_access_key="test",
|
| 214 |
+
region_name="us-east-1",
|
| 215 |
+
)
|
| 216 |
+
|
| 217 |
+
# S3
|
| 218 |
+
s3 = client("s3")
|
| 219 |
+
s3.create_bucket(Bucket="my-bucket")
|
| 220 |
+
s3.put_object(Bucket="my-bucket", Key="hello.txt", Body=b"Hello, MiniStack!")
|
| 221 |
+
obj = s3.get_object(Bucket="my-bucket", Key="hello.txt")
|
| 222 |
+
print(obj["Body"].read()) # b'Hello, MiniStack!'
|
| 223 |
+
|
| 224 |
+
# SQS
|
| 225 |
+
sqs = client("sqs")
|
| 226 |
+
q = sqs.create_queue(QueueName="my-queue")
|
| 227 |
+
sqs.send_message(QueueUrl=q["QueueUrl"], MessageBody="hello")
|
| 228 |
+
msgs = sqs.receive_message(QueueUrl=q["QueueUrl"])
|
| 229 |
+
print(msgs["Messages"][0]["Body"]) # hello
|
| 230 |
+
|
| 231 |
+
# DynamoDB
|
| 232 |
+
ddb = client("dynamodb")
|
| 233 |
+
ddb.create_table(
|
| 234 |
+
TableName="Users",
|
| 235 |
+
KeySchema=[{"AttributeName": "userId", "KeyType": "HASH"}],
|
| 236 |
+
AttributeDefinitions=[{"AttributeName": "userId", "AttributeType": "S"}],
|
| 237 |
+
BillingMode="PAY_PER_REQUEST",
|
| 238 |
+
)
|
| 239 |
+
ddb.put_item(TableName="Users", Item={"userId": {"S": "u1"}, "name": {"S": "Alice"}})
|
| 240 |
+
|
| 241 |
+
# SSM Parameter Store
|
| 242 |
+
ssm = client("ssm")
|
| 243 |
+
ssm.put_parameter(Name="/app/db/host", Value="localhost", Type="String")
|
| 244 |
+
param = ssm.get_parameter(Name="/app/db/host")
|
| 245 |
+
print(param["Parameter"]["Value"]) # localhost
|
| 246 |
+
|
| 247 |
+
# Secrets Manager
|
| 248 |
+
sm = client("secretsmanager")
|
| 249 |
+
sm.create_secret(Name="db-password", SecretString='{"password":"s3cr3t"}')
|
| 250 |
+
|
| 251 |
+
# Kinesis
|
| 252 |
+
kin = client("kinesis")
|
| 253 |
+
kin.create_stream(StreamName="events", ShardCount=1)
|
| 254 |
+
kin.put_record(StreamName="events", Data=b'{"event":"click"}', PartitionKey="user1")
|
| 255 |
+
|
| 256 |
+
# EventBridge
|
| 257 |
+
eb = client("events")
|
| 258 |
+
eb.put_events(Entries=[{
|
| 259 |
+
"Source": "myapp",
|
| 260 |
+
"DetailType": "UserSignup",
|
| 261 |
+
"Detail": '{"userId": "123"}',
|
| 262 |
+
"EventBusName": "default",
|
| 263 |
+
}])
|
| 264 |
+
|
| 265 |
+
# Step Functions
|
| 266 |
+
sfn = client("stepfunctions")
|
| 267 |
+
sfn.create_state_machine(
|
| 268 |
+
name="my-workflow",
|
| 269 |
+
definition='{"StartAt":"Hello","States":{"Hello":{"Type":"Pass","End":true}}}',
|
| 270 |
+
roleArn="arn:aws:iam::000000000000:role/role",
|
| 271 |
+
)
|
| 272 |
+
|
| 273 |
+
# Step Functions — TestState API (test a single state in isolation)
|
| 274 |
+
# Note: inject_host_prefix=False prevents boto3 from prepending "sync-" to the hostname
|
| 275 |
+
from botocore.config import Config as BotoConfig
|
| 276 |
+
sfn_test = client("stepfunctions", config=BotoConfig(inject_host_prefix=False))
|
| 277 |
+
|
| 278 |
+
result = sfn_test.test_state(
|
| 279 |
+
definition='{"Type":"Pass","Result":{"greeting":"hello"},"End":true}',
|
| 280 |
+
input='{"name":"world"}',
|
| 281 |
+
)
|
| 282 |
+
print(result["status"]) # SUCCEEDED
|
| 283 |
+
print(result["output"]) # {"greeting": "hello"}
|
| 284 |
+
|
| 285 |
+
# TestState with mock — test error handling without calling real services
|
| 286 |
+
result = sfn_test.test_state(
|
| 287 |
+
definition=json.dumps({
|
| 288 |
+
"Type": "Task",
|
| 289 |
+
"Resource": "arn:aws:lambda:us-east-1:000000000000:function:my-fn",
|
| 290 |
+
"Catch": [{"ErrorEquals": ["States.ALL"], "Next": "Fallback"}],
|
| 291 |
+
"End": True
|
| 292 |
+
}),
|
| 293 |
+
input='{}',
|
| 294 |
+
inspectionLevel="DEBUG",
|
| 295 |
+
mock={"errorOutput": {"error": "ServiceError", "cause": "Timeout"}},
|
| 296 |
+
)
|
| 297 |
+
print(result["status"]) # CAUGHT_ERROR
|
| 298 |
+
print(result["nextState"]) # Fallback
|
| 299 |
+
|
| 300 |
+
# EC2
|
| 301 |
+
ec2 = client("ec2")
|
| 302 |
+
reservation = ec2.run_instances(
|
| 303 |
+
ImageId="ami-00000001",
|
| 304 |
+
MinCount=1,
|
| 305 |
+
MaxCount=1,
|
| 306 |
+
InstanceType="t3.micro",
|
| 307 |
+
)
|
| 308 |
+
instance_id = reservation["Instances"][0]["InstanceId"]
|
| 309 |
+
print(instance_id) # i-xxxxxxxxxxxxxxxxx
|
| 310 |
+
|
| 311 |
+
# Security Groups
|
| 312 |
+
sg = ec2.create_security_group(GroupName="my-sg", Description="My SG")
|
| 313 |
+
ec2.authorize_security_group_ingress(
|
| 314 |
+
GroupId=sg["GroupId"],
|
| 315 |
+
IpPermissions=[{"IpProtocol": "tcp", "FromPort": 80, "ToPort": 80,
|
| 316 |
+
"IpRanges": [{"CidrIp": "0.0.0.0/0"}]}],
|
| 317 |
+
)
|
| 318 |
+
|
| 319 |
+
# VPC / Subnet
|
| 320 |
+
vpc = ec2.create_vpc(CidrBlock="10.0.0.0/16")
|
| 321 |
+
subnet = ec2.create_subnet(
|
| 322 |
+
VpcId=vpc["Vpc"]["VpcId"],
|
| 323 |
+
CidrBlock="10.0.1.0/24",
|
| 324 |
+
AvailabilityZone="us-east-1a",
|
| 325 |
+
)
|
| 326 |
+
```
|
| 327 |
+
|
| 328 |
+
---
|
| 329 |
+
|
| 330 |
+
## Supported Services
|
| 331 |
+
|
| 332 |
+
### Core Services
|
| 333 |
+
|
| 334 |
+
| Service | Operations | Notes |
|
| 335 |
+
|---------|-----------|-------|
|
| 336 |
+
| **S3** | CreateBucket, DeleteBucket, ListBuckets, HeadBucket, PutObject, GetObject, DeleteObject, HeadObject, CopyObject, ListObjects v1/v2, DeleteObjects, GetBucketVersioning, PutBucketVersioning, GetBucketEncryption, PutBucketEncryption, DeleteBucketEncryption, GetBucketLifecycleConfiguration, PutBucketLifecycleConfiguration, DeleteBucketLifecycle, GetBucketCors, PutBucketCors, DeleteBucketCors, GetBucketAcl, PutBucketAcl, GetBucketTagging, PutBucketTagging, DeleteBucketTagging, GetBucketPolicy, PutBucketPolicy, DeleteBucketPolicy, GetBucketNotificationConfiguration, PutBucketNotificationConfiguration, GetBucketLogging, PutBucketLogging, ListObjectVersions, CreateMultipartUpload, UploadPart, CompleteMultipartUpload, AbortMultipartUpload, PutObjectLockConfiguration, GetObjectLockConfiguration, PutObjectRetention, GetObjectRetention, PutObjectLegalHold, GetObjectLegalHold, PutBucketReplication, GetBucketReplication, DeleteBucketReplication | Optional disk persistence via `S3_PERSIST=1`; Object Lock with retention & legal hold enforcement on delete |
|
| 337 |
+
| **SQS** | CreateQueue, DeleteQueue, ListQueues, GetQueueUrl, GetQueueAttributes, SetQueueAttributes, PurgeQueue, SendMessage, ReceiveMessage, DeleteMessage, ChangeMessageVisibility, ChangeMessageVisibilityBatch, SendMessageBatch, DeleteMessageBatch, TagQueue, UntagQueue, ListQueueTags | Both Query API and JSON protocol; FIFO queues with deduplication; DLQ support |
|
| 338 |
+
| **SNS** | CreateTopic, DeleteTopic, ListTopics, GetTopicAttributes, SetTopicAttributes, Subscribe, Unsubscribe, ListSubscriptions, ListSubscriptionsByTopic, GetSubscriptionAttributes, SetSubscriptionAttributes, ConfirmSubscription, Publish, PublishBatch, TagResource, UntagResource, ListTagsForResource, CreatePlatformApplication, CreatePlatformEndpoint | SNS→SQS fanout delivery; SNS→Lambda fanout (synchronous invocation); FIFO topics with 5-minute deduplication, sequence numbers, content-based deduplication, and subscription validation |
|
| 339 |
+
| **DynamoDB** | CreateTable, UpdateTable, DeleteTable, DescribeTable, ListTables, PutItem, GetItem, DeleteItem, UpdateItem, Query, Scan, BatchWriteItem, BatchGetItem, TransactWriteItems, TransactGetItems, DescribeTimeToLive, UpdateTimeToLive, DescribeContinuousBackups, UpdateContinuousBackups, DescribeEndpoints, TagResource, UntagResource, ListTagsOfResource | TTL enforced via thread-safe background reaper (60s cadence); DynamoDB Streams — `StreamSpecification` emits INSERT/MODIFY/REMOVE records on all write operations, respects `StreamViewType` |
|
| 340 |
+
| **Lambda** | CreateFunction, DeleteFunction, GetFunction, GetFunctionConfiguration, ListFunctions, Invoke, UpdateFunctionCode, UpdateFunctionConfiguration, AddPermission, RemovePermission, GetPolicy, ListVersionsByFunction, PublishVersion, CreateAlias, GetAlias, UpdateAlias, DeleteAlias, ListAliases, TagResource, UntagResource, ListTags, CreateEventSourceMapping, DeleteEventSourceMapping, GetEventSourceMapping, ListEventSourceMappings, UpdateEventSourceMapping, CreateFunctionUrlConfig, GetFunctionUrlConfig, UpdateFunctionUrlConfig, DeleteFunctionUrlConfig, ListFunctionUrlConfigs, PutFunctionConcurrency, GetFunctionConcurrency, DeleteFunctionConcurrency, PutFunctionEventInvokeConfig, GetFunctionEventInvokeConfig, DeleteFunctionEventInvokeConfig, PublishLayerVersion, GetLayerVersion, GetLayerVersionByArn, ListLayerVersions, DeleteLayerVersion, ListLayers, AddLayerVersionPermission, RemoveLayerVersionPermission, GetLayerVersionPolicy | Python and Node.js runtimes execute with warm worker pool; `provided.al2023`/`provided.al2` runtimes execute via Docker RIE (Go, Rust, C++ support); `Publish=True` creates immutable numbered versions; Code via `ZipFile`, `S3Bucket`/`S3Key`, or `ImageUri` (Docker image); `PackageType: Image` pulls and invokes user-provided Docker images via Lambda RIE; SQS, Kinesis, and DynamoDB Streams event source mappings; Function URL CRUD; Lambda Layers CRUD; Aliases; Concurrency; EventInvokeConfig |
|
| 341 |
+
| **IAM** | CreateUser, GetUser, ListUsers, DeleteUser, CreateRole, GetRole, ListRoles, DeleteRole, CreatePolicy, GetPolicy, DeletePolicy, AttachRolePolicy, DetachRolePolicy, PutRolePolicy, GetRolePolicy, DeleteRolePolicy, ListRolePolicies, ListAttachedRolePolicies, CreateAccessKey, ListAccessKeys, DeleteAccessKey, CreateInstanceProfile, GetInstanceProfile, DeleteInstanceProfile, AddRoleToInstanceProfile, RemoveRoleFromInstanceProfile, ListInstanceProfiles, CreateGroup, GetGroup, AddUserToGroup, RemoveUserFromGroup, CreateServiceLinkedRole, DeleteServiceLinkedRole, GetServiceLinkedRoleDeletionStatus, CreateOpenIDConnectProvider, TagRole, UntagRole, TagUser, UntagUser, TagPolicy, UntagPolicy | |
|
| 342 |
+
| **STS** | GetCallerIdentity, AssumeRole, GetSessionToken, AssumeRoleWithWebIdentity | |
|
| 343 |
+
| **SecretsManager** | CreateSecret, GetSecretValue, ListSecrets, DeleteSecret, UpdateSecret, DescribeSecret, PutSecretValue, UpdateSecretVersionStage, RestoreSecret, RotateSecret, GetRandomPassword, ListSecretVersionIds, ReplicateSecretToRegions, TagResource, UntagResource, PutResourcePolicy, GetResourcePolicy, DeleteResourcePolicy, ValidateResourcePolicy | |
|
| 344 |
+
| **CloudWatch Logs** | CreateLogGroup, DeleteLogGroup, DescribeLogGroups, CreateLogStream, DeleteLogStream, DescribeLogStreams, PutLogEvents, GetLogEvents, FilterLogEvents, PutRetentionPolicy, DeleteRetentionPolicy, PutSubscriptionFilter, DeleteSubscriptionFilter, DescribeSubscriptionFilters, PutMetricFilter, DeleteMetricFilter, DescribeMetricFilters, TagLogGroup, UntagLogGroup, ListTagsLogGroup, TagResource, UntagResource, ListTagsForResource, StartQuery, GetQueryResults, StopQuery, PutDestination, DeleteDestination, DescribeDestinations, PutDestinationPolicy | `FilterLogEvents` supports `*`/`?` globs, multi-term AND, `-term` exclusion |
|
| 345 |
+
|
| 346 |
+
### Extended Services
|
| 347 |
+
|
| 348 |
+
| Service | Operations | Notes |
|
| 349 |
+
|---------|-----------|-------|
|
| 350 |
+
| **SSM Parameter Store** | PutParameter, GetParameter, GetParameters, GetParametersByPath, DeleteParameter, DeleteParameters, DescribeParameters, GetParameterHistory, LabelParameterVersion, AddTagsToResource, RemoveTagsFromResource, ListTagsForResource | Supports String, SecureString, StringList |
|
| 351 |
+
| **EventBridge** | CreateEventBus, UpdateEventBus, DeleteEventBus, ListEventBuses, DescribeEventBus, PutRule, DeleteRule, ListRules, DescribeRule, EnableRule, DisableRule, PutTargets, RemoveTargets, ListTargetsByRule, ListRuleNamesByTarget, PutEvents, TestEventPattern, TagResource, UntagResource, ListTagsForResource, CreateArchive, DeleteArchive, DescribeArchive, UpdateArchive, ListArchives, PutPermission, RemovePermission, CreateConnection, DescribeConnection, DeleteConnection, UpdateConnection, DeauthorizeConnection, ListConnections, CreateApiDestination, DescribeApiDestination, DeleteApiDestination, UpdateApiDestination, ListApiDestinations, StartReplay, DescribeReplay, ListReplays, CancelReplay, CreateEndpoint, DeleteEndpoint, DescribeEndpoint, ListEndpoints, UpdateEndpoint, ActivateEventSource, DeactivateEventSource, DescribeEventSource, CreatePartnerEventSource, DeletePartnerEventSource, DescribePartnerEventSource, ListPartnerEventSources, ListPartnerEventSourceAccounts, ListEventSources, PutPartnerEvents | Lambda target dispatch on PutEvents; S3 EventBridge notifications; replays and SaaS/partner APIs are control-plane stubs |
|
| 352 |
+
| **Kinesis** | CreateStream, DeleteStream, DescribeStream, DescribeStreamSummary, ListStreams, ListShards, PutRecord, PutRecords, GetShardIterator, GetRecords, IncreaseStreamRetentionPeriod, DecreaseStreamRetentionPeriod, MergeShards, SplitShard, UpdateShardCount, StartStreamEncryption, StopStreamEncryption, EnableEnhancedMonitoring, DisableEnhancedMonitoring, RegisterStreamConsumer, DeregisterStreamConsumer, ListStreamConsumers, DescribeStreamConsumer, AddTagsToStream, RemoveTagsFromStream, ListTagsForStream | Partition key → shard routing; AWS limits enforced (1 MB/record, 500 records/batch, 5 MB payload, 256-char partition key) |
|
| 353 |
+
| **CloudWatch Metrics** | PutMetricData, GetMetricStatistics, GetMetricData, ListMetrics, PutMetricAlarm, PutCompositeAlarm, DescribeAlarms, DescribeAlarmsForMetric, DescribeAlarmHistory, DeleteAlarms, SetAlarmState, EnableAlarmActions, DisableAlarmActions, TagResource, UntagResource, ListTagsForResource, PutDashboard, GetDashboard, DeleteDashboards, ListDashboards | CBOR and JSON protocol |
|
| 354 |
+
| **SES** | SendEmail, SendRawEmail, SendTemplatedEmail, SendBulkTemplatedEmail, VerifyEmailIdentity, VerifyEmailAddress, VerifyDomainIdentity, VerifyDomainDkim, ListIdentities, ListVerifiedEmailAddresses, GetIdentityVerificationAttributes, GetIdentityDkimAttributes, DeleteIdentity, GetSendQuota, GetSendStatistics, SetIdentityNotificationTopic, SetIdentityFeedbackForwardingEnabled, CreateConfigurationSet, DeleteConfigurationSet, DescribeConfigurationSet, ListConfigurationSets, CreateTemplate, GetTemplate, UpdateTemplate, DeleteTemplate, ListTemplates | Emails stored in-memory, not sent |
|
| 355 |
+
| **SES v2** | SendEmail, CreateEmailIdentity, GetEmailIdentity, DeleteEmailIdentity, ListEmailIdentities, CreateConfigurationSet, GetConfigurationSet, DeleteConfigurationSet, ListConfigurationSets, GetAccount, PutAccountSuppressionAttributes, ListSuppressedDestinations | REST API (`/v2/email/`); identities auto-verified; emails stored in-memory, not sent |
|
| 356 |
+
| **ACM** | RequestCertificate, DescribeCertificate, ListCertificates, DeleteCertificate, GetCertificate, ImportCertificate, AddTagsToCertificate, RemoveTagsFromCertificate, ListTagsForCertificate, UpdateCertificateOptions, RenewCertificate, ResendValidationEmail | Certificates auto-issued; DNS validation records generated; supports SANs |
|
| 357 |
+
| **WAF v2** | CreateWebACL, GetWebACL, UpdateWebACL, DeleteWebACL, ListWebACLs, AssociateWebACL, DisassociateWebACL, GetWebACLForResource, ListResourcesForWebACL, CreateIPSet, GetIPSet, UpdateIPSet, DeleteIPSet, ListIPSets, CreateRuleGroup, GetRuleGroup, UpdateRuleGroup, DeleteRuleGroup, ListRuleGroups, TagResource, UntagResource, ListTagsForResource, CheckCapacity, DescribeManagedRuleGroup | LockToken enforced on Update/Delete; resource associations tracked |
|
| 358 |
+
| **Step Functions** | CreateStateMachine, DeleteStateMachine, DescribeStateMachine, UpdateStateMachine, ListStateMachines, StartExecution, StartSyncExecution, StopExecution, DescribeExecution, DescribeStateMachineForExecution, ListExecutions, GetExecutionHistory, SendTaskSuccess, SendTaskFailure, SendTaskHeartbeat, CreateActivity, DeleteActivity, DescribeActivity, ListActivities, GetActivityTask, TestState, TagResource, UntagResource, ListTagsForResource | Full ASL interpreter; Retry/Catch; waitForTaskToken; Activities (worker pattern); Pass/Task/Choice/Wait/Succeed/Fail/Map/Parallel; TestState API with mock and inspectionLevel support; SFN_MOCK_CONFIG for AWS SFN Local compatible mock testing; intrinsic functions (States.StringToJson, States.JsonToString, States.JsonMerge, States.Format); nested startExecution.sync |
|
| 359 |
+
| **API Gateway v2** | CreateApi, GetApi, GetApis, UpdateApi, DeleteApi, CreateRoute, GetRoute, GetRoutes, UpdateRoute, DeleteRoute, CreateIntegration, GetIntegration, GetIntegrations, UpdateIntegration, DeleteIntegration, CreateRouteResponse, GetRouteResponse, GetRouteResponses, UpdateRouteResponse, DeleteRouteResponse, CreateIntegrationResponse, GetIntegrationResponse, GetIntegrationResponses, UpdateIntegrationResponse, DeleteIntegrationResponse, CreateStage, GetStage, GetStages, UpdateStage, DeleteStage, CreateDeployment, GetDeployment, GetDeployments, DeleteDeployment, CreateAuthorizer, GetAuthorizer, GetAuthorizers, UpdateAuthorizer, DeleteAuthorizer, TagResource, UntagResource, GetTags, PostToConnection, GetConnection, DeleteConnection | **HTTP API** and **WebSocket API** (`protocolType=WEBSOCKET`); Lambda proxy (`AWS_PROXY`), HTTP proxy (`HTTP_PROXY`), and MOCK integrations; HTTP data plane via `{apiId}.execute-api.localhost` Host header or path-based `/_aws/execute-api/{apiId}/{stage}/{path}` (no DNS/Host override needed — works from browsers on macOS and strict clients); `$default` stage served from the URL root (no stage segment in the path); per-API `corsConfiguration` applied to preflights + dispatched responses; qualified-alias integration URIs (`arn:...:function:<name>:<alias>`) resolve to the alias's target version; WebSocket data plane on the same two URL forms, with `$connect` / `$disconnect` / `$default` / custom-action routing, `$request.body.*` RouteSelectionExpression, `@connections` management API (PostToConnection / GetConnection / DeleteConnection), per-connection outbox for server-side push; `{param}` / `{proxy+}` matching; JWT/Lambda authorizer CRUD; pin `apiId` across runs with the `ms-custom-id` tag |
|
| 360 |
+
| **API Gateway v1** | CreateRestApi, GetRestApi, GetRestApis, UpdateRestApi, DeleteRestApi, CreateResource, GetResource, GetResources, UpdateResource, DeleteResource, PutMethod, GetMethod, DeleteMethod, UpdateMethod, PutMethodResponse, GetMethodResponse, DeleteMethodResponse, PutIntegration, GetIntegration, DeleteIntegration, UpdateIntegration, PutIntegrationResponse, GetIntegrationResponse, DeleteIntegrationResponse, CreateDeployment, GetDeployment, GetDeployments, UpdateDeployment, DeleteDeployment, CreateStage, GetStage, GetStages, UpdateStage, DeleteStage, CreateAuthorizer, GetAuthorizer, GetAuthorizers, UpdateAuthorizer, DeleteAuthorizer, CreateModel, GetModel, GetModels, DeleteModel, CreateApiKey, GetApiKey, GetApiKeys, UpdateApiKey, DeleteApiKey, CreateUsagePlan, GetUsagePlan, GetUsagePlans, UpdateUsagePlan, DeleteUsagePlan, CreateUsagePlanKey, GetUsagePlanKeys, DeleteUsagePlanKey, CreateDomainName, GetDomainName, GetDomainNames, DeleteDomainName, CreateBasePathMapping, GetBasePathMapping, GetBasePathMappings, DeleteBasePathMapping, TagResource, UntagResource, GetTags | REST API (v1) protocol; Lambda proxy format 1.0 (AWS_PROXY), HTTP proxy (HTTP_PROXY), MOCK integration; data plane via `{apiId}.execute-api.localhost` Host header, path-based `/_aws/execute-api/{apiId}/{stage}/{path}`, or legacy `/restapis/{apiId}/{stage}/_user_request_/{path}`; qualified-alias integration URIs (`arn:...:function:<name>:<alias>`) resolve to the alias's target version; resource tree with `{param}` and `{proxy+}` path matching; JSON Patch for all PATCH operations; state persistence; pin `id` across runs with the `ms-custom-id` tag |
|
| 361 |
+
| **ELBv2 / ALB** | CreateLoadBalancer, DescribeLoadBalancers, DeleteLoadBalancer, DescribeLoadBalancerAttributes, ModifyLoadBalancerAttributes, CreateTargetGroup, DescribeTargetGroups, ModifyTargetGroup, DeleteTargetGroup, DescribeTargetGroupAttributes, ModifyTargetGroupAttributes, CreateListener, DescribeListeners, ModifyListener, DeleteListener, CreateRule, DescribeRules, ModifyRule, DeleteRule, SetRulePriorities, RegisterTargets, DeregisterTargets, DescribeTargetHealth, AddTags, RemoveTags, DescribeTags | Control plane + data plane; ALB→Lambda live traffic routing; `path-pattern`, `host-header`, `http-method`, `query-string`, `http-header` rule conditions; `forward`, `redirect`, `fixed-response` actions; data plane via `{lb-name}.alb.localhost` Host header or `/_alb/{lb-name}/` path prefix |
|
| 362 |
+
| **KMS** | CreateKey, ListKeys, DescribeKey, GetPublicKey, Sign, Verify, Encrypt, Decrypt, GenerateDataKey, GenerateDataKeyWithoutPlaintext, CreateAlias, DeleteAlias, ListAliases, UpdateAlias, EnableKeyRotation, DisableKeyRotation, GetKeyRotationStatus, GetKeyPolicy, PutKeyPolicy, ListKeyPolicies, EnableKey, DisableKey, ScheduleKeyDeletion, CancelKeyDeletion, TagResource, UntagResource, ListResourceTags | 27 actions; RSA (2048/4096), ECC (SECG_P256K1, NIST P-256/384/521), and symmetric keys; PKCS1v15, PSS, and ECDSA signing; envelope encryption; alias resolution; key rotation; key policies; tags; enable/disable/schedule deletion; full Terraform `aws_kms_key` compatible; `cryptography` package included in Docker image |
|
| 363 |
+
| **CloudFront** | CreateDistribution, GetDistribution, GetDistributionConfig, ListDistributions, UpdateDistribution, DeleteDistribution, CreateInvalidation, ListInvalidations, GetInvalidation, CreateOriginAccessControl, GetOriginAccessControl, GetOriginAccessControlConfig, ListOriginAccessControls, UpdateOriginAccessControl, DeleteOriginAccessControl, TagResource, UntagResource, ListTagsForResource | REST/XML API; ETag-based optimistic concurrency; Origin Access Control (OAC) with SigV4 signing for S3, MediaStore, Lambda, MediaPackageV2 origins; field validation and name uniqueness enforcement |
|
| 364 |
+
|
| 365 |
+
### CloudFormation
|
| 366 |
+
|
| 367 |
+
| Feature | Details |
|
| 368 |
+
|---------|---------|
|
| 369 |
+
| **Stack Operations** | CreateStack, UpdateStack, DeleteStack, DescribeStacks, ListStacks, DescribeStackEvents, DescribeStackResource, DescribeStackResources, GetTemplate, ValidateTemplate, GetTemplateSummary |
|
| 370 |
+
| **Change Sets** | CreateChangeSet, DescribeChangeSet, ExecuteChangeSet, DeleteChangeSet, ListChangeSets |
|
| 371 |
+
| **Exports** | ListExports — cross-stack references via `Fn::ImportValue` |
|
| 372 |
+
| **Template Formats** | JSON and YAML (including `!Ref`, `!Sub`, `!GetAtt` shorthand tags) |
|
| 373 |
+
| **Intrinsic Functions** | Ref, Fn::GetAtt, Fn::Join, Fn::Sub (both forms), Fn::Select, Fn::Split, Fn::If, Fn::Equals, Fn::And, Fn::Or, Fn::Not, Fn::Base64, Fn::FindInMap, Fn::ImportValue, Fn::GetAZs, Fn::Cidr |
|
| 374 |
+
| **Pseudo-Parameters** | AWS::StackName, AWS::StackId, AWS::Region, AWS::AccountId, AWS::URLSuffix, AWS::Partition, AWS::NoValue |
|
| 375 |
+
| **Parameters** | Default values, AllowedValues validation, NoEcho masking, String/Number/CommaDelimitedList types |
|
| 376 |
+
| **Conditions** | Fn::Equals, Fn::And, Fn::Or, Fn::Not — conditional resource creation |
|
| 377 |
+
| **Rollback** | Configurable via `DisableRollback` — on failure, previously created resources are cleaned up in reverse dependency order |
|
| 378 |
+
| **Async Status** | Stacks deploy asynchronously (`CREATE_IN_PROGRESS` → `CREATE_COMPLETE`) — poll with DescribeStacks |
|
| 379 |
+
|
| 380 |
+
**Supported Resource Types:**
|
| 381 |
+
|
| 382 |
+
| Resource Type | Ref Returns | GetAtt |
|
| 383 |
+
|---------------|-------------|--------|
|
| 384 |
+
| `AWS::S3::Bucket` | Bucket name | Arn, DomainName, RegionalDomainName, WebsiteURL |
|
| 385 |
+
| `AWS::SQS::Queue` | Queue URL | Arn, QueueName, QueueUrl |
|
| 386 |
+
| `AWS::SNS::Topic` | Topic ARN | TopicArn, TopicName |
|
| 387 |
+
| `AWS::SNS::Subscription` | Subscription ARN | — |
|
| 388 |
+
| `AWS::DynamoDB::Table` | Table name | Arn, StreamArn |
|
| 389 |
+
| `AWS::Lambda::Function` | Function name | Arn |
|
| 390 |
+
| `AWS::IAM::Role` | Role name | Arn, RoleId |
|
| 391 |
+
| `AWS::IAM::Policy` | Policy ARN | — |
|
| 392 |
+
| `AWS::IAM::InstanceProfile` | Profile name | Arn |
|
| 393 |
+
| `AWS::SSM::Parameter` | Parameter name | Type, Value |
|
| 394 |
+
| `AWS::Logs::LogGroup` | Log group name | Arn |
|
| 395 |
+
| `AWS::Events::EventBus` | EventBus name | Arn, Name |
|
| 396 |
+
| `AWS::Events::Rule` | Rule name | Arn |
|
| 397 |
+
| `AWS::Kinesis::Stream` | Stream name | Arn, StreamId |
|
| 398 |
+
| `AWS::Lambda::Permission` | Statement ID | — |
|
| 399 |
+
| `AWS::Lambda::Version` | Version ARN | Version |
|
| 400 |
+
| `AWS::Lambda::Alias` | Alias ARN | — |
|
| 401 |
+
| `AWS::Lambda::EventSourceMapping` | UUID | — |
|
| 402 |
+
| `AWS::S3::BucketPolicy` | Bucket name | — |
|
| 403 |
+
| `AWS::SQS::QueuePolicy` | Policy ID | — |
|
| 404 |
+
| `AWS::SNS::TopicPolicy` | Policy ID | — |
|
| 405 |
+
| `AWS::ApiGateway::RestApi` | API ID | RootResourceId |
|
| 406 |
+
| `AWS::ApiGateway::Resource` | Resource ID | — |
|
| 407 |
+
| `AWS::ApiGateway::Method` | Method ID | — |
|
| 408 |
+
| `AWS::ApiGateway::Deployment` | Deployment ID | — |
|
| 409 |
+
| `AWS::ApiGateway::Stage` | Stage name | — |
|
| 410 |
+
| `AWS::AppSync::GraphQLApi` | API ID | Arn, GraphQLUrl, ApiId |
|
| 411 |
+
| `AWS::AppSync::DataSource` | DataSource name | DataSourceArn |
|
| 412 |
+
| `AWS::AppSync::Resolver` | Resolver ARN | ResolverArn |
|
| 413 |
+
| `AWS::AppSync::GraphQLSchema` | Schema ID | — |
|
| 414 |
+
| `AWS::AppSync::ApiKey` | API key ID | ApiKey, Arn |
|
| 415 |
+
| `AWS::SecretsManager::Secret` | Secret ARN | — |
|
| 416 |
+
| `AWS::Cognito::UserPool` | Pool ID | Arn, ProviderName |
|
| 417 |
+
| `AWS::Cognito::UserPoolClient` | Client ID | — |
|
| 418 |
+
| `AWS::Cognito::IdentityPool` | Pool ID | — |
|
| 419 |
+
| `AWS::Cognito::UserPoolDomain` | Domain | — |
|
| 420 |
+
| `AWS::ECR::Repository` | Repo name | Arn, RepositoryUri |
|
| 421 |
+
| `AWS::IAM::ManagedPolicy` | Policy ARN | — |
|
| 422 |
+
| `AWS::KMS::Key` | Key ID | Arn, KeyId |
|
| 423 |
+
| `AWS::KMS::Alias` | Alias name | — |
|
| 424 |
+
| `AWS::EC2::VPC` | VPC ID | VpcId, DefaultSecurityGroup, DefaultNetworkAcl |
|
| 425 |
+
| `AWS::EC2::Subnet` | Subnet ID | SubnetId, AvailabilityZone |
|
| 426 |
+
| `AWS::EC2::SecurityGroup` | SG ID | GroupId, VpcId |
|
| 427 |
+
| `AWS::EC2::InternetGateway` | IGW ID | InternetGatewayId |
|
| 428 |
+
| `AWS::EC2::VPCGatewayAttachment` | Attachment ID | — |
|
| 429 |
+
| `AWS::EC2::RouteTable` | RTB ID | RouteTableId |
|
| 430 |
+
| `AWS::EC2::Route` | Route ID | — |
|
| 431 |
+
| `AWS::EC2::SubnetRouteTableAssociation` | Association ID | — |
|
| 432 |
+
| `AWS::EC2::LaunchTemplate` | LT ID | LaunchTemplateId, LaunchTemplateName, DefaultVersionNumber, LatestVersionNumber |
|
| 433 |
+
| `AWS::ECS::Cluster` | Cluster name | Arn, ClusterName |
|
| 434 |
+
| `AWS::ECS::TaskDefinition` | Task def ARN | TaskDefinitionArn |
|
| 435 |
+
| `AWS::ECS::Service` | Service ARN | ServiceArn, Name |
|
| 436 |
+
| `AWS::ElasticLoadBalancingV2::LoadBalancer` | LB ARN | Arn, DNSName, LoadBalancerFullName, CanonicalHostedZoneID, SecurityGroups |
|
| 437 |
+
| `AWS::ElasticLoadBalancingV2::Listener` | Listener ARN | ListenerArn, Arn |
|
| 438 |
+
| `AWS::Lambda::LayerVersion` | Layer version ARN | LayerVersionArn, Arn |
|
| 439 |
+
| `AWS::StepFunctions::StateMachine` | State machine ARN | Arn, Name |
|
| 440 |
+
| `AWS::Route53::HostedZone` | Zone ID | Id, NameServers |
|
| 441 |
+
| `AWS::Route53::RecordSet` | Record FQDN (trailing dot) | Name |
|
| 442 |
+
| `AWS::ApiGatewayV2::Api` | API ID | ApiId, ApiEndpoint |
|
| 443 |
+
| `AWS::ApiGatewayV2::Stage` | Stage ID | StageName |
|
| 444 |
+
| `AWS::SES::EmailIdentity` | Identity | EmailIdentity |
|
| 445 |
+
| `AWS::WAFv2::WebACL` | WebACL ID | Arn, Id |
|
| 446 |
+
| `AWS::CloudFront::Distribution` | Distribution ID | Arn, DomainName, Id |
|
| 447 |
+
| `AWS::CloudWatch::Alarm` | Alarm name | Arn |
|
| 448 |
+
| `AWS::RDS::DBCluster` | Cluster ID | Arn, Endpoint.Address, Endpoint.Port, ReadEndpoint.Address |
|
| 449 |
+
| `AWS::AutoScaling::AutoScalingGroup` | ASG name | Arn |
|
| 450 |
+
| `AWS::AutoScaling::LaunchConfiguration` | LC name | Arn |
|
| 451 |
+
| `AWS::AutoScaling::ScalingPolicy` | Policy ARN | Arn, PolicyName |
|
| 452 |
+
| `AWS::AutoScaling::LifecycleHook` | Hook name | LifecycleHookName |
|
| 453 |
+
| `AWS::AutoScaling::ScheduledAction` | Action ARN | Arn, ScheduledActionName |
|
| 454 |
+
| `AWS::Scheduler::Schedule` | Schedule name | Arn |
|
| 455 |
+
| `AWS::Scheduler::ScheduleGroup` | Group name | Arn |
|
| 456 |
+
| `AWS::CloudFormation::WaitCondition` | Condition ID | — |
|
| 457 |
+
| `AWS::CloudFormation::WaitConditionHandle` | Handle URL | — |
|
| 458 |
+
|
| 459 |
+
Unsupported resource types fail with `CREATE_FAILED` (or `ROLLBACK_COMPLETE` if rollback is enabled), so templates with unsupported types won't silently succeed.
|
| 460 |
+
|
| 461 |
+
### Infrastructure Services
|
| 462 |
+
|
| 463 |
+
| Service | Operations | Notes |
|
| 464 |
+
|---------|-----------|-------|
|
| 465 |
+
| **ECS** | CreateCluster, DeleteCluster, DescribeClusters, ListClusters, UpdateCluster, UpdateClusterSettings, PutClusterCapacityProviders, RegisterTaskDefinition, DeregisterTaskDefinition, DescribeTaskDefinition, ListTaskDefinitions, ListTaskDefinitionFamilies, DeleteTaskDefinitions, CreateService, DeleteService, DescribeServices, UpdateService, ListServices, ListServicesByNamespace, RunTask, StopTask, DescribeTasks, ListTasks, ExecuteCommand, UpdateTaskProtection, GetTaskProtection, CreateCapacityProvider, UpdateCapacityProvider, DeleteCapacityProvider, DescribeCapacityProviders, TagResource, UntagResource, ListTagsForResource, ListAccountSettings, PutAccountSetting, PutAccountSettingDefault, DeleteAccountSetting, PutAttributes, DeleteAttributes, ListAttributes, DescribeServiceDeployments, ListServiceDeployments, DescribeServiceRevisions, SubmitTaskStateChange, SubmitContainerStateChange, SubmitAttachmentStateChanges, DiscoverPollEndpoint | 47 actions; `RunTask` starts real Docker containers via Docker socket; full Terraform ECS coverage |
|
| 466 |
+
| **RDS** | CreateDBInstance, DeleteDBInstance, DescribeDBInstances, ModifyDBInstance, StartDBInstance, StopDBInstance, RebootDBInstance, CreateDBInstanceReadReplica, RestoreDBInstanceFromDBSnapshot, CreateDBCluster, DeleteDBCluster, DescribeDBClusters, ModifyDBCluster, StartDBCluster, StopDBCluster, CreateDBSnapshot, DeleteDBSnapshot, DescribeDBSnapshots, CreateDBClusterSnapshot, DescribeDBClusterSnapshots, DeleteDBClusterSnapshot, CreateDBSubnetGroup, DeleteDBSubnetGroup, DescribeDBSubnetGroups, ModifyDBSubnetGroup, CreateDBParameterGroup, DeleteDBParameterGroup, DescribeDBParameterGroups, DescribeDBParameters, ModifyDBParameterGroup, CreateDBClusterParameterGroup, DescribeDBClusterParameterGroups, DeleteDBClusterParameterGroup, DescribeDBClusterParameters, ModifyDBClusterParameterGroup, CreateOptionGroup, DeleteOptionGroup, DescribeOptionGroups, DescribeOptionGroupOptions, ListTagsForResource, AddTagsToResource, RemoveTagsFromResource, DescribeDBEngineVersions, DescribeOrderableDBInstanceOptions, CreateGlobalCluster, DescribeGlobalClusters, DeleteGlobalCluster, RemoveFromGlobalCluster, ModifyGlobalCluster | `CreateDBInstance` spins up real Postgres/MySQL Docker container, returns actual `host:port` endpoint |
|
| 467 |
+
| **ElastiCache** | CreateCacheCluster, DeleteCacheCluster, DescribeCacheClusters, ModifyCacheCluster, RebootCacheCluster, CreateReplicationGroup, DeleteReplicationGroup, DescribeReplicationGroups, ModifyReplicationGroup, IncreaseReplicaCount, DecreaseReplicaCount, CreateCacheSubnetGroup, DescribeCacheSubnetGroups, ModifyCacheSubnetGroup, DeleteCacheSubnetGroup, CreateCacheParameterGroup, DescribeCacheParameterGroups, ModifyCacheParameterGroup, ResetCacheParameterGroup, DeleteCacheParameterGroup, DescribeCacheParameters, DescribeCacheEngineVersions, CreateUser, DescribeUsers, DeleteUser, ModifyUser, CreateUserGroup, DescribeUserGroups, DeleteUserGroup, ModifyUserGroup, ListTagsForResource, AddTagsToResource, RemoveTagsFromResource, CreateSnapshot, DeleteSnapshot, DescribeSnapshots, DescribeEvents | `CreateCacheCluster` spins up real Redis/Memcached Docker container |
|
| 468 |
+
| **Glue** | CreateDatabase, DeleteDatabase, GetDatabase, GetDatabases, UpdateDatabase, CreateTable, DeleteTable, GetTable, GetTables, UpdateTable, BatchDeleteTable, CreatePartition, DeletePartition, GetPartition, GetPartitions, BatchCreatePartition, BatchGetPartition, CreatePartitionIndex, GetPartitionIndexes, CreateConnection, DeleteConnection, GetConnection, GetConnections, CreateCrawler, DeleteCrawler, GetCrawler, GetCrawlers, UpdateCrawler, StartCrawler, StopCrawler, GetCrawlerMetrics, CreateJob, DeleteJob, GetJob, GetJobs, UpdateJob, StartJobRun, GetJobRun, GetJobRuns, BatchStopJobRun, CreateTrigger, GetTrigger, DeleteTrigger, UpdateTrigger, StartTrigger, StopTrigger, ListTriggers, BatchGetTriggers, GetTriggers, CreateWorkflow, GetWorkflow, DeleteWorkflow, UpdateWorkflow, StartWorkflowRun, CreateSecurityConfiguration, DeleteSecurityConfiguration, GetSecurityConfiguration, GetSecurityConfigurations, CreateClassifier, GetClassifier, GetClassifiers, DeleteClassifier, TagResource, UntagResource, GetTags | Python shell jobs actually execute via subprocess |
|
| 469 |
+
| **Athena** | StartQueryExecution, GetQueryExecution, GetQueryResults, StopQueryExecution, ListQueryExecutions, BatchGetQueryExecution, CreateWorkGroup, DeleteWorkGroup, GetWorkGroup, ListWorkGroups, UpdateWorkGroup, CreateNamedQuery, DeleteNamedQuery, GetNamedQuery, ListNamedQueries, BatchGetNamedQuery, CreateDataCatalog, GetDataCatalog, ListDataCatalogs, DeleteDataCatalog, UpdateDataCatalog, CreatePreparedStatement, GetPreparedStatement, DeletePreparedStatement, ListPreparedStatements, GetTableMetadata, ListTableMetadata, TagResource, UntagResource, ListTagsForResource | Real SQL via **DuckDB** when installed (`pip install duckdb`), otherwise returns mock results; result pagination; column type metadata |
|
| 470 |
+
| **Firehose** | CreateDeliveryStream, DeleteDeliveryStream, DescribeDeliveryStream, ListDeliveryStreams, PutRecord, PutRecordBatch, UpdateDestination, TagDeliveryStream, UntagDeliveryStream, ListTagsForDeliveryStream, StartDeliveryStreamEncryption, StopDeliveryStreamEncryption | S3 destinations write records to the local S3 emulator; all other destination types buffer in-memory; concurrency-safe `UpdateDestination` via `VersionId` |
|
| 471 |
+
| **Route53** | CreateHostedZone, GetHostedZone, DeleteHostedZone, ListHostedZones, ListHostedZonesByName, UpdateHostedZoneComment, ChangeResourceRecordSets (CREATE/UPSERT/DELETE), ListResourceRecordSets, GetChange, CreateHealthCheck, GetHealthCheck, DeleteHealthCheck, ListHealthChecks, UpdateHealthCheck, ChangeTagsForResource, ListTagsForResource | REST/XML protocol; SOA + NS records auto-created; CallerReference idempotency; alias records, weighted/failover/latency routing; marker-based pagination |
|
| 472 |
+
| **EC2** | RunInstances, DescribeInstances, DescribeInstanceAttribute, DescribeInstanceTypes, DescribeVpcAttribute, TerminateInstances, StopInstances, StartInstances, RebootInstances, DescribeImages, CreateSecurityGroup, DeleteSecurityGroup, DescribeSecurityGroups, AuthorizeSecurityGroupIngress, RevokeSecurityGroupIngress, AuthorizeSecurityGroupEgress, RevokeSecurityGroupEgress, DescribeSecurityGroupRules, CreateKeyPair, DeleteKeyPair, DescribeKeyPairs, ImportKeyPair, CreateVpc, DeleteVpc, DescribeVpcs, ModifyVpcAttribute, CreateSubnet, DeleteSubnet, DescribeSubnets, ModifySubnetAttribute, CreateInternetGateway, DeleteInternetGateway, DescribeInternetGateways, AttachInternetGateway, DetachInternetGateway, CreateRouteTable, DeleteRouteTable, DescribeRouteTables, AssociateRouteTable, DisassociateRouteTable, ReplaceRouteTableAssociation, CreateRoute, ReplaceRoute, DeleteRoute, CreateNetworkInterface, DeleteNetworkInterface, DescribeNetworkInterfaces, AttachNetworkInterface, DetachNetworkInterface, CreateVpcEndpoint, DeleteVpcEndpoints, DescribeVpcEndpoints, ModifyVpcEndpoint, DescribePrefixLists, DescribeAvailabilityZones, AllocateAddress, ReleaseAddress, AssociateAddress, DisassociateAddress, DescribeAddresses, DescribeAddressesAttribute, CreateTags, DeleteTags, DescribeTags, CreateNatGateway, DescribeNatGateways, DeleteNatGateway, CreateNetworkAcl, DescribeNetworkAcls, DeleteNetworkAcl, CreateNetworkAclEntry, DeleteNetworkAclEntry, ReplaceNetworkAclEntry, ReplaceNetworkAclAssociation, CreateFlowLogs, DescribeFlowLogs, DeleteFlowLogs, CreateVpcPeeringConnection, AcceptVpcPeeringConnection, DescribeVpcPeeringConnections, DeleteVpcPeeringConnection, CreateDhcpOptions, AssociateDhcpOptions, DescribeDhcpOptions, DeleteDhcpOptions, CreateEgressOnlyInternetGateway, DescribeEgressOnlyInternetGateways, DeleteEgressOnlyInternetGateway, CreateManagedPrefixList, DescribeManagedPrefixLists, GetManagedPrefixListEntries, ModifyManagedPrefixList, DeleteManagedPrefixList, CreateVpnGateway, DescribeVpnGateways, AttachVpnGateway, DetachVpnGateway, DeleteVpnGateway, EnableVgwRoutePropagation, DisableVgwRoutePropagation, CreateCustomerGateway, DescribeCustomerGateways, DeleteCustomerGateway, DescribeInstanceCreditSpecifications, DescribeInstanceMaintenanceOptions, DescribeInstanceAutoRecoveryAttribute, ModifyInstanceMaintenanceOptions, DescribeInstanceTopology, DescribeSpotInstanceRequests, DescribeCapacityReservations, DescribeInstanceStatus, DescribeVpcClassicLink, DescribeVpcClassicLinkDnsSupport, CreateLaunchTemplate, CreateLaunchTemplateVersion, DescribeLaunchTemplates, DescribeLaunchTemplateVersions, ModifyLaunchTemplate, DeleteLaunchTemplate | 136 actions; in-memory state only — no real VMs; CreateVpc provisions per-VPC default route table, network ACL, and security group; full Terraform VPC module v6.6.0 compatible; VPN/Customer gateways, managed prefix lists, VPC endpoints with modify support; launch templates with versioning ($Latest/$Default) |
|
| 473 |
+
| **EBS** | CreateVolume, DeleteVolume, DescribeVolumes, DescribeVolumeStatus, AttachVolume, DetachVolume, ModifyVolume, DescribeVolumesModifications, EnableVolumeIO, ModifyVolumeAttribute, DescribeVolumeAttribute, CreateSnapshot, DeleteSnapshot, DescribeSnapshots, CopySnapshot, ModifySnapshotAttribute, DescribeSnapshotAttribute | Part of EC2 Query/XML service; attach/detach updates volume state; snapshots stored as completed immediately; Pro-only on LocalStack — free here |
|
| 474 |
+
| **EFS** | CreateFileSystem, DescribeFileSystems, DeleteFileSystem, UpdateFileSystem, CreateMountTarget, DescribeMountTargets, DeleteMountTarget, DescribeMountTargetSecurityGroups, ModifyMountTargetSecurityGroups, CreateAccessPoint, DescribeAccessPoints, DeleteAccessPoint, TagResource, UntagResource, ListTagsForResource, PutLifecycleConfiguration, DescribeLifecycleConfiguration, PutBackupPolicy, DescribeBackupPolicy, DescribeAccountPreferences, PutAccountPreferences | REST/JSON `/2015-02-01/*`; CreationToken idempotency; FileSystem deletion blocked when mount targets exist; Pro-only on LocalStack — free here |
|
| 475 |
+
| **EMR** | RunJobFlow, DescribeCluster, ListClusters, TerminateJobFlows, ModifyCluster, SetTerminationProtection, SetVisibleToAllUsers, AddJobFlowSteps, DescribeStep, ListSteps, CancelSteps, AddInstanceFleet, ListInstanceFleets, ModifyInstanceFleet, AddInstanceGroups, ListInstanceGroups, ModifyInstanceGroups, ListBootstrapActions, AddTags, RemoveTags, GetBlockPublicAccessConfiguration, PutBlockPublicAccessConfiguration | Control plane only — no real Spark/Hadoop; clusters start in WAITING (KeepAlive=true) or TERMINATED (KeepAlive=false); steps stored as COMPLETED immediately; all three instance modes (simple, InstanceGroups, InstanceFleets); TerminationProtected enforced; Pro-only on LocalStack — free here |
|
| 476 |
+
| **Cognito** | **User Pools**: CreateUserPool, DeleteUserPool, DescribeUserPool, ListUserPools, UpdateUserPool, CreateUserPoolClient, DeleteUserPoolClient, DescribeUserPoolClient, ListUserPoolClients, UpdateUserPoolClient, AdminCreateUser, AdminDeleteUser, AdminGetUser, ListUsers, AdminSetUserPassword, AdminUpdateUserAttributes, AdminConfirmSignUp, AdminDisableUser, AdminEnableUser, AdminResetUserPassword, AdminUserGlobalSignOut, AdminAddUserToGroup, AdminRemoveUserFromGroup, AdminListGroupsForUser, AdminListUserAuthEvents, AdminInitiateAuth, AdminRespondToAuthChallenge, InitiateAuth, RespondToAuthChallenge, GlobalSignOut, RevokeToken, SignUp, ConfirmSignUp, ForgotPassword, ConfirmForgotPassword, ChangePassword, GetUser, UpdateUserAttributes, DeleteUser, CreateGroup, DeleteGroup, GetGroup, ListGroups, ListUsersInGroup, CreateUserPoolDomain, DeleteUserPoolDomain, DescribeUserPoolDomain, GetUserPoolMfaConfig, SetUserPoolMfaConfig, AssociateSoftwareToken, VerifySoftwareToken, AdminSetUserMFAPreference, SetUserMFAPreference, TagResource, UntagResource, ListTagsForResource; **Identity Pools**: CreateIdentityPool, DeleteIdentityPool, DescribeIdentityPool, ListIdentityPools, UpdateIdentityPool, GetId, GetCredentialsForIdentity, GetOpenIdToken, SetIdentityPoolRoles, GetIdentityPoolRoles, ListIdentities, DescribeIdentity, MergeDeveloperIdentities, UnlinkDeveloperIdentity, UnlinkIdentity, TagResource, UntagResource, ListTagsForResource; **OAuth2**: /oauth2/token (client_credentials) | Stub JWT tokens (structurally valid base64url JWTs); SRP auth returns PASSWORD_VERIFIER challenge; confirmation codes hardcoded (signup: 123456, forgot-password: 654321); TOTP SOFTWARE_TOKEN_MFA challenge flow; MFA config and per-user enrollment stored in-memory |
|
| 477 |
+
| **ECR** | CreateRepository, DescribeRepositories, DeleteRepository, ListImages, DescribeImages, PutImage, BatchGetImage, BatchDeleteImage, GetAuthorizationToken, GetRepositoryPolicy, SetRepositoryPolicy, DeleteRepositoryPolicy, PutLifecyclePolicy, GetLifecyclePolicy, DeleteLifecyclePolicy, ListTagsForResource, TagResource, UntagResource, PutImageTagMutability, PutImageScanningConfiguration, DescribeRegistry, GetDownloadUrlForLayer, BatchCheckLayerAvailability, InitiateLayerUpload, UploadLayerPart, CompleteLayerUpload | In-memory image registry; Docker V2 manifest support; authorization token generation; lifecycle policies; tag mutability; Pro-only on LocalStack — free here |
|
| 478 |
+
| **AppSync** | CreateGraphQLApi, GetGraphQLApi, ListGraphQLApis, UpdateGraphQLApi, DeleteGraphQLApi, CreateApiKey, DeleteApiKey, ListApiKeys, CreateDataSource, GetDataSource, ListDataSources, DeleteDataSource, CreateResolver, GetResolver, ListResolvers, DeleteResolver, CreateType, ListTypes, GetType, TagResource, UntagResource, ListTagsForResource | Control plane + data plane; GraphQL queries/mutations execute against DynamoDB resolvers (create/get/list/update/delete); Lambda resolvers supported; designed for Amplify/CDK CRUD patterns — not a full GraphQL spec engine |
|
| 479 |
+
| **Cloud Map** | CreateHttpNamespace, CreatePrivateDnsNamespace, CreatePublicDnsNamespace, GetNamespace, ListNamespaces, DeleteNamespace, UpdateHttpNamespace, UpdatePrivateDnsNamespace, UpdatePublicDnsNamespace, CreateService, GetService, ListServices, DeleteService, UpdateService, RegisterInstance, DeregisterInstance, DiscoverInstances, DiscoverInstancesRevision, ListInstances, GetInstancesHealthStatus, UpdateInstanceCustomHealthStatus, GetServiceAttributes, UpdateServiceAttributes, DeleteServiceAttributes, GetOperation, ListOperations, TagResource, UntagResource, ListTagsForResource | DNS namespaces create Route53 hosted zones; operation tracking; Terraform `aws_service_discovery_*` compatible |
|
| 480 |
+
| **RDS Data API** | ExecuteStatement, BatchExecuteStatement, BeginTransaction, CommitTransaction, RollbackTransaction | Routes SQL to real Docker-backed RDS database containers; supports MySQL (pymysql) and PostgreSQL (psycopg2); REST paths (`/Execute`, `/BeginTransaction`, etc.) |
|
| 481 |
+
| **S3 Files** | CreateFileSystem, GetFileSystem, ListFileSystems, DeleteFileSystem, CreateMountTarget, GetMountTarget, ListMountTargets, UpdateMountTarget, DeleteMountTarget, CreateAccessPoint, GetAccessPoint, ListAccessPoints, DeleteAccessPoint, GetFileSystemPolicy, PutFileSystemPolicy, DeleteFileSystemPolicy, GetSynchronizationConfiguration, PutSynchronizationConfiguration, TagResource, UntagResource, ListTagsForResource | 21 operations; control plane for the new S3 Files service (launched April 2026); file systems, mount targets, access points, policies |
|
| 482 |
+
| **AutoScaling** | CreateAutoScalingGroup, DescribeAutoScalingGroups, UpdateAutoScalingGroup, DeleteAutoScalingGroup, DescribeAutoScalingInstances, CreateLaunchConfiguration, DescribeLaunchConfigurations, DeleteLaunchConfiguration, PutScalingPolicy, DescribePolicies, DeletePolicy, PutLifecycleHook, DescribeLifecycleHooks, DeleteLifecycleHook, CompleteLifecycleAction, RecordLifecycleActionHeartbeat, PutScheduledUpdateGroupAction, DescribeScheduledActions, DeleteScheduledAction, CreateOrUpdateTags, DescribeTags, DeleteTags | 23 actions; in-memory state — no real instance scaling; full ASG lifecycle (launch configs, scaling policies, lifecycle hooks, scheduled actions, tags); CDK/Terraform compatible |
|
| 483 |
+
| **CodeBuild** | CreateProject, BatchGetProjects, ListProjects, UpdateProject, DeleteProject, StartBuild, BatchGetBuilds, StopBuild, ListBuilds, ListBuildsForProject, BatchDeleteBuilds | 11 actions; builds complete immediately with SUCCEEDED status; project and build metadata stored in-memory |
|
| 484 |
+
| **AppConfig** | CreateApplication, GetApplication, ListApplications, UpdateApplication, DeleteApplication, CreateEnvironment, GetEnvironment, ListEnvironments, UpdateEnvironment, DeleteEnvironment, CreateConfigurationProfile, GetConfigurationProfile, ListConfigurationProfiles, UpdateConfigurationProfile, DeleteConfigurationProfile, CreateHostedConfigurationVersion, GetHostedConfigurationVersion, ListHostedConfigurationVersions, DeleteHostedConfigurationVersion, CreateDeploymentStrategy, GetDeploymentStrategy, ListDeploymentStrategies, UpdateDeploymentStrategy, DeleteDeploymentStrategy, StartDeployment, GetDeployment, ListDeployments, StopDeployment, TagResource, UntagResource, ListTagsForResource, StartConfigurationSession, GetLatestConfiguration | 33 operations; control plane + data plane; hosted configuration versions; deployments complete immediately; session-based configuration retrieval with token rotation |
|
| 485 |
+
| **Transfer Family** | CreateServer, DescribeServer, DeleteServer, ListServers, CreateUser, DescribeUser, DeleteUser, ListUsers, ImportSshPublicKey, DeleteSshPublicKey | 10 operations; SFTP server and user management; SSH key rotation; LOGICAL home directory mappings to S3; in-memory state |
|
| 486 |
+
| **EventBridge Scheduler** | CreateSchedule, GetSchedule, UpdateSchedule, DeleteSchedule, ListSchedules, CreateScheduleGroup, GetScheduleGroup, DeleteScheduleGroup, ListScheduleGroups, TagResource, UntagResource, ListTagsForResource | 12 actions; schedule groups with cascading deletes; `rate()`, `cron()`, `at()` expressions; group/prefix/state filters on list; default group auto-created; CFN `AWS::Scheduler::Schedule` and `AWS::Scheduler::ScheduleGroup` supported |
|
| 487 |
+
| **EKS** | CreateCluster, DescribeCluster, ListClusters, DeleteCluster, CreateNodegroup, DescribeNodegroup, ListNodegroups, DeleteNodegroup, TagResource, UntagResource, ListTagsForResource | 11 operations; `CreateCluster` spawns a real **k3s** container (75 MB) with a full Kubernetes API server; `kubectl`, Helm, and any K8s tooling work out of the box; cascading delete removes nodegroups and k3s container; CFN `AWS::EKS::Cluster` and `AWS::EKS::Nodegroup` supported |
|
| 488 |
+
|
| 489 |
+
---
|
| 490 |
+
|
| 491 |
+
## Real Database Endpoints (RDS)
|
| 492 |
+
|
| 493 |
+
When you create an RDS instance, MiniStack starts a real database container and returns the actual connection endpoint:
|
| 494 |
+
|
| 495 |
+
```python
|
| 496 |
+
import boto3
|
| 497 |
+
import psycopg2 # pip install psycopg2-binary
|
| 498 |
+
|
| 499 |
+
rds = boto3.client("rds", endpoint_url="http://localhost:4566",
|
| 500 |
+
aws_access_key_id="test", aws_secret_access_key="test", region_name="us-east-1")
|
| 501 |
+
|
| 502 |
+
resp = rds.create_db_instance(
|
| 503 |
+
DBInstanceIdentifier="mydb",
|
| 504 |
+
DBInstanceClass="db.t3.micro",
|
| 505 |
+
Engine="postgres",
|
| 506 |
+
MasterUsername="admin",
|
| 507 |
+
MasterUserPassword="password",
|
| 508 |
+
DBName="appdb",
|
| 509 |
+
AllocatedStorage=20,
|
| 510 |
+
)
|
| 511 |
+
|
| 512 |
+
endpoint = resp["DBInstance"]["Endpoint"]
|
| 513 |
+
# Connect directly — it's a real Postgres instance
|
| 514 |
+
conn = psycopg2.connect(
|
| 515 |
+
host=endpoint["Address"], # localhost
|
| 516 |
+
port=endpoint["Port"], # 15432 (auto-assigned)
|
| 517 |
+
user="admin",
|
| 518 |
+
password="password",
|
| 519 |
+
dbname="appdb",
|
| 520 |
+
)
|
| 521 |
+
```
|
| 522 |
+
|
| 523 |
+
Supported engines: `postgres`, `mysql`, `mariadb`, `aurora-postgresql`, `aurora-mysql`
|
| 524 |
+
|
| 525 |
+
---
|
| 526 |
+
|
| 527 |
+
## Real Redis Endpoints (ElastiCache)
|
| 528 |
+
|
| 529 |
+
```python
|
| 530 |
+
import boto3
|
| 531 |
+
import redis # pip install redis
|
| 532 |
+
|
| 533 |
+
ec = boto3.client("elasticache", endpoint_url="http://localhost:4566",
|
| 534 |
+
aws_access_key_id="test", aws_secret_access_key="test", region_name="us-east-1")
|
| 535 |
+
|
| 536 |
+
resp = ec.create_cache_cluster(
|
| 537 |
+
CacheClusterId="my-redis",
|
| 538 |
+
Engine="redis",
|
| 539 |
+
CacheNodeType="cache.t3.micro",
|
| 540 |
+
NumCacheNodes=1,
|
| 541 |
+
)
|
| 542 |
+
|
| 543 |
+
node = resp["CacheCluster"]["CacheNodes"][0]["Endpoint"]
|
| 544 |
+
r = redis.Redis(host=node["Address"], port=node["Port"])
|
| 545 |
+
r.set("key", "value")
|
| 546 |
+
print(r.get("key")) # b'value'
|
| 547 |
+
```
|
| 548 |
+
|
| 549 |
+
A Redis sidecar is also always available at `localhost:6379` when using Docker Compose.
|
| 550 |
+
|
| 551 |
+
---
|
| 552 |
+
|
| 553 |
+
## Athena with Real SQL
|
| 554 |
+
|
| 555 |
+
Athena queries run via DuckDB and can query files in your local S3 data directory:
|
| 556 |
+
|
| 557 |
+
```python
|
| 558 |
+
import boto3, time
|
| 559 |
+
|
| 560 |
+
athena = boto3.client("athena", endpoint_url="http://localhost:4566",
|
| 561 |
+
aws_access_key_id="test", aws_secret_access_key="test", region_name="us-east-1")
|
| 562 |
+
|
| 563 |
+
# Query runs real SQL via DuckDB
|
| 564 |
+
resp = athena.start_query_execution(
|
| 565 |
+
QueryString="SELECT 42 AS answer, 'hello' AS greeting",
|
| 566 |
+
ResultConfiguration={"OutputLocation": "s3://athena-results/"},
|
| 567 |
+
)
|
| 568 |
+
query_id = resp["QueryExecutionId"]
|
| 569 |
+
|
| 570 |
+
# Poll for result
|
| 571 |
+
while True:
|
| 572 |
+
status = athena.get_query_execution(QueryExecutionId=query_id)
|
| 573 |
+
if status["QueryExecution"]["Status"]["State"] == "SUCCEEDED":
|
| 574 |
+
break
|
| 575 |
+
time.sleep(0.1)
|
| 576 |
+
|
| 577 |
+
results = athena.get_query_results(QueryExecutionId=query_id)
|
| 578 |
+
for row in results["ResultSet"]["Rows"][1:]: # skip header
|
| 579 |
+
print([col["VarCharValue"] for col in row["Data"]])
|
| 580 |
+
# ['42', 'hello']
|
| 581 |
+
```
|
| 582 |
+
|
| 583 |
+
---
|
| 584 |
+
|
| 585 |
+
## ECS with Real Containers
|
| 586 |
+
|
| 587 |
+
```python
|
| 588 |
+
import boto3
|
| 589 |
+
|
| 590 |
+
ecs = boto3.client("ecs", endpoint_url="http://localhost:4566",
|
| 591 |
+
aws_access_key_id="test", aws_secret_access_key="test", region_name="us-east-1")
|
| 592 |
+
|
| 593 |
+
ecs.create_cluster(clusterName="dev")
|
| 594 |
+
|
| 595 |
+
ecs.register_task_definition(
|
| 596 |
+
family="web",
|
| 597 |
+
containerDefinitions=[{
|
| 598 |
+
"name": "nginx",
|
| 599 |
+
"image": "nginx:alpine",
|
| 600 |
+
"cpu": 128,
|
| 601 |
+
"memory": 256,
|
| 602 |
+
"portMappings": [{"containerPort": 80, "hostPort": 8080}],
|
| 603 |
+
}],
|
| 604 |
+
)
|
| 605 |
+
|
| 606 |
+
# This actually runs an nginx container via Docker
|
| 607 |
+
resp = ecs.run_task(cluster="dev", taskDefinition="web", count=1)
|
| 608 |
+
task_arn = resp["tasks"][0]["taskArn"]
|
| 609 |
+
|
| 610 |
+
# Stop it (removes the container)
|
| 611 |
+
ecs.stop_task(cluster="dev", task=task_arn)
|
| 612 |
+
```
|
| 613 |
+
|
| 614 |
+
---
|
| 615 |
+
|
| 616 |
+
## Configuration
|
| 617 |
+
|
| 618 |
+
| Variable | Default | Description |
|
| 619 |
+
|----------|---------|-------------|
|
| 620 |
+
| `GATEWAY_PORT` | `4566` | Port to listen on. Also accepts `EDGE_PORT` (LocalStack compatibility alias) |
|
| 621 |
+
| `MINISTACK_HOST` | `localhost` | Hostname used in response URLs (SQS queues, SNS subscriptions, API Gateway endpoints, Lambda layers) |
|
| 622 |
+
| `MINISTACK_ACCOUNT_ID` | `000000000000` | Default AWS account ID. Overridden per-request when `AWS_ACCESS_KEY_ID` is a 12-digit number (see [Multi-Tenancy](#multi-tenancy)) |
|
| 623 |
+
| `MINISTACK_REGION` | `us-east-1` | AWS region reported in ARNs and service responses across all services |
|
| 624 |
+
| `LOG_LEVEL` | `INFO` | `DEBUG`, `INFO`, `WARNING`, `ERROR` |
|
| 625 |
+
| `S3_PERSIST` | `0` | Set `1` to persist S3 objects to disk |
|
| 626 |
+
| `S3_DATA_DIR` | `/tmp/ministack-data/s3` | S3 persistence directory |
|
| 627 |
+
| `REDIS_HOST` | `redis` | Redis host for ElastiCache fallback |
|
| 628 |
+
| `REDIS_PORT` | `6379` | Redis port for ElastiCache fallback |
|
| 629 |
+
| `RDS_BASE_PORT` | `15432` | Starting host port for RDS containers |
|
| 630 |
+
| `RDS_TMPFS_SIZE` | `256m` | Tmpfs size for RDS database containers (when `RDS_PERSIST=0`). Set to `2g` or higher for large databases |
|
| 631 |
+
| `RDS_PERSIST` | `0` | Set `1` to use Docker named volumes for RDS containers instead of tmpfs. Storage grows dynamically with no fixed cap |
|
| 632 |
+
| `ELASTICACHE_BASE_PORT` | `16379` | Starting host port for ElastiCache containers |
|
| 633 |
+
| `PERSIST_STATE` | `0` | Set `1` to persist service state across restarts |
|
| 634 |
+
| `STATE_DIR` | `/tmp/ministack-state` | Directory for persisted state files |
|
| 635 |
+
| `LAMBDA_EXECUTOR` | `local` | Lambda execution mode: `local` (subprocess) or `docker` (container). `provided` runtimes and `PackageType: Image` always use Docker |
|
| 636 |
+
| `LAMBDA_STRICT` | `0` | Set `1` for AWS-fidelity mode: every Lambda invocation runs in a Docker container via the AWS RIE image; in-process fallbacks are disabled. Missing Docker surfaces as `Runtime.DockerUnavailable` instead of degrading to a subprocess. Opt-in because the default install doesn't require Docker |
|
| 637 |
+
| `LAMBDA_DOCKER_NETWORK` | _(unset)_ | Docker network for Lambda containers. Set to your Docker Compose network name so Lambda can reach MiniStack |
|
| 638 |
+
| `LAMBDA_WARM_TTL_SECONDS` | `300` | How long an idle warm Lambda container stays in the pool before the reaper evicts it |
|
| 639 |
+
| `LAMBDA_ACCOUNT_CONCURRENCY` | `0` | Account-level concurrent-invocation cap (0 = unbounded). Match real AWS by setting to `1000`. Used to simulate `ConcurrentInvocationLimitExceeded` throttles |
|
| 640 |
+
| `SFN_MOCK_CONFIG` | _(unset)_ | Path to JSON file for Step Functions mock testing; compatible with AWS SFN Local format. Also accepts `LOCALSTACK_SFN_MOCK_CONFIG` |
|
| 641 |
+
| `ATHENA_ENGINE` | `auto` | SQL engine for Athena: `auto`, `duckdb`, `mock` |
|
| 642 |
+
| `SMTP_HOST` | _(unset)_ | SMTP server for SES email relay (e.g. `mailhog:1025`). When set, SES SendEmail/SendRawEmail actually deliver mail. When unset, emails are stored in-memory only |
|
| 643 |
+
|
| 644 |
+
### Startup Scripts
|
| 645 |
+
|
| 646 |
+
MiniStack supports two types of init scripts, with LocalStack-compatible paths:
|
| 647 |
+
|
| 648 |
+
| Phase | MiniStack path | LocalStack-compatible path |
|
| 649 |
+
|-------|----------------|---------------------------|
|
| 650 |
+
| Pre-start | `/docker-entrypoint-initaws.d/*.{sh,py}` | `/etc/localstack/init/boot.d/*.{sh,py}` |
|
| 651 |
+
| Post-ready | `/docker-entrypoint-initaws.d/ready.d/*.{sh,py}` | `/etc/localstack/init/ready.d/*.{sh,py}` |
|
| 652 |
+
|
| 653 |
+
Scripts from both paths are merged, deduplicated by filename, and run in alphabetical order.
|
| 654 |
+
If the same filename exists in both paths, the MiniStack-native path takes priority.
|
| 655 |
+
|
| 656 |
+
Init scripts automatically receive `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, `AWS_DEFAULT_REGION`, and `AWS_ENDPOINT_URL` — no manual configuration needed. The `aws` CLI is bundled in the image.
|
| 657 |
+
|
| 658 |
+
```bash
|
| 659 |
+
# ready.d/01-create-resources.sh
|
| 660 |
+
aws s3 mb s3://my-bucket
|
| 661 |
+
aws sqs create-queue --queue-name my-queue
|
| 662 |
+
```
|
| 663 |
+
|
| 664 |
+
```python
|
| 665 |
+
# ready.d/02-seed-data.py
|
| 666 |
+
import boto3, os
|
| 667 |
+
s3 = boto3.client("s3", endpoint_url=os.environ["AWS_ENDPOINT_URL"])
|
| 668 |
+
s3.put_object(Bucket="my-bucket", Key="config.json", Body=b'{"env": "local"}')
|
| 669 |
+
```
|
| 670 |
+
|
| 671 |
+
**Docker Compose** — mount scripts at either path:
|
| 672 |
+
```yaml
|
| 673 |
+
volumes:
|
| 674 |
+
- ./init-scripts:/docker-entrypoint-initaws.d # ministack-native
|
| 675 |
+
# OR
|
| 676 |
+
- ./init-scripts:/etc/localstack/init # localstack-compatible
|
| 677 |
+
```
|
| 678 |
+
|
| 679 |
+
### Athena SQL Engines
|
| 680 |
+
|
| 681 |
+
Set `ATHENA_ENGINE` to control Athena's SQL execution engine. In `auto` mode, DuckDB is used if installed, otherwise queries return mock results.
|
| 682 |
+
|
| 683 |
+
| Capability | `duckdb` | `mock` |
|
| 684 |
+
|---|---|---|
|
| 685 |
+
| Simple SELECT / expressions | Yes | Partial (regex) |
|
| 686 |
+
| Arithmetic, aggregations, JOINs, CTEs | Yes | No |
|
| 687 |
+
| Window functions, subqueries | Yes | No |
|
| 688 |
+
| Parquet / CSV / JSON file queries | Yes | No |
|
| 689 |
+
| UNNEST, ARRAY, MAP functions | Yes | No |
|
| 690 |
+
| APPROX\_DISTINCT, REGEXP\_EXTRACT | Yes | No |
|
| 691 |
+
|
| 692 |
+
Install DuckDB for full Athena SQL compatibility: `pip install ministack[full]`.
|
| 693 |
+
|
| 694 |
+
### State Persistence
|
| 695 |
+
|
| 696 |
+
When `PERSIST_STATE=1`, MiniStack saves service state to `STATE_DIR` on shutdown and reloads it on startup. Writes are atomic (write-to-tmp then rename) to prevent corruption on crash.
|
| 697 |
+
|
| 698 |
+
Services currently supporting persistence: **All services** — API Gateway v1/v2, ALB, ACM, AppConfig, AppSync, Athena, Cloud Map, CloudFront, CloudWatch, CloudWatch Logs, CodeBuild, Cognito, DynamoDB, EC2, ECR, ECS, EFS, EKS, ElastiCache, EMR, EventBridge, EventBridge Scheduler, Firehose, Glue, IAM/STS, Kinesis, KMS, Lambda, RDS, Route 53, S3, Secrets Manager, SES, SES v2, SNS, SQS, SSM, Step Functions, Transfer Family, WAF v2
|
| 699 |
+
|
| 700 |
+
```bash
|
| 701 |
+
docker run -p 4566:4566 \
|
| 702 |
+
-e PERSIST_STATE=1 \
|
| 703 |
+
-e STATE_DIR=/data/ministack-state \
|
| 704 |
+
-v /tmp/ministack-data:/data \
|
| 705 |
+
ministackorg/ministack
|
| 706 |
+
```
|
| 707 |
+
|
| 708 |
+
### Lambdas in docker
|
| 709 |
+
|
| 710 |
+
To run lambda in docker, the LAMBDA_EXECUTOR needs to be set to "docker". All lambdas will be run in an
|
| 711 |
+
AWS supplied docker image, following docker images are supported:
|
| 712 |
+
* "python3.8": "public.ecr.aws/lambda/python:3.8"
|
| 713 |
+
* "python3.9": "public.ecr.aws/lambda/python:3.9"
|
| 714 |
+
* "python3.10": "public.ecr.aws/lambda/python:3.10"
|
| 715 |
+
* "python3.11": "public.ecr.aws/lambda/python:3.11"
|
| 716 |
+
* "python3.12": "public.ecr.aws/lambda/python:3.12"
|
| 717 |
+
* "python3.13": "public.ecr.aws/lambda/python:3.13"
|
| 718 |
+
* "python3.14": "public.ecr.aws/lambda/python:3.14"
|
| 719 |
+
* "nodejs14.x": "public.ecr.aws/lambda/nodejs:14"
|
| 720 |
+
* "nodejs16.x": "public.ecr.aws/lambda/nodejs:16"
|
| 721 |
+
* "nodejs18.x": "public.ecr.aws/lambda/nodejs:18"
|
| 722 |
+
* "nodejs20.x": "public.ecr.aws/lambda/nodejs:20"
|
| 723 |
+
* "nodejs22.x": "public.ecr.aws/lambda/nodejs:22"
|
| 724 |
+
* "nodejs24.x": "public.ecr.aws/lambda/nodejs:24"
|
| 725 |
+
* "provided.al2023": "public.ecr.aws/lambda/provided:al2023"
|
| 726 |
+
* "provided.al2": "public.ecr.aws/lambda/provided:al2"
|
| 727 |
+
* "provided": "public.ecr.aws/lambda/provided:latest"
|
| 728 |
+
|
| 729 |
+
Docker containers for lambda are name lambda-<random-hex-16>.
|
| 730 |
+
|
| 731 |
+
Docker containers are always kept "warm", and reused when possible. This means that containers
|
| 732 |
+
created for lambdas need to be killed manually.
|
| 733 |
+
|
| 734 |
+
Additionally a volume is needed to mount the code (and extra layers). This must be set with
|
| 735 |
+
the LAMBDA_REMOTE_DOCKER_VOLUME_MOUNT environment variable. This must be a named volume (managed by docker).
|
| 736 |
+
|
| 737 |
+
If a ministack is not running on the default network, LAMBDA_DOCKER_NETWORK needs to be set, which will attach
|
| 738 |
+
the lambda to this network, making it posssible to access ministack (AWS) resources from the lambda.
|
| 739 |
+
|
| 740 |
+
Example docker compose file:
|
| 741 |
+
```
|
| 742 |
+
services:
|
| 743 |
+
ministack:
|
| 744 |
+
image: ministackorg/ministack:latest
|
| 745 |
+
container_name: infra_ministack
|
| 746 |
+
entrypoint: ["python", "-m", "hypercorn", "ministack.app:app", "--bind", "0.0.0.0:4566", "--keep-alive", "75"]
|
| 747 |
+
networks:
|
| 748 |
+
infra-network:
|
| 749 |
+
healthcheck:
|
| 750 |
+
test: "python -c \"import urllib.request; urllib.request.urlopen('http://localhost:4566/_ministack/health')\" || exit 1"
|
| 751 |
+
interval: 10s
|
| 752 |
+
timeout: 2s
|
| 753 |
+
start_period: 5s
|
| 754 |
+
retries: 3
|
| 755 |
+
ports:
|
| 756 |
+
- "4566:4566"
|
| 757 |
+
environment:
|
| 758 |
+
DOCKER_SOCK: ${DOCKER_SOCK:-/var/run/docker.sock}
|
| 759 |
+
LAMBDA_EXECUTOR: docker
|
| 760 |
+
LAMBDA_DOCKER_NETWORK: ${COMPOSE_PROJECT_NAME}_infra-network
|
| 761 |
+
LAMBDA_REMOTE_DOCKER_VOLUME_MOUNT: "{COMPOSE_PROJECT_NAME}_lambda-docker-volume"
|
| 762 |
+
AWS_DEFAULT_REGION: ${AWS_REGION:-eu-central-1}
|
| 763 |
+
AWS_SECRET_ACCESS_KEY: ${AWS_SECRET_ACCESS_KEY:-my_secret}
|
| 764 |
+
AWS_ACCESS_KEY_ID: ${AWS_ACCESS_KEY_ID:-my_key}
|
| 765 |
+
AWS_ENDPOINT_URL: http://localstack:4566
|
| 766 |
+
volumes:
|
| 767 |
+
- "/var/run/docker.sock:/var/run/docker.sock"
|
| 768 |
+
- "lambda-docker-volume:/var/task"
|
| 769 |
+
|
| 770 |
+
volumes:
|
| 771 |
+
lambda-docker-volume:
|
| 772 |
+
|
| 773 |
+
networks:
|
| 774 |
+
infra-network:
|
| 775 |
+
```
|
| 776 |
+
|
| 777 |
+
Option privileged set to "true" is needed if /var/run/docker.sock is root owned.
|
| 778 |
+
|
| 779 |
+
### EKS with Real Kubernetes (k3s)
|
| 780 |
+
|
| 781 |
+
MiniStack's EKS spawns a real [k3s](https://k3s.io) cluster (75 MB image) when you create a cluster. `kubectl`, Helm, and any Kubernetes tooling work out of the box.
|
| 782 |
+
|
| 783 |
+
```bash
|
| 784 |
+
# Create an EKS cluster — k3s starts automatically
|
| 785 |
+
aws --endpoint-url=http://localhost:4566 eks create-cluster \
|
| 786 |
+
--name my-cluster --role-arn arn:aws:iam::000000000000:role/eks \
|
| 787 |
+
--resources-vpc-config subnetIds=subnet-1
|
| 788 |
+
|
| 789 |
+
# Get the k3s kubeconfig (container name follows ministack-eks-{name} pattern)
|
| 790 |
+
docker exec ministack-eks-my-cluster cat /etc/rancher/k3s/k3s.yaml \
|
| 791 |
+
| sed "s/127.0.0.1:6443/localhost:$(docker port ministack-eks-my-cluster 6443/tcp | cut -d: -f2)/" \
|
| 792 |
+
> /tmp/ministack-kubeconfig.yaml
|
| 793 |
+
|
| 794 |
+
# Use kubectl against real Kubernetes
|
| 795 |
+
export KUBECONFIG=/tmp/ministack-kubeconfig.yaml
|
| 796 |
+
kubectl get nodes # Real k3s node, Ready status
|
| 797 |
+
kubectl create deployment nginx --image=nginx:alpine
|
| 798 |
+
kubectl get pods # Real pod running
|
| 799 |
+
|
| 800 |
+
# Helm works too
|
| 801 |
+
helm repo add bitnami https://charts.bitnami.com/bitnami
|
| 802 |
+
helm install my-redis bitnami/redis --set auth.enabled=false
|
| 803 |
+
|
| 804 |
+
# Clean up — k3s container is removed automatically
|
| 805 |
+
aws --endpoint-url=http://localhost:4566 eks delete-cluster --name my-cluster
|
| 806 |
+
```
|
| 807 |
+
|
| 808 |
+
> **Note:** EKS requires Docker socket access (`-v /var/run/docker.sock:/var/run/docker.sock`) to spawn k3s containers. The k3s image is pulled on first `CreateCluster` call.
|
| 809 |
+
|
| 810 |
+
### Lambda Warm Starts
|
| 811 |
+
|
| 812 |
+
MiniStack keeps Python and Node.js Lambda functions warm between invocations. After the first call (cold start), the handler module stays loaded in a persistent subprocess. Subsequent calls skip the import/require step, matching real AWS warm-start behaviour and making test suites significantly faster.
|
| 813 |
+
|
| 814 |
+
### Lambda Node.js Runtimes
|
| 815 |
+
|
| 816 |
+
MiniStack supports Node.js Lambda runtimes (`nodejs14.x`, `nodejs16.x`, `nodejs18.x`, `nodejs20.x`, `nodejs22.x`). Functions execute via a local `node` subprocess (or Docker when `LAMBDA_EXECUTOR=docker`) — no mocking, real JS execution.
|
| 817 |
+
|
| 818 |
+
```python
|
| 819 |
+
import boto3, json, zipfile, io
|
| 820 |
+
|
| 821 |
+
lam = boto3.client("lambda", endpoint_url="http://localhost:4566", region_name="us-east-1",
|
| 822 |
+
aws_access_key_id="test", aws_secret_access_key="test")
|
| 823 |
+
|
| 824 |
+
code = "exports.handler = async (event) => ({ statusCode: 200, body: JSON.stringify(event) });"
|
| 825 |
+
buf = io.BytesIO()
|
| 826 |
+
with zipfile.ZipFile(buf, "w") as zf:
|
| 827 |
+
zf.writestr("index.js", code)
|
| 828 |
+
|
| 829 |
+
lam.create_function(
|
| 830 |
+
FunctionName="my-node-fn",
|
| 831 |
+
Runtime="nodejs20.x",
|
| 832 |
+
Role="arn:aws:iam::000000000000:role/r",
|
| 833 |
+
Handler="index.handler",
|
| 834 |
+
Code={"ZipFile": buf.getvalue()},
|
| 835 |
+
)
|
| 836 |
+
|
| 837 |
+
resp = lam.invoke(FunctionName="my-node-fn", Payload=json.dumps({"hello": "world"}))
|
| 838 |
+
print(json.loads(resp["Payload"].read())) # {'statusCode': 200, 'body': '{"hello": "world"}'}
|
| 839 |
+
```
|
| 840 |
+
|
| 841 |
+
Layers that ship npm packages work too — MiniStack resolves the `nodejs/node_modules` subdirectory inside each layer zip and prepends it to the module search path.
|
| 842 |
+
|
| 843 |
+
MiniStack also sets the standard Lambda runtime environment before the handler module is loaded, including `LAMBDA_TASK_ROOT`, `AWS_LAMBDA_FUNCTION_NAME`, `AWS_LAMBDA_FUNCTION_MEMORY_SIZE`, and `_LAMBDA_FUNCTION_ARN`. That keeps import-time Lambda detection and conditional handler setup aligned with AWS warm runtime behaviour.
|
| 844 |
+
|
| 845 |
+
---
|
| 846 |
+
|
| 847 |
+
## Architecture
|
| 848 |
+
|
| 849 |
+
```
|
| 850 |
+
┌──────────────────────────────────────────┐
|
| 851 |
+
AWS CLI / boto3 │ ASGI Gateway :4566 │
|
| 852 |
+
Terraform / CDK ──►│ ┌────────────────────────────────────┐ │
|
| 853 |
+
Any AWS SDK │ │ Request Router │ │
|
| 854 |
+
│ │ 1. X-Amz-Target header │ │
|
| 855 |
+
│ │ 2. Authorization credential scope │ │
|
| 856 |
+
│ │ 3. Action query param │ │
|
| 857 |
+
│ │ 4. URL path pattern │ │
|
| 858 |
+
│ │ 5. Host header │ │
|
| 859 |
+
│ │ 6. Default → S3 │ │
|
| 860 |
+
│ └────────────────┬───────────────────┘ │
|
| 861 |
+
│ │ │
|
| 862 |
+
│ ┌────────────────────────────────────┐ │
|
| 863 |
+
│ │ Service Handlers (lazy-loaded) │ │
|
| 864 |
+
│ │ │ │
|
| 865 |
+
│ │ S3 SQS SNS DynamoDB │ │
|
| 866 |
+
│ │ Lambda IAM STS Secrets │ │
|
| 867 |
+
│ │ SSM Events Kinesis CW │ │
|
| 868 |
+
│ │ CW Logs SES SESv2 ACM │ │
|
| 869 |
+
│ │ Step Functions API GW v1/v2 │ │
|
| 870 |
+
│ │ ECS RDS ElastiCache Glue │ │
|
| 871 |
+
│ │ Athena Firehose Route53 │ │
|
| 872 |
+
│ │ Cognito EC2 EMR EBS EFS │ │
|
| 873 |
+
│ │ ALB/ELBv2 WAF v2 KMS ECR │ │
|
| 874 |
+
│ │ CloudFormation CloudFront │ │
|
| 875 |
+
│ │ AppSync Cloud Map CodeBuild │ │
|
| 876 |
+
│ │ AutoScaling AppConfig EKS │ │
|
| 877 |
+
│ │ RDS Data S3 Files Scheduler │ │
|
| 878 |
+
│ │ Transfer Family │ │
|
| 879 |
+
│ └────────────────────────────────────┘ │
|
| 880 |
+
│ │
|
| 881 |
+
│ In-Memory Storage + Optional Docker │
|
| 882 |
+
└──────────────────────────────────────────┘
|
| 883 |
+
│
|
| 884 |
+
┌──────────────┼──────────────┐
|
| 885 |
+
▼ ▼ ▼
|
| 886 |
+
Redis:6379 Postgres:15432+ MySQL:15433+
|
| 887 |
+
(ElastiCache) (RDS) (RDS)
|
| 888 |
+
```
|
| 889 |
+
|
| 890 |
+
---
|
| 891 |
+
|
| 892 |
+
## Running Tests
|
| 893 |
+
|
| 894 |
+
```bash
|
| 895 |
+
# Install test dependencies
|
| 896 |
+
pip install boto3 pytest duckdb docker cbor2
|
| 897 |
+
|
| 898 |
+
# Start MiniStack
|
| 899 |
+
docker compose up -d
|
| 900 |
+
|
| 901 |
+
# Run the full test suite (1,300+ tests across all services)
|
| 902 |
+
pytest tests/ -v
|
| 903 |
+
```
|
| 904 |
+
|
| 905 |
+
Expected output:
|
| 906 |
+
|
| 907 |
+
```
|
| 908 |
+
collected 955 items
|
| 909 |
+
|
| 910 |
+
tests/test_services.py::test_s3_create_bucket PASSED
|
| 911 |
+
...
|
| 912 |
+
tests/test_services.py::test_app_asgi_callable PASSED
|
| 913 |
+
|
| 914 |
+
955 passed in ~100s
|
| 915 |
+
```
|
| 916 |
+
|
| 917 |
+
---
|
| 918 |
+
|
| 919 |
+
## Terraform / CDK / Pulumi
|
| 920 |
+
|
| 921 |
+
### Terraform
|
| 922 |
+
|
| 923 |
+
Works with both Terraform AWS Provider v5 and v6.
|
| 924 |
+
|
| 925 |
+
```hcl
|
| 926 |
+
provider "aws" {
|
| 927 |
+
region = "us-east-1"
|
| 928 |
+
access_key = "test"
|
| 929 |
+
secret_key = "test"
|
| 930 |
+
s3_use_path_style = true
|
| 931 |
+
skip_credentials_validation = true
|
| 932 |
+
skip_metadata_api_check = true
|
| 933 |
+
skip_requesting_account_id = true
|
| 934 |
+
|
| 935 |
+
endpoints {
|
| 936 |
+
acm = "http://localhost:4566"
|
| 937 |
+
apigateway = "http://localhost:4566"
|
| 938 |
+
appsync = "http://localhost:4566"
|
| 939 |
+
athena = "http://localhost:4566"
|
| 940 |
+
cloudformation = "http://localhost:4566"
|
| 941 |
+
cloudfront = "http://localhost:4566"
|
| 942 |
+
cloudwatch = "http://localhost:4566"
|
| 943 |
+
codebuild = "http://localhost:4566"
|
| 944 |
+
cognitoidentity = "http://localhost:4566"
|
| 945 |
+
cognitoidp = "http://localhost:4566"
|
| 946 |
+
dynamodb = "http://localhost:4566"
|
| 947 |
+
ec2 = "http://localhost:4566"
|
| 948 |
+
ecr = "http://localhost:4566"
|
| 949 |
+
ecs = "http://localhost:4566"
|
| 950 |
+
efs = "http://localhost:4566"
|
| 951 |
+
elasticache = "http://localhost:4566"
|
| 952 |
+
elbv2 = "http://localhost:4566"
|
| 953 |
+
emr = "http://localhost:4566"
|
| 954 |
+
events = "http://localhost:4566"
|
| 955 |
+
firehose = "http://localhost:4566"
|
| 956 |
+
glue = "http://localhost:4566"
|
| 957 |
+
iam = "http://localhost:4566"
|
| 958 |
+
kinesis = "http://localhost:4566"
|
| 959 |
+
kms = "http://localhost:4566"
|
| 960 |
+
lambda = "http://localhost:4566"
|
| 961 |
+
logs = "http://localhost:4566"
|
| 962 |
+
rds = "http://localhost:4566"
|
| 963 |
+
route53 = "http://localhost:4566"
|
| 964 |
+
s3 = "http://localhost:4566"
|
| 965 |
+
s3control = "http://localhost:4566"
|
| 966 |
+
secretsmanager = "http://localhost:4566"
|
| 967 |
+
ses = "http://localhost:4566"
|
| 968 |
+
sesv2 = "http://localhost:4566"
|
| 969 |
+
sns = "http://localhost:4566"
|
| 970 |
+
sqs = "http://localhost:4566"
|
| 971 |
+
ssm = "http://localhost:4566"
|
| 972 |
+
stepfunctions = "http://localhost:4566"
|
| 973 |
+
sts = "http://localhost:4566"
|
| 974 |
+
wafv2 = "http://localhost:4566"
|
| 975 |
+
}
|
| 976 |
+
}
|
| 977 |
+
```
|
| 978 |
+
|
| 979 |
+
**Terraform VPC module** — fully supported (v6.6.0):
|
| 980 |
+
|
| 981 |
+
```hcl
|
| 982 |
+
module "vpc" {
|
| 983 |
+
source = "terraform-aws-modules/vpc/aws"
|
| 984 |
+
version = "6.6.0"
|
| 985 |
+
|
| 986 |
+
name = "my-vpc"
|
| 987 |
+
cidr = "10.0.0.0/16"
|
| 988 |
+
|
| 989 |
+
azs = ["us-east-1a", "us-east-1b", "us-east-1c"]
|
| 990 |
+
private_subnets = ["10.0.0.0/20", "10.0.16.0/20", "10.0.32.0/20"]
|
| 991 |
+
public_subnets = ["10.0.64.0/20", "10.0.80.0/20", "10.0.96.0/20"]
|
| 992 |
+
|
| 993 |
+
enable_nat_gateway = true
|
| 994 |
+
single_nat_gateway = true
|
| 995 |
+
}
|
| 996 |
+
```
|
| 997 |
+
|
| 998 |
+
Creates VPC with per-VPC default network ACL, security group, and main route table. All 23 resources (subnets, IGW, NAT, route tables, associations, routes, default resources) supported.
|
| 999 |
+
|
| 1000 |
+
#### Predictable API Gateway IDs (`ms-custom-id`)
|
| 1001 |
+
|
| 1002 |
+
Pin the generated `apiId` / REST API id to a caller-supplied value so URLs stay stable across `terraform apply` runs. Works on `aws_apigatewayv2_api` (HTTP + WebSocket) and `aws_apigateway_rest_api`.
|
| 1003 |
+
|
| 1004 |
+
```hcl
|
| 1005 |
+
resource "aws_apigatewayv2_api" "example" {
|
| 1006 |
+
name = "example"
|
| 1007 |
+
protocol_type = "HTTP"
|
| 1008 |
+
tags = {
|
| 1009 |
+
ms-custom-id = "example"
|
| 1010 |
+
}
|
| 1011 |
+
}
|
| 1012 |
+
# → invoke URL stays "example.execute-api.localhost:4566" every apply
|
| 1013 |
+
```
|
| 1014 |
+
|
| 1015 |
+
Duplicates in the same account fail with `ConflictException`. The LocalStack `ls-custom-id` tag is not recognised — use `ms-custom-id` only (callers hitting the old name get a clear `BadRequestException`).
|
| 1016 |
+
|
| 1017 |
+
### AWS CDK
|
| 1018 |
+
|
| 1019 |
+
Set `AWS_ENDPOINT_URL` to route all CDK requests to MiniStack:
|
| 1020 |
+
|
| 1021 |
+
```bash
|
| 1022 |
+
export AWS_ENDPOINT_URL=http://localhost:4566
|
| 1023 |
+
export AWS_ACCESS_KEY_ID=test
|
| 1024 |
+
export AWS_SECRET_ACCESS_KEY=test
|
| 1025 |
+
export AWS_DEFAULT_REGION=us-east-1
|
| 1026 |
+
|
| 1027 |
+
cdk bootstrap aws://000000000000/us-east-1
|
| 1028 |
+
cdk deploy --require-approval never
|
| 1029 |
+
```
|
| 1030 |
+
|
| 1031 |
+
> **Important:** Running `cdk deploy` without `AWS_ENDPOINT_URL` will send requests to **real AWS**, not MiniStack. If you see "The security token included in the request is invalid", your requests are hitting AWS — set the endpoint.
|
| 1032 |
+
|
| 1033 |
+
To reset the bootstrap stack or delete all state:
|
| 1034 |
+
|
| 1035 |
+
```bash
|
| 1036 |
+
# Delete a specific stack
|
| 1037 |
+
aws --endpoint-url=http://localhost:4566 cloudformation delete-stack --stack-name CDKToolkit
|
| 1038 |
+
|
| 1039 |
+
# Or reset all MiniStack state
|
| 1040 |
+
curl -X POST http://localhost:4566/_ministack/reset
|
| 1041 |
+
```
|
| 1042 |
+
|
| 1043 |
+
### Pulumi
|
| 1044 |
+
|
| 1045 |
+
```yaml
|
| 1046 |
+
# Pulumi.dev.yaml
|
| 1047 |
+
config:
|
| 1048 |
+
aws:endpoints:
|
| 1049 |
+
- s3: http://localhost:4566
|
| 1050 |
+
dynamodb: http://localhost:4566
|
| 1051 |
+
# ... etc
|
| 1052 |
+
```
|
| 1053 |
+
|
| 1054 |
+
### Amplify / CDK
|
| 1055 |
+
|
| 1056 |
+
MiniStack supports Amplify Gen 2 and CDK deployments. The underlying services are fully emulated:
|
| 1057 |
+
|
| 1058 |
+
- **Auth** — Cognito User Pools with JWKS/OIDC endpoints (`/.well-known/jwks.json`) for real JWT validation
|
| 1059 |
+
- **Data** — AppSync GraphQL queries/mutations execute against DynamoDB resolvers (create/get/list/update/delete)
|
| 1060 |
+
- **Storage** — S3
|
| 1061 |
+
- **Functions** — Lambda (Python + Node.js)
|
| 1062 |
+
|
| 1063 |
+
```bash
|
| 1064 |
+
export AWS_ENDPOINT_URL=http://localhost:4566
|
| 1065 |
+
npx ampx sandbox
|
| 1066 |
+
```
|
| 1067 |
+
|
| 1068 |
+
> **Note:** AppSync supports Amplify-style CRUD operations. Advanced GraphQL features (fragments, unions, subscriptions) are not supported.
|
| 1069 |
+
|
| 1070 |
+
### Testcontainers (Java / Go / Python)
|
| 1071 |
+
|
| 1072 |
+
See [`Testcontainers/java-testcontainers`](Testcontainers/java-testcontainers), [`Testcontainers/go-testcontainers`](Testcontainers/go-testcontainers), and [`Testcontainers/python-testcontainers`](Testcontainers/python-testcontainers) for ready-to-run integration tests using Testcontainers with the AWS SDK v2.
|
| 1073 |
+
|
| 1074 |
+
---
|
| 1075 |
+
|
| 1076 |
+
## Comparison
|
| 1077 |
+
|
| 1078 |
+
| Feature | MiniStack | LocalStack Free | LocalStack Pro |
|
| 1079 |
+
|---------|-----------|-----------------|----------------|
|
| 1080 |
+
| S3, SQS, SNS, DynamoDB | ✅ | ✅ | ✅ |
|
| 1081 |
+
| Lambda (Python + Node.js execution) | ✅ | ✅ | ✅ |
|
| 1082 |
+
| IAM, STS, SecretsManager | ✅ | ✅ | ✅ |
|
| 1083 |
+
| CloudWatch Logs | ✅ | ✅ | ✅ |
|
| 1084 |
+
| SSM Parameter Store | ✅ | ✅ | ✅ |
|
| 1085 |
+
| EventBridge | ✅ | ✅ | ✅ |
|
| 1086 |
+
| Kinesis | ✅ | ✅ | ✅ |
|
| 1087 |
+
| SES | ✅ | ✅ | ✅ |
|
| 1088 |
+
| Step Functions | ✅ | ✅ | ✅ |
|
| 1089 |
+
| **RDS (real DB containers)** | ✅ | ❌ | ✅ |
|
| 1090 |
+
| **ElastiCache (real Redis)** | ✅ | ❌ | ✅ |
|
| 1091 |
+
| **ECS (real Docker containers)** | ✅ | ❌ | ✅ |
|
| 1092 |
+
| **Athena (real SQL via DuckDB)** | ✅ | ❌ | ✅ |
|
| 1093 |
+
| **Glue Data Catalog + Jobs** | ✅ | ❌ | ✅ |
|
| 1094 |
+
| **API Gateway v2 (HTTP API)** | ✅ | ✅ | ✅ |
|
| 1095 |
+
| **API Gateway v2 (WebSocket API)** | ✅ | ❌ | ✅ |
|
| 1096 |
+
| **API Gateway v1 (REST API)** | ✅ | ✅ | ✅ |
|
| 1097 |
+
| **Firehose** | ✅ | ✅ | ✅ |
|
| 1098 |
+
| **Route53** | ✅ | ✅ | ✅ |
|
| 1099 |
+
| **Cognito** | ✅ | ✅ | ✅ |
|
| 1100 |
+
| **EC2** | ✅ | ✅ | ✅ |
|
| 1101 |
+
| **EMR** | ✅ | Paid | ✅ |
|
| 1102 |
+
| **ELBv2 / ALB** | ✅ | ✅ | ✅ |
|
| 1103 |
+
| **EBS** | ✅ | Paid | ✅ |
|
| 1104 |
+
| **EFS** | ✅ | Paid | ✅ |
|
| 1105 |
+
| **ACM** | ✅ | ✅ | ✅ |
|
| 1106 |
+
| **SES v2** | ✅ | ✅ | ✅ |
|
| 1107 |
+
| **WAF v2** | ✅ | Paid | ✅ |
|
| 1108 |
+
| **CloudFormation** | **partial** | partial | ✅ Free |
|
| 1109 |
+
| **KMS** | ✅ | Paid | ✅ Free |
|
| 1110 |
+
| **ECR** | ✅ | ✅ | ✅ |
|
| 1111 |
+
| **CloudFront** | ✅ | Paid | ✅ |
|
| 1112 |
+
| **AppSync** | ✅ | NO | ✅ |
|
| 1113 |
+
| **Cloud Map** | ✅ | ❌ | ✅ |
|
| 1114 |
+
| **CodeBuild** | ✅ | ✅ | ✅ |
|
| 1115 |
+
| **Transfer Family** | ✅ | ❌ | ❌ |
|
| 1116 |
+
| **S3 Files** | ✅ | ❌ | ❌ |
|
| 1117 |
+
| Cost | **Free forever** | Was free, now paid | $35+/mo |
|
| 1118 |
+
| Docker image size | ~250MB | ~1GB | ~1GB |
|
| 1119 |
+
| Memory at idle | ~40MB | ~500MB | ~500MB |
|
| 1120 |
+
| Startup time | <1s | ~15-30s | ~15-30s |
|
| 1121 |
+
| License | MIT | BSL (restricted) | Proprietary |
|
| 1122 |
+
|
| 1123 |
+
---
|
| 1124 |
+
|
| 1125 |
+
## Community Integrations
|
| 1126 |
+
|
| 1127 |
+
| Project | Description |
|
| 1128 |
+
|---------|-------------|
|
| 1129 |
+
| [**StackPort**](https://github.com/DaviReisVieira/stackport) | Visual dashboard to browse and inspect AWS resources in MiniStack. Available on [PyPI](https://pypi.org/project/stackport/) and [Docker Hub](https://hub.docker.com/r/davireis/stackport). |
|
| 1130 |
+
| [**McDoit.Aspire.Hosting.Ministack**](https://github.com/McDoit/aspire-hosting-ministack) | .NET Aspire hosting integration for MiniStack. |
|
| 1131 |
+
|
| 1132 |
+
---
|
| 1133 |
+
|
| 1134 |
+
## Contributing
|
| 1135 |
+
|
| 1136 |
+
PRs welcome. The codebase is intentionally simple — each service is a single self-contained Python file in `ministack/services/`. Adding a new service means:
|
| 1137 |
+
|
| 1138 |
+
1. Create `ministack/services/myservice.py` with an `async def handle_request(...)` function and a `reset()` function
|
| 1139 |
+
2. Add it to `SERVICE_REGISTRY` in `ministack/app.py` so the handler, aliases, and service filter are generated automatically
|
| 1140 |
+
3. Add detection patterns to `ministack/core/router.py`
|
| 1141 |
+
4. Add a fixture to `tests/conftest.py` and tests to `tests/test_services.py`
|
| 1142 |
+
|
| 1143 |
+
See [CONTRIBUTING.md](CONTRIBUTING.md) for a full walkthrough.
|
| 1144 |
+
|
| 1145 |
+
---
|
| 1146 |
+
|
| 1147 |
+
## License
|
| 1148 |
+
|
| 1149 |
+
MIT — free to use, modify, and distribute. No restrictions.
|
| 1150 |
+
|
| 1151 |
+
```
|
| 1152 |
+
Copyright (c) 2026 MiniStack Contributors
|
| 1153 |
+
|
| 1154 |
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
| 1155 |
+
of this software and associated documentation files (the "Software"), to deal
|
| 1156 |
+
in the Software without restriction, including without limitation the rights
|
| 1157 |
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
| 1158 |
+
copies of the Software, and to permit persons to whom the Software is
|
| 1159 |
+
furnished to do so, subject to the following conditions:
|
| 1160 |
+
|
| 1161 |
+
The above copyright notice and this permission notice shall be included in all
|
| 1162 |
+
copies or substantial portions of the Software.
|
| 1163 |
+
```
|
aws_infra/SECURITY.md
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Security Policy
|
| 2 |
+
|
| 3 |
+
## ⚠️ Important: Local Development Only
|
| 4 |
+
|
| 5 |
+
MiniStack is designed **exclusively for local development and CI/CD testing**.
|
| 6 |
+
|
| 7 |
+
**Do not expose MiniStack to the internet or any untrusted network.**
|
| 8 |
+
|
| 9 |
+
- It has no authentication — any request is accepted
|
| 10 |
+
- Credentials (`aws_access_key_id`, `aws_secret_access_key`) are ignored
|
| 11 |
+
- All data is stored in-memory and is not encrypted
|
| 12 |
+
- The Docker socket mount (for ECS/RDS/ElastiCache) gives container-level access to your host
|
| 13 |
+
|
| 14 |
+
## Reporting a Vulnerability
|
| 15 |
+
|
| 16 |
+
If you find a security issue that could affect users running MiniStack in a way that exposes their host system or data, please open a GitHub issue tagged `security`.
|
| 17 |
+
|
| 18 |
+
Since this is a local dev tool with no auth by design, most "vulnerabilities" are intentional trade-offs for simplicity. But if you find something that could cause unintended host compromise (e.g. path traversal in S3 persistence, command injection in Lambda execution), please report it.
|
| 19 |
+
|
| 20 |
+
## Recommended Usage
|
| 21 |
+
|
| 22 |
+
```yaml
|
| 23 |
+
# docker-compose.yml — bind to localhost only, never 0.0.0.0 on a shared machine
|
| 24 |
+
ports:
|
| 25 |
+
- "127.0.0.1:4566:4566"
|
| 26 |
+
```
|
| 27 |
+
|
| 28 |
+
Never run with `S3_PERSIST=1` pointing to sensitive directories.
|
aws_infra/Testcontainers/go-testcontainers/README.md
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# MiniStack — Go Testcontainers Example
|
| 2 |
+
|
| 3 |
+
Integration tests for S3, SQS, and DynamoDB using [testcontainers-go](https://golang.testcontainers.org/) and the AWS SDK v2.
|
| 4 |
+
|
| 5 |
+
## Prerequisites
|
| 6 |
+
|
| 7 |
+
- Go 1.21+
|
| 8 |
+
- Docker (running)
|
| 9 |
+
|
| 10 |
+
## Run
|
| 11 |
+
|
| 12 |
+
```bash
|
| 13 |
+
go mod tidy
|
| 14 |
+
go test ./... -v
|
| 15 |
+
```
|
| 16 |
+
|
| 17 |
+
Testcontainers will pull `ministackorg/ministack:latest`, start it, run the tests, and tear it down automatically.
|
| 18 |
+
|
| 19 |
+
## What's tested
|
| 20 |
+
|
| 21 |
+
| Service | Operations |
|
| 22 |
+
|------------|------------|
|
| 23 |
+
| S3 | CreateBucket, PutObject, GetObject, ListBuckets |
|
| 24 |
+
| SQS | CreateQueue, SendMessage, ReceiveMessage |
|
| 25 |
+
| DynamoDB | CreateTable, PutItem, GetItem, DeleteItem |
|
aws_infra/Testcontainers/go-testcontainers/go.mod
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
module github.com/ministackorg/ministack/examples/go-testcontainers
|
| 2 |
+
|
| 3 |
+
go 1.24.0
|
| 4 |
+
|
| 5 |
+
require (
|
| 6 |
+
github.com/aws/aws-sdk-go-v2 v1.41.5
|
| 7 |
+
github.com/aws/aws-sdk-go-v2/credentials v1.17.11
|
| 8 |
+
github.com/aws/aws-sdk-go-v2/service/dynamodb v1.31.1
|
| 9 |
+
github.com/aws/aws-sdk-go-v2/service/s3 v1.97.3
|
| 10 |
+
github.com/aws/aws-sdk-go-v2/service/sqs v1.31.1
|
| 11 |
+
github.com/testcontainers/testcontainers-go v0.34.0
|
| 12 |
+
)
|
| 13 |
+
|
| 14 |
+
require (
|
| 15 |
+
dario.cat/mergo v1.0.0 // indirect
|
| 16 |
+
github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 // indirect
|
| 17 |
+
github.com/Microsoft/go-winio v0.6.2 // indirect
|
| 18 |
+
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 // indirect
|
| 19 |
+
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21 // indirect
|
| 20 |
+
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21 // indirect
|
| 21 |
+
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.22 // indirect
|
| 22 |
+
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7 // indirect
|
| 23 |
+
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.13 // indirect
|
| 24 |
+
github.com/aws/aws-sdk-go-v2/service/internal/endpoint-discovery v1.9.6 // indirect
|
| 25 |
+
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.21 // indirect
|
| 26 |
+
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.21 // indirect
|
| 27 |
+
github.com/aws/smithy-go v1.24.2 // indirect
|
| 28 |
+
github.com/cenkalti/backoff/v4 v4.2.1 // indirect
|
| 29 |
+
github.com/containerd/containerd v1.7.29 // indirect
|
| 30 |
+
github.com/containerd/log v0.1.0 // indirect
|
| 31 |
+
github.com/containerd/platforms v0.2.1 // indirect
|
| 32 |
+
github.com/cpuguy83/dockercfg v0.3.2 // indirect
|
| 33 |
+
github.com/davecgh/go-spew v1.1.1 // indirect
|
| 34 |
+
github.com/distribution/reference v0.6.0 // indirect
|
| 35 |
+
github.com/docker/docker v27.1.1+incompatible // indirect
|
| 36 |
+
github.com/docker/go-connections v0.5.0 // indirect
|
| 37 |
+
github.com/docker/go-units v0.5.0 // indirect
|
| 38 |
+
github.com/felixge/httpsnoop v1.0.4 // indirect
|
| 39 |
+
github.com/go-logr/logr v1.4.2 // indirect
|
| 40 |
+
github.com/go-logr/stdr v1.2.2 // indirect
|
| 41 |
+
github.com/go-ole/go-ole v1.2.6 // indirect
|
| 42 |
+
github.com/gogo/protobuf v1.3.2 // indirect
|
| 43 |
+
github.com/google/uuid v1.6.0 // indirect
|
| 44 |
+
github.com/jmespath/go-jmespath v0.4.0 // indirect
|
| 45 |
+
github.com/klauspost/compress v1.17.4 // indirect
|
| 46 |
+
github.com/kr/text v0.2.0 // indirect
|
| 47 |
+
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect
|
| 48 |
+
github.com/magiconair/properties v1.8.7 // indirect
|
| 49 |
+
github.com/moby/docker-image-spec v1.3.1 // indirect
|
| 50 |
+
github.com/moby/patternmatcher v0.6.0 // indirect
|
| 51 |
+
github.com/moby/sys/sequential v0.5.0 // indirect
|
| 52 |
+
github.com/moby/sys/user v0.3.0 // indirect
|
| 53 |
+
github.com/moby/sys/userns v0.1.0 // indirect
|
| 54 |
+
github.com/moby/term v0.5.0 // indirect
|
| 55 |
+
github.com/morikuni/aec v1.0.0 // indirect
|
| 56 |
+
github.com/opencontainers/go-digest v1.0.0 // indirect
|
| 57 |
+
github.com/opencontainers/image-spec v1.1.0 // indirect
|
| 58 |
+
github.com/pkg/errors v0.9.1 // indirect
|
| 59 |
+
github.com/pmezard/go-difflib v1.0.0 // indirect
|
| 60 |
+
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect
|
| 61 |
+
github.com/shirou/gopsutil/v3 v3.23.12 // indirect
|
| 62 |
+
github.com/shoenig/go-m1cpu v0.1.6 // indirect
|
| 63 |
+
github.com/sirupsen/logrus v1.9.3 // indirect
|
| 64 |
+
github.com/stretchr/testify v1.9.0 // indirect
|
| 65 |
+
github.com/tklauser/go-sysconf v0.3.12 // indirect
|
| 66 |
+
github.com/tklauser/numcpus v0.6.1 // indirect
|
| 67 |
+
github.com/yusufpapurcu/wmi v1.2.3 // indirect
|
| 68 |
+
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 // indirect
|
| 69 |
+
go.opentelemetry.io/otel v1.24.0 // indirect
|
| 70 |
+
go.opentelemetry.io/otel/metric v1.24.0 // indirect
|
| 71 |
+
go.opentelemetry.io/otel/trace v1.24.0 // indirect
|
| 72 |
+
golang.org/x/crypto v0.45.0 // indirect
|
| 73 |
+
golang.org/x/sys v0.38.0 // indirect
|
| 74 |
+
gopkg.in/yaml.v3 v3.0.1 // indirect
|
| 75 |
+
)
|
aws_infra/Testcontainers/go-testcontainers/go.sum
ADDED
|
@@ -0,0 +1,232 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk=
|
| 2 |
+
dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk=
|
| 3 |
+
github.com/AdaLogics/go-fuzz-headers v0.0.0-20230811130428-ced1acdcaa24 h1:bvDV9vkmnHYOMsOr4WLk+Vo07yKIzd94sVoIqshQ4bU=
|
| 4 |
+
github.com/AdaLogics/go-fuzz-headers v0.0.0-20230811130428-ced1acdcaa24/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8=
|
| 5 |
+
github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 h1:UQHMgLO+TxOElx5B5HZ4hJQsoJ/PvUvKRhJHDQXO8P8=
|
| 6 |
+
github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
|
| 7 |
+
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
|
| 8 |
+
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
| 9 |
+
github.com/aws/aws-sdk-go-v2 v1.41.5 h1:dj5kopbwUsVUVFgO4Fi5BIT3t4WyqIDjGKCangnV/yY=
|
| 10 |
+
github.com/aws/aws-sdk-go-v2 v1.41.5/go.mod h1:mwsPRE8ceUUpiTgF7QmQIJ7lgsKUPQOUl3o72QBrE1o=
|
| 11 |
+
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 h1:eBMB84YGghSocM7PsjmmPffTa+1FBUeNvGvFou6V/4o=
|
| 12 |
+
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8/go.mod h1:lyw7GFp3qENLh7kwzf7iMzAxDn+NzjXEAGjKS2UOKqI=
|
| 13 |
+
github.com/aws/aws-sdk-go-v2/credentials v1.17.11 h1:YuIB1dJNf1Re822rriUOTxopaHHvIq0l/pX3fwO+Tzs=
|
| 14 |
+
github.com/aws/aws-sdk-go-v2/credentials v1.17.11/go.mod h1:AQtFPsDH9bI2O+71anW6EKL+NcD7LG3dpKGMV4SShgo=
|
| 15 |
+
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21 h1:Rgg6wvjjtX8bNHcvi9OnXWwcE0a2vGpbwmtICOsvcf4=
|
| 16 |
+
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21/go.mod h1:A/kJFst/nm//cyqonihbdpQZwiUhhzpqTsdbhDdRF9c=
|
| 17 |
+
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21 h1:PEgGVtPoB6NTpPrBgqSE5hE/o47Ij9qk/SEZFbUOe9A=
|
| 18 |
+
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21/go.mod h1:p+hz+PRAYlY3zcpJhPwXlLC4C+kqn70WIHwnzAfs6ps=
|
| 19 |
+
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.22 h1:rWyie/PxDRIdhNf4DzRk0lvjVOqFJuNnO8WwaIRVxzQ=
|
| 20 |
+
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.22/go.mod h1:zd/JsJ4P7oGfUhXn1VyLqaRZwPmZwg44Jf2dS84Dm3Y=
|
| 21 |
+
github.com/aws/aws-sdk-go-v2/service/dynamodb v1.31.1 h1:dZXY07Dm59TxAjJcUfNMJHLDI/gLMxTRZefn2jFAVsw=
|
| 22 |
+
github.com/aws/aws-sdk-go-v2/service/dynamodb v1.31.1/go.mod h1:lVLqEtX+ezgtfalyJs7Peb0uv9dEpAQP5yuq2O26R44=
|
| 23 |
+
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7 h1:5EniKhLZe4xzL7a+fU3C2tfUN4nWIqlLesfrjkuPFTY=
|
| 24 |
+
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7/go.mod h1:x0nZssQ3qZSnIcePWLvcoFisRXJzcTVvYpAAdYX8+GI=
|
| 25 |
+
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.13 h1:JRaIgADQS/U6uXDqlPiefP32yXTda7Kqfx+LgspooZM=
|
| 26 |
+
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.13/go.mod h1:CEuVn5WqOMilYl+tbccq8+N2ieCy0gVn3OtRb0vBNNM=
|
| 27 |
+
github.com/aws/aws-sdk-go-v2/service/internal/endpoint-discovery v1.9.6 h1:6tayEze2Y+hiL3kdnEUxSPsP+pJsUfwLSFspFl1ru9Q=
|
| 28 |
+
github.com/aws/aws-sdk-go-v2/service/internal/endpoint-discovery v1.9.6/go.mod h1:qVNb/9IOVsLCZh0x2lnagrBwQ9fxajUpXS7OZfIsKn0=
|
| 29 |
+
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.21 h1:c31//R3xgIJMSC8S6hEVq+38DcvUlgFY0FM6mSI5oto=
|
| 30 |
+
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.21/go.mod h1:r6+pf23ouCB718FUxaqzZdbpYFyDtehyZcmP5KL9FkA=
|
| 31 |
+
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.21 h1:ZlvrNcHSFFWURB8avufQq9gFsheUgjVD9536obIknfM=
|
| 32 |
+
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.21/go.mod h1:cv3TNhVrssKR0O/xxLJVRfd2oazSnZnkUeTf6ctUwfQ=
|
| 33 |
+
github.com/aws/aws-sdk-go-v2/service/s3 v1.97.3 h1:HwxWTbTrIHm5qY+CAEur0s/figc3qwvLWsNkF4RPToo=
|
| 34 |
+
github.com/aws/aws-sdk-go-v2/service/s3 v1.97.3/go.mod h1:uoA43SdFwacedBfSgfFSjjCvYe8aYBS7EnU5GZ/YKMM=
|
| 35 |
+
github.com/aws/aws-sdk-go-v2/service/sqs v1.31.1 h1:124rVNP6NbCfBZwiX1kfjMQrnsJtnpKeB0GalkuqSXo=
|
| 36 |
+
github.com/aws/aws-sdk-go-v2/service/sqs v1.31.1/go.mod h1:YijRvM1SAmuiIQ9pjfwahIEE3HMHUkx9P5oplL/Jnj4=
|
| 37 |
+
github.com/aws/smithy-go v1.24.2 h1:FzA3bu/nt/vDvmnkg+R8Xl46gmzEDam6mZ1hzmwXFng=
|
| 38 |
+
github.com/aws/smithy-go v1.24.2/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
|
| 39 |
+
github.com/cenkalti/backoff/v4 v4.2.1 h1:y4OZtCnogmCPw98Zjyt5a6+QwPLGkiQsYW5oUqylYbM=
|
| 40 |
+
github.com/cenkalti/backoff/v4 v4.2.1/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
|
| 41 |
+
github.com/containerd/containerd v1.7.29 h1:90fWABQsaN9mJhGkoVnuzEY+o1XDPbg9BTC9QTAHnuE=
|
| 42 |
+
github.com/containerd/containerd v1.7.29/go.mod h1:azUkWcOvHrWvaiUjSQH0fjzuHIwSPg1WL5PshGP4Szs=
|
| 43 |
+
github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I=
|
| 44 |
+
github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo=
|
| 45 |
+
github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A=
|
| 46 |
+
github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw=
|
| 47 |
+
github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA=
|
| 48 |
+
github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc=
|
| 49 |
+
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
| 50 |
+
github.com/creack/pty v1.1.18 h1:n56/Zwd5o6whRC5PMGretI4IdRLlmBXYNjScPaBgsbY=
|
| 51 |
+
github.com/creack/pty v1.1.18/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4=
|
| 52 |
+
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
| 53 |
+
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
| 54 |
+
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
| 55 |
+
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
|
| 56 |
+
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
|
| 57 |
+
github.com/docker/docker v27.1.1+incompatible h1:hO/M4MtV36kzKldqnA37IWhebRA+LnqqcqDja6kVaKY=
|
| 58 |
+
github.com/docker/docker v27.1.1+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
|
| 59 |
+
github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c=
|
| 60 |
+
github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc=
|
| 61 |
+
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
|
| 62 |
+
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
|
| 63 |
+
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
| 64 |
+
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
| 65 |
+
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
| 66 |
+
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
|
| 67 |
+
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
| 68 |
+
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
| 69 |
+
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
| 70 |
+
github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY=
|
| 71 |
+
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
|
| 72 |
+
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
|
| 73 |
+
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
|
| 74 |
+
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
| 75 |
+
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
| 76 |
+
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
| 77 |
+
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
| 78 |
+
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
| 79 |
+
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
| 80 |
+
github.com/grpc-ecosystem/grpc-gateway/v2 v2.16.0 h1:YBftPWNWd4WwGqtY2yeZL2ef8rHAxPBD8KFhJpmcqms=
|
| 81 |
+
github.com/grpc-ecosystem/grpc-gateway/v2 v2.16.0/go.mod h1:YN5jB8ie0yfIUg6VvR9Kz84aCaG7AsGZnLjhHbUqwPg=
|
| 82 |
+
github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg=
|
| 83 |
+
github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo=
|
| 84 |
+
github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8=
|
| 85 |
+
github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U=
|
| 86 |
+
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
|
| 87 |
+
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
|
| 88 |
+
github.com/klauspost/compress v1.17.4 h1:Ej5ixsIri7BrIjBkRZLTo6ghwrEtHFk7ijlczPW4fZ4=
|
| 89 |
+
github.com/klauspost/compress v1.17.4/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM=
|
| 90 |
+
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
|
| 91 |
+
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
|
| 92 |
+
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
| 93 |
+
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
| 94 |
+
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4=
|
| 95 |
+
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I=
|
| 96 |
+
github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY=
|
| 97 |
+
github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
|
| 98 |
+
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
|
| 99 |
+
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
|
| 100 |
+
github.com/moby/patternmatcher v0.6.0 h1:GmP9lR19aU5GqSSFko+5pRqHi+Ohk1O69aFiKkVGiPk=
|
| 101 |
+
github.com/moby/patternmatcher v0.6.0/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc=
|
| 102 |
+
github.com/moby/sys/sequential v0.5.0 h1:OPvI35Lzn9K04PBbCLW0g4LcFAJgHsvXsRyewg5lXtc=
|
| 103 |
+
github.com/moby/sys/sequential v0.5.0/go.mod h1:tH2cOOs5V9MlPiXcQzRC+eEyab644PWKGRYaaV5ZZlo=
|
| 104 |
+
github.com/moby/sys/user v0.3.0 h1:9ni5DlcW5an3SvRSx4MouotOygvzaXbaSrc/wGDFWPo=
|
| 105 |
+
github.com/moby/sys/user v0.3.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs=
|
| 106 |
+
github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g=
|
| 107 |
+
github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28=
|
| 108 |
+
github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0=
|
| 109 |
+
github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y=
|
| 110 |
+
github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
|
| 111 |
+
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
|
| 112 |
+
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
|
| 113 |
+
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
|
| 114 |
+
github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug=
|
| 115 |
+
github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM=
|
| 116 |
+
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
| 117 |
+
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
| 118 |
+
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
| 119 |
+
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
| 120 |
+
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c h1:ncq/mPwQF4JjgDlrVEn3C11VoGHZN7m8qihwgMEtzYw=
|
| 121 |
+
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
|
| 122 |
+
github.com/rogpeppe/go-internal v1.8.1 h1:geMPLpDpQOgVyCg5z5GoRwLHepNdb71NXb67XFkP+Eg=
|
| 123 |
+
github.com/rogpeppe/go-internal v1.8.1/go.mod h1:JeRgkft04UBgHMgCIwADu4Pn6Mtm5d4nPKWu0nJ5d+o=
|
| 124 |
+
github.com/shirou/gopsutil/v3 v3.23.12 h1:z90NtUkp3bMtmICZKpC4+WaknU1eXtp5vtbQ11DgpE4=
|
| 125 |
+
github.com/shirou/gopsutil/v3 v3.23.12/go.mod h1:1FrWgea594Jp7qmjHUUPlJDTPgcsb9mGnXDxavtikzM=
|
| 126 |
+
github.com/shoenig/go-m1cpu v0.1.6 h1:nxdKQNcEB6vzgA2E2bvzKIYRuNj7XNJ4S/aRSwKzFtM=
|
| 127 |
+
github.com/shoenig/go-m1cpu v0.1.6/go.mod h1:1JJMcUBvfNwpq05QDQVAnx3gUHr9IYF7GNg9SUEw2VQ=
|
| 128 |
+
github.com/shoenig/test v0.6.4 h1:kVTaSd7WLz5WZ2IaoM0RSzRsUD+m8wRR+5qvntpn4LU=
|
| 129 |
+
github.com/shoenig/test v0.6.4/go.mod h1:byHiCGXqrVaflBLAMq/srcZIHynQPQgeyvkvXnjqq0k=
|
| 130 |
+
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
|
| 131 |
+
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
|
| 132 |
+
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
| 133 |
+
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
| 134 |
+
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
| 135 |
+
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
|
| 136 |
+
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
| 137 |
+
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
| 138 |
+
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
| 139 |
+
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
| 140 |
+
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
| 141 |
+
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
| 142 |
+
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
| 143 |
+
github.com/testcontainers/testcontainers-go v0.34.0 h1:5fbgF0vIN5u+nD3IWabQwRybuB4GY8G2HHgCkbMzMHo=
|
| 144 |
+
github.com/testcontainers/testcontainers-go v0.34.0/go.mod h1:6P/kMkQe8yqPHfPWNulFGdFHTD8HB2vLq/231xY2iPQ=
|
| 145 |
+
github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU=
|
| 146 |
+
github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI=
|
| 147 |
+
github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk=
|
| 148 |
+
github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY=
|
| 149 |
+
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
| 150 |
+
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
| 151 |
+
github.com/yusufpapurcu/wmi v1.2.3 h1:E1ctvB7uKFMOJw3fdOW32DwGE9I7t++CRUEMKvFoFiw=
|
| 152 |
+
github.com/yusufpapurcu/wmi v1.2.3/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
|
| 153 |
+
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 h1:jq9TW8u3so/bN+JPT166wjOI6/vQPF6Xe7nMNIltagk=
|
| 154 |
+
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0/go.mod h1:p8pYQP+m5XfbZm9fxtSKAbM6oIllS7s2AfxrChvc7iw=
|
| 155 |
+
go.opentelemetry.io/otel v1.24.0 h1:0LAOdjNmQeSTzGBzduGe/rU4tZhMwL5rWgtp9Ku5Jfo=
|
| 156 |
+
go.opentelemetry.io/otel v1.24.0/go.mod h1:W7b9Ozg4nkF5tWI5zsXkaKKDjdVjpD4oAt9Qi/MArHo=
|
| 157 |
+
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.19.0 h1:Mne5On7VWdx7omSrSSZvM4Kw7cS7NQkOOmLcgscI51U=
|
| 158 |
+
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.19.0/go.mod h1:IPtUMKL4O3tH5y+iXVyAXqpAwMuzC1IrxVS81rummfE=
|
| 159 |
+
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.19.0 h1:IeMeyr1aBvBiPVYihXIaeIZba6b8E1bYp7lbdxK8CQg=
|
| 160 |
+
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.19.0/go.mod h1:oVdCUtjq9MK9BlS7TtucsQwUcXcymNiEDjgDD2jMtZU=
|
| 161 |
+
go.opentelemetry.io/otel/metric v1.24.0 h1:6EhoGWWK28x1fbpA4tYTOWBkPefTDQnb8WSGXlc88kI=
|
| 162 |
+
go.opentelemetry.io/otel/metric v1.24.0/go.mod h1:VYhLe1rFfxuTXLgj4CBiyz+9WYBA8pNGJgDcSFRKBco=
|
| 163 |
+
go.opentelemetry.io/otel/sdk v1.21.0 h1:FTt8qirL1EysG6sTQRZ5TokkU8d0ugCj8htOgThZXQ8=
|
| 164 |
+
go.opentelemetry.io/otel/sdk v1.21.0/go.mod h1:Nna6Yv7PWTdgJHVRD9hIYywQBRx7pbox6nwBnZIxl/E=
|
| 165 |
+
go.opentelemetry.io/otel/trace v1.24.0 h1:CsKnnL4dUAr/0llH9FKuc698G04IrpWV0MQA/Y1YELI=
|
| 166 |
+
go.opentelemetry.io/otel/trace v1.24.0/go.mod h1:HPc3Xr/cOApsBI154IU0OI0HJexz+aw5uPdbs3UCjNU=
|
| 167 |
+
go.opentelemetry.io/proto/otlp v1.0.0 h1:T0TX0tmXU8a3CbNXzEKGeU5mIVOdf0oykP+u2lIVU/I=
|
| 168 |
+
go.opentelemetry.io/proto/otlp v1.0.0/go.mod h1:Sy6pihPLfYHkr3NkUbEhGHFhINUSI/v80hjKIs5JXpM=
|
| 169 |
+
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
| 170 |
+
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
| 171 |
+
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
| 172 |
+
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
|
| 173 |
+
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
|
| 174 |
+
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
| 175 |
+
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
| 176 |
+
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
| 177 |
+
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
| 178 |
+
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
| 179 |
+
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
| 180 |
+
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
|
| 181 |
+
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
|
| 182 |
+
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
| 183 |
+
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
| 184 |
+
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
| 185 |
+
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
| 186 |
+
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
| 187 |
+
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
| 188 |
+
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
| 189 |
+
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
| 190 |
+
golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
| 191 |
+
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
| 192 |
+
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
| 193 |
+
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
| 194 |
+
golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
| 195 |
+
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
|
| 196 |
+
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
| 197 |
+
golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU=
|
| 198 |
+
golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254=
|
| 199 |
+
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
| 200 |
+
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
| 201 |
+
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
|
| 202 |
+
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
|
| 203 |
+
golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
|
| 204 |
+
golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
|
| 205 |
+
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
| 206 |
+
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
| 207 |
+
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
|
| 208 |
+
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
| 209 |
+
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
| 210 |
+
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
| 211 |
+
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
| 212 |
+
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
| 213 |
+
google.golang.org/genproto v0.0.0-20231211222908-989df2bf70f3 h1:1hfbdAfFbkmpg41000wDVqr7jUpK/Yo+LPnIxxGzmkg=
|
| 214 |
+
google.golang.org/genproto/googleapis/api v0.0.0-20231120223509-83a465c0220f h1:2yNACc1O40tTnrsbk9Cv6oxiW8pxI/pXj0wRtdlYmgY=
|
| 215 |
+
google.golang.org/genproto/googleapis/api v0.0.0-20231120223509-83a465c0220f/go.mod h1:Uy9bTZJqmfrw2rIBxgGLnamc78euZULUBrLZ9XTITKI=
|
| 216 |
+
google.golang.org/genproto/googleapis/rpc v0.0.0-20240401170217-c3f982113cda h1:LI5DOvAxUPMv/50agcLLoo+AdWc1irS9Rzz4vPuD1V4=
|
| 217 |
+
google.golang.org/genproto/googleapis/rpc v0.0.0-20240401170217-c3f982113cda/go.mod h1:WtryC6hu0hhx87FDGxWCDptyssuo68sk10vYjF+T9fY=
|
| 218 |
+
google.golang.org/grpc v1.64.1 h1:LKtvyfbX3UGVPFcGqJ9ItpVWW6oN/2XqTxfAnwRRXiA=
|
| 219 |
+
google.golang.org/grpc v1.64.1/go.mod h1:hiQF4LFZelK2WKaP6W0L92zGHtiQdZxk8CrSdvyjeP0=
|
| 220 |
+
google.golang.org/protobuf v1.35.2 h1:8Ar7bF+apOIoThw1EdZl0p1oWvMqTHmpA2fRTyZO8io=
|
| 221 |
+
google.golang.org/protobuf v1.35.2/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
|
| 222 |
+
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
| 223 |
+
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
| 224 |
+
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
| 225 |
+
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
| 226 |
+
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
| 227 |
+
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
| 228 |
+
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
| 229 |
+
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
| 230 |
+
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
| 231 |
+
gotest.tools/v3 v3.5.1 h1:EENdUnS3pdur5nybKYIh2Vfgc8IUNBjxDPSjtiJcOzU=
|
| 232 |
+
gotest.tools/v3 v3.5.1/go.mod h1:isy3WKz7GK6uNw/sbHzfKBLvlvXwUyV06n6brMxxopU=
|
aws_infra/Testcontainers/go-testcontainers/ministack_test.go
ADDED
|
@@ -0,0 +1,297 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package ministacktest
|
| 2 |
+
|
| 3 |
+
import (
|
| 4 |
+
"context"
|
| 5 |
+
"fmt"
|
| 6 |
+
"strings"
|
| 7 |
+
"testing"
|
| 8 |
+
"time"
|
| 9 |
+
|
| 10 |
+
"github.com/aws/aws-sdk-go-v2/aws"
|
| 11 |
+
"github.com/aws/aws-sdk-go-v2/credentials"
|
| 12 |
+
"github.com/aws/aws-sdk-go-v2/service/dynamodb"
|
| 13 |
+
"github.com/aws/aws-sdk-go-v2/service/dynamodb/types"
|
| 14 |
+
"github.com/aws/aws-sdk-go-v2/service/s3"
|
| 15 |
+
"github.com/aws/aws-sdk-go-v2/service/sqs"
|
| 16 |
+
"github.com/testcontainers/testcontainers-go"
|
| 17 |
+
"github.com/testcontainers/testcontainers-go/wait"
|
| 18 |
+
)
|
| 19 |
+
|
| 20 |
+
// newMiniStackContainer starts a MiniStack container and returns the endpoint URL.
|
| 21 |
+
func newMiniStackContainer(ctx context.Context, t *testing.T) (string, func()) {
|
| 22 |
+
t.Helper()
|
| 23 |
+
|
| 24 |
+
req := testcontainers.ContainerRequest{
|
| 25 |
+
Image: "ministackorg/ministack:latest",
|
| 26 |
+
ExposedPorts: []string{"4566/tcp"},
|
| 27 |
+
Env: map[string]string{
|
| 28 |
+
"GATEWAY_PORT": "4566",
|
| 29 |
+
"LOG_LEVEL": "INFO",
|
| 30 |
+
},
|
| 31 |
+
WaitingFor: wait.ForHTTP("/_ministack/health").
|
| 32 |
+
WithPort("4566/tcp").
|
| 33 |
+
WithStartupTimeout(60 * time.Second),
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
container, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{
|
| 37 |
+
ContainerRequest: req,
|
| 38 |
+
Started: true,
|
| 39 |
+
})
|
| 40 |
+
if err != nil {
|
| 41 |
+
t.Fatalf("failed to start MiniStack container: %v", err)
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
host, err := container.Host(ctx)
|
| 45 |
+
if err != nil {
|
| 46 |
+
t.Fatalf("failed to get container host: %v", err)
|
| 47 |
+
}
|
| 48 |
+
port, err := container.MappedPort(ctx, "4566")
|
| 49 |
+
if err != nil {
|
| 50 |
+
t.Fatalf("failed to get mapped port: %v", err)
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
endpoint := fmt.Sprintf("http://%s:%s", host, port.Port())
|
| 54 |
+
|
| 55 |
+
cleanup := func() {
|
| 56 |
+
if err := container.Terminate(ctx); err != nil {
|
| 57 |
+
t.Logf("failed to terminate container: %v", err)
|
| 58 |
+
}
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
return endpoint, cleanup
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
func awsCfg(endpoint string) aws.Config {
|
| 65 |
+
return aws.Config{
|
| 66 |
+
Region: "us-east-1",
|
| 67 |
+
Credentials: credentials.NewStaticCredentialsProvider("test", "test", ""),
|
| 68 |
+
EndpointResolverWithOptions: aws.EndpointResolverWithOptionsFunc(
|
| 69 |
+
func(service, region string, options ...interface{}) (aws.Endpoint, error) {
|
| 70 |
+
return aws.Endpoint{URL: endpoint}, nil
|
| 71 |
+
},
|
| 72 |
+
),
|
| 73 |
+
}
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
+
// ── S3 ──────────────────────────────────────────────────────────────────────
|
| 77 |
+
|
| 78 |
+
func TestS3_PutAndGetObject(t *testing.T) {
|
| 79 |
+
ctx := context.Background()
|
| 80 |
+
endpoint, cleanup := newMiniStackContainer(ctx, t)
|
| 81 |
+
defer cleanup()
|
| 82 |
+
|
| 83 |
+
cfg := awsCfg(endpoint)
|
| 84 |
+
client := s3.NewFromConfig(cfg, func(o *s3.Options) {
|
| 85 |
+
o.UsePathStyle = true
|
| 86 |
+
})
|
| 87 |
+
|
| 88 |
+
bucket := "go-test-bucket"
|
| 89 |
+
_, err := client.CreateBucket(ctx, &s3.CreateBucketInput{Bucket: aws.String(bucket)})
|
| 90 |
+
if err != nil {
|
| 91 |
+
t.Fatalf("CreateBucket: %v", err)
|
| 92 |
+
}
|
| 93 |
+
|
| 94 |
+
_, err = client.PutObject(ctx, &s3.PutObjectInput{
|
| 95 |
+
Bucket: aws.String(bucket),
|
| 96 |
+
Key: aws.String("hello.txt"),
|
| 97 |
+
Body: strings.NewReader("Hello from Go!"),
|
| 98 |
+
})
|
| 99 |
+
if err != nil {
|
| 100 |
+
t.Fatalf("PutObject: %v", err)
|
| 101 |
+
}
|
| 102 |
+
|
| 103 |
+
out, err := client.GetObject(ctx, &s3.GetObjectInput{
|
| 104 |
+
Bucket: aws.String(bucket),
|
| 105 |
+
Key: aws.String("hello.txt"),
|
| 106 |
+
})
|
| 107 |
+
if err != nil {
|
| 108 |
+
t.Fatalf("GetObject: %v", err)
|
| 109 |
+
}
|
| 110 |
+
defer out.Body.Close()
|
| 111 |
+
|
| 112 |
+
buf := new(strings.Builder)
|
| 113 |
+
if _, err := fmt.Fscan(out.Body, buf); err != nil && buf.Len() == 0 {
|
| 114 |
+
t.Fatalf("read body: %v", err)
|
| 115 |
+
}
|
| 116 |
+
if !strings.Contains(buf.String(), "Hello") {
|
| 117 |
+
t.Errorf("unexpected body: %q", buf.String())
|
| 118 |
+
}
|
| 119 |
+
}
|
| 120 |
+
|
| 121 |
+
func TestS3_ListBuckets(t *testing.T) {
|
| 122 |
+
ctx := context.Background()
|
| 123 |
+
endpoint, cleanup := newMiniStackContainer(ctx, t)
|
| 124 |
+
defer cleanup()
|
| 125 |
+
|
| 126 |
+
cfg := awsCfg(endpoint)
|
| 127 |
+
client := s3.NewFromConfig(cfg, func(o *s3.Options) { o.UsePathStyle = true })
|
| 128 |
+
|
| 129 |
+
_, err := client.CreateBucket(ctx, &s3.CreateBucketInput{Bucket: aws.String("list-bucket")})
|
| 130 |
+
if err != nil {
|
| 131 |
+
t.Fatalf("CreateBucket: %v", err)
|
| 132 |
+
}
|
| 133 |
+
|
| 134 |
+
out, err := client.ListBuckets(ctx, &s3.ListBucketsInput{})
|
| 135 |
+
if err != nil {
|
| 136 |
+
t.Fatalf("ListBuckets: %v", err)
|
| 137 |
+
}
|
| 138 |
+
|
| 139 |
+
found := false
|
| 140 |
+
for _, b := range out.Buckets {
|
| 141 |
+
if aws.ToString(b.Name) == "list-bucket" {
|
| 142 |
+
found = true
|
| 143 |
+
}
|
| 144 |
+
}
|
| 145 |
+
if !found {
|
| 146 |
+
t.Error("expected list-bucket in ListBuckets response")
|
| 147 |
+
}
|
| 148 |
+
}
|
| 149 |
+
|
| 150 |
+
// ── SQS ──────────────────────────────────────────────────────────────────────
|
| 151 |
+
|
| 152 |
+
func TestSQS_SendAndReceive(t *testing.T) {
|
| 153 |
+
ctx := context.Background()
|
| 154 |
+
endpoint, cleanup := newMiniStackContainer(ctx, t)
|
| 155 |
+
defer cleanup()
|
| 156 |
+
|
| 157 |
+
cfg := awsCfg(endpoint)
|
| 158 |
+
client := sqs.NewFromConfig(cfg)
|
| 159 |
+
|
| 160 |
+
q, err := client.CreateQueue(ctx, &sqs.CreateQueueInput{QueueName: aws.String("go-test-queue")})
|
| 161 |
+
if err != nil {
|
| 162 |
+
t.Fatalf("CreateQueue: %v", err)
|
| 163 |
+
}
|
| 164 |
+
|
| 165 |
+
_, err = client.SendMessage(ctx, &sqs.SendMessageInput{
|
| 166 |
+
QueueUrl: q.QueueUrl,
|
| 167 |
+
MessageBody: aws.String("hello from go"),
|
| 168 |
+
})
|
| 169 |
+
if err != nil {
|
| 170 |
+
t.Fatalf("SendMessage: %v", err)
|
| 171 |
+
}
|
| 172 |
+
|
| 173 |
+
recv, err := client.ReceiveMessage(ctx, &sqs.ReceiveMessageInput{
|
| 174 |
+
QueueUrl: q.QueueUrl,
|
| 175 |
+
MaxNumberOfMessages: 1,
|
| 176 |
+
WaitTimeSeconds: 2,
|
| 177 |
+
})
|
| 178 |
+
if err != nil {
|
| 179 |
+
t.Fatalf("ReceiveMessage: %v", err)
|
| 180 |
+
}
|
| 181 |
+
if len(recv.Messages) != 1 {
|
| 182 |
+
t.Fatalf("expected 1 message, got %d", len(recv.Messages))
|
| 183 |
+
}
|
| 184 |
+
if aws.ToString(recv.Messages[0].Body) != "hello from go" {
|
| 185 |
+
t.Errorf("unexpected body: %q", aws.ToString(recv.Messages[0].Body))
|
| 186 |
+
}
|
| 187 |
+
}
|
| 188 |
+
|
| 189 |
+
// ── DynamoDB ──────────────────────────────────────────────────────────────────
|
| 190 |
+
|
| 191 |
+
func TestDynamoDB_PutAndGet(t *testing.T) {
|
| 192 |
+
ctx := context.Background()
|
| 193 |
+
endpoint, cleanup := newMiniStackContainer(ctx, t)
|
| 194 |
+
defer cleanup()
|
| 195 |
+
|
| 196 |
+
cfg := awsCfg(endpoint)
|
| 197 |
+
client := dynamodb.NewFromConfig(cfg)
|
| 198 |
+
|
| 199 |
+
_, err := client.CreateTable(ctx, &dynamodb.CreateTableInput{
|
| 200 |
+
TableName: aws.String("go-test-table"),
|
| 201 |
+
KeySchema: []types.KeySchemaElement{
|
| 202 |
+
{AttributeName: aws.String("pk"), KeyType: types.KeyTypeHash},
|
| 203 |
+
},
|
| 204 |
+
AttributeDefinitions: []types.AttributeDefinition{
|
| 205 |
+
{AttributeName: aws.String("pk"), AttributeType: types.ScalarAttributeTypeS},
|
| 206 |
+
},
|
| 207 |
+
BillingMode: types.BillingModePayPerRequest,
|
| 208 |
+
})
|
| 209 |
+
if err != nil {
|
| 210 |
+
t.Fatalf("CreateTable: %v", err)
|
| 211 |
+
}
|
| 212 |
+
|
| 213 |
+
_, err = client.PutItem(ctx, &dynamodb.PutItemInput{
|
| 214 |
+
TableName: aws.String("go-test-table"),
|
| 215 |
+
Item: map[string]types.AttributeValue{
|
| 216 |
+
"pk": &types.AttributeValueMemberS{Value: "key1"},
|
| 217 |
+
"value": &types.AttributeValueMemberS{Value: "hello dynamodb from go"},
|
| 218 |
+
},
|
| 219 |
+
})
|
| 220 |
+
if err != nil {
|
| 221 |
+
t.Fatalf("PutItem: %v", err)
|
| 222 |
+
}
|
| 223 |
+
|
| 224 |
+
out, err := client.GetItem(ctx, &dynamodb.GetItemInput{
|
| 225 |
+
TableName: aws.String("go-test-table"),
|
| 226 |
+
Key: map[string]types.AttributeValue{
|
| 227 |
+
"pk": &types.AttributeValueMemberS{Value: "key1"},
|
| 228 |
+
},
|
| 229 |
+
})
|
| 230 |
+
if err != nil {
|
| 231 |
+
t.Fatalf("GetItem: %v", err)
|
| 232 |
+
}
|
| 233 |
+
|
| 234 |
+
val, ok := out.Item["value"].(*types.AttributeValueMemberS)
|
| 235 |
+
if !ok {
|
| 236 |
+
t.Fatal("expected string attribute 'value'")
|
| 237 |
+
}
|
| 238 |
+
if val.Value != "hello dynamodb from go" {
|
| 239 |
+
t.Errorf("unexpected value: %q", val.Value)
|
| 240 |
+
}
|
| 241 |
+
}
|
| 242 |
+
|
| 243 |
+
func TestDynamoDB_DeleteItem(t *testing.T) {
|
| 244 |
+
ctx := context.Background()
|
| 245 |
+
endpoint, cleanup := newMiniStackContainer(ctx, t)
|
| 246 |
+
defer cleanup()
|
| 247 |
+
|
| 248 |
+
cfg := awsCfg(endpoint)
|
| 249 |
+
client := dynamodb.NewFromConfig(cfg)
|
| 250 |
+
|
| 251 |
+
_, err := client.CreateTable(ctx, &dynamodb.CreateTableInput{
|
| 252 |
+
TableName: aws.String("go-delete-table"),
|
| 253 |
+
KeySchema: []types.KeySchemaElement{
|
| 254 |
+
{AttributeName: aws.String("pk"), KeyType: types.KeyTypeHash},
|
| 255 |
+
},
|
| 256 |
+
AttributeDefinitions: []types.AttributeDefinition{
|
| 257 |
+
{AttributeName: aws.String("pk"), AttributeType: types.ScalarAttributeTypeS},
|
| 258 |
+
},
|
| 259 |
+
BillingMode: types.BillingModePayPerRequest,
|
| 260 |
+
})
|
| 261 |
+
if err != nil {
|
| 262 |
+
t.Fatalf("CreateTable: %v", err)
|
| 263 |
+
}
|
| 264 |
+
|
| 265 |
+
_, err = client.PutItem(ctx, &dynamodb.PutItemInput{
|
| 266 |
+
TableName: aws.String("go-delete-table"),
|
| 267 |
+
Item: map[string]types.AttributeValue{
|
| 268 |
+
"pk": &types.AttributeValueMemberS{Value: "del1"},
|
| 269 |
+
},
|
| 270 |
+
})
|
| 271 |
+
if err != nil {
|
| 272 |
+
t.Fatalf("PutItem: %v", err)
|
| 273 |
+
}
|
| 274 |
+
|
| 275 |
+
_, err = client.DeleteItem(ctx, &dynamodb.DeleteItemInput{
|
| 276 |
+
TableName: aws.String("go-delete-table"),
|
| 277 |
+
Key: map[string]types.AttributeValue{
|
| 278 |
+
"pk": &types.AttributeValueMemberS{Value: "del1"},
|
| 279 |
+
},
|
| 280 |
+
})
|
| 281 |
+
if err != nil {
|
| 282 |
+
t.Fatalf("DeleteItem: %v", err)
|
| 283 |
+
}
|
| 284 |
+
|
| 285 |
+
out, err := client.GetItem(ctx, &dynamodb.GetItemInput{
|
| 286 |
+
TableName: aws.String("go-delete-table"),
|
| 287 |
+
Key: map[string]types.AttributeValue{
|
| 288 |
+
"pk": &types.AttributeValueMemberS{Value: "del1"},
|
| 289 |
+
},
|
| 290 |
+
})
|
| 291 |
+
if err != nil {
|
| 292 |
+
t.Fatalf("GetItem: %v", err)
|
| 293 |
+
}
|
| 294 |
+
if len(out.Item) != 0 {
|
| 295 |
+
t.Error("expected item to be deleted")
|
| 296 |
+
}
|
| 297 |
+
}
|
aws_infra/Testcontainers/java-testcontainers/README.md
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# MiniStack — Java Testcontainers Example
|
| 2 |
+
|
| 3 |
+
Integration tests for S3, SQS, and DynamoDB using [Testcontainers](https://testcontainers.com/) and the AWS SDK v2.
|
| 4 |
+
|
| 5 |
+
## Prerequisites
|
| 6 |
+
|
| 7 |
+
- Java 17+
|
| 8 |
+
- Maven 3.8+
|
| 9 |
+
- Docker (running)
|
| 10 |
+
|
| 11 |
+
## Run
|
| 12 |
+
|
| 13 |
+
```bash
|
| 14 |
+
mvn test
|
| 15 |
+
```
|
| 16 |
+
|
| 17 |
+
Testcontainers will pull `ministackorg/ministack:latest`, start it, run the tests, and tear it down automatically.
|
| 18 |
+
|
| 19 |
+
## What's tested
|
| 20 |
+
|
| 21 |
+
| Service | Operations |
|
| 22 |
+
|------------|------------|
|
| 23 |
+
| S3 | CreateBucket, PutObject, GetObject, ListBuckets, DeleteObject |
|
| 24 |
+
| SQS | CreateQueue, SendMessage, ReceiveMessage, DeleteMessage, GetQueueAttributes |
|
| 25 |
+
| DynamoDB | CreateTable, PutItem, GetItem, UpdateItem, DeleteItem |
|
aws_infra/Testcontainers/java-testcontainers/pom.xml
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<?xml version="1.0" encoding="UTF-8"?>
|
| 2 |
+
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
| 3 |
+
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
| 4 |
+
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
| 5 |
+
<modelVersion>4.0.0</modelVersion>
|
| 6 |
+
|
| 7 |
+
<groupId>io.ministack</groupId>
|
| 8 |
+
<artifactId>ministack-testcontainers-example</artifactId>
|
| 9 |
+
<version>1.0.0</version>
|
| 10 |
+
<packaging>jar</packaging>
|
| 11 |
+
|
| 12 |
+
<properties>
|
| 13 |
+
<maven.compiler.source>17</maven.compiler.source>
|
| 14 |
+
<maven.compiler.target>17</maven.compiler.target>
|
| 15 |
+
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
| 16 |
+
<testcontainers.version>1.19.8</testcontainers.version>
|
| 17 |
+
<aws.sdk.version>2.25.40</aws.sdk.version>
|
| 18 |
+
<junit.version>5.10.2</junit.version>
|
| 19 |
+
</properties>
|
| 20 |
+
|
| 21 |
+
<dependencies>
|
| 22 |
+
<!-- JUnit 5 -->
|
| 23 |
+
<dependency>
|
| 24 |
+
<groupId>org.junit.jupiter</groupId>
|
| 25 |
+
<artifactId>junit-jupiter</artifactId>
|
| 26 |
+
<version>${junit.version}</version>
|
| 27 |
+
<scope>test</scope>
|
| 28 |
+
</dependency>
|
| 29 |
+
|
| 30 |
+
<!-- Testcontainers -->
|
| 31 |
+
<dependency>
|
| 32 |
+
<groupId>org.testcontainers</groupId>
|
| 33 |
+
<artifactId>testcontainers</artifactId>
|
| 34 |
+
<version>${testcontainers.version}</version>
|
| 35 |
+
<scope>test</scope>
|
| 36 |
+
</dependency>
|
| 37 |
+
<dependency>
|
| 38 |
+
<groupId>org.testcontainers</groupId>
|
| 39 |
+
<artifactId>junit-jupiter</artifactId>
|
| 40 |
+
<version>${testcontainers.version}</version>
|
| 41 |
+
<scope>test</scope>
|
| 42 |
+
</dependency>
|
| 43 |
+
|
| 44 |
+
<!-- AWS SDK v2 -->
|
| 45 |
+
<dependency>
|
| 46 |
+
<groupId>software.amazon.awssdk</groupId>
|
| 47 |
+
<artifactId>s3</artifactId>
|
| 48 |
+
<version>${aws.sdk.version}</version>
|
| 49 |
+
<scope>test</scope>
|
| 50 |
+
</dependency>
|
| 51 |
+
<dependency>
|
| 52 |
+
<groupId>software.amazon.awssdk</groupId>
|
| 53 |
+
<artifactId>sqs</artifactId>
|
| 54 |
+
<version>${aws.sdk.version}</version>
|
| 55 |
+
<scope>test</scope>
|
| 56 |
+
</dependency>
|
| 57 |
+
<dependency>
|
| 58 |
+
<groupId>software.amazon.awssdk</groupId>
|
| 59 |
+
<artifactId>dynamodb</artifactId>
|
| 60 |
+
<version>${aws.sdk.version}</version>
|
| 61 |
+
<scope>test</scope>
|
| 62 |
+
</dependency>
|
| 63 |
+
<dependency>
|
| 64 |
+
<groupId>software.amazon.awssdk</groupId>
|
| 65 |
+
<artifactId>url-connection-client</artifactId>
|
| 66 |
+
<version>${aws.sdk.version}</version>
|
| 67 |
+
<scope>test</scope>
|
| 68 |
+
</dependency>
|
| 69 |
+
</dependencies>
|
| 70 |
+
|
| 71 |
+
<build>
|
| 72 |
+
<plugins>
|
| 73 |
+
<plugin>
|
| 74 |
+
<groupId>org.apache.maven.plugins</groupId>
|
| 75 |
+
<artifactId>maven-surefire-plugin</artifactId>
|
| 76 |
+
<version>3.2.5</version>
|
| 77 |
+
</plugin>
|
| 78 |
+
</plugins>
|
| 79 |
+
</build>
|
| 80 |
+
</project>
|
aws_infra/Testcontainers/java-testcontainers/src/test/java/io/ministack/MiniStackTest.java
ADDED
|
@@ -0,0 +1,211 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package io.ministack;
|
| 2 |
+
|
| 3 |
+
import org.junit.jupiter.api.*;
|
| 4 |
+
import org.testcontainers.containers.GenericContainer;
|
| 5 |
+
import org.testcontainers.containers.wait.strategy.HttpWaitStrategy;
|
| 6 |
+
import org.testcontainers.junit.jupiter.Container;
|
| 7 |
+
import org.testcontainers.junit.jupiter.Testcontainers;
|
| 8 |
+
import org.testcontainers.utility.DockerImageName;
|
| 9 |
+
|
| 10 |
+
import software.amazon.awssdk.auth.credentials.AwsBasicCredentials;
|
| 11 |
+
import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider;
|
| 12 |
+
import software.amazon.awssdk.core.sync.RequestBody;
|
| 13 |
+
import software.amazon.awssdk.http.urlconnection.UrlConnectionHttpClient;
|
| 14 |
+
import software.amazon.awssdk.regions.Region;
|
| 15 |
+
import software.amazon.awssdk.services.dynamodb.DynamoDbClient;
|
| 16 |
+
import software.amazon.awssdk.services.dynamodb.model.*;
|
| 17 |
+
import software.amazon.awssdk.services.s3.S3Client;
|
| 18 |
+
import software.amazon.awssdk.services.s3.model.*;
|
| 19 |
+
import software.amazon.awssdk.services.sqs.SqsClient;
|
| 20 |
+
import software.amazon.awssdk.services.sqs.model.*;
|
| 21 |
+
|
| 22 |
+
import java.net.URI;
|
| 23 |
+
import java.time.Duration;
|
| 24 |
+
import java.util.List;
|
| 25 |
+
import java.util.Map;
|
| 26 |
+
|
| 27 |
+
import static org.junit.jupiter.api.Assertions.*;
|
| 28 |
+
|
| 29 |
+
@Testcontainers
|
| 30 |
+
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
|
| 31 |
+
class MiniStackTest {
|
| 32 |
+
|
| 33 |
+
@Container
|
| 34 |
+
static final GenericContainer<?> ministack = new GenericContainer<>(
|
| 35 |
+
DockerImageName.parse("ministackorg/ministack:latest"))
|
| 36 |
+
.withExposedPorts(4566)
|
| 37 |
+
.withEnv("GATEWAY_PORT", "4566")
|
| 38 |
+
.withEnv("LOG_LEVEL", "INFO")
|
| 39 |
+
.waitingFor(new HttpWaitStrategy()
|
| 40 |
+
.forPath("/_ministack/health")
|
| 41 |
+
.forPort(4566)
|
| 42 |
+
.withStartupTimeout(Duration.ofSeconds(60)));
|
| 43 |
+
|
| 44 |
+
private static URI endpoint;
|
| 45 |
+
private static StaticCredentialsProvider credentials;
|
| 46 |
+
|
| 47 |
+
@BeforeAll
|
| 48 |
+
static void setup() {
|
| 49 |
+
endpoint = URI.create("http://" + ministack.getHost() + ":" + ministack.getMappedPort(4566));
|
| 50 |
+
credentials = StaticCredentialsProvider.create(
|
| 51 |
+
AwsBasicCredentials.create("test", "test"));
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
private S3Client s3() {
|
| 55 |
+
return S3Client.builder()
|
| 56 |
+
.endpointOverride(endpoint)
|
| 57 |
+
.credentialsProvider(credentials)
|
| 58 |
+
.region(Region.US_EAST_1)
|
| 59 |
+
.httpClient(UrlConnectionHttpClient.create())
|
| 60 |
+
.forcePathStyle(true)
|
| 61 |
+
.build();
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
private SqsClient sqs() {
|
| 65 |
+
return SqsClient.builder()
|
| 66 |
+
.endpointOverride(endpoint)
|
| 67 |
+
.credentialsProvider(credentials)
|
| 68 |
+
.region(Region.US_EAST_1)
|
| 69 |
+
.httpClient(UrlConnectionHttpClient.create())
|
| 70 |
+
.build();
|
| 71 |
+
}
|
| 72 |
+
|
| 73 |
+
private DynamoDbClient ddb() {
|
| 74 |
+
return DynamoDbClient.builder()
|
| 75 |
+
.endpointOverride(endpoint)
|
| 76 |
+
.credentialsProvider(credentials)
|
| 77 |
+
.region(Region.US_EAST_1)
|
| 78 |
+
.httpClient(UrlConnectionHttpClient.create())
|
| 79 |
+
.build();
|
| 80 |
+
}
|
| 81 |
+
|
| 82 |
+
// ── S3 ──────────────────────────────────────────────────────────────────
|
| 83 |
+
|
| 84 |
+
@Test
|
| 85 |
+
@Order(1)
|
| 86 |
+
void s3_createBucketPutAndGetObject() {
|
| 87 |
+
try (S3Client client = s3()) {
|
| 88 |
+
client.createBucket(b -> b.bucket("test-bucket"));
|
| 89 |
+
|
| 90 |
+
client.putObject(
|
| 91 |
+
PutObjectRequest.builder().bucket("test-bucket").key("hello.txt").build(),
|
| 92 |
+
RequestBody.fromString("Hello MiniStack!"));
|
| 93 |
+
|
| 94 |
+
String body = client.getObjectAsBytes(
|
| 95 |
+
GetObjectRequest.builder().bucket("test-bucket").key("hello.txt").build()
|
| 96 |
+
).asUtf8String();
|
| 97 |
+
|
| 98 |
+
assertEquals("Hello MiniStack!", body);
|
| 99 |
+
}
|
| 100 |
+
}
|
| 101 |
+
|
| 102 |
+
@Test
|
| 103 |
+
@Order(2)
|
| 104 |
+
void s3_listBuckets() {
|
| 105 |
+
try (S3Client client = s3()) {
|
| 106 |
+
List<Bucket> buckets = client.listBuckets().buckets();
|
| 107 |
+
assertTrue(buckets.stream().anyMatch(b -> b.name().equals("test-bucket")));
|
| 108 |
+
}
|
| 109 |
+
}
|
| 110 |
+
|
| 111 |
+
@Test
|
| 112 |
+
@Order(3)
|
| 113 |
+
void s3_deleteObject() {
|
| 114 |
+
try (S3Client client = s3()) {
|
| 115 |
+
client.deleteObject(b -> b.bucket("test-bucket").key("hello.txt"));
|
| 116 |
+
assertThrows(NoSuchKeyException.class, () ->
|
| 117 |
+
client.getObjectAsBytes(b -> b.bucket("test-bucket").key("hello.txt")));
|
| 118 |
+
}
|
| 119 |
+
}
|
| 120 |
+
|
| 121 |
+
// ── SQS ─────────────────────────────────────────────────────────────────
|
| 122 |
+
|
| 123 |
+
@Test
|
| 124 |
+
@Order(10)
|
| 125 |
+
void sqs_sendAndReceiveMessage() {
|
| 126 |
+
try (SqsClient client = sqs()) {
|
| 127 |
+
String queueUrl = client.createQueue(b -> b.queueName("test-queue")).queueUrl();
|
| 128 |
+
|
| 129 |
+
client.sendMessage(b -> b.queueUrl(queueUrl).messageBody("hello from java"));
|
| 130 |
+
|
| 131 |
+
ReceiveMessageResponse resp = client.receiveMessage(b -> b
|
| 132 |
+
.queueUrl(queueUrl)
|
| 133 |
+
.maxNumberOfMessages(1)
|
| 134 |
+
.waitTimeSeconds(2));
|
| 135 |
+
|
| 136 |
+
assertEquals(1, resp.messages().size());
|
| 137 |
+
assertEquals("hello from java", resp.messages().get(0).body());
|
| 138 |
+
|
| 139 |
+
client.deleteMessage(b -> b
|
| 140 |
+
.queueUrl(queueUrl)
|
| 141 |
+
.receiptHandle(resp.messages().get(0).receiptHandle()));
|
| 142 |
+
}
|
| 143 |
+
}
|
| 144 |
+
|
| 145 |
+
@Test
|
| 146 |
+
@Order(11)
|
| 147 |
+
void sqs_queueAttributes() {
|
| 148 |
+
try (SqsClient client = sqs()) {
|
| 149 |
+
String queueUrl = client.createQueue(b -> b.queueName("test-queue")).queueUrl();
|
| 150 |
+
GetQueueAttributesResponse attrs = client.getQueueAttributes(b -> b
|
| 151 |
+
.queueUrl(queueUrl)
|
| 152 |
+
.attributeNames(QueueAttributeName.ALL));
|
| 153 |
+
assertNotNull(attrs.attributes().get(QueueAttributeName.QUEUE_ARN));
|
| 154 |
+
}
|
| 155 |
+
}
|
| 156 |
+
|
| 157 |
+
// ── DynamoDB ─────────────────────────────────────────────────────────────
|
| 158 |
+
|
| 159 |
+
@Test
|
| 160 |
+
@Order(20)
|
| 161 |
+
void dynamodb_createTablePutAndGetItem() {
|
| 162 |
+
try (DynamoDbClient client = ddb()) {
|
| 163 |
+
client.createTable(b -> b
|
| 164 |
+
.tableName("test-table")
|
| 165 |
+
.keySchema(KeySchemaElement.builder().attributeName("pk").keyType(KeyType.HASH).build())
|
| 166 |
+
.attributeDefinitions(AttributeDefinition.builder()
|
| 167 |
+
.attributeName("pk").attributeType(ScalarAttributeType.S).build())
|
| 168 |
+
.billingMode(BillingMode.PAY_PER_REQUEST));
|
| 169 |
+
|
| 170 |
+
client.putItem(b -> b
|
| 171 |
+
.tableName("test-table")
|
| 172 |
+
.item(Map.of(
|
| 173 |
+
"pk", AttributeValue.fromS("key1"),
|
| 174 |
+
"value", AttributeValue.fromS("hello dynamodb"))));
|
| 175 |
+
|
| 176 |
+
GetItemResponse resp = client.getItem(b -> b
|
| 177 |
+
.tableName("test-table")
|
| 178 |
+
.key(Map.of("pk", AttributeValue.fromS("key1"))));
|
| 179 |
+
|
| 180 |
+
assertTrue(resp.hasItem());
|
| 181 |
+
assertEquals("hello dynamodb", resp.item().get("value").s());
|
| 182 |
+
}
|
| 183 |
+
}
|
| 184 |
+
|
| 185 |
+
@Test
|
| 186 |
+
@Order(21)
|
| 187 |
+
void dynamodb_updateAndDeleteItem() {
|
| 188 |
+
try (DynamoDbClient client = ddb()) {
|
| 189 |
+
client.updateItem(b -> b
|
| 190 |
+
.tableName("test-table")
|
| 191 |
+
.key(Map.of("pk", AttributeValue.fromS("key1")))
|
| 192 |
+
.updateExpression("SET #v = :v")
|
| 193 |
+
.expressionAttributeNames(Map.of("#v", "value"))
|
| 194 |
+
.expressionAttributeValues(Map.of(":v", AttributeValue.fromS("updated"))));
|
| 195 |
+
|
| 196 |
+
GetItemResponse resp = client.getItem(b -> b
|
| 197 |
+
.tableName("test-table")
|
| 198 |
+
.key(Map.of("pk", AttributeValue.fromS("key1"))));
|
| 199 |
+
assertEquals("updated", resp.item().get("value").s());
|
| 200 |
+
|
| 201 |
+
client.deleteItem(b -> b
|
| 202 |
+
.tableName("test-table")
|
| 203 |
+
.key(Map.of("pk", AttributeValue.fromS("key1"))));
|
| 204 |
+
|
| 205 |
+
GetItemResponse after = client.getItem(b -> b
|
| 206 |
+
.tableName("test-table")
|
| 207 |
+
.key(Map.of("pk", AttributeValue.fromS("key1"))));
|
| 208 |
+
assertFalse(after.hasItem());
|
| 209 |
+
}
|
| 210 |
+
}
|
| 211 |
+
}
|
aws_infra/Testcontainers/python-testcontainers/README.md
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# MiniStack Python Testcontainers Example
|
| 2 |
+
|
| 3 |
+
Integration tests for MiniStack using [Testcontainers](https://testcontainers-python.readthedocs.io/) and boto3.
|
| 4 |
+
|
| 5 |
+
## Prerequisites
|
| 6 |
+
|
| 7 |
+
- Python 3.10+
|
| 8 |
+
- Docker
|
| 9 |
+
- pip
|
| 10 |
+
|
| 11 |
+
## Setup
|
| 12 |
+
|
| 13 |
+
```bash
|
| 14 |
+
pip install -r requirements.txt
|
| 15 |
+
```
|
| 16 |
+
|
| 17 |
+
## Run tests
|
| 18 |
+
|
| 19 |
+
```bash
|
| 20 |
+
pytest test_ministack.py -v
|
| 21 |
+
```
|
| 22 |
+
|
| 23 |
+
The tests automatically start a MiniStack container, wait for it to become healthy, and run S3, SQS, and DynamoDB integration tests against it.
|
aws_infra/Testcontainers/python-testcontainers/requirements.txt
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
testcontainers
|
| 2 |
+
boto3
|
| 3 |
+
pytest
|
aws_infra/Testcontainers/python-testcontainers/test_ministack.py
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import time
|
| 2 |
+
|
| 3 |
+
import boto3
|
| 4 |
+
import pytest
|
| 5 |
+
import requests
|
| 6 |
+
from testcontainers.core.container import DockerContainer
|
| 7 |
+
from testcontainers.core.waiting_utils import wait_for_logs
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
@pytest.fixture(scope="module")
|
| 11 |
+
def ministack():
|
| 12 |
+
"""Start a MiniStack container and wait for it to be healthy."""
|
| 13 |
+
container = DockerContainer("ministackorg/ministack:latest").with_exposed_ports(4566)
|
| 14 |
+
container.start()
|
| 15 |
+
|
| 16 |
+
host = container.get_container_host_ip()
|
| 17 |
+
port = container.get_exposed_port(4566)
|
| 18 |
+
endpoint = f"http://{host}:{port}"
|
| 19 |
+
|
| 20 |
+
# Wait for health endpoint to be ready
|
| 21 |
+
deadline = time.time() + 30
|
| 22 |
+
while time.time() < deadline:
|
| 23 |
+
try:
|
| 24 |
+
resp = requests.get(f"{endpoint}/_ministack/health", timeout=2)
|
| 25 |
+
if resp.status_code == 200:
|
| 26 |
+
break
|
| 27 |
+
except Exception:
|
| 28 |
+
pass
|
| 29 |
+
time.sleep(0.5)
|
| 30 |
+
else:
|
| 31 |
+
raise RuntimeError("MiniStack container did not become healthy within 30s")
|
| 32 |
+
|
| 33 |
+
yield endpoint
|
| 34 |
+
|
| 35 |
+
container.stop()
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def _client(service: str, endpoint: str):
|
| 39 |
+
"""Create a boto3 client pointing at the MiniStack container."""
|
| 40 |
+
return boto3.client(
|
| 41 |
+
service,
|
| 42 |
+
endpoint_url=endpoint,
|
| 43 |
+
region_name="us-east-1",
|
| 44 |
+
aws_access_key_id="test",
|
| 45 |
+
aws_secret_access_key="test",
|
| 46 |
+
)
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
# ---------------------------------------------------------------------------
|
| 50 |
+
# S3
|
| 51 |
+
# ---------------------------------------------------------------------------
|
| 52 |
+
|
| 53 |
+
class TestS3:
|
| 54 |
+
def test_create_bucket_put_get_object(self, ministack):
|
| 55 |
+
s3 = _client("s3", ministack)
|
| 56 |
+
|
| 57 |
+
bucket = "test-bucket"
|
| 58 |
+
s3.create_bucket(Bucket=bucket)
|
| 59 |
+
|
| 60 |
+
s3.put_object(Bucket=bucket, Key="hello.txt", Body=b"hello world")
|
| 61 |
+
|
| 62 |
+
resp = s3.get_object(Bucket=bucket, Key="hello.txt")
|
| 63 |
+
body = resp["Body"].read()
|
| 64 |
+
assert body == b"hello world"
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
# ---------------------------------------------------------------------------
|
| 68 |
+
# SQS
|
| 69 |
+
# ---------------------------------------------------------------------------
|
| 70 |
+
|
| 71 |
+
class TestSQS:
|
| 72 |
+
def test_create_queue_send_receive(self, ministack):
|
| 73 |
+
sqs = _client("sqs", ministack)
|
| 74 |
+
|
| 75 |
+
queue = sqs.create_queue(QueueName="test-queue")
|
| 76 |
+
queue_url = queue["QueueUrl"]
|
| 77 |
+
|
| 78 |
+
sqs.send_message(QueueUrl=queue_url, MessageBody="ping")
|
| 79 |
+
|
| 80 |
+
messages = sqs.receive_message(QueueUrl=queue_url, MaxNumberOfMessages=1)
|
| 81 |
+
assert len(messages["Messages"]) == 1
|
| 82 |
+
assert messages["Messages"][0]["Body"] == "ping"
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
# ---------------------------------------------------------------------------
|
| 86 |
+
# DynamoDB
|
| 87 |
+
# ---------------------------------------------------------------------------
|
| 88 |
+
|
| 89 |
+
class TestDynamoDB:
|
| 90 |
+
def test_create_table_put_get_item(self, ministack):
|
| 91 |
+
ddb = _client("dynamodb", ministack)
|
| 92 |
+
|
| 93 |
+
table_name = "test-table"
|
| 94 |
+
ddb.create_table(
|
| 95 |
+
TableName=table_name,
|
| 96 |
+
KeySchema=[{"AttributeName": "pk", "KeyType": "HASH"}],
|
| 97 |
+
AttributeDefinitions=[{"AttributeName": "pk", "AttributeType": "S"}],
|
| 98 |
+
BillingMode="PAY_PER_REQUEST",
|
| 99 |
+
)
|
| 100 |
+
|
| 101 |
+
ddb.put_item(
|
| 102 |
+
TableName=table_name,
|
| 103 |
+
Item={"pk": {"S": "key1"}, "data": {"S": "value1"}},
|
| 104 |
+
)
|
| 105 |
+
|
| 106 |
+
resp = ddb.get_item(
|
| 107 |
+
TableName=table_name,
|
| 108 |
+
Key={"pk": {"S": "key1"}},
|
| 109 |
+
)
|
| 110 |
+
assert resp["Item"]["data"]["S"] == "value1"
|
aws_infra/bin/awslocal
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env bash
|
| 2 |
+
# awslocal — Wrapper around AWS CLI that points to local MiniStack endpoint.
|
| 3 |
+
# Usage: ./awslocal s3 ls
|
| 4 |
+
# ./awslocal sqs create-queue --queue-name my-queue
|
| 5 |
+
# ./awslocal dynamodb list-tables
|
| 6 |
+
|
| 7 |
+
ENDPOINT_URL="${MINISTACK_ENDPOINT:-http://localhost:4566}"
|
| 8 |
+
AWS_ACCESS_KEY_ID="${AWS_ACCESS_KEY_ID:-test}"
|
| 9 |
+
AWS_SECRET_ACCESS_KEY="${AWS_SECRET_ACCESS_KEY:-test}"
|
| 10 |
+
AWS_DEFAULT_REGION="${AWS_DEFAULT_REGION:-us-east-1}"
|
| 11 |
+
|
| 12 |
+
export AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY AWS_DEFAULT_REGION
|
| 13 |
+
|
| 14 |
+
exec aws --endpoint-url="$ENDPOINT_URL" "$@"
|
aws_infra/docker-compose.yml
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
services:
|
| 2 |
+
ministack:
|
| 3 |
+
build: .
|
| 4 |
+
image: ministackorg/ministack:latest
|
| 5 |
+
container_name: ministack
|
| 6 |
+
ports:
|
| 7 |
+
- "4566:4566"
|
| 8 |
+
environment:
|
| 9 |
+
- GATEWAY_PORT=4566
|
| 10 |
+
- LOG_LEVEL=INFO
|
| 11 |
+
- S3_PERSIST=0
|
| 12 |
+
- REDIS_HOST=redis
|
| 13 |
+
- REDIS_PORT=6379
|
| 14 |
+
- RDS_BASE_PORT=15432
|
| 15 |
+
- ELASTICACHE_BASE_PORT=16379
|
| 16 |
+
volumes:
|
| 17 |
+
- ./data/s3:/tmp/ministack-data/s3
|
| 18 |
+
- /var/run/docker.sock:/var/run/docker.sock
|
| 19 |
+
depends_on:
|
| 20 |
+
redis:
|
| 21 |
+
condition: service_healthy
|
| 22 |
+
healthcheck:
|
| 23 |
+
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:4566/_ministack/health')"]
|
| 24 |
+
interval: 10s
|
| 25 |
+
timeout: 3s
|
| 26 |
+
retries: 3
|
| 27 |
+
start_period: 5s
|
| 28 |
+
restart: unless-stopped
|
| 29 |
+
|
| 30 |
+
redis:
|
| 31 |
+
image: redis:7-alpine
|
| 32 |
+
container_name: ministack-redis
|
| 33 |
+
ports:
|
| 34 |
+
- "127.0.0.1:6379:6379"
|
| 35 |
+
healthcheck:
|
| 36 |
+
test: ["CMD", "redis-cli", "ping"]
|
| 37 |
+
interval: 5s
|
| 38 |
+
timeout: 3s
|
| 39 |
+
retries: 5
|
| 40 |
+
restart: unless-stopped
|
| 41 |
+
|
| 42 |
+
# Optional: Postgres for RDS (always-on, no Docker-in-Docker needed)
|
| 43 |
+
# Uncomment to have a persistent Postgres available at localhost:5432
|
| 44 |
+
# postgres:
|
| 45 |
+
# image: postgres:15-alpine
|
| 46 |
+
# container_name: ministack-postgres
|
| 47 |
+
# ports:
|
| 48 |
+
# - "5432:5432"
|
| 49 |
+
# environment:
|
| 50 |
+
# POSTGRES_USER: admin
|
| 51 |
+
# POSTGRES_PASSWORD: password
|
| 52 |
+
# POSTGRES_DB: mydb
|
| 53 |
+
# healthcheck:
|
| 54 |
+
# test: ["CMD-SHELL", "pg_isready -U admin"]
|
| 55 |
+
# interval: 5s
|
| 56 |
+
# timeout: 3s
|
| 57 |
+
# retries: 5
|
| 58 |
+
# restart: unless-stopped
|
aws_infra/ministack/__init__.py
ADDED
|
File without changes
|
aws_infra/ministack/__main__.py
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from ministack.app import main
|
| 2 |
+
|
| 3 |
+
main()
|
aws_infra/ministack/app.py
ADDED
|
@@ -0,0 +1,1414 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
MiniStack — Local AWS Service Emulator.
|
| 3 |
+
Single-port ASGI application on port 4566 (configurable via GATEWAY_PORT).
|
| 4 |
+
Routes requests to service handlers based on AWS headers, paths, and query parameters.
|
| 5 |
+
Compatible with AWS CLI, boto3, and any AWS SDK via --endpoint-url.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
import argparse
|
| 9 |
+
import asyncio
|
| 10 |
+
import base64
|
| 11 |
+
import json
|
| 12 |
+
import logging
|
| 13 |
+
import math
|
| 14 |
+
import os
|
| 15 |
+
import re
|
| 16 |
+
import shutil
|
| 17 |
+
import signal
|
| 18 |
+
import socket
|
| 19 |
+
import subprocess
|
| 20 |
+
import sys
|
| 21 |
+
import tempfile
|
| 22 |
+
import uuid
|
| 23 |
+
from urllib.parse import parse_qs, unquote
|
| 24 |
+
|
| 25 |
+
_MINISTACK_HOST = os.environ.get("MINISTACK_HOST", "localhost")
|
| 26 |
+
_MINISTACK_PORT = os.environ.get("GATEWAY_PORT", "4566")
|
| 27 |
+
|
| 28 |
+
try:
|
| 29 |
+
from importlib.metadata import version as _pkg_version
|
| 30 |
+
_VERSION = _pkg_version("ministack")
|
| 31 |
+
except Exception:
|
| 32 |
+
_VERSION = "dev"
|
| 33 |
+
|
| 34 |
+
# Matches host headers like "{apiId}.execute-api.<host>" or "{apiId}.execute-api.<host>:4566"
|
| 35 |
+
_EXECUTE_API_RE = re.compile(
|
| 36 |
+
r"^([a-f0-9]{8})\.execute-api\." + re.escape(_MINISTACK_HOST) + r"(?::\d+)?$"
|
| 37 |
+
)
|
| 38 |
+
# Matches virtual-hosted S3:
|
| 39 |
+
# "{bucket}.<host>" or "{bucket}.<host>:4566" (boto3/SDK default)
|
| 40 |
+
# "{bucket}.s3.<host>" or "{bucket}.s3.<host>:4566" (Terraform AWS provider v4+)
|
| 41 |
+
# Does NOT match execute-api, alb, or other sub-service hostnames.
|
| 42 |
+
_S3_VHOST_RE = re.compile(
|
| 43 |
+
r"^([^.]+)(?:\.s3)?\." + re.escape(_MINISTACK_HOST) + r"(?::\d+)?$"
|
| 44 |
+
)
|
| 45 |
+
_S3_VHOST_EXCLUDE_RE = re.compile(r"\.(execute-api|alb|emr|efs|elasticache|s3-control)\.")
|
| 46 |
+
_HEALTH_PATHS = ("/_ministack/health", "/_localstack/health", "/health")
|
| 47 |
+
_BODY_METHODS = ("POST", "PUT", "PATCH")
|
| 48 |
+
_COGNITO_USERINFO_PATHS = ("/oauth2/userInfo", "/oauth2/userinfo")
|
| 49 |
+
_RDS_DATA_PATHS = ("/Execute", "/BeginTransaction", "/CommitTransaction", "/RollbackTransaction", "/BatchExecute")
|
| 50 |
+
_S3_CONTROL_PREFIX = "/v20180820/"
|
| 51 |
+
_SES_V2_PREFIX = "/v2/email"
|
| 52 |
+
_ALB_PATH_PREFIX = "/_alb/"
|
| 53 |
+
_NON_S3_VHOST_NAMES = frozenset({
|
| 54 |
+
"s3", "s3-control", "sqs", "sns", "dynamodb", "lambda", "iam", "sts",
|
| 55 |
+
"secretsmanager", "logs", "ssm", "events", "kinesis", "monitoring", "ses",
|
| 56 |
+
"states", "ecs", "rds", "rds-data", "elasticache", "glue", "athena",
|
| 57 |
+
"apigateway", "cloudformation", "autoscaling", "codebuild", "transfer",
|
| 58 |
+
})
|
| 59 |
+
|
| 60 |
+
from ministack.core.hypercorn_compat import install as _install_hypercorn_compat
|
| 61 |
+
from ministack.core.persistence import PERSIST_STATE, load_state, save_all
|
| 62 |
+
from ministack.core.responses import set_request_account_id, set_request_region
|
| 63 |
+
from ministack.core.router import detect_service, extract_access_key_id, extract_region
|
| 64 |
+
|
| 65 |
+
# Must run before hypercorn emits its first Expect: 100-continue reply.
|
| 66 |
+
# See ministack/core/hypercorn_compat.py for the rationale (issue #389).
|
| 67 |
+
_install_hypercorn_compat()
|
| 68 |
+
|
| 69 |
+
# ---------------------------------------------------------------------------
|
| 70 |
+
# Lazy service loader — modules are imported on first request, not at startup.
|
| 71 |
+
# This saves ~20 MB of idle RAM and speeds up boot.
|
| 72 |
+
# ---------------------------------------------------------------------------
|
| 73 |
+
_loaded_modules: dict = {}
|
| 74 |
+
|
| 75 |
+
# Execution state of ready.d scripts — surfaced via /_ministack/health and /_ministack/ready.
|
| 76 |
+
# status: "pending" (not started) | "running" | "completed" (all scripts finished, errors included)
|
| 77 |
+
_ready_scripts_state: dict = {
|
| 78 |
+
"status": "pending",
|
| 79 |
+
"total": 0,
|
| 80 |
+
"completed": 0,
|
| 81 |
+
"failed": 0,
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
class _ErrorModule:
|
| 86 |
+
"""Stub returned when a service module fails to import."""
|
| 87 |
+
def __init__(self, name: str, error: str):
|
| 88 |
+
self._name = name
|
| 89 |
+
self._error = error
|
| 90 |
+
|
| 91 |
+
async def handle_request(self, method, path, headers, body, query_params):
|
| 92 |
+
return 500, {"Content-Type": "application/json"}, \
|
| 93 |
+
json.dumps({"__type": "ServiceUnavailable",
|
| 94 |
+
"message": f"Service module '{self._name}' failed to load: {self._error}"}).encode()
|
| 95 |
+
|
| 96 |
+
def get_state(self):
|
| 97 |
+
return {}
|
| 98 |
+
|
| 99 |
+
def restore_state(self, data):
|
| 100 |
+
pass
|
| 101 |
+
|
| 102 |
+
def load_persisted_state(self, data):
|
| 103 |
+
pass
|
| 104 |
+
|
| 105 |
+
def reset(self):
|
| 106 |
+
pass
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
def _get_module(name: str):
|
| 110 |
+
"""Import and cache a service module by short name (e.g. 's3', 'lambda_svc')."""
|
| 111 |
+
mod = _loaded_modules.get(name)
|
| 112 |
+
if mod is None:
|
| 113 |
+
try:
|
| 114 |
+
mod = __import__(f"ministack.services.{name}", fromlist=["handle_request"])
|
| 115 |
+
except (ModuleNotFoundError, ImportError) as e:
|
| 116 |
+
logger.warning("Service module failed to load: %s - %s", name, e)
|
| 117 |
+
mod = _ErrorModule(name, str(e))
|
| 118 |
+
_loaded_modules[name] = mod
|
| 119 |
+
return mod
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
def _lazy_handler(module_name: str):
|
| 123 |
+
"""Return a callable that lazily imports module_name and delegates to handle_request."""
|
| 124 |
+
async def _handler(method, path, headers, body, query_params):
|
| 125 |
+
mod = _get_module(module_name)
|
| 126 |
+
return await mod.handle_request(method, path, headers, body, query_params)
|
| 127 |
+
return _handler
|
| 128 |
+
|
| 129 |
+
LOG_LEVEL = os.environ.get("LOG_LEVEL", "INFO").upper()
|
| 130 |
+
logging.basicConfig(
|
| 131 |
+
level=getattr(logging, LOG_LEVEL, logging.INFO),
|
| 132 |
+
format="%(asctime)s %(levelname)s [%(name)s] %(message)s",
|
| 133 |
+
datefmt="%Y-%m-%d %H:%M:%S",
|
| 134 |
+
)
|
| 135 |
+
logger = logging.getLogger("ministack")
|
| 136 |
+
|
| 137 |
+
# Single source of truth for routable services, their backing modules, and aliases.
|
| 138 |
+
SERVICE_REGISTRY = {
|
| 139 |
+
"acm": {"module": "acm"},
|
| 140 |
+
"apigateway": {"module": "apigateway", "aliases": ("execute-api", "apigatewayv2")},
|
| 141 |
+
"appconfig": {"module": "appconfig"},
|
| 142 |
+
"appconfigdata": {"module": "appconfig"},
|
| 143 |
+
"appsync": {"module": "appsync"},
|
| 144 |
+
"athena": {"module": "athena"},
|
| 145 |
+
"autoscaling": {"module": "autoscaling"},
|
| 146 |
+
"cloudformation": {"module": "cloudformation"},
|
| 147 |
+
"cloudfront": {"module": "cloudfront"},
|
| 148 |
+
"codebuild": {"module": "codebuild"},
|
| 149 |
+
"cognito-identity": {"module": "cognito"},
|
| 150 |
+
"cognito-idp": {"module": "cognito"},
|
| 151 |
+
"dynamodb": {"module": "dynamodb"},
|
| 152 |
+
"ec2": {"module": "ec2"},
|
| 153 |
+
"ecr": {"module": "ecr"},
|
| 154 |
+
"ecs": {"module": "ecs"},
|
| 155 |
+
"eks": {"module": "eks"},
|
| 156 |
+
"elasticache": {"module": "elasticache"},
|
| 157 |
+
"elasticfilesystem": {"module": "efs"},
|
| 158 |
+
"elasticloadbalancing": {"module": "alb", "aliases": ("elbv2", "elb")},
|
| 159 |
+
"elasticmapreduce": {"module": "emr"},
|
| 160 |
+
"events": {"module": "eventbridge", "aliases": ("eventbridge",)},
|
| 161 |
+
"firehose": {"module": "firehose", "aliases": ("kinesis-firehose",)},
|
| 162 |
+
"glue": {"module": "glue"},
|
| 163 |
+
"iam": {"module": "iam"},
|
| 164 |
+
"kinesis": {"module": "kinesis"},
|
| 165 |
+
"kms": {"module": "kms"},
|
| 166 |
+
"lambda": {"module": "lambda_svc"},
|
| 167 |
+
"logs": {"module": "cloudwatch_logs", "aliases": ("cloudwatch-logs",)},
|
| 168 |
+
"monitoring": {"module": "cloudwatch", "aliases": ("cloudwatch",)},
|
| 169 |
+
"rds-data": {"module": "rds_data"},
|
| 170 |
+
"rds": {"module": "rds"},
|
| 171 |
+
"route53": {"module": "route53"},
|
| 172 |
+
"s3": {"module": "s3"},
|
| 173 |
+
"s3files": {"module": "s3files"},
|
| 174 |
+
"scheduler": {"module": "scheduler"},
|
| 175 |
+
"secretsmanager": {"module": "secretsmanager"},
|
| 176 |
+
"servicediscovery": {"module": "servicediscovery"},
|
| 177 |
+
"ses": {"module": "ses"},
|
| 178 |
+
"sns": {"module": "sns"},
|
| 179 |
+
"sqs": {"module": "sqs"},
|
| 180 |
+
"ssm": {"module": "ssm"},
|
| 181 |
+
"states": {"module": "stepfunctions", "aliases": ("step-functions", "stepfunctions")},
|
| 182 |
+
"sts": {"module": "sts"},
|
| 183 |
+
"tagging": {"module": "tagging"},
|
| 184 |
+
"transfer": {"module": "transfer"},
|
| 185 |
+
"wafv2": {"module": "waf"},
|
| 186 |
+
}
|
| 187 |
+
|
| 188 |
+
SERVICE_HANDLERS = {
|
| 189 |
+
service_name: _lazy_handler(service_config["module"])
|
| 190 |
+
for service_name, service_config in SERVICE_REGISTRY.items()
|
| 191 |
+
}
|
| 192 |
+
|
| 193 |
+
SERVICE_NAME_ALIASES = {
|
| 194 |
+
alias: service_name
|
| 195 |
+
for service_name, service_config in SERVICE_REGISTRY.items()
|
| 196 |
+
for alias in service_config.get("aliases", ())
|
| 197 |
+
}
|
| 198 |
+
|
| 199 |
+
|
| 200 |
+
def _resolve_port():
|
| 201 |
+
"""Resolve gateway port: GATEWAY_PORT > EDGE_PORT > 4566."""
|
| 202 |
+
return os.environ.get("GATEWAY_PORT") or os.environ.get("EDGE_PORT") or "4566"
|
| 203 |
+
|
| 204 |
+
|
| 205 |
+
if os.environ.get("LOCALSTACK_PERSISTENCE") == "1" and os.environ.get("S3_PERSIST") != "1":
|
| 206 |
+
os.environ["S3_PERSIST"] = "1"
|
| 207 |
+
logger.info("LOCALSTACK_PERSISTENCE=1 detected — enabling S3_PERSIST")
|
| 208 |
+
|
| 209 |
+
_services_env = os.environ.get("SERVICES", "").strip()
|
| 210 |
+
if _services_env:
|
| 211 |
+
_requested = {s.strip() for s in _services_env.split(",") if s.strip()}
|
| 212 |
+
_resolved = set()
|
| 213 |
+
for _name in _requested:
|
| 214 |
+
_key = SERVICE_NAME_ALIASES.get(_name, _name)
|
| 215 |
+
if _key in SERVICE_HANDLERS:
|
| 216 |
+
_resolved.add(_key)
|
| 217 |
+
else:
|
| 218 |
+
logger.warning("SERVICES: unknown service '%s' (resolved as '%s') — skipping", _name, _key)
|
| 219 |
+
SERVICE_HANDLERS = {k: v for k, v in SERVICE_HANDLERS.items() if k in _resolved}
|
| 220 |
+
logger.info("SERVICES filter active — enabled: %s", sorted(SERVICE_HANDLERS.keys()))
|
| 221 |
+
|
| 222 |
+
BANNER = r"""
|
| 223 |
+
__ __ _ _ ____ _ _
|
| 224 |
+
| \/ (_)_ __ (_) ___|| |_ __ _ ___| | __
|
| 225 |
+
| |\/| | | '_ \| \___ \| __/ _` |/ __| |/ /
|
| 226 |
+
| | | | | | | | |___) | || (_| | (__| <
|
| 227 |
+
|_| |_|_|_| |_|_|____/ \__\__,_|\___|_|\_\
|
| 228 |
+
|
| 229 |
+
Local AWS Service Emulator — Port {port}
|
| 230 |
+
Services: S3, SQS, SNS, DynamoDB, Lambda, IAM, STS, SecretsManager, CloudWatch Logs,
|
| 231 |
+
SSM, EventBridge, Kinesis, CloudWatch, SES, SES v2, ACM, WAF v2, Step Functions,
|
| 232 |
+
ECS, RDS, ElastiCache, Glue, Athena, API Gateway, Firehose, Route53,
|
| 233 |
+
Cognito, EC2, EMR, EBS, EFS, ALB/ELBv2, CloudFormation, KMS, ECR, CloudFront,
|
| 234 |
+
AppSync, Cloud Map, S3 Files, RDS Data API, CodeBuild, AppConfig, Transfer, EKS
|
| 235 |
+
"""
|
| 236 |
+
|
| 237 |
+
|
| 238 |
+
_reset_lock: "asyncio.Lock | None" = None
|
| 239 |
+
|
| 240 |
+
|
| 241 |
+
def _get_reset_lock() -> asyncio.Lock:
|
| 242 |
+
global _reset_lock
|
| 243 |
+
if _reset_lock is None:
|
| 244 |
+
_reset_lock = asyncio.Lock()
|
| 245 |
+
return _reset_lock
|
| 246 |
+
|
| 247 |
+
|
| 248 |
+
# ---------------------------------------------------------------------------
|
| 249 |
+
# Request I/O helpers
|
| 250 |
+
# ---------------------------------------------------------------------------
|
| 251 |
+
|
| 252 |
+
def _decode_aws_chunked_body(body: bytes, headers: dict) -> bytes:
|
| 253 |
+
"""Decode AWS chunked request bodies and normalize content-encoding headers."""
|
| 254 |
+
sha256_header = headers.get("x-amz-content-sha256", "")
|
| 255 |
+
content_encoding = headers.get("content-encoding", "")
|
| 256 |
+
if not (
|
| 257 |
+
sha256_header.startswith("STREAMING-")
|
| 258 |
+
or "aws-chunked" in content_encoding
|
| 259 |
+
or headers.get("x-amz-decoded-content-length")
|
| 260 |
+
):
|
| 261 |
+
return body
|
| 262 |
+
|
| 263 |
+
decoded = b""
|
| 264 |
+
remaining = body
|
| 265 |
+
while remaining:
|
| 266 |
+
crlf = remaining.find(b"\r\n")
|
| 267 |
+
if crlf == -1:
|
| 268 |
+
break
|
| 269 |
+
chunk_header = remaining[:crlf].decode("ascii", errors="replace")
|
| 270 |
+
size_hex = chunk_header.split(";")[0].strip()
|
| 271 |
+
try:
|
| 272 |
+
chunk_size = int(size_hex, 16)
|
| 273 |
+
except ValueError:
|
| 274 |
+
break
|
| 275 |
+
if chunk_size == 0:
|
| 276 |
+
break
|
| 277 |
+
data_start = crlf + 2
|
| 278 |
+
decoded += remaining[data_start:data_start + chunk_size]
|
| 279 |
+
remaining = remaining[data_start + chunk_size + 2:] # skip trailing \r\n
|
| 280 |
+
|
| 281 |
+
if decoded or not body:
|
| 282 |
+
body = decoded
|
| 283 |
+
if "aws-chunked" in content_encoding:
|
| 284 |
+
encodings = [p.strip() for p in content_encoding.split(",") if p.strip() != "aws-chunked"]
|
| 285 |
+
if encodings:
|
| 286 |
+
headers["content-encoding"] = ", ".join(encodings)
|
| 287 |
+
else:
|
| 288 |
+
headers.pop("content-encoding", None)
|
| 289 |
+
return body
|
| 290 |
+
|
| 291 |
+
|
| 292 |
+
async def _read_request_body(receive, method: str, headers: dict) -> bytes:
|
| 293 |
+
"""Read and decode the request body only for methods or headers that can carry one."""
|
| 294 |
+
body = b""
|
| 295 |
+
if headers.get("content-length") or headers.get("transfer-encoding") or method in _BODY_METHODS:
|
| 296 |
+
while True:
|
| 297 |
+
message = await receive()
|
| 298 |
+
body += message.get("body", b"")
|
| 299 |
+
if not message.get("more_body", False):
|
| 300 |
+
break
|
| 301 |
+
return _decode_aws_chunked_body(body, headers)
|
| 302 |
+
|
| 303 |
+
|
| 304 |
+
async def _send_response(send, status, headers, body):
|
| 305 |
+
"""Send ASGI HTTP response."""
|
| 306 |
+
def _encode_header_value(v: str) -> bytes:
|
| 307 |
+
try:
|
| 308 |
+
return v.encode("latin-1")
|
| 309 |
+
except UnicodeEncodeError:
|
| 310 |
+
return v.encode("utf-8")
|
| 311 |
+
|
| 312 |
+
body_bytes = body if isinstance(body, bytes) else body.encode("utf-8")
|
| 313 |
+
if "content-length" not in {k.lower() for k in headers}:
|
| 314 |
+
headers["Content-Length"] = str(len(body_bytes))
|
| 315 |
+
header_list = [(k.encode("latin-1"), _encode_header_value(str(v))) for k, v in headers.items()]
|
| 316 |
+
await send({
|
| 317 |
+
"type": "http.response.start",
|
| 318 |
+
"status": status,
|
| 319 |
+
"headers": header_list,
|
| 320 |
+
})
|
| 321 |
+
await send({
|
| 322 |
+
"type": "http.response.body",
|
| 323 |
+
"body": body_bytes,
|
| 324 |
+
"more_body": False,
|
| 325 |
+
})
|
| 326 |
+
|
| 327 |
+
|
| 328 |
+
async def _send_if_handled(send, response) -> bool:
|
| 329 |
+
"""Send a response tuple and report whether the request was handled."""
|
| 330 |
+
if response is None:
|
| 331 |
+
return False
|
| 332 |
+
await _send_response(send, *response)
|
| 333 |
+
return True
|
| 334 |
+
|
| 335 |
+
|
| 336 |
+
# ---------------------------------------------------------------------------
|
| 337 |
+
# Tier 1 — Pre-body handlers (no request body needed)
|
| 338 |
+
# ---------------------------------------------------------------------------
|
| 339 |
+
|
| 340 |
+
def _handle_options_request(method: str, request_id: str):
|
| 341 |
+
"""Return the standard CORS preflight response when applicable."""
|
| 342 |
+
if method != "OPTIONS":
|
| 343 |
+
return None
|
| 344 |
+
return 200, {
|
| 345 |
+
"Access-Control-Allow-Origin": "*",
|
| 346 |
+
"Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, HEAD, OPTIONS, PATCH",
|
| 347 |
+
"Access-Control-Allow-Headers": "*",
|
| 348 |
+
"Access-Control-Expose-Headers": "*",
|
| 349 |
+
"Access-Control-Max-Age": "86400",
|
| 350 |
+
"Content-Length": "0",
|
| 351 |
+
"x-amzn-requestid": request_id,
|
| 352 |
+
}, b""
|
| 353 |
+
|
| 354 |
+
|
| 355 |
+
def _handle_health_request(path: str, request_id: str):
|
| 356 |
+
"""Return health responses for MiniStack and LocalStack-compatible endpoints."""
|
| 357 |
+
if path not in _HEALTH_PATHS:
|
| 358 |
+
return None
|
| 359 |
+
return 200, {
|
| 360 |
+
"Content-Type": "application/json",
|
| 361 |
+
"x-amzn-requestid": request_id,
|
| 362 |
+
}, json.dumps({
|
| 363 |
+
"services": {s: "available" for s in SERVICE_HANDLERS},
|
| 364 |
+
"edition": "light",
|
| 365 |
+
"version": _VERSION,
|
| 366 |
+
"ready_scripts": dict(_ready_scripts_state),
|
| 367 |
+
}).encode()
|
| 368 |
+
|
| 369 |
+
|
| 370 |
+
def _handle_ready_request(path: str, request_id: str):
|
| 371 |
+
"""Return readiness state once ready.d scripts have completed."""
|
| 372 |
+
if path != "/_ministack/ready":
|
| 373 |
+
return None
|
| 374 |
+
ready = _ready_scripts_state["status"] == "completed"
|
| 375 |
+
status = 200 if ready else 503
|
| 376 |
+
return status, {
|
| 377 |
+
"Content-Type": "application/json",
|
| 378 |
+
"x-amzn-requestid": request_id,
|
| 379 |
+
}, json.dumps(dict(_ready_scripts_state)).encode()
|
| 380 |
+
|
| 381 |
+
|
| 382 |
+
def _handle_lambda_download_request(path: str, method: str):
|
| 383 |
+
"""Serve MiniStack's Lambda layer and function-code download endpoints."""
|
| 384 |
+
if path.startswith("/_ministack/lambda-layers/") and method == "GET":
|
| 385 |
+
path_parts = path.split("/")
|
| 386 |
+
if len(path_parts) >= 6 and path_parts[5] == "content" and path_parts[4].isdigit():
|
| 387 |
+
return _get_module("lambda_svc").serve_layer_content(path_parts[3], int(path_parts[4]))
|
| 388 |
+
|
| 389 |
+
if path.startswith("/_ministack/lambda-code/") and method == "GET":
|
| 390 |
+
path_parts = path.split("/")
|
| 391 |
+
if len(path_parts) >= 4:
|
| 392 |
+
return _get_module("lambda_svc").serve_function_code(path_parts[3])
|
| 393 |
+
return None
|
| 394 |
+
|
| 395 |
+
|
| 396 |
+
async def _handle_cognito_get_request(method: str, path: str, headers: dict, query_params: dict):
|
| 397 |
+
"""Handle Cognito GET endpoints that do not require request body parsing."""
|
| 398 |
+
if "/.well-known/" in path and method == "GET":
|
| 399 |
+
if path.endswith("/.well-known/jwks.json"):
|
| 400 |
+
pool_id = path.rsplit("/.well-known/jwks.json", 1)[0].lstrip("/")
|
| 401 |
+
if pool_id:
|
| 402 |
+
return _get_module("cognito").well_known_jwks(pool_id)
|
| 403 |
+
elif path.endswith("/.well-known/openid-configuration"):
|
| 404 |
+
pool_id = path.rsplit("/.well-known/openid-configuration", 1)[0].lstrip("/")
|
| 405 |
+
if pool_id:
|
| 406 |
+
region = extract_region(headers) or "us-east-1"
|
| 407 |
+
return _get_module("cognito").well_known_openid_configuration(pool_id, region)
|
| 408 |
+
|
| 409 |
+
if path == "/oauth2/authorize" and method == "GET":
|
| 410 |
+
return _get_module("cognito").handle_oauth2_authorize(method, path, headers, query_params)
|
| 411 |
+
if path in _COGNITO_USERINFO_PATHS and method == "GET":
|
| 412 |
+
return _get_module("cognito").handle_oauth2_userinfo(method, path, headers, b"", query_params)
|
| 413 |
+
if path == "/logout" and method == "GET":
|
| 414 |
+
return _get_module("cognito").handle_logout(method, path, headers, query_params)
|
| 415 |
+
return None
|
| 416 |
+
|
| 417 |
+
|
| 418 |
+
async def _handle_admin_reset(path: str, method: str, query_params: dict):
|
| 419 |
+
"""Handle reset requests before request body parsing."""
|
| 420 |
+
if path != "/_ministack/reset" or method != "POST":
|
| 421 |
+
return None
|
| 422 |
+
|
| 423 |
+
async with _get_reset_lock():
|
| 424 |
+
await asyncio.to_thread(_reset_all_state)
|
| 425 |
+
|
| 426 |
+
run_init = query_params.get("init", [""])[0] == "1"
|
| 427 |
+
if run_init:
|
| 428 |
+
_run_init_scripts()
|
| 429 |
+
_ready_scripts_state.update({"status": "pending", "total": 0, "completed": 0, "failed": 0})
|
| 430 |
+
asyncio.create_task(_run_ready_scripts())
|
| 431 |
+
return 200, {"Content-Type": "application/json"}, json.dumps({"reset": "ok"}).encode()
|
| 432 |
+
|
| 433 |
+
|
| 434 |
+
def _handle_admin_introspection(path: str, method: str):
|
| 435 |
+
"""Handle /_ministack/{state,handlers,handlers/<svc>} introspection endpoints."""
|
| 436 |
+
if method != "GET":
|
| 437 |
+
return None
|
| 438 |
+
if path == "/_ministack/state":
|
| 439 |
+
return 200, {"Content-Type": "application/json"}, json.dumps(_get_all_state()).encode()
|
| 440 |
+
if path == "/_ministack/handlers":
|
| 441 |
+
return 200, {"Content-Type": "application/json"}, json.dumps(_get_all_handlers()).encode()
|
| 442 |
+
if path.startswith("/_ministack/handlers/"):
|
| 443 |
+
service_name = path[len("/_ministack/handlers/"):].strip("/")
|
| 444 |
+
info = _get_service_info(service_name)
|
| 445 |
+
if info is None:
|
| 446 |
+
return 404, {"Content-Type": "application/json"}, json.dumps({"error": f"Unknown service: {service_name}"}).encode()
|
| 447 |
+
return 200, {"Content-Type": "application/json"}, json.dumps(info).encode()
|
| 448 |
+
return None
|
| 449 |
+
|
| 450 |
+
|
| 451 |
+
async def _handle_pre_body_request(method: str, path: str, headers: dict, query_params: dict, request_id: str):
|
| 452 |
+
"""Handle fast-path routes that do not require request body parsing."""
|
| 453 |
+
# OPTIONS on an execute-api host / path MUST flow through apigateway.handle_execute
|
| 454 |
+
# so the API's own corsConfiguration is applied (#406). Skip the generic wildcard
|
| 455 |
+
# preflight in that case.
|
| 456 |
+
host = headers.get("host", "")
|
| 457 |
+
is_execute_api = _parse_execute_api_url(host, path) is not None
|
| 458 |
+
for response in (
|
| 459 |
+
None if is_execute_api else _handle_options_request(method, request_id),
|
| 460 |
+
_handle_health_request(path, request_id),
|
| 461 |
+
_handle_ready_request(path, request_id),
|
| 462 |
+
_handle_lambda_download_request(path, method),
|
| 463 |
+
_handle_admin_introspection(path, method),
|
| 464 |
+
):
|
| 465 |
+
if response is not None:
|
| 466 |
+
return response
|
| 467 |
+
|
| 468 |
+
response = await _handle_cognito_get_request(method, path, headers, query_params)
|
| 469 |
+
if response is not None:
|
| 470 |
+
return response
|
| 471 |
+
return await _handle_admin_reset(path, method, query_params)
|
| 472 |
+
|
| 473 |
+
|
| 474 |
+
# ---------------------------------------------------------------------------
|
| 475 |
+
# Tier 2 — Post-body shortcuts (body required, before generic routing)
|
| 476 |
+
# ---------------------------------------------------------------------------
|
| 477 |
+
|
| 478 |
+
async def _handle_cognito_body_request(method: str, path: str, headers: dict, body: bytes, query_params: dict):
|
| 479 |
+
"""Handle Cognito routes that require the parsed request body."""
|
| 480 |
+
if path in ("/oauth2/login", "/login") and method == "POST":
|
| 481 |
+
return _get_module("cognito").handle_login_submit(method, path, headers, body, query_params)
|
| 482 |
+
if path == "/oauth2/token" and method == "POST":
|
| 483 |
+
return _get_module("cognito").handle_oauth2_token(method, path, headers, body, query_params)
|
| 484 |
+
if path in _COGNITO_USERINFO_PATHS and method == "POST":
|
| 485 |
+
return _get_module("cognito").handle_oauth2_userinfo(method, path, headers, body, query_params)
|
| 486 |
+
return None
|
| 487 |
+
|
| 488 |
+
|
| 489 |
+
async def _handle_admin_config_request(path: str, method: str, body: bytes):
|
| 490 |
+
"""Apply whitelisted runtime config changes through the admin endpoint."""
|
| 491 |
+
if path != "/_ministack/config" or method != "POST":
|
| 492 |
+
return None
|
| 493 |
+
|
| 494 |
+
allowed_config_keys = {
|
| 495 |
+
"athena.ATHENA_ENGINE", "athena.ATHENA_DATA_DIR",
|
| 496 |
+
"stepfunctions._sfn_mock_config",
|
| 497 |
+
"stepfunctions._SFN_WAIT_SCALE",
|
| 498 |
+
"lambda_svc.LAMBDA_EXECUTOR",
|
| 499 |
+
}
|
| 500 |
+
try:
|
| 501 |
+
config = json.loads(body) if body else {}
|
| 502 |
+
except json.JSONDecodeError:
|
| 503 |
+
config = {}
|
| 504 |
+
|
| 505 |
+
applied = {}
|
| 506 |
+
for key, value in config.items():
|
| 507 |
+
if key not in allowed_config_keys:
|
| 508 |
+
logger.warning("/_ministack/config: rejected key %s (not in whitelist)", key)
|
| 509 |
+
continue
|
| 510 |
+
if "." not in key:
|
| 511 |
+
continue
|
| 512 |
+
|
| 513 |
+
mod_name, var_name = key.rsplit(".", 1)
|
| 514 |
+
try:
|
| 515 |
+
mod = __import__(f"ministack.services.{mod_name}", fromlist=[var_name])
|
| 516 |
+
if key == "stepfunctions._SFN_WAIT_SCALE":
|
| 517 |
+
try:
|
| 518 |
+
float_value = float(value)
|
| 519 |
+
except (ValueError, TypeError):
|
| 520 |
+
logger.warning("/_ministack/config: invalid SFN_WAIT_SCALE=%r", value)
|
| 521 |
+
continue
|
| 522 |
+
if not math.isfinite(float_value) or float_value < 0:
|
| 523 |
+
logger.warning("/_ministack/config: invalid SFN_WAIT_SCALE=%r", value)
|
| 524 |
+
continue
|
| 525 |
+
value = float_value
|
| 526 |
+
setattr(mod, var_name, value)
|
| 527 |
+
applied[key] = value
|
| 528 |
+
except (ImportError, AttributeError) as e:
|
| 529 |
+
logger.warning("/_ministack/config: failed to set %s: %s", key, e)
|
| 530 |
+
return 200, {"Content-Type": "application/json"}, json.dumps({"applied": applied}).encode()
|
| 531 |
+
|
| 532 |
+
|
| 533 |
+
async def _handle_post_body_shortcuts(method: str, path: str, headers: dict, body: bytes, query_params: dict):
|
| 534 |
+
"""Handle body-dependent routes before the generic service router."""
|
| 535 |
+
response = await _handle_cognito_body_request(method, path, headers, body, query_params)
|
| 536 |
+
if response is not None:
|
| 537 |
+
return response
|
| 538 |
+
return await _handle_admin_config_request(path, method, body)
|
| 539 |
+
|
| 540 |
+
|
| 541 |
+
# ---------------------------------------------------------------------------
|
| 542 |
+
# Tier 3 — Special data-plane handlers (host/path-based routing)
|
| 543 |
+
# ---------------------------------------------------------------------------
|
| 544 |
+
|
| 545 |
+
async def _handle_s3_control_request(path: str, method: str, body: bytes, query_params: dict, request_id: str):
|
| 546 |
+
"""Handle S3 Control operations addressed via the /v20180820 path prefix."""
|
| 547 |
+
if not path.startswith(_S3_CONTROL_PREFIX):
|
| 548 |
+
return None
|
| 549 |
+
|
| 550 |
+
if path.startswith("/v20180820/tags/"):
|
| 551 |
+
raw_arn = path[len("/v20180820/tags/"):]
|
| 552 |
+
arn = unquote(raw_arn)
|
| 553 |
+
bucket_name = arn.split(":::")[-1].split("/")[0] if ":::" in arn else arn.split("/")[0]
|
| 554 |
+
|
| 555 |
+
if method == "GET":
|
| 556 |
+
tags = _get_module("s3")._bucket_tags.get(bucket_name, {})
|
| 557 |
+
tag_members = "".join(
|
| 558 |
+
f"<member><Key>{k}</Key><Value>{v}</Value></member>"
|
| 559 |
+
for k, v in tags.items()
|
| 560 |
+
)
|
| 561 |
+
xml_body = (
|
| 562 |
+
'<?xml version="1.0" encoding="UTF-8"?>'
|
| 563 |
+
'<ListTagsForResourceResult xmlns="https://awss3control.amazonaws.com/doc/2018-08-20/">'
|
| 564 |
+
f"<Tags>{tag_members}</Tags>"
|
| 565 |
+
"</ListTagsForResourceResult>"
|
| 566 |
+
).encode()
|
| 567 |
+
return 200, {
|
| 568 |
+
"Content-Type": "application/xml",
|
| 569 |
+
"x-amzn-requestid": request_id,
|
| 570 |
+
}, xml_body
|
| 571 |
+
|
| 572 |
+
if method == "PUT":
|
| 573 |
+
try:
|
| 574 |
+
payload = json.loads(body) if body else {}
|
| 575 |
+
new_tags = {t["Key"]: t["Value"] for t in payload.get("Tags", [])}
|
| 576 |
+
existing = _get_module("s3")._bucket_tags.get(bucket_name, {})
|
| 577 |
+
existing.update(new_tags)
|
| 578 |
+
_get_module("s3")._bucket_tags[bucket_name] = existing
|
| 579 |
+
except Exception as e:
|
| 580 |
+
logger.warning("S3 Control TagResource parse error: %s", e)
|
| 581 |
+
return 204, {"x-amzn-requestid": request_id}, b""
|
| 582 |
+
|
| 583 |
+
if method == "DELETE":
|
| 584 |
+
keys_to_remove = query_params.get("tagKeys", [])
|
| 585 |
+
if isinstance(keys_to_remove, str):
|
| 586 |
+
keys_to_remove = [keys_to_remove]
|
| 587 |
+
tags = _get_module("s3")._bucket_tags.get(bucket_name, {})
|
| 588 |
+
for key in keys_to_remove:
|
| 589 |
+
tags.pop(key, None)
|
| 590 |
+
_get_module("s3")._bucket_tags[bucket_name] = tags
|
| 591 |
+
return 204, {"x-amzn-requestid": request_id}, b""
|
| 592 |
+
|
| 593 |
+
return 200, {
|
| 594 |
+
"Content-Type": "application/json",
|
| 595 |
+
"x-amzn-requestid": request_id,
|
| 596 |
+
}, b"{}"
|
| 597 |
+
|
| 598 |
+
return 200, {
|
| 599 |
+
"Content-Type": "application/json",
|
| 600 |
+
"x-amzn-requestid": request_id,
|
| 601 |
+
}, b"{}"
|
| 602 |
+
|
| 603 |
+
|
| 604 |
+
async def _handle_rds_data_request(method: str, path: str, headers: dict, body: bytes, query_params: dict):
|
| 605 |
+
"""Handle RDS Data API operations before generic routing."""
|
| 606 |
+
if path not in _RDS_DATA_PATHS:
|
| 607 |
+
return None
|
| 608 |
+
return await _get_module("rds_data").handle_request(method, path, headers, body, query_params)
|
| 609 |
+
|
| 610 |
+
|
| 611 |
+
async def _handle_ses_v2_request(method: str, path: str, headers: dict, body: bytes, query_params: dict):
|
| 612 |
+
"""Handle SES v2 REST API operations before generic routing."""
|
| 613 |
+
if not path.startswith(_SES_V2_PREFIX):
|
| 614 |
+
return None
|
| 615 |
+
return await _get_module("ses_v2").handle_request(method, path, headers, body, query_params)
|
| 616 |
+
|
| 617 |
+
|
| 618 |
+
def _parse_execute_api_url(host: str, path: str) -> tuple[str, str, str] | None:
|
| 619 |
+
"""Resolve an execute-api request into (api_id, stage, execute_path).
|
| 620 |
+
|
| 621 |
+
Supports three addressing modes, in priority order:
|
| 622 |
+
1. Host-based (AWS-native): {apiId}.execute-api.<host>[:port]/{stage}/{path}
|
| 623 |
+
2. LocalStack-compat (new): <host>[:port]/_aws/execute-api/{apiId}/{stage}/{path}
|
| 624 |
+
3. LocalStack-compat (v1): <host>[:port]/restapis/{apiId}/{stage}/_user_request_/{path}
|
| 625 |
+
|
| 626 |
+
The path-based forms exist because (a) browsers on macOS don't resolve
|
| 627 |
+
`*.localhost` and (b) many HTTP clients can't override the `Host` header
|
| 628 |
+
(issue #401). Returns ``None`` if none of the three patterns match."""
|
| 629 |
+
m = _EXECUTE_API_RE.match(host)
|
| 630 |
+
if m:
|
| 631 |
+
api_id = m.group(1)
|
| 632 |
+
parts = path.lstrip("/").split("/", 1)
|
| 633 |
+
stage = parts[0] if parts and parts[0] else "$default"
|
| 634 |
+
execute_path = "/" + parts[1] if len(parts) > 1 else "/"
|
| 635 |
+
return api_id, stage, execute_path
|
| 636 |
+
|
| 637 |
+
# LocalStack-compat: /_aws/execute-api/{apiId}/{stage}/{path...}
|
| 638 |
+
if path.startswith("/_aws/execute-api/"):
|
| 639 |
+
rest = path[len("/_aws/execute-api/"):]
|
| 640 |
+
parts = rest.split("/", 2)
|
| 641 |
+
if len(parts) >= 2 and parts[0]:
|
| 642 |
+
api_id = parts[0]
|
| 643 |
+
stage = parts[1] if parts[1] else "$default"
|
| 644 |
+
execute_path = "/" + parts[2] if len(parts) > 2 else "/"
|
| 645 |
+
return api_id, stage, execute_path
|
| 646 |
+
|
| 647 |
+
# LocalStack v1 legacy: /restapis/{apiId}/{stage}/_user_request_/{path...}
|
| 648 |
+
if path.startswith("/restapis/"):
|
| 649 |
+
rest = path[len("/restapis/"):]
|
| 650 |
+
parts = rest.split("/", 3)
|
| 651 |
+
if len(parts) >= 3 and parts[2] == "_user_request_":
|
| 652 |
+
api_id = parts[0]
|
| 653 |
+
stage = parts[1] if parts[1] else "$default"
|
| 654 |
+
execute_path = "/" + parts[3] if len(parts) > 3 else "/"
|
| 655 |
+
return api_id, stage, execute_path
|
| 656 |
+
|
| 657 |
+
return None
|
| 658 |
+
|
| 659 |
+
|
| 660 |
+
def _resolve_stage_and_path(api_id: str, tentative_stage: str, execute_path: str) -> tuple[str, str]:
|
| 661 |
+
"""Pick (stage, execute_path) based on the API's configured stages.
|
| 662 |
+
|
| 663 |
+
AWS v2 HTTP / WebSocket APIs configured with the ``$default`` stage serve
|
| 664 |
+
from the root of the execute-api URL — no stage segment in the path. v1
|
| 665 |
+
REST APIs always carry the stage as the first path segment. We can't tell
|
| 666 |
+
from the URL alone which pattern applies, so we check the API's configured
|
| 667 |
+
stages and route accordingly (issue #404).
|
| 668 |
+
|
| 669 |
+
Rules:
|
| 670 |
+
- If the tentative first segment IS a configured stage name, strip it.
|
| 671 |
+
- Else if the API has a ``$default`` stage, use that and treat the
|
| 672 |
+
whole original path (including ``tentative_stage``) as ``execute_path``.
|
| 673 |
+
- Else fall through (``handle_execute`` will return "Stage not found").
|
| 674 |
+
"""
|
| 675 |
+
apigw_v1 = _get_module("apigateway_v1")
|
| 676 |
+
if api_id in apigw_v1._rest_apis:
|
| 677 |
+
stages_map = apigw_v1._stages_v1.get(api_id, {})
|
| 678 |
+
else:
|
| 679 |
+
stages_map = _get_module("apigateway")._stages.get(api_id, {})
|
| 680 |
+
|
| 681 |
+
if tentative_stage in stages_map:
|
| 682 |
+
return tentative_stage, execute_path
|
| 683 |
+
if "$default" in stages_map:
|
| 684 |
+
if execute_path == "/":
|
| 685 |
+
resolved_path = "/" + tentative_stage if tentative_stage else "/"
|
| 686 |
+
else:
|
| 687 |
+
resolved_path = "/" + tentative_stage + execute_path
|
| 688 |
+
return "$default", resolved_path
|
| 689 |
+
# No match — let handle_execute report the stage miss verbatim.
|
| 690 |
+
return tentative_stage, execute_path
|
| 691 |
+
|
| 692 |
+
|
| 693 |
+
async def _handle_execute_api_request(host: str, path: str, method: str, headers: dict, body: bytes, query_params: dict):
|
| 694 |
+
"""Handle API Gateway execute-api data plane requests (Host-based + path-based)."""
|
| 695 |
+
parsed = _parse_execute_api_url(host, path)
|
| 696 |
+
if parsed is None:
|
| 697 |
+
return None
|
| 698 |
+
api_id, tentative_stage, execute_path = parsed
|
| 699 |
+
try:
|
| 700 |
+
# WebSocket @connections management API — /{stage}/@connections/{id}.
|
| 701 |
+
# The @connections prefix is authoritative; skip $default resolution.
|
| 702 |
+
if execute_path.startswith("/@connections/"):
|
| 703 |
+
connection_id = execute_path[len("/@connections/"):].split("/", 1)[0]
|
| 704 |
+
return await _get_module("apigateway").handle_connections_api(
|
| 705 |
+
method, api_id, tentative_stage, connection_id, body, headers
|
| 706 |
+
)
|
| 707 |
+
stage, execute_path = _resolve_stage_and_path(api_id, tentative_stage, execute_path)
|
| 708 |
+
if api_id in _get_module("apigateway_v1")._rest_apis:
|
| 709 |
+
return await _get_module("apigateway_v1").handle_execute(
|
| 710 |
+
api_id, stage, method, execute_path, headers, body, query_params
|
| 711 |
+
)
|
| 712 |
+
return await _get_module("apigateway").handle_execute(
|
| 713 |
+
api_id, stage, execute_path, method, headers, body, query_params
|
| 714 |
+
)
|
| 715 |
+
except Exception as e:
|
| 716 |
+
logger.exception("Error in execute-api dispatch: %s", e)
|
| 717 |
+
return 500, {"Content-Type": "application/json"}, json.dumps({"message": str(e)}).encode()
|
| 718 |
+
|
| 719 |
+
|
| 720 |
+
def _is_potential_alb_request(host: str, path: str) -> bool:
|
| 721 |
+
"""Cheap ALB gate so ordinary requests avoid loading the ALB module."""
|
| 722 |
+
hostname = host.split(":")[0].lower()
|
| 723 |
+
return path.startswith(_ALB_PATH_PREFIX) or hostname.endswith(".elb.amazonaws.com") or hostname.endswith(".alb.localhost")
|
| 724 |
+
|
| 725 |
+
|
| 726 |
+
async def _handle_alb_request(host: str, path: str, method: str, headers: dict, body: bytes, query_params: dict):
|
| 727 |
+
"""Handle ALB data-plane requests for host-based and /_alb-prefixed addressing."""
|
| 728 |
+
if not _is_potential_alb_request(host, path):
|
| 729 |
+
return None
|
| 730 |
+
|
| 731 |
+
alb_module = _get_module("alb")
|
| 732 |
+
load_balancer = alb_module.find_lb_for_host(host)
|
| 733 |
+
dispatch_path = path
|
| 734 |
+
|
| 735 |
+
if load_balancer is None and path.startswith(_ALB_PATH_PREFIX):
|
| 736 |
+
path_parts = path[len(_ALB_PATH_PREFIX):].split("/", 1)
|
| 737 |
+
load_balancer = alb_module._find_lb_by_name(path_parts[0])
|
| 738 |
+
if load_balancer:
|
| 739 |
+
dispatch_path = "/" + path_parts[1] if len(path_parts) > 1 else "/"
|
| 740 |
+
|
| 741 |
+
if load_balancer is None:
|
| 742 |
+
return None
|
| 743 |
+
|
| 744 |
+
alb_port = 80
|
| 745 |
+
if ":" in host:
|
| 746 |
+
try:
|
| 747 |
+
alb_port = int(host.rsplit(":", 1)[-1])
|
| 748 |
+
except ValueError:
|
| 749 |
+
pass
|
| 750 |
+
|
| 751 |
+
try:
|
| 752 |
+
return await alb_module.dispatch_request(
|
| 753 |
+
load_balancer, method, dispatch_path, headers, body, query_params, alb_port
|
| 754 |
+
)
|
| 755 |
+
except Exception as e:
|
| 756 |
+
logger.exception("Error in ALB data-plane dispatch: %s", e)
|
| 757 |
+
return 500, {"Content-Type": "application/json"}, json.dumps({"message": str(e)}).encode()
|
| 758 |
+
|
| 759 |
+
|
| 760 |
+
async def _handle_s3_vhost_request(host: str, path: str, method: str, headers: dict, body: bytes, query_params: dict):
|
| 761 |
+
"""Handle virtual-hosted S3 requests before generic routing."""
|
| 762 |
+
s3_vhost = _S3_VHOST_RE.match(host)
|
| 763 |
+
if not s3_vhost or _S3_VHOST_EXCLUDE_RE.search(host):
|
| 764 |
+
return None
|
| 765 |
+
|
| 766 |
+
bucket = s3_vhost.group(1)
|
| 767 |
+
if bucket in _NON_S3_VHOST_NAMES:
|
| 768 |
+
return None
|
| 769 |
+
|
| 770 |
+
vhost_path = "/" + bucket + path if path != "/" else "/" + bucket + "/"
|
| 771 |
+
try:
|
| 772 |
+
return await _get_module("s3").handle_request(method, vhost_path, headers, body, query_params)
|
| 773 |
+
except Exception as e:
|
| 774 |
+
logger.exception("Error handling virtual-hosted S3 request: %s", e)
|
| 775 |
+
from xml.sax.saxutils import escape as _xml_esc
|
| 776 |
+
|
| 777 |
+
return 500, {"Content-Type": "application/xml"}, (
|
| 778 |
+
f"<Error><Code>InternalError</Code><Message>{_xml_esc(str(e))}</Message></Error>".encode()
|
| 779 |
+
)
|
| 780 |
+
|
| 781 |
+
|
| 782 |
+
def _with_data_plane_headers(response, request_id: str, include_s3_id: bool = False, wildcard_cors: bool = True):
|
| 783 |
+
"""Attach common data-plane request-id headers to a response tuple.
|
| 784 |
+
|
| 785 |
+
``wildcard_cors`` controls whether a wildcard ``Access-Control-Allow-Origin: *``
|
| 786 |
+
is added. API Gateway owns its own CORS (per-API ``corsConfiguration``,
|
| 787 |
+
issue #406) so the caller passes ``wildcard_cors=False`` there to avoid
|
| 788 |
+
clobbering the per-config value. Respects any ``Access-Control-Allow-Origin``
|
| 789 |
+
already set by the upstream handler."""
|
| 790 |
+
if response is None:
|
| 791 |
+
return None
|
| 792 |
+
status, headers, body = response
|
| 793 |
+
if wildcard_cors and "Access-Control-Allow-Origin" not in headers:
|
| 794 |
+
headers["Access-Control-Allow-Origin"] = "*"
|
| 795 |
+
headers["x-amzn-requestid"] = request_id
|
| 796 |
+
headers["x-amz-request-id"] = request_id
|
| 797 |
+
if include_s3_id:
|
| 798 |
+
headers["x-amz-id-2"] = base64.b64encode(os.urandom(48)).decode()
|
| 799 |
+
return status, headers, body
|
| 800 |
+
|
| 801 |
+
|
| 802 |
+
async def _handle_special_data_plane_request(
|
| 803 |
+
method: str,
|
| 804 |
+
path: str,
|
| 805 |
+
headers: dict,
|
| 806 |
+
body: bytes,
|
| 807 |
+
query_params: dict,
|
| 808 |
+
request_id: str,
|
| 809 |
+
):
|
| 810 |
+
"""Handle special-case service entrypoints before the generic router."""
|
| 811 |
+
if response := await _handle_s3_control_request(path, method, body, query_params, request_id):
|
| 812 |
+
return response
|
| 813 |
+
if response := await _handle_rds_data_request(method, path, headers, body, query_params):
|
| 814 |
+
return response
|
| 815 |
+
if response := await _handle_ses_v2_request(method, path, headers, body, query_params):
|
| 816 |
+
return response
|
| 817 |
+
|
| 818 |
+
host = headers.get("host", "")
|
| 819 |
+
if response := await _handle_execute_api_request(host, path, method, headers, body, query_params):
|
| 820 |
+
return _with_data_plane_headers(response, request_id, wildcard_cors=False)
|
| 821 |
+
if response := await _handle_s3_vhost_request(host, path, method, headers, body, query_params):
|
| 822 |
+
return _with_data_plane_headers(response, request_id, include_s3_id=True)
|
| 823 |
+
if response := await _handle_alb_request(host, path, method, headers, body, query_params):
|
| 824 |
+
return _with_data_plane_headers(response, request_id)
|
| 825 |
+
return None
|
| 826 |
+
|
| 827 |
+
|
| 828 |
+
# ---------------------------------------------------------------------------
|
| 829 |
+
# Tier 4 — Generic service dispatch
|
| 830 |
+
# ---------------------------------------------------------------------------
|
| 831 |
+
|
| 832 |
+
def _routing_params(method: str, path: str, headers: dict, body: bytes, query_params: dict) -> dict:
|
| 833 |
+
"""Augment routing params for unsigned form-encoded requests whose Action lives in the body."""
|
| 834 |
+
routing_params = query_params
|
| 835 |
+
if not query_params.get("Action") and headers.get("content-type", "").startswith("application/x-www-form-urlencoded"):
|
| 836 |
+
body_params = parse_qs(body.decode("utf-8", errors="replace"), keep_blank_values=True)
|
| 837 |
+
if body_params.get("Action"):
|
| 838 |
+
routing_params = {**query_params, "Action": body_params["Action"]}
|
| 839 |
+
return routing_params
|
| 840 |
+
|
| 841 |
+
|
| 842 |
+
async def _dispatch_service_request(method: str, path: str, headers: dict, body: bytes, query_params: dict, request_id: str):
|
| 843 |
+
"""Dispatch a request through the generic service router."""
|
| 844 |
+
routing_params = _routing_params(method, path, headers, body, query_params)
|
| 845 |
+
service = detect_service(method, path, headers, routing_params)
|
| 846 |
+
region = extract_region(headers)
|
| 847 |
+
|
| 848 |
+
logger.debug("%s %s -> service=%s region=%s", method, path, service, region)
|
| 849 |
+
|
| 850 |
+
handler = SERVICE_HANDLERS.get(service)
|
| 851 |
+
if not handler:
|
| 852 |
+
return 400, {"Content-Type": "application/json"}, json.dumps({"error": f"Unsupported service: {service}"}).encode()
|
| 853 |
+
|
| 854 |
+
try:
|
| 855 |
+
status, resp_headers, resp_body = await handler(method, path, headers, body, query_params)
|
| 856 |
+
except Exception as e:
|
| 857 |
+
logger.exception("Error handling %s request: %s", service, e)
|
| 858 |
+
return 500, {"Content-Type": "application/json"}, json.dumps({"__type": "InternalError", "message": str(e)}).encode()
|
| 859 |
+
|
| 860 |
+
resp_headers.update({
|
| 861 |
+
"Access-Control-Allow-Origin": "*",
|
| 862 |
+
"Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, HEAD, OPTIONS, PATCH",
|
| 863 |
+
"Access-Control-Allow-Headers": "*",
|
| 864 |
+
"Access-Control-Expose-Headers": "*",
|
| 865 |
+
"x-amzn-requestid": request_id,
|
| 866 |
+
"x-amz-request-id": request_id,
|
| 867 |
+
"x-amz-id-2": base64.b64encode(os.urandom(48)).decode(),
|
| 868 |
+
})
|
| 869 |
+
return status, resp_headers, resp_body
|
| 870 |
+
|
| 871 |
+
|
| 872 |
+
# ---------------------------------------------------------------------------
|
| 873 |
+
# ASGI entry point
|
| 874 |
+
# ---------------------------------------------------------------------------
|
| 875 |
+
|
| 876 |
+
async def app(scope, receive, send):
|
| 877 |
+
"""ASGI application entry point."""
|
| 878 |
+
if scope["type"] == "lifespan":
|
| 879 |
+
await _handle_lifespan(scope, receive, send)
|
| 880 |
+
return
|
| 881 |
+
|
| 882 |
+
if scope["type"] == "websocket":
|
| 883 |
+
# WebSocket APIs are reachable two ways:
|
| 884 |
+
# ws://{apiId}.execute-api.{host}[:port]/{stage}[/...] (Host-based)
|
| 885 |
+
# ws://<host>[:port]/_aws/execute-api/{apiId}/{stage}[/...] (LocalStack-compat path)
|
| 886 |
+
ws_headers = {}
|
| 887 |
+
for name, value in scope.get("headers", []):
|
| 888 |
+
try:
|
| 889 |
+
ws_headers[name.decode("latin-1").lower()] = value.decode("utf-8")
|
| 890 |
+
except UnicodeDecodeError:
|
| 891 |
+
ws_headers[name.decode("latin-1").lower()] = value.decode("latin-1")
|
| 892 |
+
ws_host = ws_headers.get("host", "")
|
| 893 |
+
ws_path = scope.get("path", "")
|
| 894 |
+
parsed = _parse_execute_api_url(ws_host, ws_path)
|
| 895 |
+
if not parsed:
|
| 896 |
+
msg = await receive()
|
| 897 |
+
if msg.get("type") == "websocket.connect":
|
| 898 |
+
await send({"type": "websocket.close", "code": 1008})
|
| 899 |
+
return
|
| 900 |
+
ws_api_id, _stage, _execute_path = parsed
|
| 901 |
+
try:
|
| 902 |
+
await _get_module("apigateway").handle_websocket(
|
| 903 |
+
scope, receive, send, ws_api_id, path_override=_execute_path,
|
| 904 |
+
)
|
| 905 |
+
except Exception:
|
| 906 |
+
logger.exception("Error in WebSocket dispatch")
|
| 907 |
+
try:
|
| 908 |
+
await send({"type": "websocket.close", "code": 1011})
|
| 909 |
+
except Exception:
|
| 910 |
+
pass
|
| 911 |
+
return
|
| 912 |
+
|
| 913 |
+
if scope["type"] != "http":
|
| 914 |
+
return
|
| 915 |
+
|
| 916 |
+
method = scope["method"]
|
| 917 |
+
path = scope["path"]
|
| 918 |
+
query_string = scope.get("query_string", b"").decode("utf-8")
|
| 919 |
+
query_params = parse_qs(query_string, keep_blank_values=True)
|
| 920 |
+
|
| 921 |
+
headers = {}
|
| 922 |
+
for name, value in scope.get("headers", []):
|
| 923 |
+
try:
|
| 924 |
+
headers[name.decode("latin-1").lower()] = value.decode("utf-8")
|
| 925 |
+
except UnicodeDecodeError:
|
| 926 |
+
headers[name.decode("latin-1").lower()] = value.decode("latin-1")
|
| 927 |
+
|
| 928 |
+
request_id = str(uuid.uuid4())
|
| 929 |
+
|
| 930 |
+
# If a /_ministack/reset is in flight, wait for it to finish before
|
| 931 |
+
# serving this request. The lock is uncontended in steady state
|
| 932 |
+
# (acquire/release is near-free); during a reset, new requests block
|
| 933 |
+
# until state-wipe completes so no test can observe a half-reset server.
|
| 934 |
+
if path != "/_ministack/reset":
|
| 935 |
+
async with _get_reset_lock():
|
| 936 |
+
pass
|
| 937 |
+
|
| 938 |
+
# Set per-request account ID from credentials (multi-tenancy support).
|
| 939 |
+
# If the access key is a 12-digit number, it becomes the account ID.
|
| 940 |
+
_access_key = extract_access_key_id(headers)
|
| 941 |
+
if _access_key:
|
| 942 |
+
set_request_account_id(_access_key)
|
| 943 |
+
|
| 944 |
+
# Set per-request region from SigV4 Credential scope so CFN's AWS::Region
|
| 945 |
+
# pseudo-param and ARN-building use the caller's region, not MINISTACK_REGION
|
| 946 |
+
# (issue #398). Falls back to MINISTACK_REGION env.
|
| 947 |
+
set_request_region(extract_region(headers))
|
| 948 |
+
|
| 949 |
+
if await _send_if_handled(send, await _handle_pre_body_request(method, path, headers, query_params, request_id)):
|
| 950 |
+
return
|
| 951 |
+
|
| 952 |
+
body = await _read_request_body(receive, method, headers)
|
| 953 |
+
|
| 954 |
+
if await _send_if_handled(send, await _handle_post_body_shortcuts(method, path, headers, body, query_params)):
|
| 955 |
+
return
|
| 956 |
+
|
| 957 |
+
if await _send_if_handled(send, await _handle_special_data_plane_request(
|
| 958 |
+
method, path, headers, body, query_params, request_id
|
| 959 |
+
)):
|
| 960 |
+
return
|
| 961 |
+
|
| 962 |
+
await _send_response(send, *await _dispatch_service_request(method, path, headers, body, query_params, request_id))
|
| 963 |
+
|
| 964 |
+
|
| 965 |
+
# ---------------------------------------------------------------------------
|
| 966 |
+
# Lifecycle, init scripts, and server administration
|
| 967 |
+
# ---------------------------------------------------------------------------
|
| 968 |
+
|
| 969 |
+
async def _handle_lifespan(scope, receive, send):
|
| 970 |
+
"""Handle ASGI lifespan events."""
|
| 971 |
+
while True:
|
| 972 |
+
message = await receive()
|
| 973 |
+
if message["type"] == "lifespan.startup":
|
| 974 |
+
port = _resolve_port()
|
| 975 |
+
logger.info(BANNER.format(port=port))
|
| 976 |
+
_run_init_scripts()
|
| 977 |
+
if PERSIST_STATE:
|
| 978 |
+
_load_persisted_state()
|
| 979 |
+
await send({"type": "lifespan.startup.complete"})
|
| 980 |
+
logger.info("Ready.")
|
| 981 |
+
for svc in SERVICE_HANDLERS:
|
| 982 |
+
logger.info("%s init completed.", svc.capitalize())
|
| 983 |
+
asyncio.create_task(_run_ready_scripts())
|
| 984 |
+
elif message["type"] == "lifespan.shutdown":
|
| 985 |
+
logger.info("MiniStack shutting down...")
|
| 986 |
+
if PERSIST_STATE:
|
| 987 |
+
# Only save state for modules that were actually loaded
|
| 988 |
+
_state_map = {
|
| 989 |
+
"apigateway": "apigateway", "apigateway_v1": "apigateway_v1",
|
| 990 |
+
"sqs": "sqs", "sns": "sns", "ssm": "ssm",
|
| 991 |
+
"secretsmanager": "secretsmanager", "iam": "iam",
|
| 992 |
+
"dynamodb": "dynamodb", "kms": "kms", "eventbridge": "eventbridge",
|
| 993 |
+
"cloudwatch_logs": "cloudwatch_logs", "kinesis": "kinesis",
|
| 994 |
+
"ec2": "ec2", "route53": "route53", "cognito": "cognito",
|
| 995 |
+
"ecr": "ecr", "cloudwatch": "cloudwatch", "s3": "s3",
|
| 996 |
+
"lambda": "lambda_svc", "rds": "rds", "ecs": "ecs",
|
| 997 |
+
"elasticache": "elasticache", "appsync": "appsync",
|
| 998 |
+
"stepfunctions": "stepfunctions", "alb": "alb",
|
| 999 |
+
"glue": "glue", "efs": "efs", "waf": "waf",
|
| 1000 |
+
"athena": "athena", "emr": "emr", "cloudfront": "cloudfront",
|
| 1001 |
+
"codebuild": "codebuild", "acm": "acm", "firehose": "firehose",
|
| 1002 |
+
"ses": "ses", "ses_v2": "ses_v2",
|
| 1003 |
+
"servicediscovery": "servicediscovery", "s3files": "s3files",
|
| 1004 |
+
"appconfig": "appconfig", "transfer": "transfer",
|
| 1005 |
+
"scheduler": "scheduler", "autoscaling": "autoscaling",
|
| 1006 |
+
"eks": "eks",
|
| 1007 |
+
}
|
| 1008 |
+
save_dict = {}
|
| 1009 |
+
for key, mod_name in _state_map.items():
|
| 1010 |
+
if mod_name in _loaded_modules:
|
| 1011 |
+
save_dict[key] = _loaded_modules[mod_name].get_state
|
| 1012 |
+
save_all(save_dict)
|
| 1013 |
+
_stop_docker_containers()
|
| 1014 |
+
await send({"type": "lifespan.shutdown.complete"})
|
| 1015 |
+
return
|
| 1016 |
+
|
| 1017 |
+
|
| 1018 |
+
def _stop_docker_containers():
|
| 1019 |
+
"""Stop all Docker containers managed by MiniStack (RDS, ECS, ElastiCache).
|
| 1020 |
+
Uses container labels to find them — does not touch service state."""
|
| 1021 |
+
try:
|
| 1022 |
+
import docker
|
| 1023 |
+
client = docker.from_env()
|
| 1024 |
+
except Exception:
|
| 1025 |
+
return
|
| 1026 |
+
for label in ("ministack=rds", "ministack=ecs", "ministack=elasticache", "ministack=eks", "ministack=lambda"):
|
| 1027 |
+
try:
|
| 1028 |
+
for c in client.containers.list(filters={"label": label}):
|
| 1029 |
+
try:
|
| 1030 |
+
c.stop(timeout=5)
|
| 1031 |
+
c.remove(v=True)
|
| 1032 |
+
except Exception:
|
| 1033 |
+
pass
|
| 1034 |
+
except Exception:
|
| 1035 |
+
pass
|
| 1036 |
+
|
| 1037 |
+
|
| 1038 |
+
def _load_persisted_state():
|
| 1039 |
+
"""Load persisted state for services that support it."""
|
| 1040 |
+
for svc_key in ("apigateway", "apigateway_v1", "servicediscovery"):
|
| 1041 |
+
data = load_state(svc_key)
|
| 1042 |
+
if data:
|
| 1043 |
+
_get_module(svc_key).load_persisted_state(data)
|
| 1044 |
+
logger.info("Loaded persisted state for %s", svc_key)
|
| 1045 |
+
|
| 1046 |
+
|
| 1047 |
+
async def _wait_for_port(port, timeout=30):
|
| 1048 |
+
"""Wait until the server is accepting TCP connections."""
|
| 1049 |
+
import time
|
| 1050 |
+
deadline = time.monotonic() + timeout
|
| 1051 |
+
while time.monotonic() < deadline:
|
| 1052 |
+
try:
|
| 1053 |
+
reader, writer = await asyncio.open_connection('127.0.0.1', port)
|
| 1054 |
+
writer.close()
|
| 1055 |
+
await writer.wait_closed()
|
| 1056 |
+
return
|
| 1057 |
+
except OSError:
|
| 1058 |
+
await asyncio.sleep(0.1)
|
| 1059 |
+
logger.warning('Server did not become ready within %ds — skipping ready.d scripts', timeout)
|
| 1060 |
+
|
| 1061 |
+
|
| 1062 |
+
async def _run_ready_scripts():
|
| 1063 |
+
"""Execute .sh/.py scripts from ready.d directories after the server is ready."""
|
| 1064 |
+
scripts = _collect_scripts('/docker-entrypoint-initaws.d/ready.d', '/etc/localstack/init/ready.d')
|
| 1065 |
+
if not scripts:
|
| 1066 |
+
_ready_scripts_state.update({"status": "completed", "total": 0, "completed": 0, "failed": 0})
|
| 1067 |
+
return
|
| 1068 |
+
_ready_scripts_state.update({"status": "running", "total": len(scripts), "completed": 0, "failed": 0})
|
| 1069 |
+
port = int(_resolve_port())
|
| 1070 |
+
await _wait_for_port(port)
|
| 1071 |
+
logger.info('Found %d ready script(s)', len(scripts))
|
| 1072 |
+
# Provide sensible defaults so init scripts can use aws cli / boto3
|
| 1073 |
+
# without requiring manual credential configuration. Skip credential
|
| 1074 |
+
# defaults when the user has mounted ~/.aws/credentials so the CLI
|
| 1075 |
+
# respects their configured profile.
|
| 1076 |
+
script_env = {**os.environ}
|
| 1077 |
+
_creds_paths = [os.path.expanduser("~/.aws"), "/root/.aws"]
|
| 1078 |
+
_custom_creds = os.environ.get("AWS_SHARED_CREDENTIALS_FILE")
|
| 1079 |
+
_has_creds_file = (_custom_creds and os.path.isfile(_custom_creds)) or any(
|
| 1080 |
+
os.path.isfile(os.path.join(d, "credentials")) for d in _creds_paths
|
| 1081 |
+
)
|
| 1082 |
+
if not _has_creds_file:
|
| 1083 |
+
script_env.setdefault("AWS_ACCESS_KEY_ID", "test")
|
| 1084 |
+
script_env.setdefault("AWS_SECRET_ACCESS_KEY", "test")
|
| 1085 |
+
script_env.setdefault("AWS_DEFAULT_REGION", os.environ.get("MINISTACK_REGION", "us-east-1"))
|
| 1086 |
+
script_env.setdefault("AWS_ENDPOINT_URL", f"http://{_MINISTACK_HOST}:{port}")
|
| 1087 |
+
for script_path in scripts:
|
| 1088 |
+
logger.info('Running ready script: %s', script_path)
|
| 1089 |
+
script_failed = False
|
| 1090 |
+
try:
|
| 1091 |
+
cmd = [sys.executable, script_path] if script_path.endswith('.py') else ['sh', script_path]
|
| 1092 |
+
proc = await asyncio.create_subprocess_exec(
|
| 1093 |
+
*cmd,
|
| 1094 |
+
stdout=asyncio.subprocess.PIPE,
|
| 1095 |
+
stderr=asyncio.subprocess.PIPE,
|
| 1096 |
+
env=script_env,
|
| 1097 |
+
)
|
| 1098 |
+
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=300)
|
| 1099 |
+
if stdout:
|
| 1100 |
+
logger.info(' stdout: %s', stdout.decode('utf-8', errors='replace').rstrip())
|
| 1101 |
+
if proc.returncode != 0:
|
| 1102 |
+
script_failed = True
|
| 1103 |
+
logger.error('Ready script %s failed (exit %d): %s', script_path, proc.returncode,
|
| 1104 |
+
stderr.decode('utf-8', errors='replace'))
|
| 1105 |
+
else:
|
| 1106 |
+
logger.info('Ready script %s completed successfully', script_path)
|
| 1107 |
+
except asyncio.TimeoutError:
|
| 1108 |
+
script_failed = True
|
| 1109 |
+
logger.error('Ready script %s timed out after 300s', script_path)
|
| 1110 |
+
proc.kill()
|
| 1111 |
+
except Exception as e:
|
| 1112 |
+
script_failed = True
|
| 1113 |
+
logger.error('Failed to execute ready script %s: %s', script_path, e)
|
| 1114 |
+
_ready_scripts_state["completed"] += 1
|
| 1115 |
+
if script_failed:
|
| 1116 |
+
_ready_scripts_state["failed"] += 1
|
| 1117 |
+
_ready_scripts_state["status"] = "completed"
|
| 1118 |
+
|
| 1119 |
+
|
| 1120 |
+
def _collect_scripts(*dirs):
|
| 1121 |
+
"""Collect .sh/.py scripts from multiple directories, deduped by filename."""
|
| 1122 |
+
seen = {}
|
| 1123 |
+
for d in dirs:
|
| 1124 |
+
if not os.path.isdir(d):
|
| 1125 |
+
continue
|
| 1126 |
+
for f in sorted(os.listdir(d)):
|
| 1127 |
+
if f.endswith(('.sh', '.py')) and f not in seen:
|
| 1128 |
+
seen[f] = os.path.join(d, f)
|
| 1129 |
+
return [seen[f] for f in sorted(seen)]
|
| 1130 |
+
|
| 1131 |
+
|
| 1132 |
+
def _run_init_scripts():
|
| 1133 |
+
"""Execute .sh/.py scripts from init directories in alphabetical order."""
|
| 1134 |
+
scripts = _collect_scripts('/docker-entrypoint-initaws.d', '/etc/localstack/init/boot.d')
|
| 1135 |
+
if not scripts:
|
| 1136 |
+
return
|
| 1137 |
+
logger.info("Found %d init script(s)", len(scripts))
|
| 1138 |
+
for script_path in scripts:
|
| 1139 |
+
logger.info("Running init script: %s", script_path)
|
| 1140 |
+
try:
|
| 1141 |
+
cmd = [sys.executable, script_path] if script_path.endswith('.py') else ["sh", script_path]
|
| 1142 |
+
result = subprocess.run(
|
| 1143 |
+
cmd, env=os.environ,
|
| 1144 |
+
capture_output=True, text=True, timeout=300,
|
| 1145 |
+
)
|
| 1146 |
+
if result.stdout:
|
| 1147 |
+
logger.info(" stdout: %s", result.stdout.rstrip())
|
| 1148 |
+
if result.returncode != 0:
|
| 1149 |
+
logger.error("Init script %s failed (exit %d): %s", script_path, result.returncode, result.stderr)
|
| 1150 |
+
else:
|
| 1151 |
+
logger.info("Init script %s completed successfully", script_path)
|
| 1152 |
+
except subprocess.TimeoutExpired:
|
| 1153 |
+
logger.error("Init script %s timed out after 300s", script_path)
|
| 1154 |
+
except Exception as e:
|
| 1155 |
+
logger.error("Failed to execute init script %s: %s", script_path, e)
|
| 1156 |
+
|
| 1157 |
+
|
| 1158 |
+
_EXTRA_INTROSPECTION_MODULES = (
|
| 1159 |
+
("apigateway_v1", "apigateway_v1"),
|
| 1160 |
+
("ses_v2", "ses_v2"),
|
| 1161 |
+
)
|
| 1162 |
+
|
| 1163 |
+
|
| 1164 |
+
def _service_modules() -> list:
|
| 1165 |
+
"""Return list of (canonical_name, module) for every registered service module.
|
| 1166 |
+
|
| 1167 |
+
Uses SERVICE_REGISTRY as the source of truth so new upstream services are
|
| 1168 |
+
picked up automatically. Modules are loaded lazily via _get_module.
|
| 1169 |
+
"""
|
| 1170 |
+
seen_modules: set = set()
|
| 1171 |
+
result = []
|
| 1172 |
+
for svc_name, cfg in SERVICE_REGISTRY.items():
|
| 1173 |
+
mod_name = cfg["module"]
|
| 1174 |
+
if mod_name in seen_modules:
|
| 1175 |
+
continue
|
| 1176 |
+
seen_modules.add(mod_name)
|
| 1177 |
+
result.append((svc_name, _get_module(mod_name)))
|
| 1178 |
+
for svc_name, mod_name in _EXTRA_INTROSPECTION_MODULES:
|
| 1179 |
+
if mod_name in seen_modules:
|
| 1180 |
+
continue
|
| 1181 |
+
seen_modules.add(mod_name)
|
| 1182 |
+
result.append((svc_name, _get_module(mod_name)))
|
| 1183 |
+
return result
|
| 1184 |
+
|
| 1185 |
+
|
| 1186 |
+
# Extra aliases for the /_ministack/handlers/<service> endpoint so users can
|
| 1187 |
+
# look up services using common short names (e.g. "lambda", "stepfunctions").
|
| 1188 |
+
_HANDLER_LOOKUP_ALIASES = {
|
| 1189 |
+
**SERVICE_NAME_ALIASES,
|
| 1190 |
+
"lambda": "lambda",
|
| 1191 |
+
"iam": "iam",
|
| 1192 |
+
"sts": "iam",
|
| 1193 |
+
"ses-v2": "ses_v2",
|
| 1194 |
+
"sesv2": "ses_v2",
|
| 1195 |
+
"apigateway-v1": "apigateway_v1",
|
| 1196 |
+
"apigatewayv1": "apigateway_v1",
|
| 1197 |
+
"logs": "logs",
|
| 1198 |
+
"emr": "elasticmapreduce",
|
| 1199 |
+
"alb": "elasticloadbalancing",
|
| 1200 |
+
"efs": "elasticfilesystem",
|
| 1201 |
+
"cfn": "cloudformation",
|
| 1202 |
+
"sf": "states",
|
| 1203 |
+
"sfn": "states",
|
| 1204 |
+
"cw": "monitoring",
|
| 1205 |
+
"cwl": "logs",
|
| 1206 |
+
"sm": "secretsmanager",
|
| 1207 |
+
"eb": "events",
|
| 1208 |
+
"ddb": "dynamodb",
|
| 1209 |
+
}
|
| 1210 |
+
|
| 1211 |
+
|
| 1212 |
+
def _resolve_service_module(service_name: str):
|
| 1213 |
+
"""Resolve a service name (or alias) to its (canonical_name, module) pair."""
|
| 1214 |
+
name = service_name.lower().strip()
|
| 1215 |
+
canonical = _HANDLER_LOOKUP_ALIASES.get(name, name)
|
| 1216 |
+
for svc_name, mod in _service_modules():
|
| 1217 |
+
if svc_name == canonical:
|
| 1218 |
+
return svc_name, mod
|
| 1219 |
+
return None, None
|
| 1220 |
+
|
| 1221 |
+
|
| 1222 |
+
def _get_all_state() -> dict:
|
| 1223 |
+
"""Collect summary state from every service module."""
|
| 1224 |
+
state = {}
|
| 1225 |
+
for name, mod in _service_modules():
|
| 1226 |
+
try:
|
| 1227 |
+
summary_fn = getattr(mod, "get_state_summary", None)
|
| 1228 |
+
if summary_fn is None:
|
| 1229 |
+
continue
|
| 1230 |
+
state[name] = summary_fn()
|
| 1231 |
+
except Exception as e:
|
| 1232 |
+
logger.warning("get_state_summary() failed for %s: %s", name, e)
|
| 1233 |
+
state[name] = {"error": str(e)}
|
| 1234 |
+
return {"services": state}
|
| 1235 |
+
|
| 1236 |
+
|
| 1237 |
+
def _get_all_handlers() -> dict:
|
| 1238 |
+
"""Collect SUPPORTED_ACTIONS from every service module."""
|
| 1239 |
+
handlers = {}
|
| 1240 |
+
for name, mod in _service_modules():
|
| 1241 |
+
actions = getattr(mod, "SUPPORTED_ACTIONS", None)
|
| 1242 |
+
if actions is None:
|
| 1243 |
+
continue
|
| 1244 |
+
handlers[name] = {"actions": list(actions), "count": len(actions)}
|
| 1245 |
+
return {"services": handlers}
|
| 1246 |
+
|
| 1247 |
+
|
| 1248 |
+
def _get_service_info(service_name: str) -> dict | None:
|
| 1249 |
+
"""Return detailed info for a single service: docstring, actions, and current state."""
|
| 1250 |
+
name, mod = _resolve_service_module(service_name)
|
| 1251 |
+
if mod is None:
|
| 1252 |
+
return None
|
| 1253 |
+
docstring = (mod.__doc__ or "").strip()
|
| 1254 |
+
actions = getattr(mod, "SUPPORTED_ACTIONS", [])
|
| 1255 |
+
try:
|
| 1256 |
+
summary_fn = getattr(mod, "get_state_summary", None)
|
| 1257 |
+
state = summary_fn() if summary_fn else {}
|
| 1258 |
+
except Exception:
|
| 1259 |
+
state = {}
|
| 1260 |
+
return {
|
| 1261 |
+
"service": name,
|
| 1262 |
+
"description": docstring,
|
| 1263 |
+
"supported_actions": actions,
|
| 1264 |
+
"action_count": len(actions),
|
| 1265 |
+
"state": state,
|
| 1266 |
+
}
|
| 1267 |
+
|
| 1268 |
+
|
| 1269 |
+
def _reset_all_state():
|
| 1270 |
+
"""Wipe all in-memory state across every service module, and persisted files if enabled."""
|
| 1271 |
+
|
| 1272 |
+
from ministack.core.persistence import PERSIST_STATE, STATE_DIR
|
| 1273 |
+
|
| 1274 |
+
# Stateful modules that don't have a routing entry in SERVICE_REGISTRY but
|
| 1275 |
+
# still need reset() — REST API v1 (served via the apigateway module),
|
| 1276 |
+
# SES v2 (served via the ses module), and EventBridge Pipes (CFN-only
|
| 1277 |
+
# provisioner with a background poller thread that reset() must stop).
|
| 1278 |
+
_extra_reset_modules = ("apigateway_v1", "ses_v2", "pipes")
|
| 1279 |
+
|
| 1280 |
+
module_names = {cfg["module"] for cfg in SERVICE_REGISTRY.values()}
|
| 1281 |
+
module_names.update(_extra_reset_modules)
|
| 1282 |
+
|
| 1283 |
+
for mod_name in module_names:
|
| 1284 |
+
if mod_name in _loaded_modules:
|
| 1285 |
+
mod = _loaded_modules[mod_name]
|
| 1286 |
+
try:
|
| 1287 |
+
mod.reset()
|
| 1288 |
+
except Exception as e:
|
| 1289 |
+
logger.warning("reset() failed for %s: %s", mod_name, e)
|
| 1290 |
+
|
| 1291 |
+
S3_DATA_DIR = os.environ.get("S3_DATA_DIR", "/tmp/ministack-data/s3")
|
| 1292 |
+
S3_PERSIST = os.environ.get("S3_PERSIST", "0") == "1"
|
| 1293 |
+
|
| 1294 |
+
# Wipe persisted files so a subsequent restart doesn't reload old state
|
| 1295 |
+
if PERSIST_STATE and os.path.isdir(STATE_DIR):
|
| 1296 |
+
for fname in os.listdir(STATE_DIR):
|
| 1297 |
+
if fname.endswith(".json"):
|
| 1298 |
+
try:
|
| 1299 |
+
os.remove(os.path.join(STATE_DIR, fname))
|
| 1300 |
+
except Exception as e:
|
| 1301 |
+
logger.warning("reset: failed to remove %s: %s", fname, e)
|
| 1302 |
+
logger.info("Wiped persisted state files in %s", STATE_DIR)
|
| 1303 |
+
|
| 1304 |
+
if S3_PERSIST and os.path.isdir(S3_DATA_DIR):
|
| 1305 |
+
for entry in os.listdir(S3_DATA_DIR):
|
| 1306 |
+
entry_path = os.path.join(S3_DATA_DIR, entry)
|
| 1307 |
+
try:
|
| 1308 |
+
if os.path.isdir(entry_path):
|
| 1309 |
+
shutil.rmtree(entry_path)
|
| 1310 |
+
else:
|
| 1311 |
+
os.remove(entry_path)
|
| 1312 |
+
except Exception as e:
|
| 1313 |
+
logger.warning("reset: failed to remove S3 data %s: %s", entry, e)
|
| 1314 |
+
logger.info("Wiped S3 persisted data in %s", S3_DATA_DIR)
|
| 1315 |
+
|
| 1316 |
+
logger.info("State reset complete")
|
| 1317 |
+
|
| 1318 |
+
|
| 1319 |
+
def _pid_file(port: int) -> str:
|
| 1320 |
+
return os.path.join(tempfile.gettempdir(), f"ministack-{port}.pid")
|
| 1321 |
+
|
| 1322 |
+
|
| 1323 |
+
def main():
|
| 1324 |
+
from hypercorn.config import Config as HypercornConfig
|
| 1325 |
+
from hypercorn.asyncio import serve as hypercorn_serve
|
| 1326 |
+
|
| 1327 |
+
parser = argparse.ArgumentParser(description="MiniStack — Local AWS Service Emulator")
|
| 1328 |
+
parser.add_argument("-d", "--detach", action="store_true", help="Run in the background (detached mode)")
|
| 1329 |
+
parser.add_argument("--stop", action="store_true", help="Stop a detached MiniStack server")
|
| 1330 |
+
args = parser.parse_args()
|
| 1331 |
+
|
| 1332 |
+
port = int(_resolve_port())
|
| 1333 |
+
|
| 1334 |
+
if args.stop:
|
| 1335 |
+
pf = _pid_file(port)
|
| 1336 |
+
if not os.path.exists(pf):
|
| 1337 |
+
print(f"No MiniStack PID file found for port {port}. Is it running?")
|
| 1338 |
+
raise SystemExit(1)
|
| 1339 |
+
with open(pf) as f:
|
| 1340 |
+
pid = int(f.read().strip())
|
| 1341 |
+
try:
|
| 1342 |
+
os.kill(pid, signal.SIGTERM)
|
| 1343 |
+
print(f"MiniStack (PID {pid}) on port {port} stopped.")
|
| 1344 |
+
except ProcessLookupError:
|
| 1345 |
+
print(f"MiniStack (PID {pid}) was not running. Cleaning up PID file.")
|
| 1346 |
+
os.remove(pf)
|
| 1347 |
+
return
|
| 1348 |
+
|
| 1349 |
+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
| 1350 |
+
if s.connect_ex(("127.0.0.1", port)) == 0:
|
| 1351 |
+
print(f"ERROR: Port {port} is already in use. Is MiniStack already running?\n"
|
| 1352 |
+
f" Stop it with: ministack --stop\n"
|
| 1353 |
+
f" Or use a different port: GATEWAY_PORT=4567 ministack")
|
| 1354 |
+
raise SystemExit(1)
|
| 1355 |
+
|
| 1356 |
+
if args.detach:
|
| 1357 |
+
log_file = os.path.join(os.environ.get("TMPDIR", "/tmp"), f"ministack-{port}.log")
|
| 1358 |
+
# Keep a reference to the log file handle — Popen inherits the fd so
|
| 1359 |
+
# closing it here would break child process logging. The handle is
|
| 1360 |
+
# intentionally kept open for the lifetime of this (short-lived) parent
|
| 1361 |
+
# process; the OS reclaims it when the parent exits.
|
| 1362 |
+
log_fh = open(log_file, "w")
|
| 1363 |
+
proc = subprocess.Popen(
|
| 1364 |
+
[sys.executable, "-m", "hypercorn", "ministack.app:app",
|
| 1365 |
+
"--bind", f"0.0.0.0:{port}",
|
| 1366 |
+
"--log-level", LOG_LEVEL.upper(),
|
| 1367 |
+
"--keep-alive", "75"],
|
| 1368 |
+
stdout=log_fh,
|
| 1369 |
+
stderr=subprocess.STDOUT,
|
| 1370 |
+
start_new_session=True,
|
| 1371 |
+
)
|
| 1372 |
+
pf = _pid_file(port)
|
| 1373 |
+
with open(pf, "w") as f:
|
| 1374 |
+
f.write(str(proc.pid))
|
| 1375 |
+
print(f"MiniStack started in background (PID {proc.pid}) on port {port}.")
|
| 1376 |
+
print(f" Logs: {log_file}")
|
| 1377 |
+
print(f" Stop: ministack --stop")
|
| 1378 |
+
return
|
| 1379 |
+
|
| 1380 |
+
# Foreground — write PID file and clean up on exit
|
| 1381 |
+
pf = _pid_file(port)
|
| 1382 |
+
with open(pf, "w") as f:
|
| 1383 |
+
f.write(str(os.getpid()))
|
| 1384 |
+
|
| 1385 |
+
def _cleanup(*_):
|
| 1386 |
+
try:
|
| 1387 |
+
os.remove(pf)
|
| 1388 |
+
except OSError:
|
| 1389 |
+
pass
|
| 1390 |
+
|
| 1391 |
+
signal.signal(signal.SIGTERM, lambda *_: (_cleanup(), sys.exit(0)))
|
| 1392 |
+
try:
|
| 1393 |
+
# Suppress health-check access logs at INFO level (reported by @McDoit).
|
| 1394 |
+
# Visible when LOG_LEVEL=DEBUG.
|
| 1395 |
+
class _HealthLogFilter(logging.Filter):
|
| 1396 |
+
def filter(self, record):
|
| 1397 |
+
if LOG_LEVEL == "DEBUG":
|
| 1398 |
+
return True
|
| 1399 |
+
return not any(p in record.getMessage() for p in _HEALTH_PATHS)
|
| 1400 |
+
|
| 1401 |
+
logging.getLogger("hypercorn.access").addFilter(_HealthLogFilter())
|
| 1402 |
+
|
| 1403 |
+
config = HypercornConfig()
|
| 1404 |
+
config.bind = [f"0.0.0.0:{port}"]
|
| 1405 |
+
config.keep_alive_timeout = 75
|
| 1406 |
+
config.loglevel = LOG_LEVEL.upper()
|
| 1407 |
+
|
| 1408 |
+
asyncio.run(hypercorn_serve(app, config))
|
| 1409 |
+
finally:
|
| 1410 |
+
_cleanup()
|
| 1411 |
+
|
| 1412 |
+
|
| 1413 |
+
if __name__ == "__main__":
|
| 1414 |
+
main()
|
aws_infra/ministack/core/__init__.py
ADDED
|
File without changes
|
aws_infra/ministack/core/hypercorn_compat.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Compatibility shims for the hypercorn/h11 stack.
|
| 2 |
+
|
| 3 |
+
h11 serialises ``InformationalResponse`` with an empty reason phrase by
|
| 4 |
+
default, producing ``HTTP/1.1 100 \\r\\n`` on the wire. boto3 < 1.40's
|
| 5 |
+
bundled urllib3 parses this strictly and aborts with ``BadStatusLine``
|
| 6 |
+
when the client is waiting on ``Expect: 100-continue`` (e.g. S3
|
| 7 |
+
``upload_file``). Injecting the standard reason phrase makes the wire
|
| 8 |
+
output ``HTTP/1.1 100 Continue\\r\\n``, which every SDK version accepts.
|
| 9 |
+
|
| 10 |
+
Issue: https://github.com/ministackorg/ministack/issues/389
|
| 11 |
+
Remove this module if h11 ever ships a default reason upstream.
|
| 12 |
+
"""
|
| 13 |
+
|
| 14 |
+
import h11
|
| 15 |
+
|
| 16 |
+
# RFC 9110 § 15.2 informational response reason phrases.
|
| 17 |
+
_DEFAULT_REASONS = {
|
| 18 |
+
100: b"Continue",
|
| 19 |
+
101: b"Switching Protocols",
|
| 20 |
+
102: b"Processing",
|
| 21 |
+
103: b"Early Hints",
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
_original_post_init = h11.InformationalResponse.__post_init__
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def _patched_post_init(self) -> None:
|
| 28 |
+
_original_post_init(self)
|
| 29 |
+
if not self.reason:
|
| 30 |
+
default = _DEFAULT_REASONS.get(self.status_code)
|
| 31 |
+
if default is not None:
|
| 32 |
+
# Frozen dataclass — bypass normal attribute protection.
|
| 33 |
+
object.__setattr__(self, "reason", default)
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
_patched_post_init._ministack_patched = True # type: ignore[attr-defined]
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def install() -> None:
|
| 40 |
+
"""Install the reason-phrase patch. Idempotent."""
|
| 41 |
+
if getattr(h11.InformationalResponse.__post_init__, "_ministack_patched", False):
|
| 42 |
+
return
|
| 43 |
+
h11.InformationalResponse.__post_init__ = _patched_post_init
|
aws_infra/ministack/core/lambda_runtime.py
ADDED
|
@@ -0,0 +1,629 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Lambda warm/cold start worker pool.
|
| 3 |
+
Each function gets a persistent worker process (Python or Node.js) that imports
|
| 4 |
+
the handler once (cold start) and then handles subsequent invocations without
|
| 5 |
+
re-importing (warm).
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
import base64
|
| 9 |
+
import json
|
| 10 |
+
import logging
|
| 11 |
+
import os
|
| 12 |
+
import shutil
|
| 13 |
+
import subprocess
|
| 14 |
+
import sys
|
| 15 |
+
import queue
|
| 16 |
+
import tempfile
|
| 17 |
+
import threading
|
| 18 |
+
import time
|
| 19 |
+
import zipfile
|
| 20 |
+
import queue
|
| 21 |
+
|
| 22 |
+
logger = logging.getLogger("lambda_runtime")
|
| 23 |
+
|
| 24 |
+
_workers: dict = {}
|
| 25 |
+
_lock = threading.Lock()
|
| 26 |
+
|
| 27 |
+
# ---------------------------------------------------------------------------
|
| 28 |
+
# Python worker script (runs inside a persistent subprocess)
|
| 29 |
+
# ---------------------------------------------------------------------------
|
| 30 |
+
|
| 31 |
+
_PYTHON_WORKER_SCRIPT = '''
|
| 32 |
+
import sys, json, importlib, traceback, os
|
| 33 |
+
|
| 34 |
+
def run():
|
| 35 |
+
# Redirect print() to stderr so stdout stays clean for JSON-line protocol
|
| 36 |
+
_real_stdout = sys.stdout
|
| 37 |
+
sys.stdout = sys.stderr
|
| 38 |
+
|
| 39 |
+
init = json.loads(sys.stdin.readline())
|
| 40 |
+
code_dir = init["code_dir"]
|
| 41 |
+
module_name = init["module"]
|
| 42 |
+
handler_name = init["handler"]
|
| 43 |
+
env = init.get("env", {})
|
| 44 |
+
os.environ.update(env)
|
| 45 |
+
sys.path.insert(0, code_dir)
|
| 46 |
+
for _ld in filter(None, os.environ.get("_LAMBDA_LAYERS_DIRS", "").split(os.pathsep)):
|
| 47 |
+
_py = os.path.join(_ld, "python")
|
| 48 |
+
if os.path.isdir(_py):
|
| 49 |
+
sys.path.insert(0, _py)
|
| 50 |
+
sys.path.insert(0, _ld)
|
| 51 |
+
try:
|
| 52 |
+
mod = importlib.import_module(module_name)
|
| 53 |
+
handler_fn = getattr(mod, handler_name)
|
| 54 |
+
_real_stdout.write(json.dumps({"status": "ready", "cold": True}) + "\\n")
|
| 55 |
+
_real_stdout.flush()
|
| 56 |
+
except Exception as e:
|
| 57 |
+
_real_stdout.write(json.dumps({"status": "error", "error": str(e)}) + "\\n")
|
| 58 |
+
_real_stdout.flush()
|
| 59 |
+
return
|
| 60 |
+
|
| 61 |
+
while True:
|
| 62 |
+
line = sys.stdin.readline()
|
| 63 |
+
if not line:
|
| 64 |
+
break
|
| 65 |
+
event = json.loads(line)
|
| 66 |
+
context = type("Context", (), {
|
| 67 |
+
"function_name": init.get("function_name", ""),
|
| 68 |
+
"memory_limit_in_mb": init.get("memory", 128),
|
| 69 |
+
"invoked_function_arn": init.get("arn", ""),
|
| 70 |
+
"aws_request_id": event.pop("_request_id", ""),
|
| 71 |
+
})()
|
| 72 |
+
try:
|
| 73 |
+
result = handler_fn(event, context)
|
| 74 |
+
_real_stdout.write(json.dumps({"status": "ok", "result": result}) + "\\n")
|
| 75 |
+
except Exception as e:
|
| 76 |
+
_real_stdout.write(json.dumps({"status": "error", "error": str(e), "trace": traceback.format_exc()}) + "\\n")
|
| 77 |
+
_real_stdout.flush()
|
| 78 |
+
|
| 79 |
+
run()
|
| 80 |
+
'''
|
| 81 |
+
|
| 82 |
+
# ---------------------------------------------------------------------------
|
| 83 |
+
# Node.js worker script (runs inside a persistent subprocess)
|
| 84 |
+
# ---------------------------------------------------------------------------
|
| 85 |
+
|
| 86 |
+
_NODEJS_WORKER_SCRIPT = r'''
|
| 87 |
+
const readline = require("readline");
|
| 88 |
+
const path = require("path");
|
| 89 |
+
const http = require("http");
|
| 90 |
+
const https = require("https");
|
| 91 |
+
const url = require("url");
|
| 92 |
+
|
| 93 |
+
// Redirect stdout to stderr so stdout stays clean for JSON-line protocol
|
| 94 |
+
const _realStdoutWrite = process.stdout.write.bind(process.stdout);
|
| 95 |
+
const _stderrWrite = process.stderr.write.bind(process.stderr);
|
| 96 |
+
process.stdout.write = function(chunk, encoding, callback) {
|
| 97 |
+
return _stderrWrite(chunk, encoding, callback);
|
| 98 |
+
};
|
| 99 |
+
|
| 100 |
+
function patchAwsSdk() {
|
| 101 |
+
const endpoint = process.env.AWS_ENDPOINT_URL
|
| 102 |
+
|| process.env.LOCALSTACK_ENDPOINT
|
| 103 |
+
|| process.env.MINISTACK_ENDPOINT;
|
| 104 |
+
if (!endpoint) return;
|
| 105 |
+
|
| 106 |
+
const parsed = url.parse(endpoint);
|
| 107 |
+
const msHost = parsed.hostname;
|
| 108 |
+
const msPort = parseInt(parsed.port || "4566", 10);
|
| 109 |
+
|
| 110 |
+
// Patch aws-sdk v2 global config
|
| 111 |
+
try {
|
| 112 |
+
const AWS = require("aws-sdk");
|
| 113 |
+
AWS.config.update({
|
| 114 |
+
endpoint: endpoint,
|
| 115 |
+
region: process.env.AWS_REGION || process.env.FBT_AWS_REGION || "us-east-1",
|
| 116 |
+
s3ForcePathStyle: true,
|
| 117 |
+
accessKeyId: process.env.AWS_ACCESS_KEY_ID || "test",
|
| 118 |
+
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY || "test",
|
| 119 |
+
});
|
| 120 |
+
const origHandle = AWS.NodeHttpClient.prototype.handleRequest;
|
| 121 |
+
AWS.NodeHttpClient.prototype.handleRequest = function(req, opts, cb, errCb) {
|
| 122 |
+
if (req.endpoint && req.endpoint.protocol === "http:") {
|
| 123 |
+
if (opts && opts.agent instanceof https.Agent) {
|
| 124 |
+
opts = Object.assign({}, opts, { agent: new http.Agent({ keepAlive: true }) });
|
| 125 |
+
}
|
| 126 |
+
}
|
| 127 |
+
return origHandle.call(this, req, opts, cb, errCb);
|
| 128 |
+
};
|
| 129 |
+
} catch (_) {}
|
| 130 |
+
|
| 131 |
+
// Patch https.request for bundled SDK
|
| 132 |
+
const origHttpsReq = https.request;
|
| 133 |
+
https.request = function(options, callback) {
|
| 134 |
+
if (typeof options === "string") options = url.parse(options);
|
| 135 |
+
else if (options instanceof url.URL) options = url.parse(options.toString());
|
| 136 |
+
else options = Object.assign({}, options);
|
| 137 |
+
|
| 138 |
+
const host = options.hostname || options.host || "";
|
| 139 |
+
if (host.endsWith(".amazonaws.com") || host.endsWith(".amazonaws.com.cn")) {
|
| 140 |
+
options.protocol = "http:";
|
| 141 |
+
options.hostname = msHost;
|
| 142 |
+
options.host = msHost + ":" + msPort;
|
| 143 |
+
options.port = msPort;
|
| 144 |
+
options.path = options.path || "/";
|
| 145 |
+
if (options.agent instanceof https.Agent) {
|
| 146 |
+
options.agent = new http.Agent({ keepAlive: true });
|
| 147 |
+
} else if (options.agent === undefined) {
|
| 148 |
+
options.agent = new http.Agent({ keepAlive: true });
|
| 149 |
+
}
|
| 150 |
+
delete options._defaultAgent;
|
| 151 |
+
return http.request(options, callback);
|
| 152 |
+
}
|
| 153 |
+
|
| 154 |
+
// Downgrade ES HTTPS to HTTP for local Elasticsearch
|
| 155 |
+
var esHost = process.env.ES_ENDPOINT ? process.env.ES_ENDPOINT.split(":")[0] : null;
|
| 156 |
+
if (esHost && (host === esHost || host.startsWith(esHost + ":"))) {
|
| 157 |
+
var esPort = process.env.ES_ENDPOINT ? parseInt(process.env.ES_ENDPOINT.split(":")[1] || "9200", 10) : 9200;
|
| 158 |
+
options.protocol = "http:";
|
| 159 |
+
options.hostname = esHost;
|
| 160 |
+
options.host = esHost + ":" + esPort;
|
| 161 |
+
options.port = esPort;
|
| 162 |
+
options.rejectUnauthorized = false;
|
| 163 |
+
if (options.agent instanceof https.Agent) {
|
| 164 |
+
options.agent = new http.Agent({ keepAlive: true });
|
| 165 |
+
} else if (options.agent === undefined) {
|
| 166 |
+
options.agent = new http.Agent({ keepAlive: true });
|
| 167 |
+
}
|
| 168 |
+
delete options._defaultAgent;
|
| 169 |
+
return http.request(options, callback);
|
| 170 |
+
}
|
| 171 |
+
|
| 172 |
+
return origHttpsReq.call(https, options, callback);
|
| 173 |
+
};
|
| 174 |
+
https.get = function(options, callback) {
|
| 175 |
+
var req = https.request(options, callback);
|
| 176 |
+
req.end();
|
| 177 |
+
return req;
|
| 178 |
+
};
|
| 179 |
+
}
|
| 180 |
+
|
| 181 |
+
let handlerFn = null;
|
| 182 |
+
|
| 183 |
+
const rl = readline.createInterface({ input: process.stdin, terminal: false });
|
| 184 |
+
let lineNum = 0;
|
| 185 |
+
|
| 186 |
+
rl.on("line", async (line) => {
|
| 187 |
+
lineNum++;
|
| 188 |
+
try {
|
| 189 |
+
const msg = JSON.parse(line);
|
| 190 |
+
|
| 191 |
+
// First line is the init payload
|
| 192 |
+
if (lineNum === 1) {
|
| 193 |
+
const { code_dir, module: modPath, handler: handlerName, env } = msg;
|
| 194 |
+
Object.assign(process.env, env || {});
|
| 195 |
+
process.env.LAMBDA_TASK_ROOT = code_dir;
|
| 196 |
+
process.env.AWS_LAMBDA_FUNCTION_NAME = msg.function_name || process.env.AWS_LAMBDA_FUNCTION_NAME || "";
|
| 197 |
+
process.env.AWS_LAMBDA_FUNCTION_MEMORY_SIZE = String(msg.memory || process.env.AWS_LAMBDA_FUNCTION_MEMORY_SIZE || "128");
|
| 198 |
+
process.env._LAMBDA_FUNCTION_ARN = msg.arn || process.env._LAMBDA_FUNCTION_ARN || "";
|
| 199 |
+
patchAwsSdk();
|
| 200 |
+
try {
|
| 201 |
+
const fullPath = path.resolve(code_dir, modPath);
|
| 202 |
+
let mod;
|
| 203 |
+
let resolvedPath;
|
| 204 |
+
try {
|
| 205 |
+
resolvedPath = require.resolve(fullPath);
|
| 206 |
+
} catch (resolveErr) {
|
| 207 |
+
if (resolveErr.code === "MODULE_NOT_FOUND") {
|
| 208 |
+
const fs = require("fs");
|
| 209 |
+
const mjsPath = fullPath + ".mjs";
|
| 210 |
+
if (fs.existsSync(mjsPath)) {
|
| 211 |
+
resolvedPath = mjsPath;
|
| 212 |
+
} else {
|
| 213 |
+
throw resolveErr;
|
| 214 |
+
}
|
| 215 |
+
} else {
|
| 216 |
+
throw resolveErr;
|
| 217 |
+
}
|
| 218 |
+
}
|
| 219 |
+
try {
|
| 220 |
+
mod = require(resolvedPath);
|
| 221 |
+
} catch (reqErr) {
|
| 222 |
+
if (reqErr.code === "ERR_REQUIRE_ESM") {
|
| 223 |
+
const { pathToFileURL } = require("url");
|
| 224 |
+
mod = await import(pathToFileURL(resolvedPath).href);
|
| 225 |
+
} else {
|
| 226 |
+
throw reqErr;
|
| 227 |
+
}
|
| 228 |
+
}
|
| 229 |
+
handlerFn = mod[handlerName] || (mod.default && mod.default[handlerName]) || mod.default;
|
| 230 |
+
if (typeof handlerFn !== "function") {
|
| 231 |
+
_realStdoutWrite(JSON.stringify({
|
| 232 |
+
status: "error",
|
| 233 |
+
error: `Handler ${handlerName} is not a function in ${modPath}`
|
| 234 |
+
}) + "\n");
|
| 235 |
+
return;
|
| 236 |
+
}
|
| 237 |
+
_realStdoutWrite(JSON.stringify({ status: "ready", cold: true }) + "\n");
|
| 238 |
+
} catch (e) {
|
| 239 |
+
_realStdoutWrite(JSON.stringify({
|
| 240 |
+
status: "error", error: e.message
|
| 241 |
+
}) + "\n");
|
| 242 |
+
}
|
| 243 |
+
return;
|
| 244 |
+
}
|
| 245 |
+
|
| 246 |
+
// Subsequent lines are event invocations
|
| 247 |
+
const event = msg;
|
| 248 |
+
const context = {
|
| 249 |
+
functionName: event._function_name || "",
|
| 250 |
+
memoryLimitInMB: event._memory || "128",
|
| 251 |
+
invokedFunctionArn: event._arn || "",
|
| 252 |
+
awsRequestId: event._request_id || "",
|
| 253 |
+
getRemainingTimeInMillis: () => 300000,
|
| 254 |
+
done: () => {},
|
| 255 |
+
succeed: () => {},
|
| 256 |
+
fail: () => {},
|
| 257 |
+
};
|
| 258 |
+
delete event._request_id;
|
| 259 |
+
delete event._function_name;
|
| 260 |
+
delete event._memory;
|
| 261 |
+
delete event._arn;
|
| 262 |
+
|
| 263 |
+
try {
|
| 264 |
+
let settled = false;
|
| 265 |
+
const settle = (err, res) => {
|
| 266 |
+
if (settled) return;
|
| 267 |
+
settled = true;
|
| 268 |
+
if (err) {
|
| 269 |
+
_realStdoutWrite(JSON.stringify({
|
| 270 |
+
status: "error", error: String(err.message || err), trace: err.stack || ""
|
| 271 |
+
}) + "\n");
|
| 272 |
+
} else {
|
| 273 |
+
_realStdoutWrite(JSON.stringify({ status: "ok", result: res }) + "\n");
|
| 274 |
+
}
|
| 275 |
+
};
|
| 276 |
+
const callback = (err, res) => settle(err, res);
|
| 277 |
+
context.done = (err, res) => settle(err, res);
|
| 278 |
+
context.succeed = (res) => settle(null, res);
|
| 279 |
+
context.fail = (err) => settle(err || new Error("fail"));
|
| 280 |
+
|
| 281 |
+
const result = handlerFn(event, context, callback);
|
| 282 |
+
if (result && typeof result.then === "function") {
|
| 283 |
+
// Async/Promise handler
|
| 284 |
+
result.then(res => settle(null, res), err => settle(err));
|
| 285 |
+
} else if (handlerFn.length < 3 && result !== undefined) {
|
| 286 |
+
// Sync handler that doesn't accept callback and returned a value
|
| 287 |
+
settle(null, result);
|
| 288 |
+
}
|
| 289 |
+
// If handler accepts callback (arity >= 3) or returned undefined,
|
| 290 |
+
// we wait for callback/context.done/context.succeed/context.fail
|
| 291 |
+
} catch (e) {
|
| 292 |
+
_realStdoutWrite(JSON.stringify({
|
| 293 |
+
status: "error", error: e.message, trace: e.stack
|
| 294 |
+
}) + "\n");
|
| 295 |
+
}
|
| 296 |
+
} catch (e) {
|
| 297 |
+
_realStdoutWrite(JSON.stringify({
|
| 298 |
+
status: "error", error: "JSON parse error: " + e.message
|
| 299 |
+
}) + "\n");
|
| 300 |
+
}
|
| 301 |
+
});
|
| 302 |
+
'''
|
| 303 |
+
|
| 304 |
+
|
| 305 |
+
def _detect_runtime_binary(runtime: str) -> tuple[str, str]:
|
| 306 |
+
"""Return (binary, worker_script_content) for the given Lambda runtime string."""
|
| 307 |
+
if runtime.startswith("python"):
|
| 308 |
+
return sys.executable, _PYTHON_WORKER_SCRIPT
|
| 309 |
+
if runtime.startswith("nodejs"):
|
| 310 |
+
return "node", _NODEJS_WORKER_SCRIPT
|
| 311 |
+
return "", ""
|
| 312 |
+
|
| 313 |
+
|
| 314 |
+
def _worker_script_extension(runtime: str) -> str:
|
| 315 |
+
if runtime.startswith("python"):
|
| 316 |
+
return ".py"
|
| 317 |
+
if runtime.startswith("nodejs"):
|
| 318 |
+
return ".js"
|
| 319 |
+
return ".py"
|
| 320 |
+
|
| 321 |
+
|
| 322 |
+
class Worker:
|
| 323 |
+
def __init__(self, func_name: str, config: dict, code_zip: bytes):
|
| 324 |
+
self.func_name = func_name
|
| 325 |
+
self.config = config
|
| 326 |
+
self.code_zip = code_zip
|
| 327 |
+
self._proc = None
|
| 328 |
+
self._tmpdir = None
|
| 329 |
+
self._lock = threading.Lock()
|
| 330 |
+
self._cold = True
|
| 331 |
+
self._start_time = None
|
| 332 |
+
self._stderr_queue: queue.Queue = queue.Queue()
|
| 333 |
+
self._stderr_thread: threading.Thread | None = None
|
| 334 |
+
|
| 335 |
+
def _read_stderr(self):
|
| 336 |
+
"""Background daemon thread: continuously drain stderr into queue."""
|
| 337 |
+
try:
|
| 338 |
+
for line in self._proc.stderr:
|
| 339 |
+
self._stderr_queue.put(line.rstrip("\n"))
|
| 340 |
+
except Exception:
|
| 341 |
+
pass
|
| 342 |
+
|
| 343 |
+
def _spawn(self):
|
| 344 |
+
"""Extract zip and start worker process."""
|
| 345 |
+
self._tmpdir = tempfile.mkdtemp(prefix=f"ministack-lambda-{self.func_name}-")
|
| 346 |
+
runtime = self.config.get("Runtime", "python3.12")
|
| 347 |
+
binary, worker_script = _detect_runtime_binary(runtime)
|
| 348 |
+
if not binary:
|
| 349 |
+
raise RuntimeError(f"Unsupported runtime: {runtime}")
|
| 350 |
+
|
| 351 |
+
ext = _worker_script_extension(runtime)
|
| 352 |
+
worker_path = os.path.join(self._tmpdir, f"_worker{ext}")
|
| 353 |
+
with open(worker_path, "w") as f:
|
| 354 |
+
f.write(worker_script)
|
| 355 |
+
|
| 356 |
+
code_dir = os.path.join(self._tmpdir, "code")
|
| 357 |
+
os.makedirs(code_dir)
|
| 358 |
+
with open(os.path.join(self._tmpdir, "code.zip"), "wb") as f:
|
| 359 |
+
f.write(self.code_zip)
|
| 360 |
+
with zipfile.ZipFile(os.path.join(self._tmpdir, "code.zip")) as zf:
|
| 361 |
+
zf.extractall(code_dir)
|
| 362 |
+
|
| 363 |
+
# Extract Lambda Layers and build search paths for the worker process.
|
| 364 |
+
# This mirrors the layer handling in lambda_svc._execute_function_local().
|
| 365 |
+
layers_dirs: list[str] = []
|
| 366 |
+
layer_refs = self.config.get("Layers", [])
|
| 367 |
+
if layer_refs:
|
| 368 |
+
from ministack.services.lambda_svc import _resolve_layer_zip
|
| 369 |
+
for layer_ref in layer_refs:
|
| 370 |
+
layer_arn = layer_ref if isinstance(layer_ref, str) else layer_ref.get("Arn", "")
|
| 371 |
+
if not layer_arn:
|
| 372 |
+
continue
|
| 373 |
+
try:
|
| 374 |
+
layer_data = _resolve_layer_zip(layer_arn)
|
| 375 |
+
if layer_data:
|
| 376 |
+
layer_dir = os.path.join(self._tmpdir, f"layer_{len(layers_dirs)}")
|
| 377 |
+
os.makedirs(layer_dir)
|
| 378 |
+
lzip = os.path.join(self._tmpdir, f"layer_{len(layers_dirs)}.zip")
|
| 379 |
+
try:
|
| 380 |
+
with open(lzip, "wb") as lf:
|
| 381 |
+
lf.write(layer_data)
|
| 382 |
+
with zipfile.ZipFile(lzip) as lzf:
|
| 383 |
+
# Validate paths to prevent zip-slip attacks
|
| 384 |
+
for member in lzf.namelist():
|
| 385 |
+
resolved = os.path.realpath(os.path.join(layer_dir, member))
|
| 386 |
+
if not resolved.startswith(os.path.realpath(layer_dir) + os.sep) and resolved != os.path.realpath(layer_dir):
|
| 387 |
+
raise RuntimeError(f"Zip entry escapes target dir: {member}")
|
| 388 |
+
lzf.extractall(layer_dir)
|
| 389 |
+
except (OSError, zipfile.BadZipFile, zipfile.LargeFileError) as e:
|
| 390 |
+
logger.error("Failed to extract layer %s", layer_arn, exc_info=True)
|
| 391 |
+
raise RuntimeError(f"Failed to extract layer {layer_arn}") from e
|
| 392 |
+
layers_dirs.append(layer_dir)
|
| 393 |
+
except RuntimeError:
|
| 394 |
+
raise
|
| 395 |
+
except Exception as e:
|
| 396 |
+
logger.error("Unexpected error resolving layer %s: %s", layer_arn, e)
|
| 397 |
+
raise RuntimeError(f"Failed to resolve layer {layer_arn}") from e
|
| 398 |
+
|
| 399 |
+
# Symlink layer node_modules packages into the code directory so that
|
| 400 |
+
# Node.js ESM import() can resolve them via ancestor-tree lookup.
|
| 401 |
+
# ESM does not use NODE_PATH, so packages must be physically reachable
|
| 402 |
+
# from the handler file's directory tree.
|
| 403 |
+
if layers_dirs and runtime.startswith("nodejs"):
|
| 404 |
+
code_nm = os.path.join(code_dir, "node_modules")
|
| 405 |
+
os.makedirs(code_nm, exist_ok=True)
|
| 406 |
+
for ld in layers_dirs:
|
| 407 |
+
layer_nm = os.path.join(ld, "nodejs", "node_modules")
|
| 408 |
+
if os.path.isdir(layer_nm):
|
| 409 |
+
for pkg in os.listdir(layer_nm):
|
| 410 |
+
src = os.path.join(layer_nm, pkg)
|
| 411 |
+
dst = os.path.join(code_nm, pkg)
|
| 412 |
+
if not os.path.exists(dst):
|
| 413 |
+
os.symlink(src, dst)
|
| 414 |
+
|
| 415 |
+
handler = self.config.get("Handler", "index.handler")
|
| 416 |
+
module_name, handler_name = handler.rsplit(".", 1)
|
| 417 |
+
env_vars = self.config.get("Environment", {}).get("Variables", {})
|
| 418 |
+
spawn_env = {**os.environ, **env_vars}
|
| 419 |
+
# Restore the internal endpoint URL so Lambda SDK calls reach
|
| 420 |
+
# this MiniStack instance, not a host-mapped port that may be
|
| 421 |
+
# unreachable from inside the container.
|
| 422 |
+
for key in ("AWS_ENDPOINT_URL", "LOCALSTACK_HOSTNAME"):
|
| 423 |
+
if key in os.environ:
|
| 424 |
+
spawn_env[key] = os.environ[key]
|
| 425 |
+
spawn_env.setdefault("LAMBDA_TASK_ROOT", code_dir)
|
| 426 |
+
spawn_env.setdefault("AWS_LAMBDA_FUNCTION_NAME", self.config.get("FunctionName", ""))
|
| 427 |
+
spawn_env.setdefault("AWS_LAMBDA_FUNCTION_MEMORY_SIZE", str(self.config.get("MemorySize", 128)))
|
| 428 |
+
spawn_env.setdefault("_LAMBDA_FUNCTION_ARN", self.config.get("FunctionArn", ""))
|
| 429 |
+
|
| 430 |
+
# Set layer paths so worker runtimes can find packages from extracted layers.
|
| 431 |
+
# _LAMBDA_LAYERS_DIRS is consumed by the Python worker; Node.js layer resolution
|
| 432 |
+
# is handled via NODE_PATH populated from each layer's nodejs paths below.
|
| 433 |
+
if layers_dirs:
|
| 434 |
+
spawn_env["_LAMBDA_LAYERS_DIRS"] = os.pathsep.join(layers_dirs)
|
| 435 |
+
# NODE_PATH is used by the CJS require() resolver in Node.js workers.
|
| 436 |
+
# ESM import() does not use NODE_PATH — layer packages are instead
|
| 437 |
+
# symlinked into code/node_modules/ above for ancestor-tree resolution.
|
| 438 |
+
node_paths = []
|
| 439 |
+
for ld in layers_dirs:
|
| 440 |
+
nm = os.path.join(ld, "nodejs", "node_modules")
|
| 441 |
+
if os.path.isdir(nm):
|
| 442 |
+
node_paths.append(nm)
|
| 443 |
+
nj = os.path.join(ld, "nodejs")
|
| 444 |
+
if os.path.isdir(nj):
|
| 445 |
+
node_paths.append(nj)
|
| 446 |
+
if node_paths:
|
| 447 |
+
existing = spawn_env.get("NODE_PATH")
|
| 448 |
+
if existing:
|
| 449 |
+
spawn_env["NODE_PATH"] = os.pathsep.join(node_paths + [existing])
|
| 450 |
+
else:
|
| 451 |
+
spawn_env["NODE_PATH"] = os.pathsep.join(node_paths)
|
| 452 |
+
|
| 453 |
+
self._proc = subprocess.Popen(
|
| 454 |
+
[binary, worker_path],
|
| 455 |
+
stdin=subprocess.PIPE,
|
| 456 |
+
stdout=subprocess.PIPE,
|
| 457 |
+
stderr=subprocess.PIPE,
|
| 458 |
+
text=True,
|
| 459 |
+
bufsize=1,
|
| 460 |
+
env=spawn_env,
|
| 461 |
+
)
|
| 462 |
+
|
| 463 |
+
self._stderr_queue = queue.Queue()
|
| 464 |
+
self._stderr_thread = threading.Thread(
|
| 465 |
+
target=self._read_stderr, daemon=True, name=f"stderr-{self.func_name}"
|
| 466 |
+
)
|
| 467 |
+
self._stderr_thread.start()
|
| 468 |
+
|
| 469 |
+
init = {
|
| 470 |
+
"code_dir": code_dir,
|
| 471 |
+
"module": module_name,
|
| 472 |
+
"handler": handler_name,
|
| 473 |
+
"env": env_vars,
|
| 474 |
+
"function_name": self.config.get("FunctionName", ""),
|
| 475 |
+
"memory": self.config.get("MemorySize", 128),
|
| 476 |
+
"arn": self.config.get("FunctionArn", ""),
|
| 477 |
+
}
|
| 478 |
+
self._proc.stdin.write(json.dumps(init) + "\n")
|
| 479 |
+
self._proc.stdin.flush()
|
| 480 |
+
|
| 481 |
+
# Read init response, skipping non-JSON lines (stray console output from modules)
|
| 482 |
+
response = None
|
| 483 |
+
for _ in range(200):
|
| 484 |
+
response_line = self._proc.stdout.readline()
|
| 485 |
+
if not response_line:
|
| 486 |
+
stderr_out = ""
|
| 487 |
+
try:
|
| 488 |
+
stderr_out = self._proc.stderr.read(4096)
|
| 489 |
+
except Exception:
|
| 490 |
+
pass
|
| 491 |
+
raise RuntimeError(f"Worker process exited immediately. stderr: {stderr_out}")
|
| 492 |
+
response_line = response_line.strip()
|
| 493 |
+
if not response_line or not response_line.startswith("{"):
|
| 494 |
+
continue
|
| 495 |
+
try:
|
| 496 |
+
response = json.loads(response_line)
|
| 497 |
+
break
|
| 498 |
+
except json.JSONDecodeError:
|
| 499 |
+
continue
|
| 500 |
+
if response is None:
|
| 501 |
+
raise RuntimeError("No JSON init response from worker")
|
| 502 |
+
if response.get("status") != "ready":
|
| 503 |
+
raise RuntimeError(f"Worker init failed: {response.get('error')}")
|
| 504 |
+
|
| 505 |
+
self._start_time = time.time()
|
| 506 |
+
logger.info("Lambda worker spawned for %s (%s, cold start)", self.func_name, runtime)
|
| 507 |
+
|
| 508 |
+
def _drain_stderr(self) -> str:
|
| 509 |
+
"""Collect all currently available stderr lines (non-blocking)."""
|
| 510 |
+
lines = []
|
| 511 |
+
try:
|
| 512 |
+
while True:
|
| 513 |
+
lines.append(self._stderr_queue.get_nowait())
|
| 514 |
+
except queue.Empty:
|
| 515 |
+
pass
|
| 516 |
+
return "\n".join(lines)
|
| 517 |
+
|
| 518 |
+
def invoke(self, event: dict, request_id: str) -> dict:
|
| 519 |
+
with self._lock:
|
| 520 |
+
cold = self._cold
|
| 521 |
+
|
| 522 |
+
if self._proc is None or self._proc.poll() is not None:
|
| 523 |
+
self._spawn()
|
| 524 |
+
cold = True
|
| 525 |
+
self._cold = False
|
| 526 |
+
else:
|
| 527 |
+
cold = False
|
| 528 |
+
|
| 529 |
+
timeout = self.config.get("Timeout", 30)
|
| 530 |
+
event["_request_id"] = request_id
|
| 531 |
+
result_box: list = []
|
| 532 |
+
|
| 533 |
+
def _read_response():
|
| 534 |
+
try:
|
| 535 |
+
self._proc.stdin.write(json.dumps(event) + "\n")
|
| 536 |
+
self._proc.stdin.flush()
|
| 537 |
+
for _ in range(200):
|
| 538 |
+
response_line = self._proc.stdout.readline()
|
| 539 |
+
if not response_line:
|
| 540 |
+
result_box.append({"status": "error", "error": "Worker process died"})
|
| 541 |
+
return
|
| 542 |
+
response_line = response_line.strip()
|
| 543 |
+
if not response_line:
|
| 544 |
+
continue
|
| 545 |
+
if response_line.startswith("{"):
|
| 546 |
+
try:
|
| 547 |
+
response = json.loads(response_line)
|
| 548 |
+
result_box.append(response)
|
| 549 |
+
return
|
| 550 |
+
except json.JSONDecodeError:
|
| 551 |
+
continue
|
| 552 |
+
result_box.append({"status": "error", "error": "No JSON response from worker after 200 lines"})
|
| 553 |
+
except Exception as e:
|
| 554 |
+
result_box.append({"status": "error", "error": str(e)})
|
| 555 |
+
|
| 556 |
+
reader = threading.Thread(target=_read_response, daemon=True)
|
| 557 |
+
reader.start()
|
| 558 |
+
reader.join(timeout=timeout)
|
| 559 |
+
|
| 560 |
+
if reader.is_alive():
|
| 561 |
+
# Timeout — kill the worker process
|
| 562 |
+
logger.warning("Lambda %s timed out after %ds — killing worker", self.func_name, timeout)
|
| 563 |
+
if self._proc:
|
| 564 |
+
self._proc.kill()
|
| 565 |
+
self._proc = None
|
| 566 |
+
return {
|
| 567 |
+
"status": "error",
|
| 568 |
+
"error": f"Task timed out after {timeout}.00 seconds",
|
| 569 |
+
"cold_start": cold,
|
| 570 |
+
"log": self._drain_stderr(),
|
| 571 |
+
}
|
| 572 |
+
|
| 573 |
+
if not result_box:
|
| 574 |
+
self._proc = None
|
| 575 |
+
return {"status": "error", "error": "Worker returned no response", "cold_start": cold}
|
| 576 |
+
|
| 577 |
+
response = result_box[0]
|
| 578 |
+
if response.get("status") == "error":
|
| 579 |
+
self._proc = None
|
| 580 |
+
response["cold_start"] = cold
|
| 581 |
+
response["log"] = self._drain_stderr()
|
| 582 |
+
return response
|
| 583 |
+
|
| 584 |
+
def kill(self):
|
| 585 |
+
if self._proc and self._proc.poll() is None:
|
| 586 |
+
self._proc.terminate()
|
| 587 |
+
self._proc = None
|
| 588 |
+
if self._tmpdir and os.path.exists(self._tmpdir):
|
| 589 |
+
shutil.rmtree(self._tmpdir, ignore_errors=True)
|
| 590 |
+
|
| 591 |
+
|
| 592 |
+
def get_or_create_worker(func_name: str, config: dict, code_zip: bytes,
|
| 593 |
+
qualifier: str = "$LATEST") -> Worker:
|
| 594 |
+
key = f"{func_name}:{qualifier}"
|
| 595 |
+
with _lock:
|
| 596 |
+
worker = _workers.get(key)
|
| 597 |
+
if worker is not None:
|
| 598 |
+
return worker
|
| 599 |
+
worker = Worker(func_name, config, code_zip)
|
| 600 |
+
_workers[key] = worker
|
| 601 |
+
return worker
|
| 602 |
+
|
| 603 |
+
|
| 604 |
+
def invalidate_worker(func_name: str, qualifier: str = None):
|
| 605 |
+
"""Kill and remove workers for a function.
|
| 606 |
+
|
| 607 |
+
If qualifier is provided, only kill that specific version/alias worker.
|
| 608 |
+
Otherwise kill all workers for the function (used on delete).
|
| 609 |
+
"""
|
| 610 |
+
with _lock:
|
| 611 |
+
if qualifier is not None:
|
| 612 |
+
key = f"{func_name}:{qualifier}"
|
| 613 |
+
worker = _workers.pop(key, None)
|
| 614 |
+
if worker:
|
| 615 |
+
worker.kill()
|
| 616 |
+
else:
|
| 617 |
+
to_remove = [k for k in _workers if k.startswith(f"{func_name}:")]
|
| 618 |
+
for k in to_remove:
|
| 619 |
+
worker = _workers.pop(k, None)
|
| 620 |
+
if worker:
|
| 621 |
+
worker.kill()
|
| 622 |
+
|
| 623 |
+
|
| 624 |
+
def reset():
|
| 625 |
+
"""Terminate all warm workers, clean up temp dirs, and clear the pool."""
|
| 626 |
+
with _lock:
|
| 627 |
+
for worker in list(_workers.values()):
|
| 628 |
+
worker.kill()
|
| 629 |
+
_workers.clear()
|
aws_infra/ministack/core/persistence.py
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
State persistence for MiniStack services.
|
| 3 |
+
When PERSIST_STATE=1, service state is saved to STATE_DIR on shutdown
|
| 4 |
+
and reloaded on startup.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
import ast
|
| 8 |
+
import json
|
| 9 |
+
import logging
|
| 10 |
+
import os
|
| 11 |
+
import tempfile
|
| 12 |
+
|
| 13 |
+
from ministack.core.responses import AccountScopedDict
|
| 14 |
+
|
| 15 |
+
logger = logging.getLogger("persistence")
|
| 16 |
+
|
| 17 |
+
PERSIST_STATE = os.environ.get("PERSIST_STATE", "0") == "1"
|
| 18 |
+
STATE_DIR = os.environ.get("STATE_DIR", "/tmp/ministack-state")
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def _json_default(obj):
|
| 22 |
+
"""JSON encoder fallback for AccountScopedDict and tuple keys."""
|
| 23 |
+
if isinstance(obj, AccountScopedDict):
|
| 24 |
+
# Serialize all accounts' data with string keys
|
| 25 |
+
result = {}
|
| 26 |
+
for k, v in obj._data.items():
|
| 27 |
+
# k is (account_id, original_key) tuple
|
| 28 |
+
result[f"{k[0]}\x00{k[1]!r}"] = v
|
| 29 |
+
return {"__scoped__": True, "data": result}
|
| 30 |
+
raise TypeError(f"Object of type {type(obj).__name__} is not JSON serializable")
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def _json_object_hook(obj):
|
| 34 |
+
"""JSON decoder hook to restore AccountScopedDict from serialized form."""
|
| 35 |
+
if obj.get("__scoped__"):
|
| 36 |
+
asd = AccountScopedDict()
|
| 37 |
+
for k, v in obj["data"].items():
|
| 38 |
+
account_id, key_repr = k.split("\x00", 1)
|
| 39 |
+
# Restore the original key (was serialized with repr())
|
| 40 |
+
try:
|
| 41 |
+
original_key = ast.literal_eval(key_repr)
|
| 42 |
+
except (ValueError, SyntaxError):
|
| 43 |
+
original_key = key_repr
|
| 44 |
+
asd._data[(account_id, original_key)] = v
|
| 45 |
+
return asd
|
| 46 |
+
return obj
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def save_state(service: str, data: dict) -> None:
|
| 50 |
+
if not PERSIST_STATE:
|
| 51 |
+
return
|
| 52 |
+
try:
|
| 53 |
+
os.makedirs(STATE_DIR, exist_ok=True)
|
| 54 |
+
path = os.path.join(STATE_DIR, f"{service}.json")
|
| 55 |
+
tmp = path + ".tmp"
|
| 56 |
+
try:
|
| 57 |
+
with open(tmp, "w") as f:
|
| 58 |
+
json.dump(data, f, default=_json_default)
|
| 59 |
+
os.replace(tmp, path)
|
| 60 |
+
except BaseException:
|
| 61 |
+
# Clean up temp file on any failure to avoid stale partial writes
|
| 62 |
+
try:
|
| 63 |
+
os.remove(tmp)
|
| 64 |
+
except OSError:
|
| 65 |
+
pass
|
| 66 |
+
raise
|
| 67 |
+
logger.info("Persistence: saved %s state to %s", service, path)
|
| 68 |
+
except Exception as e:
|
| 69 |
+
logger.error("Persistence: failed to save %s: %s", service, e)
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
def load_state(service: str) -> dict | None:
|
| 73 |
+
if not PERSIST_STATE:
|
| 74 |
+
return None
|
| 75 |
+
path = os.path.join(STATE_DIR, f"{service}.json")
|
| 76 |
+
if not os.path.exists(path):
|
| 77 |
+
return None
|
| 78 |
+
try:
|
| 79 |
+
with open(path) as f:
|
| 80 |
+
data = json.load(f, object_hook=_json_object_hook)
|
| 81 |
+
logger.info("Persistence: loaded %s state from %s", service, path)
|
| 82 |
+
return data
|
| 83 |
+
except (json.JSONDecodeError, OSError) as e:
|
| 84 |
+
logger.error("Persistence: failed to load %s: %s", service, e)
|
| 85 |
+
return None
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
def save_all(services: dict) -> None:
|
| 89 |
+
"""Save all service states. services = {name: get_state_fn}"""
|
| 90 |
+
for name, get_state in services.items():
|
| 91 |
+
try:
|
| 92 |
+
save_state(name, get_state())
|
| 93 |
+
except Exception as e:
|
| 94 |
+
logger.error("Persistence: error getting state for %s: %s", name, e)
|
aws_infra/ministack/core/responses.py
ADDED
|
@@ -0,0 +1,266 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
AWS Response formatting utilities.
|
| 3 |
+
Handles XML responses (S3, SQS, SNS, IAM, STS, CloudWatch) and
|
| 4 |
+
JSON responses (DynamoDB, Lambda, SecretsManager, CloudWatch Logs).
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
import contextvars
|
| 8 |
+
import hashlib
|
| 9 |
+
import json
|
| 10 |
+
import os
|
| 11 |
+
import re
|
| 12 |
+
import uuid
|
| 13 |
+
from datetime import datetime, timezone
|
| 14 |
+
from xml.etree.ElementTree import Element, SubElement, tostring
|
| 15 |
+
|
| 16 |
+
# Request-scoped account ID for multi-tenancy.
|
| 17 |
+
# Set per-request in app.py from the Authorization header.
|
| 18 |
+
_request_account_id: contextvars.ContextVar[str] = contextvars.ContextVar(
|
| 19 |
+
"_request_account_id",
|
| 20 |
+
default=os.environ.get("MINISTACK_ACCOUNT_ID", "000000000000"),
|
| 21 |
+
)
|
| 22 |
+
|
| 23 |
+
_12_DIGIT_RE = re.compile(r"^\d{12}$")
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def set_request_account_id(access_key_id: str) -> None:
|
| 27 |
+
"""Set the account ID for the current request from the access key.
|
| 28 |
+
If the access key is a 12-digit number, use it as the account ID.
|
| 29 |
+
Otherwise fall back to the MINISTACK_ACCOUNT_ID env var or 000000000000."""
|
| 30 |
+
if access_key_id and _12_DIGIT_RE.match(access_key_id):
|
| 31 |
+
_request_account_id.set(access_key_id)
|
| 32 |
+
else:
|
| 33 |
+
_request_account_id.set(
|
| 34 |
+
os.environ.get("MINISTACK_ACCOUNT_ID", "000000000000")
|
| 35 |
+
)
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def get_account_id() -> str:
|
| 39 |
+
"""Return the account ID for the current request."""
|
| 40 |
+
return _request_account_id.get()
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
# Request-scoped region. Set per-request in app.py from the SigV4 Authorization
|
| 44 |
+
# header's Credential scope. Fixes #398 (CDK bootstrap resources inheriting the
|
| 45 |
+
# wrong region when MINISTACK_REGION differs from the caller's AWS_REGION).
|
| 46 |
+
_request_region: contextvars.ContextVar[str] = contextvars.ContextVar(
|
| 47 |
+
"_request_region",
|
| 48 |
+
default=os.environ.get("MINISTACK_REGION", "us-east-1"),
|
| 49 |
+
)
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def set_request_region(region: str | None) -> None:
|
| 53 |
+
"""Set the region for the current request. Falls back to MINISTACK_REGION /
|
| 54 |
+
``us-east-1`` when the caller supplies nothing."""
|
| 55 |
+
if region:
|
| 56 |
+
_request_region.set(region)
|
| 57 |
+
else:
|
| 58 |
+
_request_region.set(os.environ.get("MINISTACK_REGION", "us-east-1"))
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def get_region() -> str:
|
| 62 |
+
"""Return the region for the current request."""
|
| 63 |
+
return _request_region.get()
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
class AccountScopedDict:
|
| 67 |
+
"""A dict-like container that namespaces keys by the current request's account ID.
|
| 68 |
+
|
| 69 |
+
Stores data as ``{account_id}\\x00{key}`` internally so that identical
|
| 70 |
+
resource names in different accounts never collide. All standard dict
|
| 71 |
+
operations (get, set, delete, iteration, ``in``, ``len``) are scoped to the
|
| 72 |
+
caller's account automatically via ``get_account_id()``.
|
| 73 |
+
|
| 74 |
+
This is a drop-in replacement for ``dict`` in service module-level state,
|
| 75 |
+
e.g. ``_roles = AccountScopedDict()`` instead of ``_roles: dict = {}``.
|
| 76 |
+
"""
|
| 77 |
+
|
| 78 |
+
__slots__ = ("_data",)
|
| 79 |
+
|
| 80 |
+
def __init__(self):
|
| 81 |
+
self._data: dict = {}
|
| 82 |
+
|
| 83 |
+
# -- internal helpers --------------------------------------------------
|
| 84 |
+
|
| 85 |
+
def _scoped(self, key):
|
| 86 |
+
return (get_account_id(), key)
|
| 87 |
+
|
| 88 |
+
def _unscope(self, scoped_key):
|
| 89 |
+
return scoped_key[1]
|
| 90 |
+
|
| 91 |
+
def _prefix(self):
|
| 92 |
+
return get_account_id()
|
| 93 |
+
|
| 94 |
+
def _is_mine(self, scoped_key):
|
| 95 |
+
return scoped_key[0] == get_account_id()
|
| 96 |
+
|
| 97 |
+
# -- dict interface ----------------------------------------------------
|
| 98 |
+
|
| 99 |
+
def __setitem__(self, key, value):
|
| 100 |
+
self._data[self._scoped(key)] = value
|
| 101 |
+
|
| 102 |
+
def __getitem__(self, key):
|
| 103 |
+
return self._data[self._scoped(key)]
|
| 104 |
+
|
| 105 |
+
def __delitem__(self, key):
|
| 106 |
+
del self._data[self._scoped(key)]
|
| 107 |
+
|
| 108 |
+
def __contains__(self, key):
|
| 109 |
+
return self._scoped(key) in self._data
|
| 110 |
+
|
| 111 |
+
def __len__(self):
|
| 112 |
+
return sum(1 for k in self._data if self._is_mine(k))
|
| 113 |
+
|
| 114 |
+
def __bool__(self):
|
| 115 |
+
return any(self._is_mine(k) for k in self._data)
|
| 116 |
+
|
| 117 |
+
def __iter__(self):
|
| 118 |
+
for k in self._data:
|
| 119 |
+
if self._is_mine(k):
|
| 120 |
+
yield self._unscope(k)
|
| 121 |
+
|
| 122 |
+
def get(self, key, default=None):
|
| 123 |
+
return self._data.get(self._scoped(key), default)
|
| 124 |
+
|
| 125 |
+
def pop(self, key, *args):
|
| 126 |
+
return self._data.pop(self._scoped(key), *args)
|
| 127 |
+
|
| 128 |
+
def setdefault(self, key, default=None):
|
| 129 |
+
return self._data.setdefault(self._scoped(key), default)
|
| 130 |
+
|
| 131 |
+
def keys(self):
|
| 132 |
+
return [self._unscope(k) for k in self._data if self._is_mine(k)]
|
| 133 |
+
|
| 134 |
+
def values(self):
|
| 135 |
+
return [v for k, v in self._data.items() if self._is_mine(k)]
|
| 136 |
+
|
| 137 |
+
def items(self):
|
| 138 |
+
return [(self._unscope(k), v) for k, v in self._data.items() if self._is_mine(k)]
|
| 139 |
+
|
| 140 |
+
def update(self, other):
|
| 141 |
+
if isinstance(other, AccountScopedDict):
|
| 142 |
+
self._data.update(other._data)
|
| 143 |
+
elif isinstance(other, dict):
|
| 144 |
+
for k, v in other.items():
|
| 145 |
+
self[k] = v
|
| 146 |
+
|
| 147 |
+
def clear(self):
|
| 148 |
+
"""Clear ALL accounts' data (used by reset)."""
|
| 149 |
+
self._data.clear()
|
| 150 |
+
|
| 151 |
+
def to_dict(self):
|
| 152 |
+
"""Convert ALL accounts' data to a plain dict for serialization.
|
| 153 |
+
Keys are stored as (account_id, original_key) tuples."""
|
| 154 |
+
return dict(self._data)
|
| 155 |
+
|
| 156 |
+
@classmethod
|
| 157 |
+
def from_dict(cls, data):
|
| 158 |
+
"""Restore from a plain dict produced by to_dict()."""
|
| 159 |
+
obj = cls()
|
| 160 |
+
obj._data = dict(data)
|
| 161 |
+
return obj
|
| 162 |
+
|
| 163 |
+
def __repr__(self):
|
| 164 |
+
return f"AccountScopedDict({dict(self.items())})"
|
| 165 |
+
|
| 166 |
+
|
| 167 |
+
def xml_response(root_tag: str, namespace: str, children: dict, status: int = 200) -> tuple:
|
| 168 |
+
"""Build an AWS-style XML response."""
|
| 169 |
+
root = Element(root_tag, xmlns=namespace)
|
| 170 |
+
_dict_to_xml(root, children)
|
| 171 |
+
|
| 172 |
+
# Add RequestId in ResponseMetadata
|
| 173 |
+
metadata = SubElement(root, "ResponseMetadata")
|
| 174 |
+
req_id = SubElement(metadata, "RequestId")
|
| 175 |
+
req_id.text = str(uuid.uuid4())
|
| 176 |
+
|
| 177 |
+
body = b'<?xml version="1.0" encoding="UTF-8"?>\n' + tostring(root, encoding="unicode").encode("utf-8")
|
| 178 |
+
return status, {"Content-Type": "application/xml"}, body
|
| 179 |
+
|
| 180 |
+
|
| 181 |
+
def _dict_to_xml(parent: Element, data):
|
| 182 |
+
"""Recursively convert dict/list to XML elements."""
|
| 183 |
+
if isinstance(data, dict):
|
| 184 |
+
for key, value in data.items():
|
| 185 |
+
if isinstance(value, list):
|
| 186 |
+
for item in value:
|
| 187 |
+
child = SubElement(parent, key)
|
| 188 |
+
if isinstance(item, dict):
|
| 189 |
+
_dict_to_xml(child, item)
|
| 190 |
+
else:
|
| 191 |
+
child.text = str(item)
|
| 192 |
+
elif isinstance(value, dict):
|
| 193 |
+
child = SubElement(parent, key)
|
| 194 |
+
_dict_to_xml(child, value)
|
| 195 |
+
else:
|
| 196 |
+
child = SubElement(parent, key)
|
| 197 |
+
child.text = str(value) if value is not None else ""
|
| 198 |
+
elif isinstance(data, str):
|
| 199 |
+
parent.text = data
|
| 200 |
+
|
| 201 |
+
|
| 202 |
+
def json_response(data: dict, status: int = 200) -> tuple:
|
| 203 |
+
"""Build an AWS-style JSON response."""
|
| 204 |
+
body = json.dumps(data, ensure_ascii=False).encode("utf-8")
|
| 205 |
+
return status, {"Content-Type": "application/x-amz-json-1.0"}, body
|
| 206 |
+
|
| 207 |
+
|
| 208 |
+
def error_response_xml(code: str, message: str, status: int, namespace: str = "http://s3.amazonaws.com/doc/2006-03-01/") -> tuple:
|
| 209 |
+
"""AWS-style XML error response."""
|
| 210 |
+
root = Element("ErrorResponse", xmlns=namespace)
|
| 211 |
+
error = SubElement(root, "Error")
|
| 212 |
+
t = SubElement(error, "Type")
|
| 213 |
+
t.text = "Sender" if status < 500 else "Receiver"
|
| 214 |
+
c = SubElement(error, "Code")
|
| 215 |
+
c.text = code
|
| 216 |
+
m = SubElement(error, "Message")
|
| 217 |
+
m.text = message
|
| 218 |
+
req = SubElement(root, "RequestId")
|
| 219 |
+
req.text = str(uuid.uuid4())
|
| 220 |
+
|
| 221 |
+
body = b'<?xml version="1.0" encoding="UTF-8"?>\n' + tostring(root, encoding="unicode").encode("utf-8")
|
| 222 |
+
return status, {"Content-Type": "application/xml"}, body
|
| 223 |
+
|
| 224 |
+
|
| 225 |
+
def error_response_json(code: str, message: str, status: int = 400) -> tuple:
|
| 226 |
+
"""AWS-style JSON error response."""
|
| 227 |
+
data = {
|
| 228 |
+
"__type": code,
|
| 229 |
+
"message": message,
|
| 230 |
+
}
|
| 231 |
+
return json_response(data, status)
|
| 232 |
+
|
| 233 |
+
|
| 234 |
+
def now_iso() -> str:
|
| 235 |
+
"""Current time in AWS ISO format."""
|
| 236 |
+
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z"
|
| 237 |
+
|
| 238 |
+
|
| 239 |
+
def now_rfc7231() -> str:
|
| 240 |
+
"""Current time in RFC 7231 format for HTTP headers (e.g. Last-Modified)."""
|
| 241 |
+
return datetime.now(timezone.utc).strftime("%a, %d %b %Y %H:%M:%S GMT")
|
| 242 |
+
|
| 243 |
+
|
| 244 |
+
def iso_to_rfc7231(iso_str: str) -> str:
|
| 245 |
+
"""Convert an ISO 8601 timestamp to RFC 7231 format."""
|
| 246 |
+
try:
|
| 247 |
+
dt = datetime.fromisoformat(iso_str.replace("Z", "+00:00"))
|
| 248 |
+
return dt.strftime("%a, %d %b %Y %H:%M:%S GMT")
|
| 249 |
+
except (ValueError, AttributeError):
|
| 250 |
+
return iso_str
|
| 251 |
+
|
| 252 |
+
|
| 253 |
+
def now_epoch() -> float:
|
| 254 |
+
return datetime.now(timezone.utc).timestamp()
|
| 255 |
+
|
| 256 |
+
|
| 257 |
+
def md5_hash(data: bytes) -> str:
|
| 258 |
+
return hashlib.md5(data).hexdigest()
|
| 259 |
+
|
| 260 |
+
|
| 261 |
+
def sha256_hash(data: bytes) -> str:
|
| 262 |
+
return hashlib.sha256(data).hexdigest()
|
| 263 |
+
|
| 264 |
+
|
| 265 |
+
def new_uuid() -> str:
|
| 266 |
+
return str(uuid.uuid4())
|
aws_infra/ministack/core/router.py
ADDED
|
@@ -0,0 +1,559 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
AWS API Request Router.
|
| 3 |
+
Routes incoming requests to the correct service handler based on:
|
| 4 |
+
- Authorization header (AWS4-HMAC-SHA256 ... SignedHeaders=host;...)
|
| 5 |
+
- X-Amz-Target header (e.g., DynamoDB_20120810.PutItem)
|
| 6 |
+
- Host header (e.g., sqs.us-east-1.amazonaws.com)
|
| 7 |
+
- URL path patterns (e.g., /2015-03-31/functions for Lambda)
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
import logging
|
| 11 |
+
import os
|
| 12 |
+
import re
|
| 13 |
+
|
| 14 |
+
logger = logging.getLogger("ministack")
|
| 15 |
+
|
| 16 |
+
# Service detection patterns
|
| 17 |
+
SERVICE_PATTERNS = {
|
| 18 |
+
"s3": {
|
| 19 |
+
"host_patterns": [r"s3[\.\-]", r"\.s3\."],
|
| 20 |
+
"path_patterns": [r"^/(?!2\d{3}-)"], # S3 is the fallback for non-API paths
|
| 21 |
+
},
|
| 22 |
+
"sqs": {
|
| 23 |
+
"host_patterns": [r"sqs\."],
|
| 24 |
+
"target_prefixes": ["AmazonSQS"],
|
| 25 |
+
"path_patterns": [r"/queue/", r"Action="],
|
| 26 |
+
},
|
| 27 |
+
"sns": {
|
| 28 |
+
"host_patterns": [r"sns\."],
|
| 29 |
+
"target_prefixes": ["AmazonSNS"],
|
| 30 |
+
},
|
| 31 |
+
"dynamodb": {
|
| 32 |
+
"target_prefixes": ["DynamoDB_20120810"],
|
| 33 |
+
"host_patterns": [r"dynamodb\."],
|
| 34 |
+
},
|
| 35 |
+
"lambda": {
|
| 36 |
+
"path_patterns": [r"^/2015-03-31/", r"^/2018-10-31/layers"],
|
| 37 |
+
"host_patterns": [r"lambda\."],
|
| 38 |
+
},
|
| 39 |
+
"iam": {
|
| 40 |
+
"host_patterns": [r"iam\."],
|
| 41 |
+
"path_patterns": [r"Action=.*(CreateRole|GetRole|ListRoles|PutRolePolicy)"],
|
| 42 |
+
},
|
| 43 |
+
"sts": {
|
| 44 |
+
"host_patterns": [r"sts\."],
|
| 45 |
+
"target_prefixes": ["AWSSecurityTokenService"],
|
| 46 |
+
},
|
| 47 |
+
"secretsmanager": {
|
| 48 |
+
"target_prefixes": ["secretsmanager"],
|
| 49 |
+
"host_patterns": [r"secretsmanager\."],
|
| 50 |
+
},
|
| 51 |
+
"monitoring": {
|
| 52 |
+
"host_patterns": [r"monitoring\."],
|
| 53 |
+
"target_prefixes": ["GraniteServiceVersion20100801"],
|
| 54 |
+
},
|
| 55 |
+
"logs": {
|
| 56 |
+
"target_prefixes": ["Logs_20140328"],
|
| 57 |
+
"host_patterns": [r"logs\."],
|
| 58 |
+
},
|
| 59 |
+
"ssm": {
|
| 60 |
+
"target_prefixes": ["AmazonSSM"],
|
| 61 |
+
"host_patterns": [r"ssm\."],
|
| 62 |
+
},
|
| 63 |
+
"events": {
|
| 64 |
+
"target_prefixes": ["AmazonEventBridge", "AWSEvents"],
|
| 65 |
+
"host_patterns": [r"events\."],
|
| 66 |
+
},
|
| 67 |
+
"kinesis": {
|
| 68 |
+
"target_prefixes": ["Kinesis_20131202"],
|
| 69 |
+
"host_patterns": [r"kinesis\."],
|
| 70 |
+
},
|
| 71 |
+
"ses": {
|
| 72 |
+
"host_patterns": [r"email\."],
|
| 73 |
+
"path_patterns": [r"Action=Send"],
|
| 74 |
+
},
|
| 75 |
+
"states": {
|
| 76 |
+
"target_prefixes": ["AWSStepFunctions"],
|
| 77 |
+
"host_patterns": [r"states\."],
|
| 78 |
+
},
|
| 79 |
+
"ecs": {
|
| 80 |
+
"target_prefixes": ["AmazonEC2ContainerServiceV20141113"],
|
| 81 |
+
"host_patterns": [r"ecs\."],
|
| 82 |
+
"path_patterns": [r"^/clusters", r"^/taskdefinitions", r"^/tasks", r"^/services", r"^/stoptask"],
|
| 83 |
+
},
|
| 84 |
+
"rds": {
|
| 85 |
+
"host_patterns": [r"rds\."],
|
| 86 |
+
"path_patterns": [r"Action=.*DB"],
|
| 87 |
+
},
|
| 88 |
+
"elasticache": {
|
| 89 |
+
"host_patterns": [r"elasticache\."],
|
| 90 |
+
"path_patterns": [r"Action=.*Cache"],
|
| 91 |
+
},
|
| 92 |
+
"glue": {
|
| 93 |
+
"target_prefixes": ["AWSGlue"],
|
| 94 |
+
"host_patterns": [r"glue\."],
|
| 95 |
+
},
|
| 96 |
+
"athena": {
|
| 97 |
+
"target_prefixes": ["AmazonAthena"],
|
| 98 |
+
"host_patterns": [r"athena\."],
|
| 99 |
+
},
|
| 100 |
+
"firehose": {
|
| 101 |
+
"target_prefixes": ["Firehose_20150804"],
|
| 102 |
+
"host_patterns": [r"firehose\.", r"kinesis-firehose\."],
|
| 103 |
+
},
|
| 104 |
+
"apigateway": {
|
| 105 |
+
"host_patterns": [r"apigateway\.", r"execute-api\."],
|
| 106 |
+
"path_patterns": [r"^/v2/apis"],
|
| 107 |
+
},
|
| 108 |
+
"route53": {
|
| 109 |
+
"host_patterns": [r"route53\."],
|
| 110 |
+
"path_patterns": [r"^/2013-04-01/"],
|
| 111 |
+
},
|
| 112 |
+
"cognito-idp": {
|
| 113 |
+
"target_prefixes": ["AWSCognitoIdentityProviderService"],
|
| 114 |
+
"host_patterns": [r"cognito-idp\."],
|
| 115 |
+
},
|
| 116 |
+
"cognito-identity": {
|
| 117 |
+
"target_prefixes": ["AWSCognitoIdentityService"],
|
| 118 |
+
"host_patterns": [r"cognito-identity\."],
|
| 119 |
+
},
|
| 120 |
+
"elasticmapreduce": {
|
| 121 |
+
"target_prefixes": ["ElasticMapReduce"],
|
| 122 |
+
"host_patterns": [r"elasticmapreduce\."],
|
| 123 |
+
},
|
| 124 |
+
"elasticfilesystem": {
|
| 125 |
+
"host_patterns": [r"elasticfilesystem\."],
|
| 126 |
+
"path_prefixes": ["/2015-02-01/"],
|
| 127 |
+
"credential_scope": "elasticfilesystem",
|
| 128 |
+
},
|
| 129 |
+
"ecr": {
|
| 130 |
+
"target_prefixes": ["AmazonEC2ContainerRegistry_V20150921"],
|
| 131 |
+
"host_patterns": [r"api\.ecr\.", r"ecr\."],
|
| 132 |
+
"credential_scope": "ecr",
|
| 133 |
+
},
|
| 134 |
+
"ec2": {
|
| 135 |
+
"host_patterns": [r"ec2\."],
|
| 136 |
+
"path_patterns": [r"Action=.*Instance", r"Action=.*Security", r"Action=.*KeyPair",
|
| 137 |
+
r"Action=.*Vpc", r"Action=.*Subnet", r"Action=.*Address",
|
| 138 |
+
r"Action=.*Image", r"Action=.*Tag", r"Action=.*InternetGateway",
|
| 139 |
+
r"Action=.*AvailabilityZone"],
|
| 140 |
+
},
|
| 141 |
+
"elasticloadbalancing": {
|
| 142 |
+
"host_patterns": [r"elasticloadbalancing\."],
|
| 143 |
+
},
|
| 144 |
+
"acm": {
|
| 145 |
+
"target_prefixes": ["CertificateManager"],
|
| 146 |
+
"host_patterns": [r"acm\."],
|
| 147 |
+
"credential_scope": "acm",
|
| 148 |
+
},
|
| 149 |
+
"wafv2": {
|
| 150 |
+
"target_prefixes": ["AWSWAF_20190729"],
|
| 151 |
+
"host_patterns": [r"wafv2\."],
|
| 152 |
+
"credential_scope": "wafv2",
|
| 153 |
+
},
|
| 154 |
+
"cloudformation": {
|
| 155 |
+
"host_patterns": [r"cloudformation\."],
|
| 156 |
+
},
|
| 157 |
+
"kms": {
|
| 158 |
+
"target_prefixes": ["TrentService"],
|
| 159 |
+
"host_patterns": [r"kms\."]
|
| 160 |
+
},
|
| 161 |
+
"cloudfront": {
|
| 162 |
+
"host_patterns": [r"cloudfront\."],
|
| 163 |
+
"credential_scope": "cloudfront",
|
| 164 |
+
},
|
| 165 |
+
"codebuild": {
|
| 166 |
+
"target_prefixes": ["CodeBuild_20161006"],
|
| 167 |
+
"host_patterns": [r"codebuild\."],
|
| 168 |
+
"credential_scope": "codebuild",
|
| 169 |
+
},
|
| 170 |
+
"transfer": {
|
| 171 |
+
"target_prefixes": ["TransferService"],
|
| 172 |
+
"host_patterns": [r"transfer\."],
|
| 173 |
+
"credential_scope": "transfer",
|
| 174 |
+
},
|
| 175 |
+
"appsync": {
|
| 176 |
+
"host_patterns": [r"appsync\."],
|
| 177 |
+
"path_prefixes": ["/v1/apis", "/v1/tags"],
|
| 178 |
+
"credential_scope": "appsync",
|
| 179 |
+
},
|
| 180 |
+
"servicediscovery": {
|
| 181 |
+
"target_prefixes": ["Route53AutoNaming_v20170314"],
|
| 182 |
+
"host_patterns": [r"servicediscovery\."],
|
| 183 |
+
"credential_scope": "servicediscovery",
|
| 184 |
+
},
|
| 185 |
+
"s3files": {
|
| 186 |
+
"host_patterns": [r"s3files\."],
|
| 187 |
+
"credential_scope": "s3files",
|
| 188 |
+
"path_prefixes": ["/file-systems", "/mount-targets", "/access-points"],
|
| 189 |
+
},
|
| 190 |
+
"rds-data": {
|
| 191 |
+
"host_patterns": [r"rds-data\."],
|
| 192 |
+
"credential_scope": "rds-data",
|
| 193 |
+
},
|
| 194 |
+
"autoscaling": {
|
| 195 |
+
"host_patterns": [r"autoscaling\."],
|
| 196 |
+
"credential_scope": "autoscaling",
|
| 197 |
+
},
|
| 198 |
+
"appconfig": {
|
| 199 |
+
"host_patterns": [r"appconfig\."],
|
| 200 |
+
"path_prefixes": ["/applications", "/deploymentstrategies", "/deployementstrategies"],
|
| 201 |
+
"credential_scope": "appconfig",
|
| 202 |
+
},
|
| 203 |
+
"appconfigdata": {
|
| 204 |
+
"host_patterns": [r"appconfigdata\."],
|
| 205 |
+
"path_prefixes": ["/configurationsessions", "/configuration"],
|
| 206 |
+
"credential_scope": "appconfigdata",
|
| 207 |
+
},
|
| 208 |
+
"scheduler": {
|
| 209 |
+
"host_patterns": [r"scheduler\."],
|
| 210 |
+
"path_prefixes": ["/schedules", "/schedule-groups"],
|
| 211 |
+
"credential_scope": "scheduler",
|
| 212 |
+
},
|
| 213 |
+
"eks": {
|
| 214 |
+
"host_patterns": [r"eks\."],
|
| 215 |
+
"credential_scope": "eks",
|
| 216 |
+
},
|
| 217 |
+
"tagging": {
|
| 218 |
+
"target_prefixes": ["ResourceGroupsTaggingAPI_20170126"],
|
| 219 |
+
"host_patterns": [r"tagging\."],
|
| 220 |
+
"credential_scope": "tagging",
|
| 221 |
+
},
|
| 222 |
+
}
|
| 223 |
+
|
| 224 |
+
|
| 225 |
+
def detect_service(method: str, path: str, headers: dict, query_params: dict) -> str:
|
| 226 |
+
"""Detect which AWS service a request is targeting."""
|
| 227 |
+
host = headers.get("host", "")
|
| 228 |
+
target = headers.get("x-amz-target", "")
|
| 229 |
+
auth = headers.get("authorization", "")
|
| 230 |
+
content_type = headers.get("content-type", "")
|
| 231 |
+
|
| 232 |
+
# 1. Check X-Amz-Target header (most reliable for JSON-based services)
|
| 233 |
+
if target:
|
| 234 |
+
for svc, patterns in SERVICE_PATTERNS.items():
|
| 235 |
+
for prefix in patterns.get("target_prefixes", []):
|
| 236 |
+
if target.startswith(prefix):
|
| 237 |
+
return svc
|
| 238 |
+
|
| 239 |
+
# 2. Check Authorization header for service name in credential scope
|
| 240 |
+
if auth:
|
| 241 |
+
match = re.search(r"Credential=[^/]+/[^/]+/[^/]+/([^/]+)/", auth)
|
| 242 |
+
if match:
|
| 243 |
+
svc_name = match.group(1)
|
| 244 |
+
if svc_name in SERVICE_PATTERNS:
|
| 245 |
+
return svc_name
|
| 246 |
+
# Map common credential scope names
|
| 247 |
+
scope_map = {
|
| 248 |
+
"monitoring": "monitoring",
|
| 249 |
+
"execute-api": "apigateway",
|
| 250 |
+
"ses": "ses",
|
| 251 |
+
"states": "states",
|
| 252 |
+
"kinesis": "kinesis",
|
| 253 |
+
"events": "events",
|
| 254 |
+
"ssm": "ssm",
|
| 255 |
+
"ecs": "ecs",
|
| 256 |
+
"rds": "rds",
|
| 257 |
+
"elasticache": "elasticache",
|
| 258 |
+
"glue": "glue",
|
| 259 |
+
"athena": "athena",
|
| 260 |
+
"kinesis-firehose": "firehose",
|
| 261 |
+
"route53": "route53",
|
| 262 |
+
"acm": "acm",
|
| 263 |
+
"wafv2": "wafv2",
|
| 264 |
+
"cognito-idp": "cognito-idp",
|
| 265 |
+
"cognito-identity": "cognito-identity",
|
| 266 |
+
"ecr": "ecr",
|
| 267 |
+
"elasticmapreduce": "elasticmapreduce",
|
| 268 |
+
"elasticloadbalancing": "elasticloadbalancing",
|
| 269 |
+
"elasticfilesystem": "elasticfilesystem",
|
| 270 |
+
"cloudformation": "cloudformation",
|
| 271 |
+
"kms": "kms",
|
| 272 |
+
"cloudfront": "cloudfront",
|
| 273 |
+
"codebuild": "codebuild",
|
| 274 |
+
"transfer": "transfer",
|
| 275 |
+
"appsync": "appsync",
|
| 276 |
+
"servicediscovery": "servicediscovery",
|
| 277 |
+
"s3files": "s3files",
|
| 278 |
+
"rds-data": "rds-data",
|
| 279 |
+
"autoscaling": "autoscaling",
|
| 280 |
+
"appconfig": "appconfig",
|
| 281 |
+
"appconfigdata": "appconfigdata",
|
| 282 |
+
"scheduler": "scheduler",
|
| 283 |
+
"eks": "eks",
|
| 284 |
+
"tagging": "tagging",
|
| 285 |
+
}
|
| 286 |
+
if svc_name in scope_map:
|
| 287 |
+
return scope_map[svc_name]
|
| 288 |
+
|
| 289 |
+
# 3. Check query parameters for Action-based APIs (SQS, SNS, IAM, STS, CloudWatch)
|
| 290 |
+
action = query_params.get("Action", [""])[0] if isinstance(query_params.get("Action"), list) else query_params.get("Action", "")
|
| 291 |
+
if action:
|
| 292 |
+
action_service_map = {
|
| 293 |
+
# SQS actions
|
| 294 |
+
"SendMessage": "sqs", "ReceiveMessage": "sqs", "DeleteMessage": "sqs",
|
| 295 |
+
"CreateQueue": "sqs", "DeleteQueue": "sqs", "ListQueues": "sqs",
|
| 296 |
+
"GetQueueUrl": "sqs", "GetQueueAttributes": "sqs", "SetQueueAttributes": "sqs",
|
| 297 |
+
"PurgeQueue": "sqs", "ChangeMessageVisibility": "sqs",
|
| 298 |
+
"ChangeMessageVisibilityBatch": "sqs",
|
| 299 |
+
"SendMessageBatch": "sqs", "DeleteMessageBatch": "sqs",
|
| 300 |
+
"ListQueueTags": "sqs", "TagQueue": "sqs", "UntagQueue": "sqs",
|
| 301 |
+
# SNS actions
|
| 302 |
+
"Publish": "sns", "Subscribe": "sns", "Unsubscribe": "sns",
|
| 303 |
+
"CreateTopic": "sns", "DeleteTopic": "sns", "ListTopics": "sns",
|
| 304 |
+
"ListSubscriptions": "sns", "ConfirmSubscription": "sns",
|
| 305 |
+
"SetTopicAttributes": "sns", "GetTopicAttributes": "sns",
|
| 306 |
+
"ListSubscriptionsByTopic": "sns",
|
| 307 |
+
"GetSubscriptionAttributes": "sns", "SetSubscriptionAttributes": "sns",
|
| 308 |
+
"PublishBatch": "sns",
|
| 309 |
+
# Note: ListTagsForResource is shared by SNS, RDS, and ElastiCache.
|
| 310 |
+
# Routed via credential scope or host header instead.
|
| 311 |
+
"TagResource": "sns", "UntagResource": "sns",
|
| 312 |
+
"CreatePlatformApplication": "sns", "CreatePlatformEndpoint": "sns",
|
| 313 |
+
# IAM actions
|
| 314 |
+
"CreateRole": "iam", "GetRole": "iam", "ListRoles": "iam",
|
| 315 |
+
"DeleteRole": "iam", "CreateUser": "iam", "GetUser": "iam",
|
| 316 |
+
"ListUsers": "iam", "DeleteUser": "iam",
|
| 317 |
+
"CreatePolicy": "iam", "GetPolicy": "iam", "DeletePolicy": "iam",
|
| 318 |
+
"GetPolicyVersion": "iam", "ListPolicyVersions": "iam",
|
| 319 |
+
"CreatePolicyVersion": "iam", "DeletePolicyVersion": "iam",
|
| 320 |
+
"ListPolicies": "iam",
|
| 321 |
+
"AttachRolePolicy": "iam", "DetachRolePolicy": "iam",
|
| 322 |
+
"ListAttachedRolePolicies": "iam",
|
| 323 |
+
"PutRolePolicy": "iam", "GetRolePolicy": "iam",
|
| 324 |
+
"DeleteRolePolicy": "iam", "ListRolePolicies": "iam",
|
| 325 |
+
"CreateAccessKey": "iam", "ListAccessKeys": "iam", "DeleteAccessKey": "iam",
|
| 326 |
+
"CreateInstanceProfile": "iam", "DeleteInstanceProfile": "iam",
|
| 327 |
+
"GetInstanceProfile": "iam", "AddRoleToInstanceProfile": "iam",
|
| 328 |
+
"RemoveRoleFromInstanceProfile": "iam",
|
| 329 |
+
"ListInstanceProfiles": "iam", "ListInstanceProfilesForRole": "iam",
|
| 330 |
+
"UpdateAssumeRolePolicy": "iam",
|
| 331 |
+
"AttachUserPolicy": "iam", "DetachUserPolicy": "iam",
|
| 332 |
+
"ListAttachedUserPolicies": "iam",
|
| 333 |
+
"TagRole": "iam", "UntagRole": "iam", "ListRoleTags": "iam",
|
| 334 |
+
"TagUser": "iam", "UntagUser": "iam", "ListUserTags": "iam",
|
| 335 |
+
"SimulatePrincipalPolicy": "iam", "SimulateCustomPolicy": "iam",
|
| 336 |
+
# STS actions
|
| 337 |
+
"GetCallerIdentity": "sts", "AssumeRole": "sts",
|
| 338 |
+
"GetSessionToken": "sts", "AssumeRoleWithWebIdentity": "sts",
|
| 339 |
+
"AssumeRoleWithSAML": "sts",
|
| 340 |
+
# CloudWatch actions
|
| 341 |
+
"PutMetricData": "monitoring", "GetMetricData": "monitoring",
|
| 342 |
+
"ListMetrics": "monitoring", "PutMetricAlarm": "monitoring",
|
| 343 |
+
"DescribeAlarms": "monitoring", "DeleteAlarms": "monitoring",
|
| 344 |
+
"GetMetricStatistics": "monitoring", "SetAlarmState": "monitoring",
|
| 345 |
+
"EnableAlarmActions": "monitoring", "DisableAlarmActions": "monitoring",
|
| 346 |
+
"DescribeAlarmsForMetric": "monitoring", "DescribeAlarmHistory": "monitoring",
|
| 347 |
+
"PutCompositeAlarm": "monitoring",
|
| 348 |
+
# SES actions
|
| 349 |
+
"SendEmail": "ses", "SendRawEmail": "ses",
|
| 350 |
+
"VerifyEmailIdentity": "ses", "VerifyEmailAddress": "ses",
|
| 351 |
+
"VerifyDomainIdentity": "ses", "VerifyDomainDkim": "ses",
|
| 352 |
+
"ListIdentities": "ses", "DeleteIdentity": "ses",
|
| 353 |
+
"GetSendQuota": "ses", "GetSendStatistics": "ses",
|
| 354 |
+
"ListVerifiedEmailAddresses": "ses",
|
| 355 |
+
"GetIdentityVerificationAttributes": "ses",
|
| 356 |
+
"GetIdentityDkimAttributes": "ses",
|
| 357 |
+
"SetIdentityNotificationTopic": "ses",
|
| 358 |
+
"SetIdentityFeedbackForwardingEnabled": "ses",
|
| 359 |
+
"CreateConfigurationSet": "ses", "DeleteConfigurationSet": "ses",
|
| 360 |
+
"DescribeConfigurationSet": "ses", "ListConfigurationSets": "ses",
|
| 361 |
+
# Note: GetTemplate is shared by SES and CloudFormation.
|
| 362 |
+
# Routed via credential scope or host header instead.
|
| 363 |
+
"CreateTemplate": "ses",
|
| 364 |
+
"DeleteTemplate": "ses", "ListTemplates": "ses", "UpdateTemplate": "ses",
|
| 365 |
+
"SendTemplatedEmail": "ses", "SendBulkTemplatedEmail": "ses",
|
| 366 |
+
# RDS actions
|
| 367 |
+
"CreateDBInstance": "rds", "DeleteDBInstance": "rds", "DescribeDBInstances": "rds",
|
| 368 |
+
"StartDBInstance": "rds", "StopDBInstance": "rds", "RebootDBInstance": "rds",
|
| 369 |
+
"ModifyDBInstance": "rds", "CreateDBCluster": "rds", "DeleteDBCluster": "rds",
|
| 370 |
+
"ModifyDBCluster": "rds",
|
| 371 |
+
"DescribeDBClusters": "rds", "CreateDBSubnetGroup": "rds", "DescribeDBSubnetGroups": "rds",
|
| 372 |
+
"DeleteDBSubnetGroup": "rds",
|
| 373 |
+
"CreateDBParameterGroup": "rds", "DescribeDBParameterGroups": "rds",
|
| 374 |
+
"DeleteDBParameterGroup": "rds", "DescribeDBParameters": "rds",
|
| 375 |
+
"ModifyDBParameterGroup": "rds", "ResetDBParameterGroup": "rds",
|
| 376 |
+
"CreateDBClusterParameterGroup": "rds", "DescribeDBClusterParameterGroups": "rds",
|
| 377 |
+
"DeleteDBClusterParameterGroup": "rds", "DescribeDBClusterParameters": "rds",
|
| 378 |
+
"ModifyDBClusterParameterGroup": "rds", "ResetDBClusterParameterGroup": "rds",
|
| 379 |
+
"DescribeDBEngineVersions": "rds",
|
| 380 |
+
"DescribeOrderableDBInstanceOptions": "rds",
|
| 381 |
+
"CreateDBSnapshot": "rds", "DeleteDBSnapshot": "rds", "DescribeDBSnapshots": "rds",
|
| 382 |
+
"CreateDBInstanceReadReplica": "rds", "RestoreDBInstanceFromDBSnapshot": "rds",
|
| 383 |
+
"AddTagsToResource": "rds", "RemoveTagsFromResource": "rds",
|
| 384 |
+
# ElastiCache actions
|
| 385 |
+
"CreateCacheCluster": "elasticache", "DeleteCacheCluster": "elasticache",
|
| 386 |
+
"DescribeCacheClusters": "elasticache", "ModifyCacheCluster": "elasticache",
|
| 387 |
+
"RebootCacheCluster": "elasticache",
|
| 388 |
+
"CreateReplicationGroup": "elasticache", "DeleteReplicationGroup": "elasticache",
|
| 389 |
+
"DescribeReplicationGroups": "elasticache", "ModifyReplicationGroup": "elasticache",
|
| 390 |
+
"CreateCacheSubnetGroup": "elasticache", "DescribeCacheSubnetGroups": "elasticache",
|
| 391 |
+
"DeleteCacheSubnetGroup": "elasticache",
|
| 392 |
+
"CreateCacheParameterGroup": "elasticache", "DescribeCacheParameterGroups": "elasticache",
|
| 393 |
+
"DeleteCacheParameterGroup": "elasticache",
|
| 394 |
+
"DescribeCacheParameters": "elasticache", "ModifyCacheParameterGroup": "elasticache",
|
| 395 |
+
"DescribeCacheEngineVersions": "elasticache",
|
| 396 |
+
"CreateSnapshot": "elasticache", "DeleteSnapshot": "elasticache",
|
| 397 |
+
"DescribeSnapshots": "elasticache",
|
| 398 |
+
"IncreaseReplicaCount": "elasticache", "DecreaseReplicaCount": "elasticache",
|
| 399 |
+
# EC2 actions
|
| 400 |
+
"RunInstances": "ec2", "DescribeInstances": "ec2", "TerminateInstances": "ec2",
|
| 401 |
+
"StopInstances": "ec2", "StartInstances": "ec2", "RebootInstances": "ec2",
|
| 402 |
+
"DescribeImages": "ec2",
|
| 403 |
+
"CreateSecurityGroup": "ec2", "DeleteSecurityGroup": "ec2",
|
| 404 |
+
"DescribeSecurityGroups": "ec2",
|
| 405 |
+
"AuthorizeSecurityGroupIngress": "ec2", "RevokeSecurityGroupIngress": "ec2",
|
| 406 |
+
"AuthorizeSecurityGroupEgress": "ec2", "RevokeSecurityGroupEgress": "ec2",
|
| 407 |
+
"CreateKeyPair": "ec2", "DeleteKeyPair": "ec2", "DescribeKeyPairs": "ec2",
|
| 408 |
+
"ImportKeyPair": "ec2",
|
| 409 |
+
"DescribeVpcs": "ec2", "CreateVpc": "ec2", "DeleteVpc": "ec2",
|
| 410 |
+
"DescribeSubnets": "ec2", "CreateSubnet": "ec2", "DeleteSubnet": "ec2",
|
| 411 |
+
"CreateInternetGateway": "ec2", "DeleteInternetGateway": "ec2",
|
| 412 |
+
"DescribeInternetGateways": "ec2",
|
| 413 |
+
"AttachInternetGateway": "ec2", "DetachInternetGateway": "ec2",
|
| 414 |
+
"DescribeAvailabilityZones": "ec2",
|
| 415 |
+
"AllocateAddress": "ec2", "ReleaseAddress": "ec2",
|
| 416 |
+
"AssociateAddress": "ec2", "DisassociateAddress": "ec2",
|
| 417 |
+
"DescribeAddresses": "ec2",
|
| 418 |
+
"CreateTags": "ec2", "DeleteTags": "ec2", "DescribeTags": "ec2",
|
| 419 |
+
"ModifyVpcAttribute": "ec2", "ModifySubnetAttribute": "ec2",
|
| 420 |
+
"CreateRouteTable": "ec2", "DeleteRouteTable": "ec2", "DescribeRouteTables": "ec2",
|
| 421 |
+
"AssociateRouteTable": "ec2", "DisassociateRouteTable": "ec2",
|
| 422 |
+
"CreateRoute": "ec2", "ReplaceRoute": "ec2", "DeleteRoute": "ec2",
|
| 423 |
+
"CreateNetworkInterface": "ec2", "DeleteNetworkInterface": "ec2",
|
| 424 |
+
"DescribeNetworkInterfaces": "ec2",
|
| 425 |
+
"AttachNetworkInterface": "ec2", "DetachNetworkInterface": "ec2",
|
| 426 |
+
"CreateVpcEndpoint": "ec2", "DeleteVpcEndpoints": "ec2",
|
| 427 |
+
"DescribeVpcEndpoints": "ec2",
|
| 428 |
+
# ELBv2 / ALB actions
|
| 429 |
+
"CreateLoadBalancer": "elasticloadbalancing",
|
| 430 |
+
"DescribeLoadBalancers": "elasticloadbalancing",
|
| 431 |
+
"DeleteLoadBalancer": "elasticloadbalancing",
|
| 432 |
+
"DescribeLoadBalancerAttributes": "elasticloadbalancing",
|
| 433 |
+
"ModifyLoadBalancerAttributes": "elasticloadbalancing",
|
| 434 |
+
"CreateTargetGroup": "elasticloadbalancing",
|
| 435 |
+
"DescribeTargetGroups": "elasticloadbalancing",
|
| 436 |
+
"ModifyTargetGroup": "elasticloadbalancing",
|
| 437 |
+
"DeleteTargetGroup": "elasticloadbalancing",
|
| 438 |
+
"DescribeTargetGroupAttributes": "elasticloadbalancing",
|
| 439 |
+
"ModifyTargetGroupAttributes": "elasticloadbalancing",
|
| 440 |
+
"CreateListener": "elasticloadbalancing",
|
| 441 |
+
"DescribeListeners": "elasticloadbalancing",
|
| 442 |
+
"ModifyListener": "elasticloadbalancing",
|
| 443 |
+
"DeleteListener": "elasticloadbalancing",
|
| 444 |
+
"CreateRule": "elasticloadbalancing",
|
| 445 |
+
"DescribeRules": "elasticloadbalancing",
|
| 446 |
+
"ModifyRule": "elasticloadbalancing",
|
| 447 |
+
"DeleteRule": "elasticloadbalancing",
|
| 448 |
+
"SetRulePriorities": "elasticloadbalancing",
|
| 449 |
+
"RegisterTargets": "elasticloadbalancing",
|
| 450 |
+
"DeregisterTargets": "elasticloadbalancing",
|
| 451 |
+
"DescribeTargetHealth": "elasticloadbalancing",
|
| 452 |
+
"AddTags": "elasticloadbalancing",
|
| 453 |
+
"RemoveTags": "elasticloadbalancing",
|
| 454 |
+
"DescribeTags": "elasticloadbalancing",
|
| 455 |
+
# EBS Volumes
|
| 456 |
+
"CreateVolume": "ec2", "DeleteVolume": "ec2", "DescribeVolumes": "ec2",
|
| 457 |
+
"DescribeVolumeStatus": "ec2", "AttachVolume": "ec2", "DetachVolume": "ec2",
|
| 458 |
+
"ModifyVolume": "ec2", "DescribeVolumesModifications": "ec2",
|
| 459 |
+
"EnableVolumeIO": "ec2", "ModifyVolumeAttribute": "ec2",
|
| 460 |
+
"DescribeVolumeAttribute": "ec2",
|
| 461 |
+
# CloudFormation actions
|
| 462 |
+
"CreateStack": "cloudformation", "DescribeStacks": "cloudformation",
|
| 463 |
+
"UpdateStack": "cloudformation", "DeleteStack": "cloudformation",
|
| 464 |
+
"ListStacks": "cloudformation",
|
| 465 |
+
"DescribeStackEvents": "cloudformation",
|
| 466 |
+
"DescribeStackResource": "cloudformation", "DescribeStackResources": "cloudformation",
|
| 467 |
+
"ListStackResources": "cloudformation",
|
| 468 |
+
"GetTemplateSummary": "cloudformation",
|
| 469 |
+
"ValidateTemplate": "cloudformation",
|
| 470 |
+
"CreateChangeSet": "cloudformation", "DescribeChangeSet": "cloudformation",
|
| 471 |
+
"ExecuteChangeSet": "cloudformation", "DeleteChangeSet": "cloudformation",
|
| 472 |
+
"ListChangeSets": "cloudformation",
|
| 473 |
+
"ListExports": "cloudformation", "ListImports": "cloudformation",
|
| 474 |
+
"UpdateTerminationProtection": "cloudformation",
|
| 475 |
+
"SetStackPolicy": "cloudformation", "GetStackPolicy": "cloudformation",
|
| 476 |
+
# EBS Snapshots
|
| 477 |
+
# Note: CreateSnapshot, DeleteSnapshot, DescribeSnapshots are intentionally
|
| 478 |
+
# omitted here because they conflict with ElastiCache actions of the same
|
| 479 |
+
# name. These are routed via credential scope or host header instead.
|
| 480 |
+
"CopySnapshot": "ec2", "ModifySnapshotAttribute": "ec2",
|
| 481 |
+
"DescribeSnapshotAttribute": "ec2",
|
| 482 |
+
# AutoScaling actions
|
| 483 |
+
"CreateAutoScalingGroup": "autoscaling", "DescribeAutoScalingGroups": "autoscaling",
|
| 484 |
+
"UpdateAutoScalingGroup": "autoscaling", "DeleteAutoScalingGroup": "autoscaling",
|
| 485 |
+
"CreateLaunchConfiguration": "autoscaling", "DescribeLaunchConfigurations": "autoscaling",
|
| 486 |
+
"DeleteLaunchConfiguration": "autoscaling",
|
| 487 |
+
"PutScalingPolicy": "autoscaling", "DescribePolicies": "autoscaling",
|
| 488 |
+
"DeletePolicy": "autoscaling",
|
| 489 |
+
"PutLifecycleHook": "autoscaling", "DescribeLifecycleHooks": "autoscaling",
|
| 490 |
+
"DeleteLifecycleHook": "autoscaling",
|
| 491 |
+
"PutScheduledUpdateGroupAction": "autoscaling", "DescribeScheduledActions": "autoscaling",
|
| 492 |
+
"DeleteScheduledAction": "autoscaling",
|
| 493 |
+
"DescribeAutoScalingInstances": "autoscaling",
|
| 494 |
+
}
|
| 495 |
+
if action in action_service_map:
|
| 496 |
+
return action_service_map[action]
|
| 497 |
+
|
| 498 |
+
# 4. Check URL path patterns
|
| 499 |
+
path_lower = path.lower()
|
| 500 |
+
if path_lower.startswith("/v1/apis") or path_lower.startswith("/v1/tags/arn:aws:appsync"):
|
| 501 |
+
return "appsync"
|
| 502 |
+
if path_lower.startswith("/2020-05-31/"):
|
| 503 |
+
return "cloudfront"
|
| 504 |
+
if path_lower.startswith("/2013-04-01/"):
|
| 505 |
+
return "route53"
|
| 506 |
+
if path_lower.startswith("/v2/apis"):
|
| 507 |
+
return "apigateway"
|
| 508 |
+
if (path_lower.startswith("/restapis") or path_lower.startswith("/apikeys")
|
| 509 |
+
or path_lower.startswith("/usageplans") or path_lower.startswith("/domainnames")):
|
| 510 |
+
return "apigateway"
|
| 511 |
+
if path_lower.startswith("/2015-03-31/functions"):
|
| 512 |
+
return "lambda"
|
| 513 |
+
if path_lower.startswith(("/oauth2/", "/login", "/logout")):
|
| 514 |
+
return "cognito-idp"
|
| 515 |
+
if path_lower.startswith("/oauth2/authorize"):
|
| 516 |
+
return "cognito-idp"
|
| 517 |
+
if path_lower.startswith("/saml2/idpresponse"):
|
| 518 |
+
return "cognito-idp"
|
| 519 |
+
if path_lower.startswith(("/clusters", "/taskdefinitions", "/tasks", "/services", "/stoptask")):
|
| 520 |
+
return "ecs"
|
| 521 |
+
# smithy-rpc-v2-cbor path: /service/ServiceName/operation/ActionName
|
| 522 |
+
if "/service/" in path_lower and "/operation/" in path_lower:
|
| 523 |
+
if "granite" in path_lower or "cloudwatch" in path_lower:
|
| 524 |
+
return "monitoring"
|
| 525 |
+
|
| 526 |
+
# 5. Check host header patterns
|
| 527 |
+
for svc, patterns in SERVICE_PATTERNS.items():
|
| 528 |
+
for hp in patterns.get("host_patterns", []):
|
| 529 |
+
if re.search(hp, host):
|
| 530 |
+
return svc
|
| 531 |
+
|
| 532 |
+
# 6. Default to S3 (same as real LocalStack behavior)
|
| 533 |
+
return "s3"
|
| 534 |
+
|
| 535 |
+
|
| 536 |
+
def extract_region(headers: dict) -> str:
|
| 537 |
+
"""Extract AWS region from the request."""
|
| 538 |
+
auth = headers.get("authorization", "")
|
| 539 |
+
match = re.search(r"Credential=[^/]+/[^/]+/([^/]+)/", auth)
|
| 540 |
+
if match:
|
| 541 |
+
return match.group(1)
|
| 542 |
+
return os.environ.get("MINISTACK_REGION", "us-east-1")
|
| 543 |
+
|
| 544 |
+
|
| 545 |
+
def extract_access_key_id(headers: dict) -> str:
|
| 546 |
+
"""Extract the AWS access key ID from the Authorization header."""
|
| 547 |
+
auth = headers.get("authorization", "")
|
| 548 |
+
if auth:
|
| 549 |
+
match = re.search(r"Credential=([^/]+)/", auth)
|
| 550 |
+
if match:
|
| 551 |
+
return match.group(1)
|
| 552 |
+
return ""
|
| 553 |
+
|
| 554 |
+
|
| 555 |
+
def extract_account_id(headers: dict) -> str:
|
| 556 |
+
"""Extract account ID from credentials or env var.
|
| 557 |
+
If the access key is a 12-digit number, use it as the account ID."""
|
| 558 |
+
from ministack.core.responses import get_account_id
|
| 559 |
+
return get_account_id()
|
aws_infra/ministack/services/__init__.py
ADDED
|
File without changes
|
aws_infra/ministack/services/acm.py
ADDED
|
@@ -0,0 +1,278 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
ACM (Certificate Manager) Service Emulator.
|
| 3 |
+
JSON-based API via X-Amz-Target.
|
| 4 |
+
Supports: RequestCertificate, DescribeCertificate, ListCertificates,
|
| 5 |
+
DeleteCertificate, GetCertificate, ImportCertificate,
|
| 6 |
+
AddTagsToCertificate, RemoveTagsFromCertificate, ListTagsForCertificate,
|
| 7 |
+
UpdateCertificateOptions, RenewCertificate, ResendValidationEmail.
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
import copy
|
| 11 |
+
import json
|
| 12 |
+
import os
|
| 13 |
+
import logging
|
| 14 |
+
import time
|
| 15 |
+
|
| 16 |
+
from ministack.core.persistence import PERSIST_STATE, load_state
|
| 17 |
+
|
| 18 |
+
from ministack.core.responses import AccountScopedDict, get_account_id, error_response_json, json_response, new_uuid, now_iso, get_region
|
| 19 |
+
|
| 20 |
+
logger = logging.getLogger("acm")
|
| 21 |
+
|
| 22 |
+
REGION = os.environ.get("MINISTACK_REGION", "us-east-1")
|
| 23 |
+
|
| 24 |
+
_certificates = AccountScopedDict() # arn -> certificate dict
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def get_state():
|
| 28 |
+
return copy.deepcopy({"_certificates": _certificates})
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def restore_state(data):
|
| 32 |
+
_certificates.update(data.get("_certificates", {}))
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
_restored = load_state("acm")
|
| 36 |
+
if _restored:
|
| 37 |
+
restore_state(_restored)
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def _future_iso(seconds):
|
| 41 |
+
return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(time.time() + seconds))
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def _epoch(iso_or_epoch):
|
| 45 |
+
"""Convert ISO timestamp to epoch float if needed. ACM API returns epoch floats."""
|
| 46 |
+
if isinstance(iso_or_epoch, (int, float)):
|
| 47 |
+
return float(iso_or_epoch)
|
| 48 |
+
try:
|
| 49 |
+
return time.mktime(time.strptime(iso_or_epoch, "%Y-%m-%dT%H:%M:%SZ")) - time.timezone
|
| 50 |
+
except (ValueError, TypeError):
|
| 51 |
+
return time.time()
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def _cert_arn():
|
| 55 |
+
return f"arn:aws:acm:{get_region()}:{get_account_id()}:certificate/{new_uuid()}"
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def _validation_options(domain, method):
|
| 59 |
+
return {
|
| 60 |
+
"DomainName": domain,
|
| 61 |
+
"ValidationMethod": method,
|
| 62 |
+
"ValidationStatus": "SUCCESS",
|
| 63 |
+
"ResourceRecord": {
|
| 64 |
+
"Name": f"_acme-challenge.{domain}.",
|
| 65 |
+
"Type": "CNAME",
|
| 66 |
+
"Value": f"fake-validation-{new_uuid()[:8]}.acm.amazonaws.com.",
|
| 67 |
+
},
|
| 68 |
+
}
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
def _cert_shape(cert):
|
| 72 |
+
return {
|
| 73 |
+
"CertificateArn": cert["CertificateArn"],
|
| 74 |
+
"DomainName": cert["DomainName"],
|
| 75 |
+
"SubjectAlternativeNames": cert.get("SubjectAlternativeNames", [cert["DomainName"]]),
|
| 76 |
+
"Status": cert["Status"],
|
| 77 |
+
"Type": cert.get("Type", "AMAZON_ISSUED"),
|
| 78 |
+
"KeyAlgorithm": "RSA_2048",
|
| 79 |
+
"SignatureAlgorithm": "SHA256WITHRSA",
|
| 80 |
+
"InUseBy": cert.get("InUseBy", []),
|
| 81 |
+
"CreatedAt": _epoch(cert["CreatedAt"]),
|
| 82 |
+
"IssuedAt": _epoch(cert.get("IssuedAt", cert["CreatedAt"])),
|
| 83 |
+
"NotBefore": _epoch(cert.get("NotBefore", cert["CreatedAt"])),
|
| 84 |
+
"NotAfter": _epoch(cert.get("NotAfter", _future_iso(365 * 24 * 3600))),
|
| 85 |
+
"DomainValidationOptions": cert.get("DomainValidationOptions", []),
|
| 86 |
+
"Options": cert.get("Options", {}),
|
| 87 |
+
"Tags": cert.get("Tags", []),
|
| 88 |
+
}
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
async def handle_request(method, path, headers, body, query_params):
|
| 92 |
+
target = headers.get("x-amz-target", "")
|
| 93 |
+
action = target.split(".")[-1] if "." in target else ""
|
| 94 |
+
|
| 95 |
+
try:
|
| 96 |
+
data = json.loads(body) if body else {}
|
| 97 |
+
except json.JSONDecodeError:
|
| 98 |
+
return error_response_json("SerializationException", "Invalid JSON", 400)
|
| 99 |
+
|
| 100 |
+
handlers = {
|
| 101 |
+
"RequestCertificate": _request_certificate,
|
| 102 |
+
"DescribeCertificate": _describe_certificate,
|
| 103 |
+
"ListCertificates": _list_certificates,
|
| 104 |
+
"DeleteCertificate": _delete_certificate,
|
| 105 |
+
"GetCertificate": _get_certificate,
|
| 106 |
+
"ImportCertificate": _import_certificate,
|
| 107 |
+
"AddTagsToCertificate": _add_tags,
|
| 108 |
+
"RemoveTagsFromCertificate": _remove_tags,
|
| 109 |
+
"ListTagsForCertificate": _list_tags,
|
| 110 |
+
"UpdateCertificateOptions": _update_options,
|
| 111 |
+
"RenewCertificate": _renew_certificate,
|
| 112 |
+
"ResendValidationEmail": _resend_validation_email,
|
| 113 |
+
}
|
| 114 |
+
|
| 115 |
+
handler = handlers.get(action)
|
| 116 |
+
if not handler:
|
| 117 |
+
return error_response_json("InvalidAction", f"Unknown action: {action}", 400)
|
| 118 |
+
return handler(data)
|
| 119 |
+
|
| 120 |
+
|
| 121 |
+
def _request_certificate(data):
|
| 122 |
+
domain = data.get("DomainName", "")
|
| 123 |
+
if not domain:
|
| 124 |
+
return error_response_json("InvalidParameterException", "DomainName is required", 400)
|
| 125 |
+
method = data.get("ValidationMethod", "DNS")
|
| 126 |
+
sans = data.get("SubjectAlternativeNames", [domain])
|
| 127 |
+
if domain not in sans:
|
| 128 |
+
sans = [domain] + sans
|
| 129 |
+
arn = _cert_arn()
|
| 130 |
+
now = now_iso()
|
| 131 |
+
_certificates[arn] = {
|
| 132 |
+
"CertificateArn": arn,
|
| 133 |
+
"DomainName": domain,
|
| 134 |
+
"SubjectAlternativeNames": sans,
|
| 135 |
+
"Status": "ISSUED",
|
| 136 |
+
"Type": "AMAZON_ISSUED",
|
| 137 |
+
"CreatedAt": now,
|
| 138 |
+
"IssuedAt": now,
|
| 139 |
+
"NotBefore": now,
|
| 140 |
+
"NotAfter": _future_iso(365 * 24 * 3600),
|
| 141 |
+
"DomainValidationOptions": [_validation_options(d, method) for d in sans],
|
| 142 |
+
"ValidationMethod": method,
|
| 143 |
+
"Tags": data.get("Tags", []),
|
| 144 |
+
"Options": {},
|
| 145 |
+
}
|
| 146 |
+
logger.info("RequestCertificate: %s -> %s", domain, arn)
|
| 147 |
+
return json_response({"CertificateArn": arn})
|
| 148 |
+
|
| 149 |
+
|
| 150 |
+
def _describe_certificate(data):
|
| 151 |
+
arn = data.get("CertificateArn", "")
|
| 152 |
+
cert = _certificates.get(arn)
|
| 153 |
+
if not cert:
|
| 154 |
+
return error_response_json("ResourceNotFoundException", f"Certificate {arn} not found", 400)
|
| 155 |
+
return json_response({"Certificate": _cert_shape(cert)})
|
| 156 |
+
|
| 157 |
+
|
| 158 |
+
def _list_certificates(data):
|
| 159 |
+
statuses = data.get("CertificateStatuses", [])
|
| 160 |
+
summaries = []
|
| 161 |
+
for arn, cert in _certificates.items():
|
| 162 |
+
if statuses and cert["Status"] not in statuses:
|
| 163 |
+
continue
|
| 164 |
+
summaries.append({
|
| 165 |
+
"CertificateArn": arn,
|
| 166 |
+
"DomainName": cert["DomainName"],
|
| 167 |
+
"Status": cert["Status"],
|
| 168 |
+
})
|
| 169 |
+
return json_response({"CertificateSummaryList": summaries, "NextToken": None})
|
| 170 |
+
|
| 171 |
+
|
| 172 |
+
def _delete_certificate(data):
|
| 173 |
+
arn = data.get("CertificateArn", "")
|
| 174 |
+
if arn not in _certificates:
|
| 175 |
+
return error_response_json("ResourceNotFoundException", f"Certificate {arn} not found", 400)
|
| 176 |
+
del _certificates[arn]
|
| 177 |
+
return json_response({})
|
| 178 |
+
|
| 179 |
+
|
| 180 |
+
def _get_certificate(data):
|
| 181 |
+
arn = data.get("CertificateArn", "")
|
| 182 |
+
if arn not in _certificates:
|
| 183 |
+
return error_response_json("ResourceNotFoundException", f"Certificate {arn} not found", 400)
|
| 184 |
+
fake_pem = "-----BEGIN CERTIFICATE-----\nMIIFakeCertificateDataHere\n-----END CERTIFICATE-----"
|
| 185 |
+
fake_chain = "-----BEGIN CERTIFICATE-----\nMIIFakeChainDataHere\n-----END CERTIFICATE-----"
|
| 186 |
+
return json_response({"Certificate": fake_pem, "CertificateChain": fake_chain})
|
| 187 |
+
|
| 188 |
+
|
| 189 |
+
def _import_certificate(data):
|
| 190 |
+
arn = data.get("CertificateArn") or _cert_arn()
|
| 191 |
+
now = now_iso()
|
| 192 |
+
_certificates[arn] = {
|
| 193 |
+
"CertificateArn": arn,
|
| 194 |
+
"DomainName": "imported.example.com",
|
| 195 |
+
"SubjectAlternativeNames": ["imported.example.com"],
|
| 196 |
+
"Status": "ISSUED",
|
| 197 |
+
"Type": "IMPORTED",
|
| 198 |
+
"CreatedAt": now,
|
| 199 |
+
"IssuedAt": now,
|
| 200 |
+
"NotBefore": now,
|
| 201 |
+
"NotAfter": _future_iso(365 * 24 * 3600),
|
| 202 |
+
"DomainValidationOptions": [],
|
| 203 |
+
"Tags": data.get("Tags", []),
|
| 204 |
+
"Options": {},
|
| 205 |
+
}
|
| 206 |
+
return json_response({"CertificateArn": arn})
|
| 207 |
+
|
| 208 |
+
|
| 209 |
+
def _add_tags(data):
|
| 210 |
+
arn = data.get("CertificateArn", "")
|
| 211 |
+
cert = _certificates.get(arn)
|
| 212 |
+
if not cert:
|
| 213 |
+
return error_response_json("ResourceNotFoundException", f"Certificate {arn} not found", 400)
|
| 214 |
+
existing = {t["Key"]: t for t in cert.get("Tags", [])}
|
| 215 |
+
for tag in data.get("Tags", []):
|
| 216 |
+
existing[tag["Key"]] = tag
|
| 217 |
+
cert["Tags"] = list(existing.values())
|
| 218 |
+
return json_response({})
|
| 219 |
+
|
| 220 |
+
|
| 221 |
+
def _remove_tags(data):
|
| 222 |
+
arn = data.get("CertificateArn", "")
|
| 223 |
+
cert = _certificates.get(arn)
|
| 224 |
+
if not cert:
|
| 225 |
+
return error_response_json("ResourceNotFoundException", f"Certificate {arn} not found", 400)
|
| 226 |
+
remove_keys = {t["Key"] for t in data.get("Tags", [])}
|
| 227 |
+
cert["Tags"] = [t for t in cert.get("Tags", []) if t["Key"] not in remove_keys]
|
| 228 |
+
return json_response({})
|
| 229 |
+
|
| 230 |
+
|
| 231 |
+
def _list_tags(data):
|
| 232 |
+
arn = data.get("CertificateArn", "")
|
| 233 |
+
cert = _certificates.get(arn)
|
| 234 |
+
if not cert:
|
| 235 |
+
return error_response_json("ResourceNotFoundException", f"Certificate {arn} not found", 400)
|
| 236 |
+
return json_response({"Tags": cert.get("Tags", [])})
|
| 237 |
+
|
| 238 |
+
|
| 239 |
+
def _update_options(data):
|
| 240 |
+
arn = data.get("CertificateArn", "")
|
| 241 |
+
cert = _certificates.get(arn)
|
| 242 |
+
if not cert:
|
| 243 |
+
return error_response_json("ResourceNotFoundException", f"Certificate {arn} not found", 400)
|
| 244 |
+
cert["Options"] = data.get("Options", {})
|
| 245 |
+
return json_response({})
|
| 246 |
+
|
| 247 |
+
|
| 248 |
+
def _renew_certificate(data):
|
| 249 |
+
arn = data.get("CertificateArn", "")
|
| 250 |
+
if arn not in _certificates:
|
| 251 |
+
return error_response_json("ResourceNotFoundException", f"Certificate {arn} not found", 400)
|
| 252 |
+
return json_response({})
|
| 253 |
+
|
| 254 |
+
|
| 255 |
+
def _resend_validation_email(data):
|
| 256 |
+
arn = data.get("CertificateArn", "")
|
| 257 |
+
if arn not in _certificates:
|
| 258 |
+
return error_response_json("ResourceNotFoundException", f"Certificate {arn} not found", 400)
|
| 259 |
+
return json_response({})
|
| 260 |
+
|
| 261 |
+
|
| 262 |
+
SUPPORTED_ACTIONS = [
|
| 263 |
+
"RequestCertificate", "DescribeCertificate", "ListCertificates",
|
| 264 |
+
"DeleteCertificate", "GetCertificate", "ImportCertificate",
|
| 265 |
+
"AddTagsToCertificate", "RemoveTagsFromCertificate",
|
| 266 |
+
"ListTagsForCertificate", "UpdateCertificateOptions",
|
| 267 |
+
"RenewCertificate", "ResendValidationEmail",
|
| 268 |
+
]
|
| 269 |
+
|
| 270 |
+
|
| 271 |
+
def get_state_summary() -> dict:
|
| 272 |
+
return {
|
| 273 |
+
"certificates": {"count": len(_certificates), "ids": list(_certificates.keys())},
|
| 274 |
+
}
|
| 275 |
+
|
| 276 |
+
|
| 277 |
+
def reset():
|
| 278 |
+
_certificates.clear()
|
aws_infra/ministack/services/alb.py
ADDED
|
@@ -0,0 +1,1169 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
ALB / ELBv2 (Elastic Load Balancing v2) Service Emulator.
|
| 3 |
+
Query API (Action=...) with XML responses. In-memory only.
|
| 4 |
+
|
| 5 |
+
Supports:
|
| 6 |
+
Load Balancers: CreateLoadBalancer, DescribeLoadBalancers, DeleteLoadBalancer,
|
| 7 |
+
ModifyLoadBalancerAttributes, DescribeLoadBalancerAttributes
|
| 8 |
+
Target Groups: CreateTargetGroup, DescribeTargetGroups, ModifyTargetGroup,
|
| 9 |
+
DeleteTargetGroup, DescribeTargetGroupAttributes,
|
| 10 |
+
ModifyTargetGroupAttributes
|
| 11 |
+
Listeners: CreateListener, DescribeListeners, ModifyListener, DeleteListener,
|
| 12 |
+
DescribeListenerAttributes, ModifyListenerAttributes
|
| 13 |
+
Rules: CreateRule, DescribeRules, ModifyRule, DeleteRule,
|
| 14 |
+
SetRulePriorities
|
| 15 |
+
Target Registration: RegisterTargets, DeregisterTargets, DescribeTargetHealth
|
| 16 |
+
Tags: AddTags, RemoveTags, DescribeTags
|
| 17 |
+
"""
|
| 18 |
+
|
| 19 |
+
import base64
|
| 20 |
+
import copy
|
| 21 |
+
import fnmatch
|
| 22 |
+
import json
|
| 23 |
+
import logging
|
| 24 |
+
import os
|
| 25 |
+
import random
|
| 26 |
+
import string
|
| 27 |
+
import time
|
| 28 |
+
from urllib.parse import parse_qs
|
| 29 |
+
|
| 30 |
+
from ministack.core.persistence import PERSIST_STATE, load_state
|
| 31 |
+
from ministack.core.responses import AccountScopedDict, get_account_id, new_uuid, get_region
|
| 32 |
+
|
| 33 |
+
logger = logging.getLogger("alb")
|
| 34 |
+
|
| 35 |
+
REGION = os.environ.get("MINISTACK_REGION", "us-east-1")
|
| 36 |
+
NS = "http://elasticloadbalancing.amazonaws.com/doc/2015-12-01/"
|
| 37 |
+
|
| 38 |
+
# ---------------------------------------------------------------------------
|
| 39 |
+
# State
|
| 40 |
+
# ---------------------------------------------------------------------------
|
| 41 |
+
_lbs = AccountScopedDict() # lb_arn -> LB record
|
| 42 |
+
_tgs = AccountScopedDict() # tg_arn -> TG record
|
| 43 |
+
_listeners = AccountScopedDict() # l_arn -> Listener record
|
| 44 |
+
_rules = AccountScopedDict() # r_arn -> Rule record
|
| 45 |
+
_targets = AccountScopedDict() # tg_arn -> [target dict]
|
| 46 |
+
_tags = AccountScopedDict() # res_arn -> [{Key, Value}]
|
| 47 |
+
_lb_attrs = AccountScopedDict() # lb_arn -> [{Key, Value}]
|
| 48 |
+
_tg_attrs = AccountScopedDict() # tg_arn -> [{Key, Value}]
|
| 49 |
+
_listener_attrs = AccountScopedDict() # l_arn -> [{Key, Value}]
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def get_state():
|
| 53 |
+
return copy.deepcopy({
|
| 54 |
+
"_lbs": _lbs,
|
| 55 |
+
"_tgs": _tgs,
|
| 56 |
+
"_listeners": _listeners,
|
| 57 |
+
"_rules": _rules,
|
| 58 |
+
"_targets": _targets,
|
| 59 |
+
"_tags": _tags,
|
| 60 |
+
"_lb_attrs": _lb_attrs,
|
| 61 |
+
"_tg_attrs": _tg_attrs,
|
| 62 |
+
"_listener_attrs": _listener_attrs,
|
| 63 |
+
})
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
def restore_state(data):
|
| 67 |
+
_lbs.update(data.get("_lbs", {}))
|
| 68 |
+
_tgs.update(data.get("_tgs", {}))
|
| 69 |
+
_listeners.update(data.get("_listeners", {}))
|
| 70 |
+
_rules.update(data.get("_rules", {}))
|
| 71 |
+
_targets.update(data.get("_targets", {}))
|
| 72 |
+
_tags.update(data.get("_tags", {}))
|
| 73 |
+
_lb_attrs.update(data.get("_lb_attrs", {}))
|
| 74 |
+
_tg_attrs.update(data.get("_tg_attrs", {}))
|
| 75 |
+
_listener_attrs.update(data.get("_listener_attrs", {}))
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
_restored = load_state("alb")
|
| 79 |
+
if _restored:
|
| 80 |
+
restore_state(_restored)
|
| 81 |
+
|
| 82 |
+
# ---------------------------------------------------------------------------
|
| 83 |
+
# Small helpers
|
| 84 |
+
# ---------------------------------------------------------------------------
|
| 85 |
+
|
| 86 |
+
def _p(params, key, default=""):
|
| 87 |
+
val = params.get(key, [default])
|
| 88 |
+
return (val[0] if val else default) if isinstance(val, list) else val
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
def _parse_member_list(params, prefix):
|
| 92 |
+
items, i = [], 1
|
| 93 |
+
while True:
|
| 94 |
+
v = _p(params, f"{prefix}.member.{i}")
|
| 95 |
+
if not v:
|
| 96 |
+
break
|
| 97 |
+
items.append(v)
|
| 98 |
+
i += 1
|
| 99 |
+
return items
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
def _parse_tags(params):
|
| 103 |
+
tags, i = [], 1
|
| 104 |
+
while True:
|
| 105 |
+
k = _p(params, f"Tags.member.{i}.Key")
|
| 106 |
+
if not k:
|
| 107 |
+
break
|
| 108 |
+
tags.append({"Key": k, "Value": _p(params, f"Tags.member.{i}.Value")})
|
| 109 |
+
i += 1
|
| 110 |
+
return tags
|
| 111 |
+
|
| 112 |
+
|
| 113 |
+
def _parse_actions(params, prefix="DefaultActions"):
|
| 114 |
+
actions, i = [], 1
|
| 115 |
+
while True:
|
| 116 |
+
t = _p(params, f"{prefix}.member.{i}.Type")
|
| 117 |
+
if not t:
|
| 118 |
+
break
|
| 119 |
+
action = {"Type": t, "Order": int(_p(params, f"{prefix}.member.{i}.Order", str(i)))}
|
| 120 |
+
tg = _p(params, f"{prefix}.member.{i}.TargetGroupArn")
|
| 121 |
+
if tg:
|
| 122 |
+
action["TargetGroupArn"] = tg
|
| 123 |
+
rc_code = _p(params, f"{prefix}.member.{i}.RedirectConfig.StatusCode")
|
| 124 |
+
if rc_code:
|
| 125 |
+
action["RedirectConfig"] = {
|
| 126 |
+
"Protocol": _p(params, f"{prefix}.member.{i}.RedirectConfig.Protocol", "#{protocol}"),
|
| 127 |
+
"Port": _p(params, f"{prefix}.member.{i}.RedirectConfig.Port", "#{port}"),
|
| 128 |
+
"Host": _p(params, f"{prefix}.member.{i}.RedirectConfig.Host", "#{host}"),
|
| 129 |
+
"Path": _p(params, f"{prefix}.member.{i}.RedirectConfig.Path", "/#{path}"),
|
| 130 |
+
"StatusCode": rc_code,
|
| 131 |
+
}
|
| 132 |
+
fr_code = _p(params, f"{prefix}.member.{i}.FixedResponseConfig.StatusCode")
|
| 133 |
+
if fr_code:
|
| 134 |
+
action["FixedResponseConfig"] = {
|
| 135 |
+
"StatusCode": fr_code,
|
| 136 |
+
"ContentType": _p(params, f"{prefix}.member.{i}.FixedResponseConfig.ContentType", "text/plain"),
|
| 137 |
+
"MessageBody": _p(params, f"{prefix}.member.{i}.FixedResponseConfig.MessageBody", ""),
|
| 138 |
+
}
|
| 139 |
+
actions.append(action)
|
| 140 |
+
i += 1
|
| 141 |
+
return actions
|
| 142 |
+
|
| 143 |
+
|
| 144 |
+
def _parse_conditions(params, prefix="Conditions"):
|
| 145 |
+
conditions, i = [], 1
|
| 146 |
+
while True:
|
| 147 |
+
field = _p(params, f"{prefix}.member.{i}.Field")
|
| 148 |
+
if not field:
|
| 149 |
+
break
|
| 150 |
+
values, j = [], 1
|
| 151 |
+
while True:
|
| 152 |
+
v = _p(params, f"{prefix}.member.{i}.Values.member.{j}")
|
| 153 |
+
if not v:
|
| 154 |
+
break
|
| 155 |
+
values.append(v)
|
| 156 |
+
j += 1
|
| 157 |
+
conditions.append({"Field": field, "Values": values})
|
| 158 |
+
i += 1
|
| 159 |
+
return conditions
|
| 160 |
+
|
| 161 |
+
|
| 162 |
+
def _parse_targets_param(params, prefix="Targets"):
|
| 163 |
+
targets, i = [], 1
|
| 164 |
+
while True:
|
| 165 |
+
tid = _p(params, f"{prefix}.member.{i}.Id")
|
| 166 |
+
if not tid:
|
| 167 |
+
break
|
| 168 |
+
t = {"Id": tid}
|
| 169 |
+
port = _p(params, f"{prefix}.member.{i}.Port")
|
| 170 |
+
if port:
|
| 171 |
+
t["Port"] = int(port)
|
| 172 |
+
targets.append(t)
|
| 173 |
+
i += 1
|
| 174 |
+
return targets
|
| 175 |
+
|
| 176 |
+
|
| 177 |
+
def _now_iso():
|
| 178 |
+
return time.strftime("%Y-%m-%dT%H:%M:%S.000Z", time.gmtime())
|
| 179 |
+
|
| 180 |
+
|
| 181 |
+
def _short_id():
|
| 182 |
+
return "".join(random.choices(string.ascii_lowercase + string.digits, k=16))
|
| 183 |
+
|
| 184 |
+
# ---------------------------------------------------------------------------
|
| 185 |
+
# XML builders
|
| 186 |
+
# ---------------------------------------------------------------------------
|
| 187 |
+
|
| 188 |
+
def _xml(status, action, inner):
|
| 189 |
+
body = (
|
| 190 |
+
f'<?xml version="1.0" encoding="UTF-8"?>'
|
| 191 |
+
f'<{action}Response xmlns="{NS}">'
|
| 192 |
+
f'<{action}Result>{inner}</{action}Result>'
|
| 193 |
+
f'<ResponseMetadata><RequestId>{new_uuid()}</RequestId></ResponseMetadata>'
|
| 194 |
+
f'</{action}Response>'
|
| 195 |
+
).encode("utf-8")
|
| 196 |
+
return status, {"Content-Type": "text/xml"}, body
|
| 197 |
+
|
| 198 |
+
|
| 199 |
+
def _empty(action):
|
| 200 |
+
body = (
|
| 201 |
+
f'<?xml version="1.0" encoding="UTF-8"?>'
|
| 202 |
+
f'<{action}Response xmlns="{NS}">'
|
| 203 |
+
f'<{action}Result/>'
|
| 204 |
+
f'<ResponseMetadata><RequestId>{new_uuid()}</RequestId></ResponseMetadata>'
|
| 205 |
+
f'</{action}Response>'
|
| 206 |
+
).encode("utf-8")
|
| 207 |
+
return 200, {"Content-Type": "text/xml"}, body
|
| 208 |
+
|
| 209 |
+
|
| 210 |
+
def _error(code, message, status=400):
|
| 211 |
+
body = (
|
| 212 |
+
f'<?xml version="1.0" encoding="UTF-8"?>'
|
| 213 |
+
f'<ErrorResponse xmlns="{NS}">'
|
| 214 |
+
f'<Error><Code>{code}</Code><Message>{message}</Message></Error>'
|
| 215 |
+
f'<RequestId>{new_uuid()}</RequestId>'
|
| 216 |
+
f'</ErrorResponse>'
|
| 217 |
+
).encode("utf-8")
|
| 218 |
+
return status, {"Content-Type": "text/xml"}, body
|
| 219 |
+
|
| 220 |
+
|
| 221 |
+
def _attrs_xml(attrs):
|
| 222 |
+
return "".join(
|
| 223 |
+
f"<member><Key>{a['Key']}</Key><Value>{a['Value']}</Value></member>"
|
| 224 |
+
for a in attrs
|
| 225 |
+
)
|
| 226 |
+
|
| 227 |
+
# ---------------------------------------------------------------------------
|
| 228 |
+
# XML serialisers for each resource type
|
| 229 |
+
# ---------------------------------------------------------------------------
|
| 230 |
+
|
| 231 |
+
def _lb_xml(lb):
|
| 232 |
+
azs = "".join(
|
| 233 |
+
f"<member><ZoneName>{get_region()}a</ZoneName><SubnetId>{s}</SubnetId>"
|
| 234 |
+
f"<LoadBalancerAddresses/></member>"
|
| 235 |
+
for s in lb.get("Subnets", [])
|
| 236 |
+
)
|
| 237 |
+
sgs = "".join(f"<member>{sg}</member>" for sg in lb.get("SecurityGroups", []))
|
| 238 |
+
return (
|
| 239 |
+
f"<member>"
|
| 240 |
+
f"<LoadBalancerArn>{lb['LoadBalancerArn']}</LoadBalancerArn>"
|
| 241 |
+
f"<LoadBalancerName>{lb['LoadBalancerName']}</LoadBalancerName>"
|
| 242 |
+
f"<DNSName>{lb['DNSName']}</DNSName>"
|
| 243 |
+
f"<CanonicalHostedZoneId>Z35SXDOTRQ7X7K</CanonicalHostedZoneId>"
|
| 244 |
+
f"<CreatedTime>{lb['CreatedTime']}</CreatedTime>"
|
| 245 |
+
f"<Scheme>{lb['Scheme']}</Scheme>"
|
| 246 |
+
f"<VpcId>{lb.get('VpcId','')}</VpcId>"
|
| 247 |
+
f"<State><Code>{lb['State']}</Code></State>"
|
| 248 |
+
f"<Type>{lb['Type']}</Type>"
|
| 249 |
+
f"<AvailabilityZones>{azs}</AvailabilityZones>"
|
| 250 |
+
f"<SecurityGroups>{sgs}</SecurityGroups>"
|
| 251 |
+
f"<IpAddressType>{lb.get('IpAddressType','ipv4')}</IpAddressType>"
|
| 252 |
+
f"</member>"
|
| 253 |
+
)
|
| 254 |
+
|
| 255 |
+
|
| 256 |
+
def _tg_xml(tg):
|
| 257 |
+
lb_arns = "".join(f"<member>{a}</member>" for a in tg.get("LoadBalancerArns", []))
|
| 258 |
+
return (
|
| 259 |
+
f"<member>"
|
| 260 |
+
f"<TargetGroupArn>{tg['TargetGroupArn']}</TargetGroupArn>"
|
| 261 |
+
f"<TargetGroupName>{tg['TargetGroupName']}</TargetGroupName>"
|
| 262 |
+
f"<Protocol>{tg.get('Protocol','HTTP')}</Protocol>"
|
| 263 |
+
f"<Port>{tg.get('Port',80)}</Port>"
|
| 264 |
+
f"<VpcId>{tg.get('VpcId','')}</VpcId>"
|
| 265 |
+
f"<HealthCheckProtocol>{tg.get('HealthCheckProtocol','HTTP')}</HealthCheckProtocol>"
|
| 266 |
+
f"<HealthCheckPort>{tg.get('HealthCheckPort','traffic-port')}</HealthCheckPort>"
|
| 267 |
+
f"<HealthCheckEnabled>{str(tg.get('HealthCheckEnabled',True)).lower()}</HealthCheckEnabled>"
|
| 268 |
+
f"<HealthCheckPath>{tg.get('HealthCheckPath','/')}</HealthCheckPath>"
|
| 269 |
+
f"<HealthCheckIntervalSeconds>{tg.get('HealthCheckIntervalSeconds',30)}</HealthCheckIntervalSeconds>"
|
| 270 |
+
f"<HealthCheckTimeoutSeconds>{tg.get('HealthCheckTimeoutSeconds',5)}</HealthCheckTimeoutSeconds>"
|
| 271 |
+
f"<HealthyThresholdCount>{tg.get('HealthyThresholdCount',5)}</HealthyThresholdCount>"
|
| 272 |
+
f"<UnhealthyThresholdCount>{tg.get('UnhealthyThresholdCount',2)}</UnhealthyThresholdCount>"
|
| 273 |
+
f"<Matcher><HttpCode>{tg.get('Matcher',{}).get('HttpCode','200')}</HttpCode></Matcher>"
|
| 274 |
+
f"<LoadBalancerArns>{lb_arns}</LoadBalancerArns>"
|
| 275 |
+
f"<TargetType>{tg.get('TargetType','instance')}</TargetType>"
|
| 276 |
+
f"</member>"
|
| 277 |
+
)
|
| 278 |
+
|
| 279 |
+
|
| 280 |
+
def _action_xml(a):
|
| 281 |
+
inner = f"<Type>{a['Type']}</Type><Order>{a.get('Order',1)}</Order>"
|
| 282 |
+
if "TargetGroupArn" in a:
|
| 283 |
+
inner += f"<TargetGroupArn>{a['TargetGroupArn']}</TargetGroupArn>"
|
| 284 |
+
if "RedirectConfig" in a:
|
| 285 |
+
rc = a["RedirectConfig"]
|
| 286 |
+
inner += (
|
| 287 |
+
f"<RedirectConfig>"
|
| 288 |
+
f"<Protocol>{rc.get('Protocol','#{protocol}')}</Protocol>"
|
| 289 |
+
f"<Port>{rc.get('Port','#{port}')}</Port>"
|
| 290 |
+
f"<Host>{rc.get('Host','#{host}')}</Host>"
|
| 291 |
+
f"<Path>{rc.get('Path','/#{path}')}</Path>"
|
| 292 |
+
f"<StatusCode>{rc.get('StatusCode','HTTP_301')}</StatusCode>"
|
| 293 |
+
f"</RedirectConfig>"
|
| 294 |
+
)
|
| 295 |
+
if "FixedResponseConfig" in a:
|
| 296 |
+
frc = a["FixedResponseConfig"]
|
| 297 |
+
inner += (
|
| 298 |
+
f"<FixedResponseConfig>"
|
| 299 |
+
f"<StatusCode>{frc.get('StatusCode','200')}</StatusCode>"
|
| 300 |
+
f"<ContentType>{frc.get('ContentType','text/plain')}</ContentType>"
|
| 301 |
+
f"<MessageBody>{frc.get('MessageBody','')}</MessageBody>"
|
| 302 |
+
f"</FixedResponseConfig>"
|
| 303 |
+
)
|
| 304 |
+
return f"<member>{inner}</member>"
|
| 305 |
+
|
| 306 |
+
|
| 307 |
+
def _listener_xml(l):
|
| 308 |
+
acts = "".join(_action_xml(a) for a in l.get("DefaultActions", []))
|
| 309 |
+
return (
|
| 310 |
+
f"<member>"
|
| 311 |
+
f"<ListenerArn>{l['ListenerArn']}</ListenerArn>"
|
| 312 |
+
f"<LoadBalancerArn>{l['LoadBalancerArn']}</LoadBalancerArn>"
|
| 313 |
+
f"<Port>{l.get('Port',80)}</Port>"
|
| 314 |
+
f"<Protocol>{l.get('Protocol','HTTP')}</Protocol>"
|
| 315 |
+
f"<DefaultActions>{acts}</DefaultActions>"
|
| 316 |
+
f"</member>"
|
| 317 |
+
)
|
| 318 |
+
|
| 319 |
+
|
| 320 |
+
def _rule_xml(r):
|
| 321 |
+
conds = "".join(
|
| 322 |
+
f"<member><Field>{c['Field']}</Field>"
|
| 323 |
+
f"<Values>{''.join(f'<member>{v}</member>' for v in c.get('Values',[]))}</Values>"
|
| 324 |
+
f"</member>"
|
| 325 |
+
for c in r.get("Conditions", [])
|
| 326 |
+
)
|
| 327 |
+
acts = "".join(_action_xml(a) for a in r.get("Actions", []))
|
| 328 |
+
return (
|
| 329 |
+
f"<member>"
|
| 330 |
+
f"<RuleArn>{r['RuleArn']}</RuleArn>"
|
| 331 |
+
f"<Priority>{r['Priority']}</Priority>"
|
| 332 |
+
f"<Conditions>{conds}</Conditions>"
|
| 333 |
+
f"<Actions>{acts}</Actions>"
|
| 334 |
+
f"<IsDefault>{str(r.get('IsDefault',False)).lower()}</IsDefault>"
|
| 335 |
+
f"</member>"
|
| 336 |
+
)
|
| 337 |
+
|
| 338 |
+
# ---------------------------------------------------------------------------
|
| 339 |
+
# Load Balancer handlers
|
| 340 |
+
# ---------------------------------------------------------------------------
|
| 341 |
+
|
| 342 |
+
def _create_lb(params):
|
| 343 |
+
name = _p(params, "Name")
|
| 344 |
+
if not name:
|
| 345 |
+
return _error("ValidationError", "Name is required")
|
| 346 |
+
for lb in _lbs.values():
|
| 347 |
+
if lb["LoadBalancerName"] == name:
|
| 348 |
+
return _error("DuplicateLoadBalancerName",
|
| 349 |
+
f"A load balancer with name '{name}' already exists.")
|
| 350 |
+
lid = _short_id()
|
| 351 |
+
arn = f"arn:aws:elasticloadbalancing:{get_region()}:{get_account_id()}:loadbalancer/app/{name}/{lid}"
|
| 352 |
+
lb = {
|
| 353 |
+
"LoadBalancerArn": arn,
|
| 354 |
+
"LoadBalancerName": name,
|
| 355 |
+
"DNSName": f"{name}-{lid[:8]}.{get_region()}.elb.amazonaws.com",
|
| 356 |
+
"Scheme": _p(params, "Scheme", "internet-facing"),
|
| 357 |
+
"VpcId": _p(params, "VpcId", "vpc-00000001"),
|
| 358 |
+
"State": "active",
|
| 359 |
+
"Type": _p(params, "Type", "application"),
|
| 360 |
+
"Subnets": _parse_member_list(params, "Subnets"),
|
| 361 |
+
"SecurityGroups": _parse_member_list(params, "SecurityGroups"),
|
| 362 |
+
"IpAddressType": _p(params, "IpAddressType", "ipv4"),
|
| 363 |
+
"CreatedTime": _now_iso(),
|
| 364 |
+
}
|
| 365 |
+
_lbs[arn] = lb
|
| 366 |
+
_tags[arn] = _parse_tags(params)
|
| 367 |
+
_lb_attrs[arn] = [
|
| 368 |
+
{"Key": "access_logs.s3.enabled", "Value": "false"},
|
| 369 |
+
{"Key": "deletion_protection.enabled", "Value": "false"},
|
| 370 |
+
{"Key": "idle_timeout.timeout_seconds", "Value": "60"},
|
| 371 |
+
]
|
| 372 |
+
return _xml(200, "CreateLoadBalancer", f"<LoadBalancers>{_lb_xml(lb)}</LoadBalancers>")
|
| 373 |
+
|
| 374 |
+
|
| 375 |
+
def _describe_lbs(params):
|
| 376 |
+
arn_filter = _parse_member_list(params, "LoadBalancerArns")
|
| 377 |
+
name_filter = _parse_member_list(params, "Names")
|
| 378 |
+
results = list(_lbs.values())
|
| 379 |
+
if arn_filter:
|
| 380 |
+
results = [lb for lb in results if lb["LoadBalancerArn"] in arn_filter]
|
| 381 |
+
if not results:
|
| 382 |
+
return _error("LoadBalancerNotFound", "One or more load balancers not found", 400)
|
| 383 |
+
if name_filter:
|
| 384 |
+
results = [lb for lb in results if lb["LoadBalancerName"] in name_filter]
|
| 385 |
+
if not results:
|
| 386 |
+
return _error("LoadBalancerNotFound", "One or more load balancers not found", 400)
|
| 387 |
+
return _xml(200, "DescribeLoadBalancers",
|
| 388 |
+
f"<LoadBalancers>{''.join(_lb_xml(lb) for lb in results)}</LoadBalancers>")
|
| 389 |
+
|
| 390 |
+
|
| 391 |
+
def _delete_lb(params):
|
| 392 |
+
arn = _p(params, "LoadBalancerArn")
|
| 393 |
+
_lbs.pop(arn, None)
|
| 394 |
+
_lb_attrs.pop(arn, None)
|
| 395 |
+
_tags.pop(arn, None)
|
| 396 |
+
return _empty("DeleteLoadBalancer")
|
| 397 |
+
|
| 398 |
+
|
| 399 |
+
def _describe_lb_attrs(params):
|
| 400 |
+
arn = _p(params, "LoadBalancerArn")
|
| 401 |
+
if arn not in _lbs:
|
| 402 |
+
return _error("LoadBalancerNotFound", f"Load balancer '{arn}' not found.")
|
| 403 |
+
return _xml(200, "DescribeLoadBalancerAttributes",
|
| 404 |
+
f"<Attributes>{_attrs_xml(_lb_attrs.get(arn,[]))}</Attributes>")
|
| 405 |
+
|
| 406 |
+
|
| 407 |
+
def _modify_lb_attrs(params):
|
| 408 |
+
arn = _p(params, "LoadBalancerArn")
|
| 409 |
+
if arn not in _lbs:
|
| 410 |
+
return _error("LoadBalancerNotFound", f"Load balancer '{arn}' not found.")
|
| 411 |
+
attrs = _lb_attrs.setdefault(arn, [])
|
| 412 |
+
idx = {a["Key"]: i for i, a in enumerate(attrs)}
|
| 413 |
+
i = 1
|
| 414 |
+
while True:
|
| 415 |
+
key = _p(params, f"Attributes.member.{i}.Key")
|
| 416 |
+
if not key:
|
| 417 |
+
break
|
| 418 |
+
val = _p(params, f"Attributes.member.{i}.Value")
|
| 419 |
+
if key in idx:
|
| 420 |
+
attrs[idx[key]]["Value"] = val
|
| 421 |
+
else:
|
| 422 |
+
attrs.append({"Key": key, "Value": val})
|
| 423 |
+
idx[key] = len(attrs) - 1
|
| 424 |
+
i += 1
|
| 425 |
+
return _xml(200, "ModifyLoadBalancerAttributes",
|
| 426 |
+
f"<Attributes>{_attrs_xml(attrs)}</Attributes>")
|
| 427 |
+
|
| 428 |
+
|
| 429 |
+
|
| 430 |
+
# ---------------------------------------------------------------------------
|
| 431 |
+
# Target Group handlers
|
| 432 |
+
# ---------------------------------------------------------------------------
|
| 433 |
+
|
| 434 |
+
def _create_tg(params):
|
| 435 |
+
name = _p(params, "Name")
|
| 436 |
+
if not name:
|
| 437 |
+
return _error("ValidationError", "Name is required")
|
| 438 |
+
for tg in _tgs.values():
|
| 439 |
+
if tg["TargetGroupName"] == name:
|
| 440 |
+
return _error("DuplicateTargetGroupName",
|
| 441 |
+
f"A target group with name '{name}' already exists.")
|
| 442 |
+
tid = _short_id()
|
| 443 |
+
arn = f"arn:aws:elasticloadbalancing:{get_region()}:{get_account_id()}:targetgroup/{name}/{tid}"
|
| 444 |
+
tg = {
|
| 445 |
+
"TargetGroupArn": arn,
|
| 446 |
+
"TargetGroupName": name,
|
| 447 |
+
"Protocol": _p(params, "Protocol", "HTTP"),
|
| 448 |
+
"Port": int(_p(params, "Port", "80") or 80),
|
| 449 |
+
"VpcId": _p(params, "VpcId", ""),
|
| 450 |
+
"HealthCheckProtocol": _p(params, "HealthCheckProtocol", "HTTP"),
|
| 451 |
+
"HealthCheckPort": _p(params, "HealthCheckPort", "traffic-port"),
|
| 452 |
+
"HealthCheckEnabled": _p(params, "HealthCheckEnabled", "true").lower() == "true",
|
| 453 |
+
"HealthCheckPath": _p(params, "HealthCheckPath", "/"),
|
| 454 |
+
"HealthCheckIntervalSeconds": int(_p(params, "HealthCheckIntervalSeconds", "30") or 30),
|
| 455 |
+
"HealthCheckTimeoutSeconds": int(_p(params, "HealthCheckTimeoutSeconds", "5") or 5),
|
| 456 |
+
"HealthyThresholdCount": int(_p(params, "HealthyThresholdCount", "5") or 5),
|
| 457 |
+
"UnhealthyThresholdCount": int(_p(params, "UnhealthyThresholdCount", "2") or 2),
|
| 458 |
+
"Matcher": {"HttpCode": _p(params, "Matcher.HttpCode", "200")},
|
| 459 |
+
"LoadBalancerArns": [],
|
| 460 |
+
"TargetType": _p(params, "TargetType", "instance"),
|
| 461 |
+
}
|
| 462 |
+
_tgs[arn] = tg
|
| 463 |
+
_targets[arn] = []
|
| 464 |
+
_tags[arn] = _parse_tags(params)
|
| 465 |
+
_tg_attrs[arn] = [
|
| 466 |
+
{"Key": "deregistration_delay.timeout_seconds", "Value": "300"},
|
| 467 |
+
{"Key": "stickiness.enabled", "Value": "false"},
|
| 468 |
+
{"Key": "stickiness.type", "Value": "lb_cookie"},
|
| 469 |
+
]
|
| 470 |
+
return _xml(200, "CreateTargetGroup", f"<TargetGroups>{_tg_xml(tg)}</TargetGroups>")
|
| 471 |
+
|
| 472 |
+
|
| 473 |
+
def _describe_tgs(params):
|
| 474 |
+
arn_filter = _parse_member_list(params, "TargetGroupArns")
|
| 475 |
+
name_filter = _parse_member_list(params, "Names")
|
| 476 |
+
lb_arn = _p(params, "LoadBalancerArn")
|
| 477 |
+
results = list(_tgs.values())
|
| 478 |
+
if arn_filter:
|
| 479 |
+
results = [tg for tg in results if tg["TargetGroupArn"] in arn_filter]
|
| 480 |
+
if not results:
|
| 481 |
+
return _error("TargetGroupNotFound", "One or more target groups not found", 400)
|
| 482 |
+
if name_filter:
|
| 483 |
+
results = [tg for tg in results if tg["TargetGroupName"] in name_filter]
|
| 484 |
+
if lb_arn:
|
| 485 |
+
results = [tg for tg in results if lb_arn in tg.get("LoadBalancerArns", [])]
|
| 486 |
+
return _xml(200, "DescribeTargetGroups",
|
| 487 |
+
f"<TargetGroups>{''.join(_tg_xml(tg) for tg in results)}</TargetGroups>")
|
| 488 |
+
|
| 489 |
+
|
| 490 |
+
def _modify_tg(params):
|
| 491 |
+
arn = _p(params, "TargetGroupArn")
|
| 492 |
+
tg = _tgs.get(arn)
|
| 493 |
+
if not tg:
|
| 494 |
+
return _error("TargetGroupNotFound", f"Target group '{arn}' not found.")
|
| 495 |
+
for field, param in [("HealthCheckProtocol", "HealthCheckProtocol"),
|
| 496 |
+
("HealthCheckPort", "HealthCheckPort"),
|
| 497 |
+
("HealthCheckPath", "HealthCheckPath")]:
|
| 498 |
+
v = _p(params, param)
|
| 499 |
+
if v:
|
| 500 |
+
tg[field] = v
|
| 501 |
+
for field, param, cast in [
|
| 502 |
+
("HealthCheckEnabled", "HealthCheckEnabled", lambda v: v.lower() == "true"),
|
| 503 |
+
("HealthCheckIntervalSeconds", "HealthCheckIntervalSeconds", int),
|
| 504 |
+
("HealthCheckTimeoutSeconds", "HealthCheckTimeoutSeconds", int),
|
| 505 |
+
("HealthyThresholdCount", "HealthyThresholdCount", int),
|
| 506 |
+
("UnhealthyThresholdCount", "UnhealthyThresholdCount", int),
|
| 507 |
+
]:
|
| 508 |
+
v = _p(params, param)
|
| 509 |
+
if v:
|
| 510 |
+
tg[field] = cast(v)
|
| 511 |
+
http_code = _p(params, "Matcher.HttpCode")
|
| 512 |
+
if http_code:
|
| 513 |
+
tg["Matcher"]["HttpCode"] = http_code
|
| 514 |
+
return _xml(200, "ModifyTargetGroup", f"<TargetGroups>{_tg_xml(tg)}</TargetGroups>")
|
| 515 |
+
|
| 516 |
+
|
| 517 |
+
def _delete_tg(params):
|
| 518 |
+
arn = _p(params, "TargetGroupArn")
|
| 519 |
+
if arn not in _tgs:
|
| 520 |
+
return _error("TargetGroupNotFound", f"Target group '{arn}' not found", 400)
|
| 521 |
+
_tgs.pop(arn, None)
|
| 522 |
+
_targets.pop(arn, None)
|
| 523 |
+
_tg_attrs.pop(arn, None)
|
| 524 |
+
_tags.pop(arn, None)
|
| 525 |
+
return _empty("DeleteTargetGroup")
|
| 526 |
+
|
| 527 |
+
|
| 528 |
+
def _describe_tg_attrs(params):
|
| 529 |
+
arn = _p(params, "TargetGroupArn")
|
| 530 |
+
if arn not in _tgs:
|
| 531 |
+
return _error("TargetGroupNotFound", f"Target group '{arn}' not found.")
|
| 532 |
+
return _xml(200, "DescribeTargetGroupAttributes",
|
| 533 |
+
f"<Attributes>{_attrs_xml(_tg_attrs.get(arn,[]))}</Attributes>")
|
| 534 |
+
|
| 535 |
+
|
| 536 |
+
def _modify_tg_attrs(params):
|
| 537 |
+
arn = _p(params, "TargetGroupArn")
|
| 538 |
+
if arn not in _tgs:
|
| 539 |
+
return _error("TargetGroupNotFound", f"Target group '{arn}' not found.")
|
| 540 |
+
attrs = _tg_attrs.setdefault(arn, [])
|
| 541 |
+
idx = {a["Key"]: i for i, a in enumerate(attrs)}
|
| 542 |
+
i = 1
|
| 543 |
+
while True:
|
| 544 |
+
key = _p(params, f"Attributes.member.{i}.Key")
|
| 545 |
+
if not key:
|
| 546 |
+
break
|
| 547 |
+
val = _p(params, f"Attributes.member.{i}.Value")
|
| 548 |
+
if key in idx:
|
| 549 |
+
attrs[idx[key]]["Value"] = val
|
| 550 |
+
else:
|
| 551 |
+
attrs.append({"Key": key, "Value": val})
|
| 552 |
+
idx[key] = len(attrs) - 1
|
| 553 |
+
i += 1
|
| 554 |
+
return _xml(200, "ModifyTargetGroupAttributes",
|
| 555 |
+
f"<Attributes>{_attrs_xml(attrs)}</Attributes>")
|
| 556 |
+
|
| 557 |
+
|
| 558 |
+
# ---------------------------------------------------------------------------
|
| 559 |
+
# Listener handlers
|
| 560 |
+
# ---------------------------------------------------------------------------
|
| 561 |
+
|
| 562 |
+
def _create_listener(params):
|
| 563 |
+
lb_arn = _p(params, "LoadBalancerArn")
|
| 564 |
+
if lb_arn not in _lbs:
|
| 565 |
+
return _error("LoadBalancerNotFound", f"Load balancer '{lb_arn}' not found.")
|
| 566 |
+
lid = _short_id()
|
| 567 |
+
lb = _lbs[lb_arn]
|
| 568 |
+
lb_name = lb["LoadBalancerName"]
|
| 569 |
+
lb_id = lb_arn.split("/")[-1]
|
| 570 |
+
l_arn = (f"arn:aws:elasticloadbalancing:{get_region()}:{get_account_id()}"
|
| 571 |
+
f":listener/app/{lb_name}/{lb_id}/{lid}")
|
| 572 |
+
actions = _parse_actions(params, "DefaultActions")
|
| 573 |
+
for action in actions:
|
| 574 |
+
tg_arn = action.get("TargetGroupArn")
|
| 575 |
+
if tg_arn and tg_arn in _tgs and lb_arn not in _tgs[tg_arn]["LoadBalancerArns"]:
|
| 576 |
+
_tgs[tg_arn]["LoadBalancerArns"].append(lb_arn)
|
| 577 |
+
listener = {
|
| 578 |
+
"ListenerArn": l_arn,
|
| 579 |
+
"LoadBalancerArn": lb_arn,
|
| 580 |
+
"Port": int(_p(params, "Port", "80") or 80),
|
| 581 |
+
"Protocol": _p(params, "Protocol", "HTTP"),
|
| 582 |
+
"DefaultActions": actions,
|
| 583 |
+
}
|
| 584 |
+
_listeners[l_arn] = listener
|
| 585 |
+
_listener_attrs[l_arn] = [
|
| 586 |
+
{"Key": "routing.http.response.server.enabled", "Value": "true"},
|
| 587 |
+
]
|
| 588 |
+
_tags[l_arn] = _parse_tags(params)
|
| 589 |
+
# auto-create default rule
|
| 590 |
+
rule_id = _short_id()
|
| 591 |
+
rule_arn = (f"arn:aws:elasticloadbalancing:{get_region()}:{get_account_id()}"
|
| 592 |
+
f":listener-rule/app/{lb_name}/{lb_id}/{lid}/{rule_id}")
|
| 593 |
+
_rules[rule_arn] = {
|
| 594 |
+
"RuleArn": rule_arn, "ListenerArn": l_arn,
|
| 595 |
+
"Priority": "default", "Conditions": [],
|
| 596 |
+
"Actions": actions, "IsDefault": True,
|
| 597 |
+
}
|
| 598 |
+
return _xml(200, "CreateListener", f"<Listeners>{_listener_xml(listener)}</Listeners>")
|
| 599 |
+
|
| 600 |
+
|
| 601 |
+
def _describe_listeners(params):
|
| 602 |
+
lb_arn = _p(params, "LoadBalancerArn")
|
| 603 |
+
arn_filter = _parse_member_list(params, "ListenerArns")
|
| 604 |
+
results = list(_listeners.values())
|
| 605 |
+
if lb_arn:
|
| 606 |
+
results = [l for l in results if l["LoadBalancerArn"] == lb_arn]
|
| 607 |
+
if arn_filter:
|
| 608 |
+
results = [l for l in results if l["ListenerArn"] in arn_filter]
|
| 609 |
+
return _xml(200, "DescribeListeners",
|
| 610 |
+
f"<Listeners>{''.join(_listener_xml(l) for l in results)}</Listeners>")
|
| 611 |
+
|
| 612 |
+
|
| 613 |
+
def _modify_listener(params):
|
| 614 |
+
arn = _p(params, "ListenerArn")
|
| 615 |
+
listener = _listeners.get(arn)
|
| 616 |
+
if not listener:
|
| 617 |
+
return _error("ListenerNotFound", f"Listener '{arn}' not found.")
|
| 618 |
+
port = _p(params, "Port")
|
| 619 |
+
if port:
|
| 620 |
+
listener["Port"] = int(port)
|
| 621 |
+
protocol = _p(params, "Protocol")
|
| 622 |
+
if protocol:
|
| 623 |
+
listener["Protocol"] = protocol
|
| 624 |
+
actions = _parse_actions(params, "DefaultActions")
|
| 625 |
+
if actions:
|
| 626 |
+
listener["DefaultActions"] = actions
|
| 627 |
+
return _xml(200, "ModifyListener", f"<Listeners>{_listener_xml(listener)}</Listeners>")
|
| 628 |
+
|
| 629 |
+
|
| 630 |
+
def _delete_listener(params):
|
| 631 |
+
arn = _p(params, "ListenerArn")
|
| 632 |
+
if arn not in _listeners:
|
| 633 |
+
return _error("ListenerNotFound", f"Listener '{arn}' not found", 400)
|
| 634 |
+
_listeners.pop(arn, None)
|
| 635 |
+
_listener_attrs.pop(arn, None)
|
| 636 |
+
_tags.pop(arn, None)
|
| 637 |
+
for rarn in [k for k, v in list(_rules.items()) if v.get("ListenerArn") == arn]:
|
| 638 |
+
_rules.pop(rarn, None)
|
| 639 |
+
return _empty("DeleteListener")
|
| 640 |
+
|
| 641 |
+
|
| 642 |
+
def _describe_listener_attrs(params):
|
| 643 |
+
arn = _p(params, "ListenerArn")
|
| 644 |
+
if arn not in _listeners:
|
| 645 |
+
return _error("ListenerNotFound", f"Listener '{arn}' not found.")
|
| 646 |
+
attrs = _listener_attrs.get(arn, [])
|
| 647 |
+
return _xml(200, "DescribeListenerAttributes",
|
| 648 |
+
f"<Attributes>{_attrs_xml(attrs)}</Attributes>")
|
| 649 |
+
|
| 650 |
+
|
| 651 |
+
def _modify_listener_attrs(params):
|
| 652 |
+
arn = _p(params, "ListenerArn")
|
| 653 |
+
if arn not in _listeners:
|
| 654 |
+
return _error("ListenerNotFound", f"Listener '{arn}' not found.")
|
| 655 |
+
attrs = _listener_attrs.setdefault(arn, [])
|
| 656 |
+
idx = {a["Key"]: i for i, a in enumerate(attrs)}
|
| 657 |
+
i = 1
|
| 658 |
+
while True:
|
| 659 |
+
key = _p(params, f"Attributes.member.{i}.Key")
|
| 660 |
+
if not key:
|
| 661 |
+
break
|
| 662 |
+
val = _p(params, f"Attributes.member.{i}.Value")
|
| 663 |
+
if key in idx:
|
| 664 |
+
attrs[idx[key]]["Value"] = val
|
| 665 |
+
else:
|
| 666 |
+
attrs.append({"Key": key, "Value": val})
|
| 667 |
+
idx[key] = len(attrs) - 1
|
| 668 |
+
i += 1
|
| 669 |
+
return _xml(200, "ModifyListenerAttributes",
|
| 670 |
+
f"<Attributes>{_attrs_xml(attrs)}</Attributes>")
|
| 671 |
+
|
| 672 |
+
|
| 673 |
+
# ---------------------------------------------------------------------------
|
| 674 |
+
# Rule handlers
|
| 675 |
+
# ---------------------------------------------------------------------------
|
| 676 |
+
|
| 677 |
+
def _create_rule(params):
|
| 678 |
+
l_arn = _p(params, "ListenerArn")
|
| 679 |
+
if l_arn not in _listeners:
|
| 680 |
+
return _error("ListenerNotFound", f"Listener '{l_arn}' not found.")
|
| 681 |
+
listener = _listeners[l_arn]
|
| 682 |
+
lb_arn = listener["LoadBalancerArn"]
|
| 683 |
+
lb_name = _lbs[lb_arn]["LoadBalancerName"]
|
| 684 |
+
lb_id = lb_arn.split("/")[-1]
|
| 685 |
+
l_id = l_arn.split("/")[-1]
|
| 686 |
+
rule_id = _short_id()
|
| 687 |
+
rule_arn = (f"arn:aws:elasticloadbalancing:{get_region()}:{get_account_id()}"
|
| 688 |
+
f":listener-rule/app/{lb_name}/{lb_id}/{l_id}/{rule_id}")
|
| 689 |
+
rule = {
|
| 690 |
+
"RuleArn": rule_arn, "ListenerArn": l_arn,
|
| 691 |
+
"Priority": _p(params, "Priority", "1"),
|
| 692 |
+
"Conditions": _parse_conditions(params),
|
| 693 |
+
"Actions": _parse_actions(params, "Actions"),
|
| 694 |
+
"IsDefault": False,
|
| 695 |
+
}
|
| 696 |
+
_rules[rule_arn] = rule
|
| 697 |
+
_tags[rule_arn] = _parse_tags(params)
|
| 698 |
+
return _xml(200, "CreateRule", f"<Rules>{_rule_xml(rule)}</Rules>")
|
| 699 |
+
|
| 700 |
+
|
| 701 |
+
def _describe_rules(params):
|
| 702 |
+
l_arn = _p(params, "ListenerArn")
|
| 703 |
+
arn_filter = _parse_member_list(params, "RuleArns")
|
| 704 |
+
results = list(_rules.values())
|
| 705 |
+
if l_arn:
|
| 706 |
+
results = [r for r in results if r.get("ListenerArn") == l_arn]
|
| 707 |
+
if arn_filter:
|
| 708 |
+
results = [r for r in results if r["RuleArn"] in arn_filter]
|
| 709 |
+
return _xml(200, "DescribeRules", f"<Rules>{''.join(_rule_xml(r) for r in results)}</Rules>")
|
| 710 |
+
|
| 711 |
+
|
| 712 |
+
def _modify_rule(params):
|
| 713 |
+
arn = _p(params, "RuleArn")
|
| 714 |
+
rule = _rules.get(arn)
|
| 715 |
+
if not rule:
|
| 716 |
+
return _error("RuleNotFound", f"Rule '{arn}' not found.")
|
| 717 |
+
conds = _parse_conditions(params)
|
| 718 |
+
if conds:
|
| 719 |
+
rule["Conditions"] = conds
|
| 720 |
+
acts = _parse_actions(params, "Actions")
|
| 721 |
+
if acts:
|
| 722 |
+
rule["Actions"] = acts
|
| 723 |
+
return _xml(200, "ModifyRule", f"<Rules>{_rule_xml(rule)}</Rules>")
|
| 724 |
+
|
| 725 |
+
|
| 726 |
+
def _delete_rule(params):
|
| 727 |
+
arn = _p(params, "RuleArn")
|
| 728 |
+
if _rules.get(arn, {}).get("IsDefault"):
|
| 729 |
+
return _error("OperationNotPermitted", "Cannot delete a default rule.")
|
| 730 |
+
_rules.pop(arn, None)
|
| 731 |
+
_tags.pop(arn, None)
|
| 732 |
+
return _empty("DeleteRule")
|
| 733 |
+
|
| 734 |
+
|
| 735 |
+
def _set_rule_priorities(params):
|
| 736 |
+
updated, i = [], 1
|
| 737 |
+
while True:
|
| 738 |
+
arn = _p(params, f"RulePriorities.member.{i}.RuleArn")
|
| 739 |
+
if not arn:
|
| 740 |
+
break
|
| 741 |
+
priority = _p(params, f"RulePriorities.member.{i}.Priority")
|
| 742 |
+
if arn in _rules:
|
| 743 |
+
_rules[arn]["Priority"] = priority
|
| 744 |
+
updated.append(_rules[arn])
|
| 745 |
+
i += 1
|
| 746 |
+
return _xml(200, "SetRulePriorities",
|
| 747 |
+
f"<Rules>{''.join(_rule_xml(r) for r in updated)}</Rules>")
|
| 748 |
+
|
| 749 |
+
|
| 750 |
+
# ---------------------------------------------------------------------------
|
| 751 |
+
# Target registration handlers
|
| 752 |
+
# ---------------------------------------------------------------------------
|
| 753 |
+
|
| 754 |
+
def _register_targets(params):
|
| 755 |
+
tg_arn = _p(params, "TargetGroupArn")
|
| 756 |
+
if tg_arn not in _tgs:
|
| 757 |
+
return _error("TargetGroupNotFound", f"Target group '{tg_arn}' not found.")
|
| 758 |
+
new_tgts = _parse_targets_param(params)
|
| 759 |
+
existing = _targets.setdefault(tg_arn, [])
|
| 760 |
+
existing_ids = {t["Id"] for t in existing}
|
| 761 |
+
for t in new_tgts:
|
| 762 |
+
if t["Id"] not in existing_ids:
|
| 763 |
+
existing.append(t)
|
| 764 |
+
existing_ids.add(t["Id"])
|
| 765 |
+
return _empty("RegisterTargets")
|
| 766 |
+
|
| 767 |
+
|
| 768 |
+
def _deregister_targets(params):
|
| 769 |
+
tg_arn = _p(params, "TargetGroupArn")
|
| 770 |
+
if tg_arn not in _tgs:
|
| 771 |
+
return _error("TargetGroupNotFound", f"Target group '{tg_arn}' not found.")
|
| 772 |
+
to_remove = {t["Id"] for t in _parse_targets_param(params)}
|
| 773 |
+
_targets[tg_arn] = [t for t in _targets.get(tg_arn, []) if t["Id"] not in to_remove]
|
| 774 |
+
return _empty("DeregisterTargets")
|
| 775 |
+
|
| 776 |
+
|
| 777 |
+
def _describe_target_health(params):
|
| 778 |
+
tg_arn = _p(params, "TargetGroupArn")
|
| 779 |
+
if tg_arn not in _tgs:
|
| 780 |
+
return _error("TargetGroupNotFound", f"Target group '{tg_arn}' not found.")
|
| 781 |
+
registered = _targets.get(tg_arn, [])
|
| 782 |
+
target_filter = {t["Id"] for t in _parse_targets_param(params)}
|
| 783 |
+
if target_filter:
|
| 784 |
+
registered = [t for t in registered if t["Id"] in target_filter]
|
| 785 |
+
default_port = _tgs[tg_arn].get("Port", 80)
|
| 786 |
+
descs = "".join(
|
| 787 |
+
f"<member>"
|
| 788 |
+
f"<Target><Id>{t['Id']}</Id><Port>{t.get('Port', default_port)}</Port></Target>"
|
| 789 |
+
f"<HealthStatus>healthy</HealthStatus>"
|
| 790 |
+
f"<TargetHealth><State>healthy</State></TargetHealth>"
|
| 791 |
+
f"</member>"
|
| 792 |
+
for t in registered
|
| 793 |
+
)
|
| 794 |
+
return _xml(200, "DescribeTargetHealth",
|
| 795 |
+
f"<TargetHealthDescriptions>{descs}</TargetHealthDescriptions>")
|
| 796 |
+
|
| 797 |
+
|
| 798 |
+
# ---------------------------------------------------------------------------
|
| 799 |
+
# Tag handlers
|
| 800 |
+
# ---------------------------------------------------------------------------
|
| 801 |
+
|
| 802 |
+
def _add_tags(params):
|
| 803 |
+
arns = _parse_member_list(params, "ResourceArns")
|
| 804 |
+
new_tags = _parse_tags(params)
|
| 805 |
+
for arn in arns:
|
| 806 |
+
existing = _tags.setdefault(arn, [])
|
| 807 |
+
idx = {t["Key"]: i for i, t in enumerate(existing)}
|
| 808 |
+
for tag in new_tags:
|
| 809 |
+
if tag["Key"] in idx:
|
| 810 |
+
existing[idx[tag["Key"]]]["Value"] = tag["Value"]
|
| 811 |
+
else:
|
| 812 |
+
existing.append(tag)
|
| 813 |
+
idx[tag["Key"]] = len(existing) - 1
|
| 814 |
+
return _empty("AddTags")
|
| 815 |
+
|
| 816 |
+
|
| 817 |
+
def _remove_tags(params):
|
| 818 |
+
arns = _parse_member_list(params, "ResourceArns")
|
| 819 |
+
key_set = set(_parse_member_list(params, "TagKeys"))
|
| 820 |
+
for arn in arns:
|
| 821 |
+
if arn in _tags:
|
| 822 |
+
_tags[arn] = [t for t in _tags[arn] if t["Key"] not in key_set]
|
| 823 |
+
return _empty("RemoveTags")
|
| 824 |
+
|
| 825 |
+
|
| 826 |
+
def _describe_tags(params):
|
| 827 |
+
arns = _parse_member_list(params, "ResourceArns")
|
| 828 |
+
descs = ""
|
| 829 |
+
for arn in arns:
|
| 830 |
+
tags_xml = "".join(
|
| 831 |
+
f"<member><Key>{t['Key']}</Key><Value>{t['Value']}</Value></member>"
|
| 832 |
+
for t in _tags.get(arn, [])
|
| 833 |
+
)
|
| 834 |
+
descs += f"<member><ResourceArn>{arn}</ResourceArn><Tags>{tags_xml}</Tags></member>"
|
| 835 |
+
return _xml(200, "DescribeTags", f"<TagDescriptions>{descs}</TagDescriptions>")
|
| 836 |
+
|
| 837 |
+
|
| 838 |
+
# ---------------------------------------------------------------------------
|
| 839 |
+
# Action map, request routing, and reset
|
| 840 |
+
# ---------------------------------------------------------------------------
|
| 841 |
+
|
| 842 |
+
_ACTION_MAP = {
|
| 843 |
+
"CreateLoadBalancer": _create_lb,
|
| 844 |
+
"DescribeLoadBalancers": _describe_lbs,
|
| 845 |
+
"DeleteLoadBalancer": _delete_lb,
|
| 846 |
+
"DescribeLoadBalancerAttributes": _describe_lb_attrs,
|
| 847 |
+
"ModifyLoadBalancerAttributes": _modify_lb_attrs,
|
| 848 |
+
"CreateTargetGroup": _create_tg,
|
| 849 |
+
"DescribeTargetGroups": _describe_tgs,
|
| 850 |
+
"ModifyTargetGroup": _modify_tg,
|
| 851 |
+
"DeleteTargetGroup": _delete_tg,
|
| 852 |
+
"DescribeTargetGroupAttributes": _describe_tg_attrs,
|
| 853 |
+
"ModifyTargetGroupAttributes": _modify_tg_attrs,
|
| 854 |
+
"CreateListener": _create_listener,
|
| 855 |
+
"DescribeListeners": _describe_listeners,
|
| 856 |
+
"DescribeListenerAttributes": _describe_listener_attrs,
|
| 857 |
+
"ModifyListenerAttributes": _modify_listener_attrs,
|
| 858 |
+
"ModifyListener": _modify_listener,
|
| 859 |
+
"DeleteListener": _delete_listener,
|
| 860 |
+
"CreateRule": _create_rule,
|
| 861 |
+
"DescribeRules": _describe_rules,
|
| 862 |
+
"ModifyRule": _modify_rule,
|
| 863 |
+
"DeleteRule": _delete_rule,
|
| 864 |
+
"SetRulePriorities": _set_rule_priorities,
|
| 865 |
+
"RegisterTargets": _register_targets,
|
| 866 |
+
"DeregisterTargets": _deregister_targets,
|
| 867 |
+
"DescribeTargetHealth": _describe_target_health,
|
| 868 |
+
"AddTags": _add_tags,
|
| 869 |
+
"RemoveTags": _remove_tags,
|
| 870 |
+
"DescribeTags": _describe_tags,
|
| 871 |
+
}
|
| 872 |
+
|
| 873 |
+
|
| 874 |
+
async def handle_request(method, path, headers, body, query_params):
|
| 875 |
+
params = dict(query_params)
|
| 876 |
+
if method == "POST" and body:
|
| 877 |
+
raw = body if isinstance(body, str) else body.decode("utf-8", errors="replace")
|
| 878 |
+
for k, v in parse_qs(raw).items():
|
| 879 |
+
params[k] = v
|
| 880 |
+
action = _p(params, "Action")
|
| 881 |
+
handler = _ACTION_MAP.get(action)
|
| 882 |
+
if not handler:
|
| 883 |
+
return _error("InvalidAction", f"Unknown ELBv2 action: {action}", 400)
|
| 884 |
+
return handler(params)
|
| 885 |
+
|
| 886 |
+
|
| 887 |
+
# ---------------------------------------------------------------------------
|
| 888 |
+
# Data-plane: host/name lookup
|
| 889 |
+
# ---------------------------------------------------------------------------
|
| 890 |
+
|
| 891 |
+
def find_lb_for_host(host):
|
| 892 |
+
hostname = host.split(":")[0].lower()
|
| 893 |
+
for lb in _lbs.values():
|
| 894 |
+
if lb["DNSName"].lower() == hostname:
|
| 895 |
+
return lb
|
| 896 |
+
if hostname == f"{lb['LoadBalancerName'].lower()}.alb.localhost":
|
| 897 |
+
return lb
|
| 898 |
+
return None
|
| 899 |
+
|
| 900 |
+
|
| 901 |
+
def _find_lb_by_name(name):
|
| 902 |
+
name_lc = name.lower()
|
| 903 |
+
for lb in _lbs.values():
|
| 904 |
+
if lb["LoadBalancerName"].lower() == name_lc:
|
| 905 |
+
return lb
|
| 906 |
+
return None
|
| 907 |
+
|
| 908 |
+
|
| 909 |
+
# ---------------------------------------------------------------------------
|
| 910 |
+
# Data-plane: rule matching
|
| 911 |
+
# ---------------------------------------------------------------------------
|
| 912 |
+
|
| 913 |
+
def _match_condition(cond, method, path, headers, query_params):
|
| 914 |
+
field = cond.get("Field", "")
|
| 915 |
+
values = cond.get("Values", [])
|
| 916 |
+
|
| 917 |
+
if field == "path-pattern":
|
| 918 |
+
return any(fnmatch.fnmatch(path, v) for v in values)
|
| 919 |
+
|
| 920 |
+
if field == "host-header":
|
| 921 |
+
hostname = headers.get("host", "").split(":")[0]
|
| 922 |
+
return any(fnmatch.fnmatch(hostname, v) for v in values)
|
| 923 |
+
|
| 924 |
+
if field == "http-method":
|
| 925 |
+
return method.upper() in [v.upper() for v in values]
|
| 926 |
+
|
| 927 |
+
if field == "query-string":
|
| 928 |
+
# Values stored as "key=value" strings
|
| 929 |
+
for v in values:
|
| 930 |
+
if "=" in v:
|
| 931 |
+
k, expected = v.split("=", 1)
|
| 932 |
+
actual = query_params.get(k, [""])[0] if isinstance(query_params.get(k), list) else query_params.get(k, "")
|
| 933 |
+
if actual != expected:
|
| 934 |
+
return False
|
| 935 |
+
else:
|
| 936 |
+
if v not in query_params:
|
| 937 |
+
return False
|
| 938 |
+
return True
|
| 939 |
+
|
| 940 |
+
if field == "http-header":
|
| 941 |
+
cfg = cond.get("HttpHeaderConfig", {})
|
| 942 |
+
hname = cfg.get("HttpHeaderName", "").lower()
|
| 943 |
+
hvals = cfg.get("Values", values)
|
| 944 |
+
actual = headers.get(hname, "")
|
| 945 |
+
return any(fnmatch.fnmatch(actual, v) for v in hvals)
|
| 946 |
+
|
| 947 |
+
# source-ip is not implemented (no real network in emulator) — always matches.
|
| 948 |
+
# Unknown condition types also always match to avoid silently dropping traffic.
|
| 949 |
+
return True
|
| 950 |
+
|
| 951 |
+
|
| 952 |
+
def _rule_sort_key(rule):
|
| 953 |
+
p = rule.get("Priority", "default")
|
| 954 |
+
if p == "default":
|
| 955 |
+
return (1, 0)
|
| 956 |
+
try:
|
| 957 |
+
return (0, int(p))
|
| 958 |
+
except (ValueError, TypeError):
|
| 959 |
+
return (0, 9999)
|
| 960 |
+
|
| 961 |
+
|
| 962 |
+
# ---------------------------------------------------------------------------
|
| 963 |
+
# Data-plane: action execution
|
| 964 |
+
# ---------------------------------------------------------------------------
|
| 965 |
+
|
| 966 |
+
async def _execute_action(action, method, path, headers, body, query_params):
|
| 967 |
+
atype = action.get("Type", "").lower()
|
| 968 |
+
|
| 969 |
+
if atype == "fixed-response":
|
| 970 |
+
frc = action.get("FixedResponseConfig", {})
|
| 971 |
+
code = int(frc.get("StatusCode", "200"))
|
| 972 |
+
ct = frc.get("ContentType", "text/plain")
|
| 973 |
+
msg = frc.get("MessageBody", "")
|
| 974 |
+
return code, {"Content-Type": ct}, msg.encode("utf-8")
|
| 975 |
+
|
| 976 |
+
if atype == "redirect":
|
| 977 |
+
rc = action.get("RedirectConfig", {})
|
| 978 |
+
code = int(rc.get("StatusCode", "HTTP_301").replace("HTTP_", ""))
|
| 979 |
+
src_host = headers.get("host", "localhost").split(":")[0]
|
| 980 |
+
proto = rc.get("Protocol", "#{protocol}").replace("#{protocol}", "http")
|
| 981 |
+
rhost = rc.get("Host", "#{host}").replace("#{host}", src_host)
|
| 982 |
+
rport = rc.get("Port", "#{port}").replace("#{port}", "")
|
| 983 |
+
rpath = rc.get("Path", "/#{path}").replace("#{path}", path.lstrip("/"))
|
| 984 |
+
location = f"{proto}://{rhost}"
|
| 985 |
+
if rport and rport not in ("80", "443", ""):
|
| 986 |
+
location += f":{rport}"
|
| 987 |
+
location += rpath
|
| 988 |
+
return code, {"Location": location, "Content-Type": "text/plain"}, b""
|
| 989 |
+
|
| 990 |
+
if atype == "forward":
|
| 991 |
+
tg_arn = action.get("TargetGroupArn", "")
|
| 992 |
+
return await _forward_to_tg(tg_arn, method, path, headers, body, query_params)
|
| 993 |
+
|
| 994 |
+
return (502, {"Content-Type": "application/json"},
|
| 995 |
+
json.dumps({"message": f"Unsupported action type: {atype}"}).encode())
|
| 996 |
+
|
| 997 |
+
|
| 998 |
+
async def _forward_to_tg(tg_arn, method, path, headers, body, query_params):
|
| 999 |
+
tg = _tgs.get(tg_arn)
|
| 1000 |
+
if not tg:
|
| 1001 |
+
return (502, {"Content-Type": "application/json"},
|
| 1002 |
+
json.dumps({"message": f"Target group '{tg_arn}' not found"}).encode())
|
| 1003 |
+
|
| 1004 |
+
registered = _targets.get(tg_arn, [])
|
| 1005 |
+
if not registered:
|
| 1006 |
+
return (503, {"Content-Type": "application/json"},
|
| 1007 |
+
json.dumps({"message": "No registered targets in target group"}).encode())
|
| 1008 |
+
|
| 1009 |
+
target_type = tg.get("TargetType", "instance")
|
| 1010 |
+
|
| 1011 |
+
if target_type == "lambda":
|
| 1012 |
+
func_id = registered[0]["Id"]
|
| 1013 |
+
func_name = func_id.split(":function:")[-1].split(":")[0] if ":function:" in func_id else func_id
|
| 1014 |
+
return await _invoke_lambda_target(func_name, tg_arn, method, path,
|
| 1015 |
+
headers, body, query_params)
|
| 1016 |
+
|
| 1017 |
+
return (502, {"Content-Type": "application/json"},
|
| 1018 |
+
json.dumps({"message": f"Target type '{target_type}' not supported."}).encode())
|
| 1019 |
+
|
| 1020 |
+
|
| 1021 |
+
async def _invoke_lambda_target(func_name, tg_arn, method, path, headers, body, query_params):
|
| 1022 |
+
try:
|
| 1023 |
+
from ministack.services import lambda_svc
|
| 1024 |
+
except ImportError:
|
| 1025 |
+
return (502, {"Content-Type": "application/json"},
|
| 1026 |
+
json.dumps({"message": "Lambda service unavailable"}).encode())
|
| 1027 |
+
|
| 1028 |
+
if func_name not in lambda_svc._functions:
|
| 1029 |
+
return (502, {"Content-Type": "application/json"},
|
| 1030 |
+
json.dumps({"message": f"Lambda function '{func_name}' not found"}).encode())
|
| 1031 |
+
|
| 1032 |
+
body_str = None
|
| 1033 |
+
is_b64 = False
|
| 1034 |
+
if body:
|
| 1035 |
+
try:
|
| 1036 |
+
body_str = body.decode("utf-8")
|
| 1037 |
+
except UnicodeDecodeError:
|
| 1038 |
+
body_str = base64.b64encode(body).decode("ascii")
|
| 1039 |
+
is_b64 = True
|
| 1040 |
+
|
| 1041 |
+
qs_single = {k: (v[0] if isinstance(v, list) else v) for k, v in query_params.items()}
|
| 1042 |
+
qs_multi = {k: (v if isinstance(v, list) else [v]) for k, v in query_params.items()}
|
| 1043 |
+
|
| 1044 |
+
event = {
|
| 1045 |
+
"requestContext": {"elb": {"targetGroupArn": tg_arn}},
|
| 1046 |
+
"httpMethod": method.upper(),
|
| 1047 |
+
"path": path,
|
| 1048 |
+
"queryStringParameters": qs_single,
|
| 1049 |
+
"multiValueQueryStringParameters": qs_multi,
|
| 1050 |
+
"headers": {k.lower(): v for k, v in headers.items()},
|
| 1051 |
+
"multiValueHeaders": {k.lower(): [v] for k, v in headers.items()},
|
| 1052 |
+
"body": body_str,
|
| 1053 |
+
"isBase64Encoded": is_b64,
|
| 1054 |
+
}
|
| 1055 |
+
|
| 1056 |
+
_, resp_headers, resp_body = await lambda_svc._invoke(func_name, event, {})
|
| 1057 |
+
|
| 1058 |
+
if resp_headers.get("X-Amz-Function-Error"):
|
| 1059 |
+
raw = resp_body.decode("utf-8", errors="replace") if isinstance(resp_body, bytes) else str(resp_body)
|
| 1060 |
+
return (502, {"Content-Type": "application/json"},
|
| 1061 |
+
json.dumps({"message": f"Lambda error: {raw}"}).encode())
|
| 1062 |
+
|
| 1063 |
+
try:
|
| 1064 |
+
result = json.loads(resp_body) if isinstance(resp_body, bytes) else resp_body
|
| 1065 |
+
if not isinstance(result, dict):
|
| 1066 |
+
return (200, {"Content-Type": "text/plain"},
|
| 1067 |
+
str(result).encode("utf-8"))
|
| 1068 |
+
|
| 1069 |
+
resp_code = int(result.get("statusCode", 200))
|
| 1070 |
+
out_headers = dict(result.get("headers") or {})
|
| 1071 |
+
for k, vals in (result.get("multiValueHeaders") or {}).items():
|
| 1072 |
+
out_headers[k] = vals[-1]
|
| 1073 |
+
|
| 1074 |
+
out_body = result.get("body", "")
|
| 1075 |
+
if result.get("isBase64Encoded"):
|
| 1076 |
+
out_body = base64.b64decode(out_body)
|
| 1077 |
+
elif isinstance(out_body, str):
|
| 1078 |
+
out_body = out_body.encode("utf-8")
|
| 1079 |
+
elif not isinstance(out_body, bytes):
|
| 1080 |
+
out_body = json.dumps(out_body).encode("utf-8")
|
| 1081 |
+
|
| 1082 |
+
return resp_code, out_headers, out_body
|
| 1083 |
+
|
| 1084 |
+
except Exception:
|
| 1085 |
+
raw = resp_body if isinstance(resp_body, bytes) else str(resp_body).encode()
|
| 1086 |
+
return 200, {"Content-Type": "text/plain"}, raw
|
| 1087 |
+
|
| 1088 |
+
|
| 1089 |
+
# ---------------------------------------------------------------------------
|
| 1090 |
+
# Data-plane: main dispatcher
|
| 1091 |
+
# ---------------------------------------------------------------------------
|
| 1092 |
+
|
| 1093 |
+
async def dispatch_request(lb, method, path, headers, body, query_params, port=80):
|
| 1094 |
+
lb_arn = lb["LoadBalancerArn"]
|
| 1095 |
+
|
| 1096 |
+
candidates = [l for l in _listeners.values() if l["LoadBalancerArn"] == lb_arn]
|
| 1097 |
+
matching = [l for l in candidates if l.get("Port", 80) == port] or candidates
|
| 1098 |
+
|
| 1099 |
+
if not matching:
|
| 1100 |
+
return (503, {"Content-Type": "application/json"},
|
| 1101 |
+
json.dumps({"message": f"No listeners configured for '{lb['LoadBalancerName']}'"}).encode())
|
| 1102 |
+
|
| 1103 |
+
listener = matching[0]
|
| 1104 |
+
l_arn = listener["ListenerArn"]
|
| 1105 |
+
|
| 1106 |
+
listener_rules = sorted(
|
| 1107 |
+
(r for r in _rules.values() if r.get("ListenerArn") == l_arn),
|
| 1108 |
+
key=_rule_sort_key,
|
| 1109 |
+
)
|
| 1110 |
+
|
| 1111 |
+
for rule in listener_rules:
|
| 1112 |
+
conditions = rule.get("Conditions", [])
|
| 1113 |
+
is_default = rule.get("IsDefault", False)
|
| 1114 |
+
matched = is_default or all(
|
| 1115 |
+
_match_condition(c, method, path, headers, query_params)
|
| 1116 |
+
for c in conditions
|
| 1117 |
+
)
|
| 1118 |
+
if matched:
|
| 1119 |
+
actions = rule.get("Actions") or listener.get("DefaultActions", [])
|
| 1120 |
+
if actions:
|
| 1121 |
+
return await _execute_action(actions[0], method, path,
|
| 1122 |
+
headers, body, query_params)
|
| 1123 |
+
|
| 1124 |
+
return (502, {"Content-Type": "application/json"},
|
| 1125 |
+
json.dumps({"message": "No matching ALB rule found"}).encode())
|
| 1126 |
+
|
| 1127 |
+
|
| 1128 |
+
# ---------------------------------------------------------------------------
|
| 1129 |
+
# Supported Actions
|
| 1130 |
+
# ---------------------------------------------------------------------------
|
| 1131 |
+
|
| 1132 |
+
SUPPORTED_ACTIONS = [
|
| 1133 |
+
"CreateLoadBalancer", "DeleteLoadBalancer", "DescribeLoadBalancers",
|
| 1134 |
+
"ModifyLoadBalancerAttributes", "AddTags", "RemoveTags", "DescribeTags",
|
| 1135 |
+
"CreateTargetGroup", "DeleteTargetGroup", "DescribeTargetGroups",
|
| 1136 |
+
"ModifyTargetGroup", "ModifyTargetGroupAttributes", "CreateListener",
|
| 1137 |
+
"DeleteListener", "DescribeListeners", "ModifyListener", "CreateRule",
|
| 1138 |
+
"DeleteRule", "DescribeRules", "ModifyRule", "RegisterTargets",
|
| 1139 |
+
"DeregisterTargets", "DescribeTargetHealth", "SetRulePriorities",
|
| 1140 |
+
]
|
| 1141 |
+
|
| 1142 |
+
|
| 1143 |
+
# ---------------------------------------------------------------------------
|
| 1144 |
+
# State
|
| 1145 |
+
# ---------------------------------------------------------------------------
|
| 1146 |
+
|
| 1147 |
+
def get_state_summary() -> dict:
|
| 1148 |
+
return {
|
| 1149 |
+
"load_balancers": {"count": len(_lbs), "names": list(_lbs.keys())},
|
| 1150 |
+
"target_groups": {"count": len(_tgs), "names": list(_tgs.keys())},
|
| 1151 |
+
"listeners": {"count": len(_listeners), "ids": list(_listeners.keys())},
|
| 1152 |
+
"rules": {"count": len(_rules), "ids": list(_rules.keys())},
|
| 1153 |
+
"targets": {"count": sum(len(tgts) for tgts in _targets.values())},
|
| 1154 |
+
"tags": {"count": sum(len(tags) for tags in _tags.values())},
|
| 1155 |
+
"load_balancer_attributes": {"count": sum(len(attrs) for attrs in _lb_attrs.values())},
|
| 1156 |
+
"target_group_attributes": {"count": sum(len(attrs) for attrs in _tg_attrs.values())},
|
| 1157 |
+
}
|
| 1158 |
+
|
| 1159 |
+
|
| 1160 |
+
def reset():
|
| 1161 |
+
_lbs.clear()
|
| 1162 |
+
_tgs.clear()
|
| 1163 |
+
_listeners.clear()
|
| 1164 |
+
_rules.clear()
|
| 1165 |
+
_targets.clear()
|
| 1166 |
+
_tags.clear()
|
| 1167 |
+
_lb_attrs.clear()
|
| 1168 |
+
_tg_attrs.clear()
|
| 1169 |
+
_listener_attrs.clear()
|
aws_infra/ministack/services/apigateway.py
ADDED
|
@@ -0,0 +1,1456 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
API Gateway HTTP API v2 Emulator.
|
| 3 |
+
|
| 4 |
+
Control plane endpoints implemented:
|
| 5 |
+
POST /v2/apis — CreateApi
|
| 6 |
+
GET /v2/apis — GetApis
|
| 7 |
+
GET /v2/apis/{apiId} — GetApi
|
| 8 |
+
PATCH /v2/apis/{apiId} — UpdateApi
|
| 9 |
+
DELETE /v2/apis/{apiId} — DeleteApi
|
| 10 |
+
POST /v2/apis/{apiId}/routes — CreateRoute
|
| 11 |
+
GET /v2/apis/{apiId}/routes — GetRoutes
|
| 12 |
+
GET /v2/apis/{apiId}/routes/{routeId} — GetRoute
|
| 13 |
+
PATCH /v2/apis/{apiId}/routes/{routeId} — UpdateRoute
|
| 14 |
+
DELETE /v2/apis/{apiId}/routes/{routeId} — DeleteRoute
|
| 15 |
+
POST /v2/apis/{apiId}/integrations — CreateIntegration
|
| 16 |
+
GET /v2/apis/{apiId}/integrations — GetIntegrations
|
| 17 |
+
GET /v2/apis/{apiId}/integrations/{integId} — GetIntegration
|
| 18 |
+
PATCH /v2/apis/{apiId}/integrations/{integId} — UpdateIntegration
|
| 19 |
+
DELETE /v2/apis/{apiId}/integrations/{integId} — DeleteIntegration
|
| 20 |
+
POST /v2/apis/{apiId}/stages — CreateStage
|
| 21 |
+
GET /v2/apis/{apiId}/stages — GetStages
|
| 22 |
+
GET /v2/apis/{apiId}/stages/{stageName} — GetStage
|
| 23 |
+
PATCH /v2/apis/{apiId}/stages/{stageName} — UpdateStage
|
| 24 |
+
DELETE /v2/apis/{apiId}/stages/{stageName} — DeleteStage
|
| 25 |
+
POST /v2/apis/{apiId}/deployments — CreateDeployment
|
| 26 |
+
GET /v2/apis/{apiId}/deployments — GetDeployments
|
| 27 |
+
GET /v2/apis/{apiId}/deployments/{deployId} — GetDeployment
|
| 28 |
+
DELETE /v2/apis/{apiId}/deployments/{deployId} — DeleteDeployment
|
| 29 |
+
GET /v2/tags/{resourceArn} — GetTags
|
| 30 |
+
POST /v2/tags/{resourceArn} — TagResource
|
| 31 |
+
DELETE /v2/tags/{resourceArn} — UntagResource
|
| 32 |
+
POST /v2/apis/{apiId}/authorizers — CreateAuthorizer
|
| 33 |
+
GET /v2/apis/{apiId}/authorizers — GetAuthorizers
|
| 34 |
+
GET /v2/apis/{apiId}/authorizers/{authId} — GetAuthorizer
|
| 35 |
+
PATCH /v2/apis/{apiId}/authorizers/{authId} — UpdateAuthorizer
|
| 36 |
+
DELETE /v2/apis/{apiId}/authorizers/{authId} — DeleteAuthorizer
|
| 37 |
+
|
| 38 |
+
Data plane:
|
| 39 |
+
Requests to /{apiId}.execute-api.localhost/{stage}/{path} are forwarded to
|
| 40 |
+
Lambda (AWS_PROXY) or HTTP backends (HTTP_PROXY) via handle_execute().
|
| 41 |
+
"""
|
| 42 |
+
|
| 43 |
+
import asyncio
|
| 44 |
+
import json
|
| 45 |
+
import logging
|
| 46 |
+
import os
|
| 47 |
+
import re
|
| 48 |
+
import time
|
| 49 |
+
import urllib.error
|
| 50 |
+
import urllib.request
|
| 51 |
+
|
| 52 |
+
from ministack.core.responses import AccountScopedDict, get_account_id, error_response_json, new_uuid, get_region
|
| 53 |
+
|
| 54 |
+
_HOST = os.environ.get("MINISTACK_HOST", "localhost")
|
| 55 |
+
_PORT = os.environ.get("GATEWAY_PORT", "4566")
|
| 56 |
+
|
| 57 |
+
logger = logging.getLogger("apigateway")
|
| 58 |
+
|
| 59 |
+
REGION = os.environ.get("MINISTACK_REGION", "us-east-1")
|
| 60 |
+
|
| 61 |
+
# ---- Module-level state ----
|
| 62 |
+
_apis = AccountScopedDict() # api_id -> api object
|
| 63 |
+
_routes = AccountScopedDict() # api_id -> {route_id -> route object}
|
| 64 |
+
_integrations = AccountScopedDict() # api_id -> {integration_id -> integration object}
|
| 65 |
+
_stages = AccountScopedDict() # api_id -> {stage_name -> stage object}
|
| 66 |
+
_deployments = AccountScopedDict() # api_id -> {deployment_id -> deployment object}
|
| 67 |
+
_authorizers = AccountScopedDict() # api_id -> {authorizer_id -> authorizer object}
|
| 68 |
+
_api_tags = AccountScopedDict() # resource_arn -> {key -> value}
|
| 69 |
+
_route_responses = AccountScopedDict() # api_id -> {route_id -> {rr_id -> route_response}}
|
| 70 |
+
_integration_responses = AccountScopedDict() # api_id -> {integration_id -> {ir_id -> int_response}}
|
| 71 |
+
|
| 72 |
+
# WebSocket connection registry — connections are not per-account-scoped at the store level
|
| 73 |
+
# because the @connections management API may arrive on any host/account; instead we store
|
| 74 |
+
# the owning account id inside each connection record and check on access.
|
| 75 |
+
# { connectionId -> {apiId, accountId, stage, connectedAt, sourceIp, outbox (asyncio.Queue),
|
| 76 |
+
# close_event (asyncio.Event), lastActiveAt, identity} }
|
| 77 |
+
_ws_connections: dict = {}
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
# ---- Response helpers ----
|
| 81 |
+
|
| 82 |
+
def _apigw_response(data: dict, status: int = 200) -> tuple:
|
| 83 |
+
"""API Gateway v2 uses application/json (not application/x-amz-json-1.0)."""
|
| 84 |
+
return status, {"Content-Type": "application/json"}, json.dumps(data, ensure_ascii=False).encode("utf-8")
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
def _apigw_error(code: str, message: str, status: int) -> tuple:
|
| 88 |
+
return status, {"Content-Type": "application/json"}, json.dumps({"message": message, "__type": code}, ensure_ascii=False).encode("utf-8")
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
def _api_arn(api_id: str) -> str:
|
| 92 |
+
return f"arn:aws:apigateway:{get_region()}::/apis/{api_id}"
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
SUPPORTED_ACTIONS = [
|
| 96 |
+
"CreateApi", "GetApis", "GetApi", "UpdateApi", "DeleteApi",
|
| 97 |
+
"CreateRoute", "GetRoutes", "GetRoute", "UpdateRoute", "DeleteRoute",
|
| 98 |
+
"CreateIntegration", "GetIntegrations", "GetIntegration",
|
| 99 |
+
"UpdateIntegration", "DeleteIntegration", "CreateStage", "GetStages",
|
| 100 |
+
"GetStage", "UpdateStage", "DeleteStage", "CreateDeployment",
|
| 101 |
+
"GetDeployments", "GetDeployment", "DeleteDeployment", "GetTags",
|
| 102 |
+
"TagResource", "UntagResource", "CreateAuthorizer", "GetAuthorizers",
|
| 103 |
+
"GetAuthorizer", "UpdateAuthorizer", "DeleteAuthorizer",
|
| 104 |
+
]
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
# ---- Persistence hooks ----
|
| 108 |
+
|
| 109 |
+
def get_state() -> dict:
|
| 110 |
+
"""Return full module state for persistence."""
|
| 111 |
+
return {
|
| 112 |
+
"apis": _apis,
|
| 113 |
+
"routes": _routes,
|
| 114 |
+
"integrations": _integrations,
|
| 115 |
+
"stages": _stages,
|
| 116 |
+
"deployments": _deployments,
|
| 117 |
+
"authorizers": _authorizers,
|
| 118 |
+
"api_tags": _api_tags,
|
| 119 |
+
"route_responses": _route_responses,
|
| 120 |
+
"integration_responses": _integration_responses,
|
| 121 |
+
}
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
def load_persisted_state(data: dict) -> None:
|
| 125 |
+
"""Restore module state from a previously persisted snapshot."""
|
| 126 |
+
_apis.update(data.get("apis", {}))
|
| 127 |
+
_routes.update(data.get("routes", {}))
|
| 128 |
+
_integrations.update(data.get("integrations", {}))
|
| 129 |
+
_stages.update(data.get("stages", {}))
|
| 130 |
+
_deployments.update(data.get("deployments", {}))
|
| 131 |
+
_authorizers.update(data.get("authorizers", {}))
|
| 132 |
+
_api_tags.update(data.get("api_tags", {}))
|
| 133 |
+
_route_responses.update(data.get("route_responses", {}))
|
| 134 |
+
_integration_responses.update(data.get("integration_responses", {}))
|
| 135 |
+
|
| 136 |
+
|
| 137 |
+
# ---- Control plane router ----
|
| 138 |
+
|
| 139 |
+
async def handle_request(method, path, headers, body, query_params):
|
| 140 |
+
"""Route API Gateway v2 control plane requests."""
|
| 141 |
+
# Dispatch v1 REST API requests first
|
| 142 |
+
parts = [p for p in path.strip("/").split("/") if p]
|
| 143 |
+
if parts and parts[0] in ("restapis", "apikeys", "usageplans", "domainnames", "tags"):
|
| 144 |
+
from ministack.services import apigateway_v1
|
| 145 |
+
return await apigateway_v1.handle_request(method, path, headers, body, query_params)
|
| 146 |
+
|
| 147 |
+
try:
|
| 148 |
+
data = json.loads(body) if body else {}
|
| 149 |
+
except json.JSONDecodeError:
|
| 150 |
+
data = {}
|
| 151 |
+
|
| 152 |
+
# Minimum expected: ["v2", <resource>]
|
| 153 |
+
|
| 154 |
+
if not parts or parts[0] != "v2":
|
| 155 |
+
return _apigw_error("NotFoundException", f"Unknown path: {path}", 404)
|
| 156 |
+
|
| 157 |
+
resource = parts[1] if len(parts) > 1 else ""
|
| 158 |
+
|
| 159 |
+
# /v2/tags/{resourceArn} — tags endpoint
|
| 160 |
+
if resource == "tags":
|
| 161 |
+
# resourceArn may contain slashes; rejoin everything after "tags/"
|
| 162 |
+
resource_arn = "/".join(parts[2:]) if len(parts) > 2 else ""
|
| 163 |
+
if method == "GET":
|
| 164 |
+
return _get_tags(resource_arn)
|
| 165 |
+
if method == "POST":
|
| 166 |
+
return _tag_resource(resource_arn, data)
|
| 167 |
+
if method == "DELETE":
|
| 168 |
+
tag_keys = query_params.get("tagKeys", [])
|
| 169 |
+
if isinstance(tag_keys, str):
|
| 170 |
+
tag_keys = [tag_keys]
|
| 171 |
+
return _untag_resource(resource_arn, tag_keys)
|
| 172 |
+
|
| 173 |
+
if resource == "apis":
|
| 174 |
+
api_id = parts[2] if len(parts) > 2 else None
|
| 175 |
+
sub = parts[3] if len(parts) > 3 else None
|
| 176 |
+
sub_id = parts[4] if len(parts) > 4 else None
|
| 177 |
+
|
| 178 |
+
# /v2/apis
|
| 179 |
+
if not api_id:
|
| 180 |
+
if method == "POST":
|
| 181 |
+
return _create_api(data)
|
| 182 |
+
if method == "GET":
|
| 183 |
+
return _get_apis()
|
| 184 |
+
|
| 185 |
+
# /v2/apis/{apiId}
|
| 186 |
+
if api_id and not sub:
|
| 187 |
+
if method == "GET":
|
| 188 |
+
return _get_api(api_id)
|
| 189 |
+
if method == "DELETE":
|
| 190 |
+
return _delete_api(api_id)
|
| 191 |
+
if method == "PATCH":
|
| 192 |
+
return _update_api(api_id, data)
|
| 193 |
+
|
| 194 |
+
# /v2/apis/{apiId}/routes[/{routeId}[/routeresponses[/{routeResponseId}]]]
|
| 195 |
+
if api_id and sub == "routes":
|
| 196 |
+
rr_segment = parts[5] if len(parts) > 5 else None
|
| 197 |
+
rr_id = parts[6] if len(parts) > 6 else None
|
| 198 |
+
if not sub_id:
|
| 199 |
+
if method == "POST":
|
| 200 |
+
return _create_route(api_id, data)
|
| 201 |
+
if method == "GET":
|
| 202 |
+
return _get_routes(api_id)
|
| 203 |
+
elif rr_segment == "routeresponses":
|
| 204 |
+
if not rr_id:
|
| 205 |
+
if method == "POST":
|
| 206 |
+
return _create_route_response(api_id, sub_id, data)
|
| 207 |
+
if method == "GET":
|
| 208 |
+
return _get_route_responses(api_id, sub_id)
|
| 209 |
+
else:
|
| 210 |
+
if method == "GET":
|
| 211 |
+
return _get_route_response(api_id, sub_id, rr_id)
|
| 212 |
+
if method == "PATCH":
|
| 213 |
+
return _update_route_response(api_id, sub_id, rr_id, data)
|
| 214 |
+
if method == "DELETE":
|
| 215 |
+
return _delete_route_response(api_id, sub_id, rr_id)
|
| 216 |
+
else:
|
| 217 |
+
if method == "GET":
|
| 218 |
+
return _get_route(api_id, sub_id)
|
| 219 |
+
if method == "PATCH":
|
| 220 |
+
return _update_route(api_id, sub_id, data)
|
| 221 |
+
if method == "DELETE":
|
| 222 |
+
return _delete_route(api_id, sub_id)
|
| 223 |
+
|
| 224 |
+
# /v2/apis/{apiId}/integrations[/{integrationId}[/integrationresponses[/{irId}]]]
|
| 225 |
+
if api_id and sub == "integrations":
|
| 226 |
+
ir_segment = parts[5] if len(parts) > 5 else None
|
| 227 |
+
ir_id = parts[6] if len(parts) > 6 else None
|
| 228 |
+
if not sub_id:
|
| 229 |
+
if method == "POST":
|
| 230 |
+
return _create_integration(api_id, data)
|
| 231 |
+
if method == "GET":
|
| 232 |
+
return _get_integrations(api_id)
|
| 233 |
+
elif ir_segment == "integrationresponses":
|
| 234 |
+
if not ir_id:
|
| 235 |
+
if method == "POST":
|
| 236 |
+
return _create_integration_response(api_id, sub_id, data)
|
| 237 |
+
if method == "GET":
|
| 238 |
+
return _get_integration_responses(api_id, sub_id)
|
| 239 |
+
else:
|
| 240 |
+
if method == "GET":
|
| 241 |
+
return _get_integration_response(api_id, sub_id, ir_id)
|
| 242 |
+
if method == "PATCH":
|
| 243 |
+
return _update_integration_response(api_id, sub_id, ir_id, data)
|
| 244 |
+
if method == "DELETE":
|
| 245 |
+
return _delete_integration_response(api_id, sub_id, ir_id)
|
| 246 |
+
else:
|
| 247 |
+
if method == "GET":
|
| 248 |
+
return _get_integration(api_id, sub_id)
|
| 249 |
+
if method == "PATCH":
|
| 250 |
+
return _update_integration(api_id, sub_id, data)
|
| 251 |
+
if method == "DELETE":
|
| 252 |
+
return _delete_integration(api_id, sub_id)
|
| 253 |
+
|
| 254 |
+
# /v2/apis/{apiId}/stages[/{stageName}]
|
| 255 |
+
if api_id and sub == "stages":
|
| 256 |
+
if not sub_id:
|
| 257 |
+
if method == "POST":
|
| 258 |
+
return _create_stage(api_id, data)
|
| 259 |
+
if method == "GET":
|
| 260 |
+
return _get_stages(api_id)
|
| 261 |
+
else:
|
| 262 |
+
if method == "GET":
|
| 263 |
+
return _get_stage(api_id, sub_id)
|
| 264 |
+
if method == "PATCH":
|
| 265 |
+
return _update_stage(api_id, sub_id, data)
|
| 266 |
+
if method == "DELETE":
|
| 267 |
+
return _delete_stage(api_id, sub_id)
|
| 268 |
+
|
| 269 |
+
# /v2/apis/{apiId}/deployments[/{deploymentId}]
|
| 270 |
+
if api_id and sub == "deployments":
|
| 271 |
+
if not sub_id:
|
| 272 |
+
if method == "POST":
|
| 273 |
+
return _create_deployment(api_id, data)
|
| 274 |
+
if method == "GET":
|
| 275 |
+
return _get_deployments(api_id)
|
| 276 |
+
else:
|
| 277 |
+
if method == "GET":
|
| 278 |
+
return _get_deployment(api_id, sub_id)
|
| 279 |
+
if method == "DELETE":
|
| 280 |
+
return _delete_deployment(api_id, sub_id)
|
| 281 |
+
|
| 282 |
+
# /v2/apis/{apiId}/authorizers[/{authorizerId}]
|
| 283 |
+
if api_id and sub == "authorizers":
|
| 284 |
+
if not sub_id:
|
| 285 |
+
if method == "POST":
|
| 286 |
+
return _create_authorizer(api_id, data)
|
| 287 |
+
if method == "GET":
|
| 288 |
+
return _get_authorizers(api_id)
|
| 289 |
+
else:
|
| 290 |
+
if method == "GET":
|
| 291 |
+
return _get_authorizer(api_id, sub_id)
|
| 292 |
+
if method == "PATCH":
|
| 293 |
+
return _update_authorizer(api_id, sub_id, data)
|
| 294 |
+
if method == "DELETE":
|
| 295 |
+
return _delete_authorizer(api_id, sub_id)
|
| 296 |
+
|
| 297 |
+
return _apigw_error("NotFoundException", f"Unknown API Gateway path: {path}", 404)
|
| 298 |
+
|
| 299 |
+
|
| 300 |
+
# ---- Data plane ----
|
| 301 |
+
|
| 302 |
+
def _cors_response_headers(cors_cfg: dict, origin: str) -> dict:
|
| 303 |
+
"""Build CORS response headers for a non-OPTIONS dispatched response.
|
| 304 |
+
|
| 305 |
+
Per AWS (https://docs.aws.amazon.com/apigateway/latest/developerguide/http-api-cors.html):
|
| 306 |
+
- If Origin matches allow_origins (or allow_origins contains "*"), echo
|
| 307 |
+
the caller's Origin back (or "*"); else omit CORS headers entirely.
|
| 308 |
+
- allow_credentials is only emitted when true, and requires a concrete
|
| 309 |
+
origin — never paired with "*".
|
| 310 |
+
- expose_headers / max_age / etc. are attached if configured.
|
| 311 |
+
"""
|
| 312 |
+
if not cors_cfg:
|
| 313 |
+
return {}
|
| 314 |
+
allowed_origins = [o.lower() for o in cors_cfg.get("allowOrigins", [])]
|
| 315 |
+
origin_lc = (origin or "").lower()
|
| 316 |
+
if allowed_origins == ["*"]:
|
| 317 |
+
allow_origin_value = "*"
|
| 318 |
+
elif origin_lc and origin_lc in allowed_origins:
|
| 319 |
+
allow_origin_value = origin # echo exact caller-supplied casing
|
| 320 |
+
else:
|
| 321 |
+
return {}
|
| 322 |
+
|
| 323 |
+
out: dict = {"Access-Control-Allow-Origin": allow_origin_value}
|
| 324 |
+
if cors_cfg.get("allowCredentials") and allow_origin_value != "*":
|
| 325 |
+
out["Access-Control-Allow-Credentials"] = "true"
|
| 326 |
+
if cors_cfg.get("exposeHeaders"):
|
| 327 |
+
out["Access-Control-Expose-Headers"] = ",".join(cors_cfg["exposeHeaders"])
|
| 328 |
+
if "Origin" not in out.get("Vary", ""):
|
| 329 |
+
out["Vary"] = "Origin"
|
| 330 |
+
return out
|
| 331 |
+
|
| 332 |
+
|
| 333 |
+
def _cors_preflight_response(cors_cfg: dict, origin: str) -> tuple:
|
| 334 |
+
"""Build the full OPTIONS preflight response from corsConfiguration."""
|
| 335 |
+
if not cors_cfg:
|
| 336 |
+
# AWS behaviour: API without CORS configured returns 403 on preflight.
|
| 337 |
+
return 403, {"Content-Type": "application/json"}, json.dumps(
|
| 338 |
+
{"message": "CORS not configured"}
|
| 339 |
+
).encode()
|
| 340 |
+
|
| 341 |
+
base = _cors_response_headers(cors_cfg, origin)
|
| 342 |
+
if not base:
|
| 343 |
+
# Origin not in allow_origins — 403, no CORS headers echoed back.
|
| 344 |
+
return 403, {"Content-Type": "application/json"}, json.dumps(
|
| 345 |
+
{"message": "CORS origin denied"}
|
| 346 |
+
).encode()
|
| 347 |
+
|
| 348 |
+
if cors_cfg.get("allowMethods"):
|
| 349 |
+
base["Access-Control-Allow-Methods"] = ",".join(cors_cfg["allowMethods"])
|
| 350 |
+
if cors_cfg.get("allowHeaders"):
|
| 351 |
+
base["Access-Control-Allow-Headers"] = ",".join(cors_cfg["allowHeaders"])
|
| 352 |
+
if cors_cfg.get("maxAge") is not None:
|
| 353 |
+
base["Access-Control-Max-Age"] = str(cors_cfg["maxAge"])
|
| 354 |
+
base["Content-Length"] = "0"
|
| 355 |
+
return 204, base, b""
|
| 356 |
+
|
| 357 |
+
|
| 358 |
+
async def handle_execute(api_id, stage, path, method, headers, body, query_params):
|
| 359 |
+
"""Execute an API request through a deployed API (data plane)."""
|
| 360 |
+
api = _apis.get(api_id)
|
| 361 |
+
if not api:
|
| 362 |
+
return 404, {"Content-Type": "application/json"}, json.dumps({"message": "Not Found"}).encode()
|
| 363 |
+
|
| 364 |
+
# CORS preflight: served from the API's corsConfiguration before any route
|
| 365 |
+
# matching, because AWS responds to OPTIONS itself without invoking the
|
| 366 |
+
# integration. (#406)
|
| 367 |
+
cors_cfg = api.get("corsConfiguration") or {}
|
| 368 |
+
if method == "OPTIONS":
|
| 369 |
+
return _cors_preflight_response(cors_cfg, headers.get("origin") or headers.get("Origin", ""))
|
| 370 |
+
|
| 371 |
+
api_stages = _stages.get(api_id, {})
|
| 372 |
+
if stage not in api_stages and stage != "$default":
|
| 373 |
+
return 404, {"Content-Type": "application/json"}, json.dumps({"message": f"Stage '{stage}' not found"}).encode()
|
| 374 |
+
|
| 375 |
+
route = _match_route(api_id, method, path)
|
| 376 |
+
if not route:
|
| 377 |
+
return 404, {"Content-Type": "application/json"}, json.dumps({"message": "No route found"}).encode()
|
| 378 |
+
|
| 379 |
+
integration_id = route.get("target", "").replace("integrations/", "")
|
| 380 |
+
integration = _integrations.get(api_id, {}).get(integration_id)
|
| 381 |
+
if not integration:
|
| 382 |
+
return 500, {"Content-Type": "application/json"}, json.dumps({"message": "No integration configured"}).encode()
|
| 383 |
+
|
| 384 |
+
integration_type = integration.get("integrationType", "")
|
| 385 |
+
|
| 386 |
+
if integration_type == "AWS_PROXY":
|
| 387 |
+
route_key = route.get("routeKey", "$default")
|
| 388 |
+
path_params = None
|
| 389 |
+
rk_parts = route_key.split(" ", 1)
|
| 390 |
+
if len(rk_parts) == 2:
|
| 391 |
+
path_params = _extract_path_params(rk_parts[1], path) or None
|
| 392 |
+
response = await _invoke_lambda_proxy(integration, api_id, stage, path, method, headers, body, query_params, route_key, path_params)
|
| 393 |
+
elif integration_type == "HTTP_PROXY":
|
| 394 |
+
response = await _invoke_http_proxy(integration, path, method, headers, body, query_params)
|
| 395 |
+
else:
|
| 396 |
+
return 500, {"Content-Type": "application/json"}, json.dumps({"message": f"Unsupported integration type: {integration_type}"}).encode()
|
| 397 |
+
|
| 398 |
+
# Decorate dispatched response with per-API CORS headers (#406) — AWS adds
|
| 399 |
+
# these in front of the integration response for non-OPTIONS requests.
|
| 400 |
+
if cors_cfg:
|
| 401 |
+
status, resp_headers, resp_body = response
|
| 402 |
+
resp_headers.update(_cors_response_headers(cors_cfg, headers.get("origin") or headers.get("Origin", "")))
|
| 403 |
+
response = status, resp_headers, resp_body
|
| 404 |
+
return response
|
| 405 |
+
|
| 406 |
+
|
| 407 |
+
def _match_route(api_id, method, path):
|
| 408 |
+
"""Find the best matching route for method+path. $default route is the fallback."""
|
| 409 |
+
routes = _routes.get(api_id, {})
|
| 410 |
+
# First pass: look for a specific method+path match (skip $default)
|
| 411 |
+
for route in routes.values():
|
| 412 |
+
key = route.get("routeKey", "")
|
| 413 |
+
if key == "$default":
|
| 414 |
+
continue
|
| 415 |
+
parts = key.split(" ", 1)
|
| 416 |
+
if len(parts) == 2:
|
| 417 |
+
r_method, r_path = parts
|
| 418 |
+
if (r_method == "ANY" or r_method == method) and _path_matches(r_path, path):
|
| 419 |
+
return route
|
| 420 |
+
# Second pass: $default catch-all
|
| 421 |
+
for route in routes.values():
|
| 422 |
+
if route.get("routeKey") == "$default":
|
| 423 |
+
return route
|
| 424 |
+
return None
|
| 425 |
+
|
| 426 |
+
|
| 427 |
+
def _extract_path_params(route_path: str, request_path: str) -> dict | None:
|
| 428 |
+
"""
|
| 429 |
+
Extract path parameter values from a request path using the route template.
|
| 430 |
+
|
| 431 |
+
Returns a dict of {paramName: value} on match, or None if no match.
|
| 432 |
+
Supports:
|
| 433 |
+
{param} — single path segment (no slashes)
|
| 434 |
+
{proxy+} — greedy match (one or more path segments, may include slashes)
|
| 435 |
+
"""
|
| 436 |
+
parts = re.split(r"(\{[^}]+\})", route_path)
|
| 437 |
+
pattern_parts = []
|
| 438 |
+
param_names = []
|
| 439 |
+
for part in parts:
|
| 440 |
+
if part.startswith("{") and part.endswith("}"):
|
| 441 |
+
inner = part[1:-1]
|
| 442 |
+
if inner.endswith("+"):
|
| 443 |
+
param_names.append(inner[:-1])
|
| 444 |
+
pattern_parts.append("(.+)")
|
| 445 |
+
else:
|
| 446 |
+
param_names.append(inner)
|
| 447 |
+
pattern_parts.append("([^/]+)")
|
| 448 |
+
else:
|
| 449 |
+
pattern_parts.append(re.escape(part))
|
| 450 |
+
m = re.fullmatch("".join(pattern_parts), request_path)
|
| 451 |
+
if not m:
|
| 452 |
+
return None
|
| 453 |
+
return dict(zip(param_names, m.groups())) if param_names else {}
|
| 454 |
+
|
| 455 |
+
|
| 456 |
+
def _path_matches(route_path: str, request_path: str) -> bool:
|
| 457 |
+
"""Match a route path against a request path."""
|
| 458 |
+
return _extract_path_params(route_path, request_path) is not None
|
| 459 |
+
|
| 460 |
+
|
| 461 |
+
async def _invoke_lambda_proxy(integration, api_id, stage, path, method, headers, body, query_params, route_key="$default", path_params=None):
|
| 462 |
+
"""Invoke a Lambda function using the API Gateway v2 proxy event format."""
|
| 463 |
+
from ministack.core.lambda_runtime import get_or_create_worker
|
| 464 |
+
from ministack.services import lambda_svc
|
| 465 |
+
|
| 466 |
+
# integrationUri is typically a Lambda ARN; strip the trailing /invocations
|
| 467 |
+
# that the apigateway:lambda:path form appends, then parse name + qualifier.
|
| 468 |
+
# Qualified aliases (arn:...:function:<name>:<alias>) must resolve to the
|
| 469 |
+
# alias's target version, not be treated as the function name itself (#407).
|
| 470 |
+
uri = integration.get("integrationUri", "").replace("/invocations", "")
|
| 471 |
+
func_name, qualifier = lambda_svc._resolve_name_and_qualifier(uri)
|
| 472 |
+
func_data, func_config = lambda_svc._get_func_record_for_qualifier(func_name, qualifier)
|
| 473 |
+
if func_data is None:
|
| 474 |
+
return 502, {"Content-Type": "application/json"}, json.dumps({
|
| 475 |
+
"message": f"Lambda function '{func_name}'" +
|
| 476 |
+
(f" (qualifier '{qualifier}')" if qualifier else "") +
|
| 477 |
+
" not found"
|
| 478 |
+
}).encode()
|
| 479 |
+
|
| 480 |
+
# Build API Gateway v2 proxy event (payload format 2.0)
|
| 481 |
+
# AWS API Gateway v2 joins multi-value query params with commas
|
| 482 |
+
qs = {k: ",".join(v) for k, v in query_params.items()} if query_params else None
|
| 483 |
+
raw_qs = "&".join(f"{k}={val}" for k, vals in query_params.items() for val in vals)
|
| 484 |
+
event = {
|
| 485 |
+
"version": "2.0",
|
| 486 |
+
"routeKey": route_key,
|
| 487 |
+
"rawPath": path,
|
| 488 |
+
"rawQueryString": raw_qs,
|
| 489 |
+
"headers": dict(headers),
|
| 490 |
+
"queryStringParameters": qs,
|
| 491 |
+
"requestContext": {
|
| 492 |
+
"accountId": get_account_id(),
|
| 493 |
+
"apiId": api_id,
|
| 494 |
+
"domainName": f"{api_id}.execute-api.{_HOST}",
|
| 495 |
+
"http": {
|
| 496 |
+
"method": method,
|
| 497 |
+
"path": path,
|
| 498 |
+
"protocol": "HTTP/1.1",
|
| 499 |
+
"sourceIp": "127.0.0.1",
|
| 500 |
+
"userAgent": headers.get("user-agent", ""),
|
| 501 |
+
},
|
| 502 |
+
"requestId": new_uuid(),
|
| 503 |
+
"routeKey": route_key,
|
| 504 |
+
"stage": stage,
|
| 505 |
+
"time": time.strftime("%d/%b/%Y:%H:%M:%S +0000"),
|
| 506 |
+
"timeEpoch": int(time.time() * 1000),
|
| 507 |
+
},
|
| 508 |
+
"pathParameters": path_params,
|
| 509 |
+
"body": body.decode("utf-8", errors="replace") if body else None,
|
| 510 |
+
"isBase64Encoded": False,
|
| 511 |
+
}
|
| 512 |
+
|
| 513 |
+
code_zip = func_data.get("code_zip")
|
| 514 |
+
runtime = func_config.get("Runtime", "")
|
| 515 |
+
if code_zip and runtime.startswith(("python", "nodejs")):
|
| 516 |
+
# Key the worker by name+qualifier so versioned / aliased invocations
|
| 517 |
+
# use their own cached process, matching Lambda.Invoke semantics.
|
| 518 |
+
worker_key = f"{func_name}:{qualifier}" if qualifier else func_name
|
| 519 |
+
worker = get_or_create_worker(worker_key, func_config, code_zip)
|
| 520 |
+
result = await asyncio.to_thread(worker.invoke, event, new_uuid())
|
| 521 |
+
if result.get("status") == "error":
|
| 522 |
+
return 502, {"Content-Type": "application/json"}, json.dumps({"message": result.get("error")}).encode()
|
| 523 |
+
lambda_response = result.get("result", {})
|
| 524 |
+
else:
|
| 525 |
+
lambda_response = {"statusCode": 200, "body": "Mock response"}
|
| 526 |
+
|
| 527 |
+
status = lambda_response.get("statusCode", 200)
|
| 528 |
+
resp_headers = {"Content-Type": "application/json"}
|
| 529 |
+
resp_headers.update(lambda_response.get("headers", {}))
|
| 530 |
+
resp_body = lambda_response.get("body", "")
|
| 531 |
+
if isinstance(resp_body, str):
|
| 532 |
+
resp_body = resp_body.encode("utf-8")
|
| 533 |
+
elif isinstance(resp_body, dict):
|
| 534 |
+
resp_body = json.dumps(resp_body, ensure_ascii=False).encode("utf-8")
|
| 535 |
+
|
| 536 |
+
return status, resp_headers, resp_body
|
| 537 |
+
|
| 538 |
+
|
| 539 |
+
async def _invoke_http_proxy(integration, path, method, headers, body, query_params):
|
| 540 |
+
"""Forward a request to an HTTP backend."""
|
| 541 |
+
uri = integration.get("integrationUri", "")
|
| 542 |
+
url = uri.rstrip("/") + path
|
| 543 |
+
|
| 544 |
+
req = urllib.request.Request(url, data=body or None, method=method)
|
| 545 |
+
for k, v in headers.items():
|
| 546 |
+
if k.lower() not in ("host", "content-length"):
|
| 547 |
+
req.add_header(k, v)
|
| 548 |
+
try:
|
| 549 |
+
with urllib.request.urlopen(req, timeout=30) as resp:
|
| 550 |
+
resp_body = resp.read()
|
| 551 |
+
resp_headers = {"Content-Type": resp.headers.get("Content-Type", "application/json")}
|
| 552 |
+
return resp.status, resp_headers, resp_body
|
| 553 |
+
except urllib.error.HTTPError as e:
|
| 554 |
+
return e.code, {"Content-Type": "application/json"}, e.read()
|
| 555 |
+
except Exception as ex:
|
| 556 |
+
return 502, {"Content-Type": "application/json"}, json.dumps({"message": str(ex)}).encode()
|
| 557 |
+
|
| 558 |
+
|
| 559 |
+
# ---- Control plane: APIs ----
|
| 560 |
+
|
| 561 |
+
def _resolve_custom_api_id(tags: dict, existing: "AccountScopedDict") -> str | None:
|
| 562 |
+
"""Return a caller-pinned API id from the ``ms-custom-id`` tag, or None
|
| 563 |
+
if no tag is set (issue #400).
|
| 564 |
+
|
| 565 |
+
Raises ``ValueError`` if the requested id is already in use in the caller's
|
| 566 |
+
account, so misconfigs surface immediately instead of silently falling back
|
| 567 |
+
to a random id.
|
| 568 |
+
|
| 569 |
+
``ls-custom-id`` (LocalStack's tag) is intentionally NOT supported — callers
|
| 570 |
+
hitting it get a clear ``BadRequestException`` pointing them at
|
| 571 |
+
``ms-custom-id`` so the ministack-native key is the only contract."""
|
| 572 |
+
if not isinstance(tags, dict):
|
| 573 |
+
return None
|
| 574 |
+
if "ls-custom-id" in tags and "ms-custom-id" not in tags:
|
| 575 |
+
raise ValueError(
|
| 576 |
+
"ls-custom-id tag is not supported; use 'ms-custom-id' instead"
|
| 577 |
+
)
|
| 578 |
+
custom = tags.get("ms-custom-id")
|
| 579 |
+
if not custom:
|
| 580 |
+
return None
|
| 581 |
+
if custom in existing:
|
| 582 |
+
raise ValueError(
|
| 583 |
+
f"API id '{custom}' (from ms-custom-id tag) is already in use"
|
| 584 |
+
)
|
| 585 |
+
return str(custom)
|
| 586 |
+
|
| 587 |
+
|
| 588 |
+
def _create_api(data):
|
| 589 |
+
tags = data.get("tags", {})
|
| 590 |
+
try:
|
| 591 |
+
api_id = _resolve_custom_api_id(tags, _apis) or new_uuid()[:8]
|
| 592 |
+
except ValueError as exc:
|
| 593 |
+
msg = str(exc)
|
| 594 |
+
if "already in use" in msg:
|
| 595 |
+
return _apigw_error("ConflictException", msg, 409)
|
| 596 |
+
return _apigw_error("BadRequestException", msg, 400)
|
| 597 |
+
protocol = data.get("protocolType", "HTTP")
|
| 598 |
+
# AWS defaults: HTTP → "$request.method $request.path"; WEBSOCKET → "$request.body.action".
|
| 599 |
+
default_rse = "$request.body.action" if protocol == "WEBSOCKET" else "$request.method $request.path"
|
| 600 |
+
api = {
|
| 601 |
+
"apiId": api_id,
|
| 602 |
+
"name": data.get("name", "unnamed"),
|
| 603 |
+
"protocolType": protocol,
|
| 604 |
+
"apiEndpoint": f"http://{api_id}.execute-api.{_HOST}:{_PORT}",
|
| 605 |
+
"createdDate": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
| 606 |
+
"routeSelectionExpression": data.get("routeSelectionExpression", default_rse),
|
| 607 |
+
"apiKeySelectionExpression": data.get("apiKeySelectionExpression", "$request.header.x-api-key"),
|
| 608 |
+
"tags": data.get("tags", {}),
|
| 609 |
+
"disableSchemaValidation": data.get("disableSchemaValidation", False),
|
| 610 |
+
"disableExecuteApiEndpoint": data.get("disableExecuteApiEndpoint", False),
|
| 611 |
+
"version": data.get("version", ""),
|
| 612 |
+
"description": data.get("description", ""),
|
| 613 |
+
}
|
| 614 |
+
if data.get("corsConfiguration"):
|
| 615 |
+
api["corsConfiguration"] = data["corsConfiguration"]
|
| 616 |
+
_apis[api_id] = api
|
| 617 |
+
_routes[api_id] = {}
|
| 618 |
+
_integrations[api_id] = {}
|
| 619 |
+
_stages[api_id] = {}
|
| 620 |
+
_deployments[api_id] = {}
|
| 621 |
+
_api_tags[_api_arn(api_id)] = dict(data.get("tags", {}))
|
| 622 |
+
return _apigw_response(api, 201)
|
| 623 |
+
|
| 624 |
+
|
| 625 |
+
def _get_api(api_id):
|
| 626 |
+
api = _apis.get(api_id)
|
| 627 |
+
if not api:
|
| 628 |
+
return _apigw_error("NotFoundException", f"API {api_id} not found", 404)
|
| 629 |
+
return _apigw_response(api)
|
| 630 |
+
|
| 631 |
+
|
| 632 |
+
def _get_apis():
|
| 633 |
+
return _apigw_response({"items": list(_apis.values()), "nextToken": None})
|
| 634 |
+
|
| 635 |
+
|
| 636 |
+
def _delete_api(api_id):
|
| 637 |
+
_apis.pop(api_id, None)
|
| 638 |
+
_routes.pop(api_id, None)
|
| 639 |
+
_integrations.pop(api_id, None)
|
| 640 |
+
_stages.pop(api_id, None)
|
| 641 |
+
_deployments.pop(api_id, None)
|
| 642 |
+
_api_tags.pop(_api_arn(api_id), None)
|
| 643 |
+
return 204, {}, b""
|
| 644 |
+
|
| 645 |
+
|
| 646 |
+
def _update_api(api_id, data):
|
| 647 |
+
api = _apis.get(api_id)
|
| 648 |
+
if not api:
|
| 649 |
+
return _apigw_error("NotFoundException", f"API {api_id} not found", 404)
|
| 650 |
+
for k in ("name", "corsConfiguration", "routeSelectionExpression",
|
| 651 |
+
"disableSchemaValidation", "disableExecuteApiEndpoint", "version"):
|
| 652 |
+
if k in data:
|
| 653 |
+
api[k] = data[k]
|
| 654 |
+
return _apigw_response(api)
|
| 655 |
+
|
| 656 |
+
|
| 657 |
+
# ---- Control plane: Routes ----
|
| 658 |
+
|
| 659 |
+
def _create_route(api_id, data):
|
| 660 |
+
if api_id not in _apis:
|
| 661 |
+
return _apigw_error("NotFoundException", f"API {api_id} not found", 404)
|
| 662 |
+
route_id = new_uuid()[:8]
|
| 663 |
+
route = {
|
| 664 |
+
"routeId": route_id,
|
| 665 |
+
"routeKey": data.get("routeKey", "$default"),
|
| 666 |
+
"target": data.get("target", ""),
|
| 667 |
+
"authorizationType": data.get("authorizationType", "NONE"),
|
| 668 |
+
"apiKeyRequired": data.get("apiKeyRequired", False),
|
| 669 |
+
"operationName": data.get("operationName", ""),
|
| 670 |
+
"requestModels": data.get("requestModels", {}),
|
| 671 |
+
"requestParameters": data.get("requestParameters", {}),
|
| 672 |
+
}
|
| 673 |
+
_routes.setdefault(api_id, {})[route_id] = route
|
| 674 |
+
return _apigw_response(route, 201)
|
| 675 |
+
|
| 676 |
+
|
| 677 |
+
def _get_routes(api_id):
|
| 678 |
+
return _apigw_response({"items": list(_routes.get(api_id, {}).values()), "nextToken": None})
|
| 679 |
+
|
| 680 |
+
|
| 681 |
+
def _get_route(api_id, route_id):
|
| 682 |
+
route = _routes.get(api_id, {}).get(route_id)
|
| 683 |
+
if not route:
|
| 684 |
+
return _apigw_error("NotFoundException", f"Route {route_id} not found", 404)
|
| 685 |
+
return _apigw_response(route)
|
| 686 |
+
|
| 687 |
+
|
| 688 |
+
def _update_route(api_id, route_id, data):
|
| 689 |
+
route = _routes.get(api_id, {}).get(route_id)
|
| 690 |
+
if not route:
|
| 691 |
+
return _apigw_error("NotFoundException", f"Route {route_id} not found", 404)
|
| 692 |
+
for k in ("routeKey", "target", "authorizationType", "apiKeyRequired", "operationName"):
|
| 693 |
+
if k in data:
|
| 694 |
+
route[k] = data[k]
|
| 695 |
+
return _apigw_response(route)
|
| 696 |
+
|
| 697 |
+
|
| 698 |
+
def _delete_route(api_id, route_id):
|
| 699 |
+
_routes.get(api_id, {}).pop(route_id, None)
|
| 700 |
+
return 204, {}, b""
|
| 701 |
+
|
| 702 |
+
|
| 703 |
+
# ---- Control plane: Integrations ----
|
| 704 |
+
|
| 705 |
+
def _create_integration(api_id, data):
|
| 706 |
+
if api_id not in _apis:
|
| 707 |
+
return _apigw_error("NotFoundException", f"API {api_id} not found", 404)
|
| 708 |
+
int_id = new_uuid()[:8]
|
| 709 |
+
integration = {
|
| 710 |
+
"integrationId": int_id,
|
| 711 |
+
"integrationType": data.get("integrationType", "AWS_PROXY"),
|
| 712 |
+
"integrationUri": data.get("integrationUri", ""),
|
| 713 |
+
"integrationMethod": data.get("integrationMethod", "POST"),
|
| 714 |
+
"payloadFormatVersion": data.get("payloadFormatVersion", "2.0"),
|
| 715 |
+
"timeoutInMillis": data.get("timeoutInMillis", 30000),
|
| 716 |
+
"connectionType": data.get("connectionType", "INTERNET"),
|
| 717 |
+
"description": data.get("description", ""),
|
| 718 |
+
"requestParameters": data.get("requestParameters", {}),
|
| 719 |
+
"requestTemplates": data.get("requestTemplates", {}),
|
| 720 |
+
"responseParameters": data.get("responseParameters", {}),
|
| 721 |
+
}
|
| 722 |
+
_integrations.setdefault(api_id, {})[int_id] = integration
|
| 723 |
+
return _apigw_response(integration, 201)
|
| 724 |
+
|
| 725 |
+
|
| 726 |
+
def _get_integrations(api_id):
|
| 727 |
+
return _apigw_response({"items": list(_integrations.get(api_id, {}).values()), "nextToken": None})
|
| 728 |
+
|
| 729 |
+
|
| 730 |
+
def _get_integration(api_id, int_id):
|
| 731 |
+
integration = _integrations.get(api_id, {}).get(int_id)
|
| 732 |
+
if not integration:
|
| 733 |
+
return _apigw_error("NotFoundException", f"Integration {int_id} not found", 404)
|
| 734 |
+
return _apigw_response(integration)
|
| 735 |
+
|
| 736 |
+
|
| 737 |
+
def _update_integration(api_id, int_id, data):
|
| 738 |
+
integration = _integrations.get(api_id, {}).get(int_id)
|
| 739 |
+
if not integration:
|
| 740 |
+
return _apigw_error("NotFoundException", f"Integration {int_id} not found", 404)
|
| 741 |
+
for k in ("integrationType", "integrationUri", "integrationMethod",
|
| 742 |
+
"payloadFormatVersion", "timeoutInMillis", "connectionType",
|
| 743 |
+
"description", "requestParameters", "requestTemplates", "responseParameters"):
|
| 744 |
+
if k in data:
|
| 745 |
+
integration[k] = data[k]
|
| 746 |
+
return _apigw_response(integration)
|
| 747 |
+
|
| 748 |
+
|
| 749 |
+
def _delete_integration(api_id, int_id):
|
| 750 |
+
_integrations.get(api_id, {}).pop(int_id, None)
|
| 751 |
+
return 204, {}, b""
|
| 752 |
+
|
| 753 |
+
|
| 754 |
+
# ---- Control plane: Stages ----
|
| 755 |
+
|
| 756 |
+
def _create_stage(api_id, data):
|
| 757 |
+
if api_id not in _apis:
|
| 758 |
+
return _apigw_error("NotFoundException", f"API {api_id} not found", 404)
|
| 759 |
+
stage_name = data.get("stageName", "$default")
|
| 760 |
+
stage = {
|
| 761 |
+
"stageName": stage_name,
|
| 762 |
+
"autoDeploy": data.get("autoDeploy", False),
|
| 763 |
+
"createdDate": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
| 764 |
+
"lastUpdatedDate": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
| 765 |
+
"stageVariables": data.get("stageVariables", {}),
|
| 766 |
+
"description": data.get("description", ""),
|
| 767 |
+
"defaultRouteSettings": data.get("defaultRouteSettings", {}),
|
| 768 |
+
"routeSettings": data.get("routeSettings", {}),
|
| 769 |
+
"tags": data.get("tags", {}),
|
| 770 |
+
}
|
| 771 |
+
_stages.setdefault(api_id, {})[stage_name] = stage
|
| 772 |
+
return _apigw_response(stage, 201)
|
| 773 |
+
|
| 774 |
+
|
| 775 |
+
def _get_stages(api_id):
|
| 776 |
+
return _apigw_response({"items": list(_stages.get(api_id, {}).values()), "nextToken": None})
|
| 777 |
+
|
| 778 |
+
|
| 779 |
+
def _get_stage(api_id, stage_name):
|
| 780 |
+
stage = _stages.get(api_id, {}).get(stage_name)
|
| 781 |
+
if not stage:
|
| 782 |
+
return _apigw_error("NotFoundException", f"Stage '{stage_name}' not found", 404)
|
| 783 |
+
return _apigw_response(stage)
|
| 784 |
+
|
| 785 |
+
|
| 786 |
+
def _update_stage(api_id, stage_name, data):
|
| 787 |
+
stage = _stages.get(api_id, {}).get(stage_name)
|
| 788 |
+
if not stage:
|
| 789 |
+
return _apigw_error("NotFoundException", f"Stage '{stage_name}' not found", 404)
|
| 790 |
+
for k in ("autoDeploy", "stageVariables", "description",
|
| 791 |
+
"defaultRouteSettings", "routeSettings"):
|
| 792 |
+
if k in data:
|
| 793 |
+
stage[k] = data[k]
|
| 794 |
+
stage["lastUpdatedDate"] = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
| 795 |
+
return _apigw_response(stage)
|
| 796 |
+
|
| 797 |
+
|
| 798 |
+
def _delete_stage(api_id, stage_name):
|
| 799 |
+
_stages.get(api_id, {}).pop(stage_name, None)
|
| 800 |
+
return 204, {}, b""
|
| 801 |
+
|
| 802 |
+
|
| 803 |
+
# ---- Control plane: Deployments ----
|
| 804 |
+
|
| 805 |
+
def _create_deployment(api_id, data):
|
| 806 |
+
if api_id not in _apis:
|
| 807 |
+
return _apigw_error("NotFoundException", f"API {api_id} not found", 404)
|
| 808 |
+
deployment_id = new_uuid()[:8]
|
| 809 |
+
deployment = {
|
| 810 |
+
"deploymentId": deployment_id,
|
| 811 |
+
"deploymentStatus": "DEPLOYED",
|
| 812 |
+
"createdDate": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
| 813 |
+
"description": data.get("description", ""),
|
| 814 |
+
}
|
| 815 |
+
_deployments.setdefault(api_id, {})[deployment_id] = deployment
|
| 816 |
+
return _apigw_response(deployment, 201)
|
| 817 |
+
|
| 818 |
+
|
| 819 |
+
def _get_deployments(api_id):
|
| 820 |
+
return _apigw_response({"items": list(_deployments.get(api_id, {}).values()), "nextToken": None})
|
| 821 |
+
|
| 822 |
+
|
| 823 |
+
def _get_deployment(api_id, deployment_id):
|
| 824 |
+
deployment = _deployments.get(api_id, {}).get(deployment_id)
|
| 825 |
+
if not deployment:
|
| 826 |
+
return _apigw_error("NotFoundException", f"Deployment {deployment_id} not found", 404)
|
| 827 |
+
return _apigw_response(deployment)
|
| 828 |
+
|
| 829 |
+
|
| 830 |
+
def _delete_deployment(api_id, deployment_id):
|
| 831 |
+
_deployments.get(api_id, {}).pop(deployment_id, None)
|
| 832 |
+
return 204, {}, b""
|
| 833 |
+
|
| 834 |
+
|
| 835 |
+
# ---- Control plane: Tags ----
|
| 836 |
+
|
| 837 |
+
def _get_tags(resource_arn: str):
|
| 838 |
+
tags = _api_tags.get(resource_arn, {})
|
| 839 |
+
return _apigw_response({"tags": tags})
|
| 840 |
+
|
| 841 |
+
|
| 842 |
+
def _tag_resource(resource_arn: str, data: dict):
|
| 843 |
+
tags = data.get("tags", {})
|
| 844 |
+
_api_tags.setdefault(resource_arn, {}).update(tags)
|
| 845 |
+
return 201, {}, b""
|
| 846 |
+
|
| 847 |
+
|
| 848 |
+
def _untag_resource(resource_arn: str, tag_keys: list):
|
| 849 |
+
existing = _api_tags.get(resource_arn, {})
|
| 850 |
+
for key in tag_keys:
|
| 851 |
+
existing.pop(key, None)
|
| 852 |
+
return 204, {}, b""
|
| 853 |
+
|
| 854 |
+
|
| 855 |
+
# ---- Control plane: Authorizers ----
|
| 856 |
+
|
| 857 |
+
def _create_authorizer(api_id, data):
|
| 858 |
+
if api_id not in _apis:
|
| 859 |
+
return _apigw_error("NotFoundException", f"API {api_id} not found", 404)
|
| 860 |
+
auth_id = new_uuid()[:8]
|
| 861 |
+
authorizer = {
|
| 862 |
+
"authorizerId": auth_id,
|
| 863 |
+
"authorizerType": data.get("authorizerType", "JWT"),
|
| 864 |
+
"name": data.get("name", ""),
|
| 865 |
+
"identitySource": data.get("identitySource", ["$request.header.Authorization"]),
|
| 866 |
+
"jwtConfiguration": data.get("jwtConfiguration", {}),
|
| 867 |
+
"authorizerUri": data.get("authorizerUri", ""),
|
| 868 |
+
"authorizerPayloadFormatVersion": data.get("authorizerPayloadFormatVersion", "2.0"),
|
| 869 |
+
"authorizerResultTtlInSeconds": data.get("authorizerResultTtlInSeconds", 300),
|
| 870 |
+
"enableSimpleResponses": data.get("enableSimpleResponses", False),
|
| 871 |
+
"authorizerCredentialsArn": data.get("authorizerCredentialsArn", ""),
|
| 872 |
+
}
|
| 873 |
+
_authorizers.setdefault(api_id, {})[auth_id] = authorizer
|
| 874 |
+
return _apigw_response(authorizer, 201)
|
| 875 |
+
|
| 876 |
+
|
| 877 |
+
def _get_authorizers(api_id):
|
| 878 |
+
return _apigw_response({"items": list(_authorizers.get(api_id, {}).values()), "nextToken": None})
|
| 879 |
+
|
| 880 |
+
|
| 881 |
+
def _get_authorizer(api_id, auth_id):
|
| 882 |
+
authorizer = _authorizers.get(api_id, {}).get(auth_id)
|
| 883 |
+
if not authorizer:
|
| 884 |
+
return _apigw_error("NotFoundException", f"Authorizer {auth_id} not found", 404)
|
| 885 |
+
return _apigw_response(authorizer)
|
| 886 |
+
|
| 887 |
+
|
| 888 |
+
def _update_authorizer(api_id, auth_id, data):
|
| 889 |
+
authorizer = _authorizers.get(api_id, {}).get(auth_id)
|
| 890 |
+
if not authorizer:
|
| 891 |
+
return _apigw_error("NotFoundException", f"Authorizer {auth_id} not found", 404)
|
| 892 |
+
for k in ("name", "identitySource", "jwtConfiguration", "authorizerUri",
|
| 893 |
+
"authorizerPayloadFormatVersion", "authorizerResultTtlInSeconds",
|
| 894 |
+
"enableSimpleResponses", "authorizerCredentialsArn"):
|
| 895 |
+
if k in data:
|
| 896 |
+
authorizer[k] = data[k]
|
| 897 |
+
return _apigw_response(authorizer)
|
| 898 |
+
|
| 899 |
+
|
| 900 |
+
def _delete_authorizer(api_id, auth_id):
|
| 901 |
+
_authorizers.get(api_id, {}).pop(auth_id, None)
|
| 902 |
+
return 204, {}, b""
|
| 903 |
+
|
| 904 |
+
|
| 905 |
+
def reset():
|
| 906 |
+
_apis.clear()
|
| 907 |
+
_routes.clear()
|
| 908 |
+
_integrations.clear()
|
| 909 |
+
_stages.clear()
|
| 910 |
+
_deployments.clear()
|
| 911 |
+
_authorizers.clear()
|
| 912 |
+
_api_tags.clear()
|
| 913 |
+
_route_responses.clear()
|
| 914 |
+
_integration_responses.clear()
|
| 915 |
+
# Signal any live WS connections to shut down, then drop registry.
|
| 916 |
+
for conn in list(_ws_connections.values()):
|
| 917 |
+
ev = conn.get("close_event")
|
| 918 |
+
if ev is not None:
|
| 919 |
+
try:
|
| 920 |
+
ev.set()
|
| 921 |
+
except Exception:
|
| 922 |
+
pass
|
| 923 |
+
_ws_connections.clear()
|
| 924 |
+
|
| 925 |
+
|
| 926 |
+
# ==========================================================================
|
| 927 |
+
# Route responses (WebSocket)
|
| 928 |
+
# ==========================================================================
|
| 929 |
+
|
| 930 |
+
def _create_route_response(api_id, route_id, data):
|
| 931 |
+
routes = _routes.get(api_id, {})
|
| 932 |
+
if route_id not in routes:
|
| 933 |
+
return _apigw_error("NotFoundException", f"Route {route_id} not found", 404)
|
| 934 |
+
rr_id = new_uuid()[:8]
|
| 935 |
+
rr = {
|
| 936 |
+
"routeResponseId": rr_id,
|
| 937 |
+
"routeResponseKey": data.get("routeResponseKey", "$default"),
|
| 938 |
+
"modelSelectionExpression": data.get("modelSelectionExpression"),
|
| 939 |
+
"responseModels": data.get("responseModels", {}),
|
| 940 |
+
"responseParameters": data.get("responseParameters", {}),
|
| 941 |
+
}
|
| 942 |
+
by_route = _route_responses.setdefault(api_id, {}).setdefault(route_id, {})
|
| 943 |
+
by_route[rr_id] = rr
|
| 944 |
+
return _apigw_response(rr, 201)
|
| 945 |
+
|
| 946 |
+
|
| 947 |
+
def _get_route_responses(api_id, route_id):
|
| 948 |
+
items = list(_route_responses.get(api_id, {}).get(route_id, {}).values())
|
| 949 |
+
return _apigw_response({"items": items})
|
| 950 |
+
|
| 951 |
+
|
| 952 |
+
def _get_route_response(api_id, route_id, rr_id):
|
| 953 |
+
rr = _route_responses.get(api_id, {}).get(route_id, {}).get(rr_id)
|
| 954 |
+
if not rr:
|
| 955 |
+
return _apigw_error("NotFoundException", f"RouteResponse {rr_id} not found", 404)
|
| 956 |
+
return _apigw_response(rr)
|
| 957 |
+
|
| 958 |
+
|
| 959 |
+
def _update_route_response(api_id, route_id, rr_id, data):
|
| 960 |
+
rr = _route_responses.get(api_id, {}).get(route_id, {}).get(rr_id)
|
| 961 |
+
if not rr:
|
| 962 |
+
return _apigw_error("NotFoundException", f"RouteResponse {rr_id} not found", 404)
|
| 963 |
+
for k in ("routeResponseKey", "modelSelectionExpression", "responseModels", "responseParameters"):
|
| 964 |
+
if k in data:
|
| 965 |
+
rr[k] = data[k]
|
| 966 |
+
return _apigw_response(rr)
|
| 967 |
+
|
| 968 |
+
|
| 969 |
+
def _delete_route_response(api_id, route_id, rr_id):
|
| 970 |
+
_route_responses.get(api_id, {}).get(route_id, {}).pop(rr_id, None)
|
| 971 |
+
return 204, {}, b""
|
| 972 |
+
|
| 973 |
+
|
| 974 |
+
# ==========================================================================
|
| 975 |
+
# Integration responses (WebSocket)
|
| 976 |
+
# ==========================================================================
|
| 977 |
+
|
| 978 |
+
def _create_integration_response(api_id, integration_id, data):
|
| 979 |
+
integs = _integrations.get(api_id, {})
|
| 980 |
+
if integration_id not in integs:
|
| 981 |
+
return _apigw_error("NotFoundException", f"Integration {integration_id} not found", 404)
|
| 982 |
+
ir_id = new_uuid()[:8]
|
| 983 |
+
ir = {
|
| 984 |
+
"integrationResponseId": ir_id,
|
| 985 |
+
"integrationResponseKey": data.get("integrationResponseKey", "$default"),
|
| 986 |
+
"contentHandlingStrategy": data.get("contentHandlingStrategy"),
|
| 987 |
+
"templateSelectionExpression": data.get("templateSelectionExpression"),
|
| 988 |
+
"responseParameters": data.get("responseParameters", {}),
|
| 989 |
+
"responseTemplates": data.get("responseTemplates", {}),
|
| 990 |
+
}
|
| 991 |
+
by_int = _integration_responses.setdefault(api_id, {}).setdefault(integration_id, {})
|
| 992 |
+
by_int[ir_id] = ir
|
| 993 |
+
return _apigw_response(ir, 201)
|
| 994 |
+
|
| 995 |
+
|
| 996 |
+
def _get_integration_responses(api_id, integration_id):
|
| 997 |
+
items = list(_integration_responses.get(api_id, {}).get(integration_id, {}).values())
|
| 998 |
+
return _apigw_response({"items": items})
|
| 999 |
+
|
| 1000 |
+
|
| 1001 |
+
def _get_integration_response(api_id, integration_id, ir_id):
|
| 1002 |
+
ir = _integration_responses.get(api_id, {}).get(integration_id, {}).get(ir_id)
|
| 1003 |
+
if not ir:
|
| 1004 |
+
return _apigw_error("NotFoundException", f"IntegrationResponse {ir_id} not found", 404)
|
| 1005 |
+
return _apigw_response(ir)
|
| 1006 |
+
|
| 1007 |
+
|
| 1008 |
+
def _update_integration_response(api_id, integration_id, ir_id, data):
|
| 1009 |
+
ir = _integration_responses.get(api_id, {}).get(integration_id, {}).get(ir_id)
|
| 1010 |
+
if not ir:
|
| 1011 |
+
return _apigw_error("NotFoundException", f"IntegrationResponse {ir_id} not found", 404)
|
| 1012 |
+
for k in ("integrationResponseKey", "contentHandlingStrategy", "templateSelectionExpression",
|
| 1013 |
+
"responseParameters", "responseTemplates"):
|
| 1014 |
+
if k in data:
|
| 1015 |
+
ir[k] = data[k]
|
| 1016 |
+
return _apigw_response(ir)
|
| 1017 |
+
|
| 1018 |
+
|
| 1019 |
+
def _delete_integration_response(api_id, integration_id, ir_id):
|
| 1020 |
+
_integration_responses.get(api_id, {}).get(integration_id, {}).pop(ir_id, None)
|
| 1021 |
+
return 204, {}, b""
|
| 1022 |
+
|
| 1023 |
+
|
| 1024 |
+
# ==========================================================================
|
| 1025 |
+
# WebSocket data plane
|
| 1026 |
+
# ==========================================================================
|
| 1027 |
+
|
| 1028 |
+
def _api_protocol(api_id: str) -> str | None:
|
| 1029 |
+
"""Return the protocolType for an API id, checking all accounts.
|
| 1030 |
+
|
| 1031 |
+
WebSocket connections arrive on the execute-api host before we've resolved
|
| 1032 |
+
which account owns the api. We scan every AccountScopedDict bucket to find
|
| 1033 |
+
the owning account, then return (protocol, account_id).
|
| 1034 |
+
"""
|
| 1035 |
+
info = _api_owner(api_id)
|
| 1036 |
+
return info[0] if info else None
|
| 1037 |
+
|
| 1038 |
+
|
| 1039 |
+
def _api_owner(api_id: str):
|
| 1040 |
+
"""Return (protocolType, owner_account_id) for an API or None if unknown."""
|
| 1041 |
+
# AccountScopedDict stores keys as (account_id, original_key). Walk internals
|
| 1042 |
+
# so we can find the owning account without knowing it up front.
|
| 1043 |
+
for (acct, key), api in _apis._data.items():
|
| 1044 |
+
if key == api_id:
|
| 1045 |
+
return (api.get("protocolType", "HTTP"), acct)
|
| 1046 |
+
return None
|
| 1047 |
+
|
| 1048 |
+
|
| 1049 |
+
def _match_ws_route(api_id: str, route_key: str):
|
| 1050 |
+
"""Find the route for a WS route key (e.g. '$connect', '$disconnect', '$default',
|
| 1051 |
+
or a custom action like 'sendMessage'). Fallback to $default."""
|
| 1052 |
+
routes = _routes.get(api_id, {})
|
| 1053 |
+
for r in routes.values():
|
| 1054 |
+
if r.get("routeKey") == route_key:
|
| 1055 |
+
return r
|
| 1056 |
+
for r in routes.values():
|
| 1057 |
+
if r.get("routeKey") == "$default":
|
| 1058 |
+
return r
|
| 1059 |
+
return None
|
| 1060 |
+
|
| 1061 |
+
|
| 1062 |
+
def _evaluate_route_selection(expr: str, payload_text: str) -> str:
|
| 1063 |
+
"""Evaluate a WebSocket RouteSelectionExpression against an incoming frame.
|
| 1064 |
+
|
| 1065 |
+
AWS supports '$request.body.<dotted.path>' (the common case) and any plain
|
| 1066 |
+
literal that the client includes. Anything we can't parse falls back to
|
| 1067 |
+
'$default'.
|
| 1068 |
+
"""
|
| 1069 |
+
if not expr:
|
| 1070 |
+
return "$default"
|
| 1071 |
+
if expr.startswith("$request.body."):
|
| 1072 |
+
path = expr[len("$request.body."):]
|
| 1073 |
+
try:
|
| 1074 |
+
obj = json.loads(payload_text) if payload_text else {}
|
| 1075 |
+
except (ValueError, TypeError):
|
| 1076 |
+
return "$default"
|
| 1077 |
+
cur = obj
|
| 1078 |
+
for segment in path.split("."):
|
| 1079 |
+
if isinstance(cur, dict) and segment in cur:
|
| 1080 |
+
cur = cur[segment]
|
| 1081 |
+
else:
|
| 1082 |
+
return "$default"
|
| 1083 |
+
return str(cur) if cur is not None else "$default"
|
| 1084 |
+
return "$default"
|
| 1085 |
+
|
| 1086 |
+
|
| 1087 |
+
async def _invoke_ws_lambda(api_id: str, account_id: str, route: dict, stage: str,
|
| 1088 |
+
connection_id: str, event_type: str, message_id: str,
|
| 1089 |
+
body_text: str, source_ip: str, headers: dict,
|
| 1090 |
+
query_params: dict | None = None, **kwargs) -> dict | None:
|
| 1091 |
+
"""Invoke a WS route's integration. Returns the integration's response dict or None.
|
| 1092 |
+
|
| 1093 |
+
The event shape matches AWS WebSocket v2 proxy (see docs: "Set up integration
|
| 1094 |
+
request in API Gateway" under WebSocket). Headers include the incoming
|
| 1095 |
+
handshake headers for $connect (along with query string params); for
|
| 1096 |
+
MESSAGE/DISCONNECT the body is the frame payload.
|
| 1097 |
+
|
| 1098 |
+
Integration type handling:
|
| 1099 |
+
- AWS / AWS_PROXY → dispatch to Lambda via the warm worker pool.
|
| 1100 |
+
- MOCK → synthesise a 200 response (no Lambda). Any
|
| 1101 |
+
`responseTemplates.$default` on a matching
|
| 1102 |
+
integration response is returned as the body.
|
| 1103 |
+
- anything else → returns None (caller treats as "no reply").
|
| 1104 |
+
AWS itself only supports AWS/AWS_PROXY/MOCK for
|
| 1105 |
+
WebSocket routes, so this also covers the
|
| 1106 |
+
never-valid HTTP_PROXY case.
|
| 1107 |
+
"""
|
| 1108 |
+
from ministack.core.lambda_runtime import get_or_create_worker
|
| 1109 |
+
from ministack.services import lambda_svc
|
| 1110 |
+
|
| 1111 |
+
integration_id = route.get("target", "").replace("integrations/", "")
|
| 1112 |
+
integration = _integrations.get(api_id, {}).get(integration_id)
|
| 1113 |
+
if not integration:
|
| 1114 |
+
return None
|
| 1115 |
+
|
| 1116 |
+
int_type = integration.get("integrationType", "")
|
| 1117 |
+
if int_type == "MOCK":
|
| 1118 |
+
ir_map = _integration_responses.get(api_id, {}).get(integration_id, {})
|
| 1119 |
+
body = ""
|
| 1120 |
+
for ir in ir_map.values():
|
| 1121 |
+
templates = ir.get("responseTemplates", {}) or {}
|
| 1122 |
+
if "$default" in templates:
|
| 1123 |
+
body = templates["$default"]
|
| 1124 |
+
break
|
| 1125 |
+
if templates:
|
| 1126 |
+
body = next(iter(templates.values()))
|
| 1127 |
+
break
|
| 1128 |
+
return {"statusCode": 200, "body": body}
|
| 1129 |
+
|
| 1130 |
+
if int_type not in ("AWS_PROXY", "AWS"):
|
| 1131 |
+
logger.warning(
|
| 1132 |
+
"WebSocket route %s has unsupported integrationType %r; "
|
| 1133 |
+
"AWS only supports AWS / AWS_PROXY / MOCK for WebSocket APIs",
|
| 1134 |
+
route.get("routeKey"), int_type,
|
| 1135 |
+
)
|
| 1136 |
+
return None
|
| 1137 |
+
|
| 1138 |
+
# Parse name + qualifier so alias ARNs resolve to their target version (#407).
|
| 1139 |
+
uri = integration.get("integrationUri", "").replace("/invocations", "")
|
| 1140 |
+
func_name, qualifier = lambda_svc._resolve_name_and_qualifier(uri)
|
| 1141 |
+
func_data, func_config = lambda_svc._get_func_record_for_qualifier(func_name, qualifier)
|
| 1142 |
+
if func_data is None:
|
| 1143 |
+
return None
|
| 1144 |
+
|
| 1145 |
+
request_context = {
|
| 1146 |
+
"routeKey": route.get("routeKey", "$default"),
|
| 1147 |
+
"eventType": event_type,
|
| 1148 |
+
"extendedRequestId": new_uuid(),
|
| 1149 |
+
"requestTime": time.strftime("%d/%b/%Y:%H:%M:%S +0000"),
|
| 1150 |
+
"stage": stage,
|
| 1151 |
+
"connectedAt": int(time.time() * 1000),
|
| 1152 |
+
"requestTimeEpoch": int(time.time() * 1000),
|
| 1153 |
+
"identity": {"sourceIp": source_ip, "userAgent": headers.get("user-agent", "")},
|
| 1154 |
+
"requestId": message_id,
|
| 1155 |
+
"domainName": f"{api_id}.execute-api.{_HOST}",
|
| 1156 |
+
"connectionId": connection_id,
|
| 1157 |
+
"apiId": api_id,
|
| 1158 |
+
}
|
| 1159 |
+
if event_type == "DISCONNECT":
|
| 1160 |
+
# Populated by handle_websocket from the ASGI disconnect message.
|
| 1161 |
+
request_context["disconnectReason"] = kwargs.get("disconnect_reason", "")
|
| 1162 |
+
request_context["disconnectStatusCode"] = int(kwargs.get("disconnect_code", 1005))
|
| 1163 |
+
if event_type == "MESSAGE":
|
| 1164 |
+
request_context["messageId"] = message_id
|
| 1165 |
+
|
| 1166 |
+
event = {
|
| 1167 |
+
"requestContext": request_context,
|
| 1168 |
+
"body": body_text if body_text is not None else "",
|
| 1169 |
+
"isBase64Encoded": False,
|
| 1170 |
+
}
|
| 1171 |
+
if event_type == "CONNECT":
|
| 1172 |
+
event["headers"] = dict(headers)
|
| 1173 |
+
event["multiValueHeaders"] = {k: [v] for k, v in headers.items()}
|
| 1174 |
+
if query_params:
|
| 1175 |
+
# AWS flattens single-valued QS params to string, keeps multi-valued as lists.
|
| 1176 |
+
event["queryStringParameters"] = {
|
| 1177 |
+
k: (v[-1] if isinstance(v, list) else v)
|
| 1178 |
+
for k, v in query_params.items()
|
| 1179 |
+
}
|
| 1180 |
+
event["multiValueQueryStringParameters"] = {
|
| 1181 |
+
k: (v if isinstance(v, list) else [v])
|
| 1182 |
+
for k, v in query_params.items()
|
| 1183 |
+
}
|
| 1184 |
+
else:
|
| 1185 |
+
event["queryStringParameters"] = None
|
| 1186 |
+
event["multiValueQueryStringParameters"] = None
|
| 1187 |
+
|
| 1188 |
+
runtime = func_config.get("Runtime", "")
|
| 1189 |
+
code_zip = func_data.get("code_zip")
|
| 1190 |
+
if code_zip and runtime.startswith(("python", "nodejs")):
|
| 1191 |
+
worker_key = f"{func_name}:{qualifier}" if qualifier else func_name
|
| 1192 |
+
worker = get_or_create_worker(worker_key, func_config, code_zip)
|
| 1193 |
+
result = await asyncio.to_thread(worker.invoke, event, message_id)
|
| 1194 |
+
if result.get("status") == "error":
|
| 1195 |
+
return {"statusCode": 500, "body": result.get("error", "")}
|
| 1196 |
+
return result.get("result", {})
|
| 1197 |
+
# Image/unsupported runtime stub — success without body.
|
| 1198 |
+
return {"statusCode": 200, "body": ""}
|
| 1199 |
+
|
| 1200 |
+
|
| 1201 |
+
async def handle_websocket(scope, receive, send, api_id: str, path_override: str | None = None):
|
| 1202 |
+
"""Drive a WebSocket session for a $WEBSOCKET API.
|
| 1203 |
+
|
| 1204 |
+
Flow:
|
| 1205 |
+
1. Receive `websocket.connect` from ASGI.
|
| 1206 |
+
2. Invoke `$connect` route Lambda (if any). 2xx → accept; else close.
|
| 1207 |
+
3. Loop on `websocket.receive`: evaluate routeSelectionExpression, dispatch
|
| 1208 |
+
to the matching route's Lambda. If the Lambda returns a body, forward it
|
| 1209 |
+
back on the same socket.
|
| 1210 |
+
4. Concurrently drain the per-connection outbox (fed by @connections
|
| 1211 |
+
PostToConnection) and forward messages to the socket.
|
| 1212 |
+
5. On client disconnect, invoke `$disconnect` route Lambda (fire-and-forget).
|
| 1213 |
+
|
| 1214 |
+
``path_override`` is used when the caller addressed us via the LocalStack-
|
| 1215 |
+
compat path form (``/_aws/execute-api/{apiId}/{stage}``) so we read the
|
| 1216 |
+
stage from the rewritten path instead of the raw URL.
|
| 1217 |
+
"""
|
| 1218 |
+
owner = _api_owner(api_id)
|
| 1219 |
+
if not owner or owner[0] != "WEBSOCKET":
|
| 1220 |
+
# Not a WS API — refuse the upgrade.
|
| 1221 |
+
await receive() # consume websocket.connect
|
| 1222 |
+
await send({"type": "websocket.close", "code": 1008})
|
| 1223 |
+
return
|
| 1224 |
+
|
| 1225 |
+
protocol, account_id = owner
|
| 1226 |
+
|
| 1227 |
+
# Stage parsing: Host-based URLs look like wss://{apiId}.execute-api.../stage;
|
| 1228 |
+
# path-based compat URLs (#401) use path_override with the rewritten path.
|
| 1229 |
+
# If the first segment isn't a configured stage name but the API has a
|
| 1230 |
+
# ``$default`` stage, route to it (issue #404).
|
| 1231 |
+
path = path_override if path_override is not None else scope.get("path", "")
|
| 1232 |
+
path_parts = path.lstrip("/").split("/", 1)
|
| 1233 |
+
tentative = path_parts[0] if path_parts and path_parts[0] else "$default"
|
| 1234 |
+
configured_stages = _stages.get(api_id, {})
|
| 1235 |
+
if tentative in configured_stages:
|
| 1236 |
+
stage = tentative
|
| 1237 |
+
elif "$default" in configured_stages:
|
| 1238 |
+
stage = "$default"
|
| 1239 |
+
else:
|
| 1240 |
+
stage = tentative # pass through; downstream will handle unknown-stage
|
| 1241 |
+
|
| 1242 |
+
headers = {}
|
| 1243 |
+
for name, value in scope.get("headers", []):
|
| 1244 |
+
try:
|
| 1245 |
+
headers[name.decode("latin-1").lower()] = value.decode("utf-8")
|
| 1246 |
+
except UnicodeDecodeError:
|
| 1247 |
+
headers[name.decode("latin-1").lower()] = value.decode("latin-1")
|
| 1248 |
+
|
| 1249 |
+
qs = scope.get("query_string", b"").decode("utf-8")
|
| 1250 |
+
from urllib.parse import parse_qs as _pq
|
| 1251 |
+
query_params = {k: v for k, v in _pq(qs, keep_blank_values=True).items()}
|
| 1252 |
+
|
| 1253 |
+
client = scope.get("client") or ("127.0.0.1", 0)
|
| 1254 |
+
source_ip = client[0] if isinstance(client, (tuple, list)) else "127.0.0.1"
|
| 1255 |
+
|
| 1256 |
+
# Wait for websocket.connect.
|
| 1257 |
+
msg = await receive()
|
| 1258 |
+
if msg.get("type") != "websocket.connect":
|
| 1259 |
+
return
|
| 1260 |
+
|
| 1261 |
+
connection_id = new_uuid().replace("-", "")[:16]
|
| 1262 |
+
|
| 1263 |
+
# Set account context so downstream Lambda invocations see the right tenant.
|
| 1264 |
+
from ministack.core.responses import _request_account_id
|
| 1265 |
+
token = _request_account_id.set(account_id)
|
| 1266 |
+
try:
|
| 1267 |
+
# $connect hook
|
| 1268 |
+
connect_route = _match_ws_route(api_id, "$connect")
|
| 1269 |
+
if connect_route is not None:
|
| 1270 |
+
resp = await _invoke_ws_lambda(
|
| 1271 |
+
api_id, account_id, connect_route, stage, connection_id,
|
| 1272 |
+
"CONNECT", new_uuid(), "", source_ip, headers,
|
| 1273 |
+
query_params=query_params,
|
| 1274 |
+
)
|
| 1275 |
+
status = int((resp or {}).get("statusCode", 200))
|
| 1276 |
+
if status < 200 or status >= 300:
|
| 1277 |
+
await send({"type": "websocket.close", "code": 1008})
|
| 1278 |
+
return
|
| 1279 |
+
|
| 1280 |
+
await send({"type": "websocket.accept"})
|
| 1281 |
+
|
| 1282 |
+
outbox: asyncio.Queue = asyncio.Queue()
|
| 1283 |
+
close_event = asyncio.Event()
|
| 1284 |
+
now_epoch = int(time.time())
|
| 1285 |
+
conn_record = {
|
| 1286 |
+
"apiId": api_id,
|
| 1287 |
+
"accountId": account_id,
|
| 1288 |
+
"stage": stage,
|
| 1289 |
+
# Int epoch seconds — matches ministack JSON timestamp convention.
|
| 1290 |
+
"connectedAt": now_epoch,
|
| 1291 |
+
"lastActiveAt": now_epoch,
|
| 1292 |
+
"sourceIp": source_ip,
|
| 1293 |
+
"identity": {"sourceIp": source_ip, "userAgent": headers.get("user-agent", "")},
|
| 1294 |
+
"outbox": outbox,
|
| 1295 |
+
"close_event": close_event,
|
| 1296 |
+
}
|
| 1297 |
+
_ws_connections[connection_id] = conn_record
|
| 1298 |
+
|
| 1299 |
+
selection_expr = None
|
| 1300 |
+
api_obj = _apis.get(api_id)
|
| 1301 |
+
if api_obj:
|
| 1302 |
+
selection_expr = api_obj.get("routeSelectionExpression", "$request.body.action")
|
| 1303 |
+
|
| 1304 |
+
async def _drain_outbox():
|
| 1305 |
+
while not close_event.is_set():
|
| 1306 |
+
try:
|
| 1307 |
+
item = await asyncio.wait_for(outbox.get(), timeout=0.5)
|
| 1308 |
+
except asyncio.TimeoutError:
|
| 1309 |
+
continue
|
| 1310 |
+
if item is None:
|
| 1311 |
+
return
|
| 1312 |
+
if isinstance(item, bytes):
|
| 1313 |
+
await send({"type": "websocket.send", "bytes": item})
|
| 1314 |
+
else:
|
| 1315 |
+
await send({"type": "websocket.send", "text": str(item)})
|
| 1316 |
+
|
| 1317 |
+
drain_task = asyncio.create_task(_drain_outbox())
|
| 1318 |
+
|
| 1319 |
+
disconnect_code = 1005 # 1005 = "no status rcvd" per RFC 6455, matches AWS default
|
| 1320 |
+
disconnect_reason = ""
|
| 1321 |
+
try:
|
| 1322 |
+
while True:
|
| 1323 |
+
message = await receive()
|
| 1324 |
+
mtype = message.get("type")
|
| 1325 |
+
if mtype == "websocket.disconnect":
|
| 1326 |
+
disconnect_code = int(message.get("code", 1005) or 1005)
|
| 1327 |
+
# ASGI extension: some servers (incl. modern hypercorn) pass the
|
| 1328 |
+
# close-frame reason; fall back to empty string if not present.
|
| 1329 |
+
disconnect_reason = message.get("reason") or ""
|
| 1330 |
+
break
|
| 1331 |
+
if mtype != "websocket.receive":
|
| 1332 |
+
continue
|
| 1333 |
+
frame_text = message.get("text")
|
| 1334 |
+
frame_bytes = message.get("bytes")
|
| 1335 |
+
payload = frame_text if frame_text is not None else (
|
| 1336 |
+
frame_bytes.decode("utf-8", errors="replace") if frame_bytes else ""
|
| 1337 |
+
)
|
| 1338 |
+
conn_record["lastActiveAt"] = int(time.time())
|
| 1339 |
+
|
| 1340 |
+
route_key = _evaluate_route_selection(selection_expr or "", payload)
|
| 1341 |
+
route = _match_ws_route(api_id, route_key)
|
| 1342 |
+
if route is None:
|
| 1343 |
+
# No $default — AWS sends GoneException to the client; we log and continue.
|
| 1344 |
+
continue
|
| 1345 |
+
msg_id = new_uuid()
|
| 1346 |
+
resp = await _invoke_ws_lambda(
|
| 1347 |
+
api_id, account_id, route, stage, connection_id, "MESSAGE",
|
| 1348 |
+
msg_id, payload, source_ip, headers,
|
| 1349 |
+
)
|
| 1350 |
+
if resp is None:
|
| 1351 |
+
continue
|
| 1352 |
+
body = resp.get("body")
|
| 1353 |
+
if body:
|
| 1354 |
+
if isinstance(body, (dict, list)):
|
| 1355 |
+
body = json.dumps(body)
|
| 1356 |
+
if isinstance(body, bytes):
|
| 1357 |
+
await send({"type": "websocket.send", "bytes": body})
|
| 1358 |
+
else:
|
| 1359 |
+
await send({"type": "websocket.send", "text": str(body)})
|
| 1360 |
+
finally:
|
| 1361 |
+
close_event.set()
|
| 1362 |
+
try:
|
| 1363 |
+
await drain_task
|
| 1364 |
+
except Exception:
|
| 1365 |
+
pass
|
| 1366 |
+
_ws_connections.pop(connection_id, None)
|
| 1367 |
+
# Fire $disconnect route best-effort.
|
| 1368 |
+
disconnect_route = _match_ws_route(api_id, "$disconnect")
|
| 1369 |
+
if disconnect_route is not None:
|
| 1370 |
+
try:
|
| 1371 |
+
await _invoke_ws_lambda(
|
| 1372 |
+
api_id, account_id, disconnect_route, stage, connection_id,
|
| 1373 |
+
"DISCONNECT", new_uuid(), "", source_ip, headers,
|
| 1374 |
+
disconnect_code=disconnect_code,
|
| 1375 |
+
disconnect_reason=disconnect_reason,
|
| 1376 |
+
)
|
| 1377 |
+
except Exception:
|
| 1378 |
+
logger.exception("error firing $disconnect")
|
| 1379 |
+
try:
|
| 1380 |
+
await send({"type": "websocket.close", "code": 1000})
|
| 1381 |
+
except Exception:
|
| 1382 |
+
pass
|
| 1383 |
+
finally:
|
| 1384 |
+
try:
|
| 1385 |
+
_request_account_id.reset(token)
|
| 1386 |
+
except Exception:
|
| 1387 |
+
pass
|
| 1388 |
+
|
| 1389 |
+
|
| 1390 |
+
# ==========================================================================
|
| 1391 |
+
# @connections management API
|
| 1392 |
+
# ==========================================================================
|
| 1393 |
+
|
| 1394 |
+
async def handle_connections_api(method: str, api_id: str, stage: str,
|
| 1395 |
+
connection_id: str, body: bytes, headers: dict):
|
| 1396 |
+
"""Serve the @connections runtime API.
|
| 1397 |
+
|
| 1398 |
+
Paths (on execute-api host):
|
| 1399 |
+
POST /{stage}/@connections/{connectionId} → PostToConnection
|
| 1400 |
+
GET /{stage}/@connections/{connectionId} → GetConnection
|
| 1401 |
+
DELETE /{stage}/@connections/{connectionId} → DeleteConnection
|
| 1402 |
+
|
| 1403 |
+
AWS behaviour:
|
| 1404 |
+
- 410 Gone if the connection is unknown or already closed.
|
| 1405 |
+
- 403 Forbidden if the caller does not own the API (not enforced locally).
|
| 1406 |
+
- 200 on success; POST returns empty body, GET returns JSON.
|
| 1407 |
+
"""
|
| 1408 |
+
conn = _ws_connections.get(connection_id)
|
| 1409 |
+
if not conn or conn.get("apiId") != api_id:
|
| 1410 |
+
return 410, {"Content-Type": "application/json"}, json.dumps(
|
| 1411 |
+
{"message": "GoneException"}
|
| 1412 |
+
).encode()
|
| 1413 |
+
|
| 1414 |
+
if method == "POST":
|
| 1415 |
+
# Push the message into the connection outbox; drain_task will forward it.
|
| 1416 |
+
try:
|
| 1417 |
+
if body:
|
| 1418 |
+
await conn["outbox"].put(body)
|
| 1419 |
+
except Exception as exc:
|
| 1420 |
+
return 500, {"Content-Type": "application/json"}, json.dumps(
|
| 1421 |
+
{"message": str(exc)}
|
| 1422 |
+
).encode()
|
| 1423 |
+
return 200, {"Content-Type": "application/json"}, b""
|
| 1424 |
+
|
| 1425 |
+
if method == "GET":
|
| 1426 |
+
payload = {
|
| 1427 |
+
"ConnectedAt": conn.get("connectedAt"),
|
| 1428 |
+
"Identity": conn.get("identity", {}),
|
| 1429 |
+
"LastActiveAt": conn.get("lastActiveAt"),
|
| 1430 |
+
}
|
| 1431 |
+
return 200, {"Content-Type": "application/json"}, json.dumps(payload).encode()
|
| 1432 |
+
|
| 1433 |
+
if method == "DELETE":
|
| 1434 |
+
ev = conn.get("close_event")
|
| 1435 |
+
if ev is not None:
|
| 1436 |
+
try:
|
| 1437 |
+
ev.set()
|
| 1438 |
+
except Exception:
|
| 1439 |
+
pass
|
| 1440 |
+
# Flush the outbox with a sentinel so drain_task exits promptly.
|
| 1441 |
+
try:
|
| 1442 |
+
await conn["outbox"].put(None)
|
| 1443 |
+
except Exception:
|
| 1444 |
+
pass
|
| 1445 |
+
return 204, {}, b""
|
| 1446 |
+
|
| 1447 |
+
return 405, {"Content-Type": "application/json"}, json.dumps(
|
| 1448 |
+
{"message": f"Method {method} not allowed on @connections"}
|
| 1449 |
+
).encode()
|
| 1450 |
+
|
| 1451 |
+
def get_state_summary() -> dict:
|
| 1452 |
+
return {
|
| 1453 |
+
"apis": {"count": len(_apis), "ids": list(_apis.keys())},
|
| 1454 |
+
"routes": {"count": sum(len(r) for r in _routes.values()) if _routes else 0},
|
| 1455 |
+
"integrations": {"count": sum(len(i) for i in _integrations.values()) if _integrations else 0},
|
| 1456 |
+
}
|
aws_infra/ministack/services/apigateway_v1.py
ADDED
|
@@ -0,0 +1,1602 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
API Gateway REST API v1 Emulator.
|
| 3 |
+
|
| 4 |
+
Control plane endpoints implemented:
|
| 5 |
+
POST /restapis — CreateRestApi
|
| 6 |
+
GET /restapis — GetRestApis
|
| 7 |
+
GET /restapis/{id} — GetRestApi
|
| 8 |
+
PATCH /restapis/{id} — UpdateRestApi
|
| 9 |
+
DELETE /restapis/{id} — DeleteRestApi
|
| 10 |
+
GET /restapis/{id}/resources — GetResources
|
| 11 |
+
GET /restapis/{id}/resources/{resourceId} — GetResource
|
| 12 |
+
POST /restapis/{id}/resources/{parentId} — CreateResource
|
| 13 |
+
PATCH /restapis/{id}/resources/{resourceId} — UpdateResource
|
| 14 |
+
DELETE /restapis/{id}/resources/{resourceId} — DeleteResource
|
| 15 |
+
PUT /restapis/{id}/resources/{resourceId}/methods/{httpMethod} — PutMethod
|
| 16 |
+
GET /restapis/{id}/resources/{resourceId}/methods/{httpMethod} — GetMethod
|
| 17 |
+
DELETE /restapis/{id}/resources/{resourceId}/methods/{httpMethod} — DeleteMethod
|
| 18 |
+
PUT /restapis/{id}/resources/{resourceId}/methods/{httpMethod}/responses/{code} — PutMethodResponse
|
| 19 |
+
GET /restapis/{id}/resources/{resourceId}/methods/{httpMethod}/responses/{code} — GetMethodResponse
|
| 20 |
+
DELETE /restapis/{id}/resources/{resourceId}/methods/{httpMethod}/responses/{code} — DeleteMethodResponse
|
| 21 |
+
PUT /restapis/{id}/resources/{resourceId}/methods/{httpMethod}/integration — PutIntegration
|
| 22 |
+
GET /restapis/{id}/resources/{resourceId}/methods/{httpMethod}/integration — GetIntegration
|
| 23 |
+
DELETE /restapis/{id}/resources/{resourceId}/methods/{httpMethod}/integration — DeleteIntegration
|
| 24 |
+
PUT /restapis/{id}/resources/{resourceId}/methods/{httpMethod}/integration/responses/{code} — PutIntegrationResponse
|
| 25 |
+
GET /restapis/{id}/resources/{resourceId}/methods/{httpMethod}/integration/responses/{code} — GetIntegrationResponse
|
| 26 |
+
DELETE /restapis/{id}/resources/{resourceId}/methods/{httpMethod}/integration/responses/{code} — DeleteIntegrationResponse
|
| 27 |
+
POST /restapis/{id}/deployments — CreateDeployment
|
| 28 |
+
GET /restapis/{id}/deployments — GetDeployments
|
| 29 |
+
GET /restapis/{id}/deployments/{deploymentId} — GetDeployment
|
| 30 |
+
PATCH /restapis/{id}/deployments/{deploymentId} — UpdateDeployment
|
| 31 |
+
DELETE /restapis/{id}/deployments/{deploymentId} — DeleteDeployment
|
| 32 |
+
POST /restapis/{id}/stages — CreateStage
|
| 33 |
+
GET /restapis/{id}/stages — GetStages
|
| 34 |
+
GET /restapis/{id}/stages/{stageName} — GetStage
|
| 35 |
+
PATCH /restapis/{id}/stages/{stageName} — UpdateStage
|
| 36 |
+
DELETE /restapis/{id}/stages/{stageName} — DeleteStage
|
| 37 |
+
POST /restapis/{id}/authorizers — CreateAuthorizer
|
| 38 |
+
GET /restapis/{id}/authorizers — GetAuthorizers
|
| 39 |
+
GET /restapis/{id}/authorizers/{authorizerId} — GetAuthorizer
|
| 40 |
+
PATCH /restapis/{id}/authorizers/{authorizerId} — UpdateAuthorizer
|
| 41 |
+
DELETE /restapis/{id}/authorizers/{authorizerId} — DeleteAuthorizer
|
| 42 |
+
POST /restapis/{id}/models — CreateModel
|
| 43 |
+
GET /restapis/{id}/models — GetModels
|
| 44 |
+
GET /restapis/{id}/models/{modelName} — GetModel
|
| 45 |
+
DELETE /restapis/{id}/models/{modelName} — DeleteModel
|
| 46 |
+
GET /apikeys — GetApiKeys
|
| 47 |
+
POST /apikeys — CreateApiKey
|
| 48 |
+
GET /apikeys/{keyId} — GetApiKey
|
| 49 |
+
DELETE /apikeys/{keyId} — DeleteApiKey
|
| 50 |
+
GET /usageplans — GetUsagePlans
|
| 51 |
+
POST /usageplans — CreateUsagePlan
|
| 52 |
+
GET /usageplans/{planId} — GetUsagePlan
|
| 53 |
+
DELETE /usageplans/{planId} — DeleteUsagePlan
|
| 54 |
+
GET /usageplans/{planId}/keys — GetUsagePlanKeys
|
| 55 |
+
POST /usageplans/{planId}/keys — CreateUsagePlanKey
|
| 56 |
+
DELETE /usageplans/{planId}/keys/{keyId} — DeleteUsagePlanKey
|
| 57 |
+
GET /domainnames — GetDomainNames
|
| 58 |
+
POST /domainnames — CreateDomainName
|
| 59 |
+
GET /domainnames/{domainName} — GetDomainName
|
| 60 |
+
DELETE /domainnames/{domainName} — DeleteDomainName
|
| 61 |
+
GET /tags/{resourceArn} — GetTags
|
| 62 |
+
PUT /tags/{resourceArn} — TagResource
|
| 63 |
+
DELETE /tags/{resourceArn} — UntagResource
|
| 64 |
+
|
| 65 |
+
Data plane:
|
| 66 |
+
Requests to /{apiId}.execute-api.localhost/{stage}/{path} are dispatched
|
| 67 |
+
when api_id is found in _rest_apis.
|
| 68 |
+
"""
|
| 69 |
+
|
| 70 |
+
import asyncio
|
| 71 |
+
import datetime
|
| 72 |
+
import json
|
| 73 |
+
import logging
|
| 74 |
+
import os
|
| 75 |
+
import re
|
| 76 |
+
import time
|
| 77 |
+
import urllib.error
|
| 78 |
+
import urllib.request
|
| 79 |
+
|
| 80 |
+
from ministack.core.responses import AccountScopedDict, get_account_id, new_uuid, get_region
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
def _now_unix():
|
| 84 |
+
"""Return current UTC time as Unix timestamp (float).
|
| 85 |
+
API Gateway v1 createdDate/lastUpdatedDate fields must be numbers, not strings.
|
| 86 |
+
Terraform's AWS provider deserializes them as JSON Number and errors on ISO strings."""
|
| 87 |
+
return int(time.time())
|
| 88 |
+
|
| 89 |
+
logger = logging.getLogger("apigateway_v1")
|
| 90 |
+
|
| 91 |
+
REGION = os.environ.get("MINISTACK_REGION", "us-east-1")
|
| 92 |
+
|
| 93 |
+
# ---- Module-level state ----
|
| 94 |
+
# All per-tenant state uses AccountScopedDict so the same REST API id in two
|
| 95 |
+
# different accounts never collides and list operations don't leak cross-account.
|
| 96 |
+
_rest_apis = AccountScopedDict() # rest_api_id -> RestApi
|
| 97 |
+
_resources = AccountScopedDict() # rest_api_id -> {resource_id -> Resource}
|
| 98 |
+
_stages_v1 = AccountScopedDict() # rest_api_id -> {stage_name -> Stage}
|
| 99 |
+
_deployments_v1 = AccountScopedDict() # rest_api_id -> {deployment_id -> Deployment}
|
| 100 |
+
_authorizers_v1 = AccountScopedDict() # rest_api_id -> {authorizer_id -> Authorizer}
|
| 101 |
+
_models = AccountScopedDict() # rest_api_id -> {model_id -> Model}
|
| 102 |
+
_api_keys = AccountScopedDict() # key_id -> ApiKey
|
| 103 |
+
_usage_plans = AccountScopedDict() # plan_id -> UsagePlan
|
| 104 |
+
_usage_plan_keys = AccountScopedDict() # plan_id -> {key_id -> UsagePlanKey}
|
| 105 |
+
_domain_names = AccountScopedDict() # domain_name -> DomainName
|
| 106 |
+
_base_path_mappings = AccountScopedDict() # domain_name -> {base_path -> BasePathMapping}
|
| 107 |
+
_v1_tags = AccountScopedDict() # resource_arn -> {key -> value}
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
# ---- Helpers ----
|
| 111 |
+
|
| 112 |
+
def _new_id():
|
| 113 |
+
"""Return a 10-char hex id."""
|
| 114 |
+
return new_uuid().replace("-", "")[:10]
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
def _v1_response(data, status=200):
|
| 118 |
+
"""API Gateway v1 uses application/json."""
|
| 119 |
+
return status, {"Content-Type": "application/json"}, json.dumps(data, ensure_ascii=False).encode("utf-8")
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
def _v1_error(code, message, status):
|
| 123 |
+
# AWS API Gateway errors use __type (double underscore), matching every
|
| 124 |
+
# other JSON-protocol AWS service. boto3 reads this to populate
|
| 125 |
+
# ``ClientError.response["Error"]["Code"]``; with plain "type" it falls
|
| 126 |
+
# back to the numeric HTTP status as the code.
|
| 127 |
+
return status, {"Content-Type": "application/json"}, json.dumps({"message": message, "__type": code}, ensure_ascii=False).encode("utf-8")
|
| 128 |
+
|
| 129 |
+
|
| 130 |
+
def _rest_api_arn(api_id):
|
| 131 |
+
return f"arn:aws:apigateway:{get_region()}::/restapis/{api_id}"
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
def _compute_path(api_id, resource_id):
|
| 135 |
+
"""Walk the parent chain to build the full resource path."""
|
| 136 |
+
resources = _resources.get(api_id, {})
|
| 137 |
+
parts = []
|
| 138 |
+
rid = resource_id
|
| 139 |
+
while rid:
|
| 140 |
+
r = resources.get(rid)
|
| 141 |
+
if not r:
|
| 142 |
+
break
|
| 143 |
+
pp = r.get("pathPart", "")
|
| 144 |
+
if pp:
|
| 145 |
+
parts.append(pp)
|
| 146 |
+
rid = r.get("parentId")
|
| 147 |
+
if not parts:
|
| 148 |
+
return "/"
|
| 149 |
+
parts.reverse()
|
| 150 |
+
return "/" + "/".join(parts)
|
| 151 |
+
|
| 152 |
+
|
| 153 |
+
def _apply_patch(obj, patch_ops):
|
| 154 |
+
"""Apply JSON Patch operations (replace/add/remove) to a dict in place."""
|
| 155 |
+
for op in patch_ops:
|
| 156 |
+
operation = op.get("op", "replace")
|
| 157 |
+
path = op.get("path", "")
|
| 158 |
+
value = op.get("value")
|
| 159 |
+
|
| 160 |
+
# Strip leading slash and split
|
| 161 |
+
keys = path.lstrip("/").split("/")
|
| 162 |
+
if not keys or keys == [""]:
|
| 163 |
+
continue
|
| 164 |
+
|
| 165 |
+
if operation in ("replace", "add"):
|
| 166 |
+
if len(keys) == 1:
|
| 167 |
+
obj[keys[0]] = value
|
| 168 |
+
else:
|
| 169 |
+
# Walk into nested dicts, create if needed
|
| 170 |
+
target = obj
|
| 171 |
+
for k in keys[:-1]:
|
| 172 |
+
if k not in target or not isinstance(target[k], dict):
|
| 173 |
+
target[k] = {}
|
| 174 |
+
target = target[k]
|
| 175 |
+
target[keys[-1]] = value
|
| 176 |
+
elif operation == "remove":
|
| 177 |
+
if len(keys) == 1:
|
| 178 |
+
obj.pop(keys[0], None)
|
| 179 |
+
else:
|
| 180 |
+
target = obj
|
| 181 |
+
for k in keys[:-1]:
|
| 182 |
+
if not isinstance(target.get(k), dict):
|
| 183 |
+
break
|
| 184 |
+
target = target[k]
|
| 185 |
+
else:
|
| 186 |
+
target.pop(keys[-1], None)
|
| 187 |
+
return obj
|
| 188 |
+
|
| 189 |
+
|
| 190 |
+
def _match_resource_tree(api_id, segments):
|
| 191 |
+
"""Match path segments against the resource tree. Returns (resource, path_params) or (None, {})."""
|
| 192 |
+
resources = _resources.get(api_id, {})
|
| 193 |
+
root = next((r for r in resources.values() if r.get("path") == "/"), None)
|
| 194 |
+
if not root:
|
| 195 |
+
return None, {}
|
| 196 |
+
if not segments or segments == [""]:
|
| 197 |
+
return root, {}
|
| 198 |
+
return _match_recursive(resources, root["id"], segments, {})
|
| 199 |
+
|
| 200 |
+
|
| 201 |
+
def _match_recursive(resources, parent_id, segments, params):
|
| 202 |
+
if not segments:
|
| 203 |
+
return None, params
|
| 204 |
+
segment = segments[0]
|
| 205 |
+
remaining = segments[1:]
|
| 206 |
+
children = [r for r in resources.values() if r.get("parentId") == parent_id]
|
| 207 |
+
for child in children:
|
| 208 |
+
pp = child.get("pathPart", "")
|
| 209 |
+
if pp.endswith("+}") and pp.startswith("{"):
|
| 210 |
+
# greedy {proxy+}
|
| 211 |
+
param_name = pp[1:-2]
|
| 212 |
+
new_params = dict(params)
|
| 213 |
+
new_params[param_name] = "/".join([segment] + list(remaining))
|
| 214 |
+
return child, new_params
|
| 215 |
+
elif pp.startswith("{") and pp.endswith("}"):
|
| 216 |
+
param_name = pp[1:-1]
|
| 217 |
+
new_params = dict(params)
|
| 218 |
+
new_params[param_name] = segment
|
| 219 |
+
if not remaining:
|
| 220 |
+
return child, new_params
|
| 221 |
+
result, rp = _match_recursive(resources, child["id"], list(remaining), new_params)
|
| 222 |
+
if result:
|
| 223 |
+
return result, rp
|
| 224 |
+
elif pp == segment:
|
| 225 |
+
if not remaining:
|
| 226 |
+
return child, params
|
| 227 |
+
result, rp = _match_recursive(resources, child["id"], list(remaining), dict(params))
|
| 228 |
+
if result:
|
| 229 |
+
return result, rp
|
| 230 |
+
return None, params
|
| 231 |
+
|
| 232 |
+
|
| 233 |
+
async def _call_lambda(func_name, event, qualifier=None):
|
| 234 |
+
"""Invoke a Lambda function and return the parsed response dict.
|
| 235 |
+
|
| 236 |
+
``qualifier`` may be a version number or alias name; aliases resolve to
|
| 237 |
+
their target version via ``_get_func_record_for_qualifier`` so aliased
|
| 238 |
+
integration URIs (arn:...:function:<name>:<alias>) invoke correctly (#407)."""
|
| 239 |
+
from ministack.core.lambda_runtime import get_or_create_worker
|
| 240 |
+
from ministack.services import lambda_svc
|
| 241 |
+
|
| 242 |
+
func_data, func_config = lambda_svc._get_func_record_for_qualifier(func_name, qualifier)
|
| 243 |
+
if func_data is None:
|
| 244 |
+
label = f"{func_name}:{qualifier}" if qualifier else func_name
|
| 245 |
+
return None, f"Lambda function '{label}' not found"
|
| 246 |
+
|
| 247 |
+
code_zip = func_data.get("code_zip")
|
| 248 |
+
runtime = func_config.get("Runtime", "")
|
| 249 |
+
if code_zip and runtime.startswith(("python", "nodejs")):
|
| 250 |
+
worker_key = f"{func_name}:{qualifier}" if qualifier else func_name
|
| 251 |
+
worker = get_or_create_worker(worker_key, func_config, code_zip)
|
| 252 |
+
result = await asyncio.to_thread(worker.invoke, event, new_uuid())
|
| 253 |
+
if result.get("status") == "error":
|
| 254 |
+
return None, result.get("error", "Lambda invocation error")
|
| 255 |
+
return result.get("result", {}), None
|
| 256 |
+
else:
|
| 257 |
+
return {"statusCode": 200, "body": "Mock response"}, None
|
| 258 |
+
|
| 259 |
+
|
| 260 |
+
SUPPORTED_ACTIONS = [
|
| 261 |
+
"CreateRestApi", "GetRestApis", "GetRestApi", "UpdateRestApi",
|
| 262 |
+
"DeleteRestApi", "GetResources", "GetResource", "CreateResource",
|
| 263 |
+
"UpdateResource", "DeleteResource", "PutMethod", "GetMethod",
|
| 264 |
+
"DeleteMethod", "PutMethodResponse", "GetMethodResponse",
|
| 265 |
+
"DeleteMethodResponse", "PutIntegration", "GetIntegration",
|
| 266 |
+
"DeleteIntegration", "PutIntegrationResponse", "GetIntegrationResponse",
|
| 267 |
+
"DeleteIntegrationResponse", "CreateDeployment", "GetDeployments",
|
| 268 |
+
"GetDeployment", "UpdateDeployment", "DeleteDeployment", "CreateStage",
|
| 269 |
+
"GetStages", "GetStage", "UpdateStage", "DeleteStage",
|
| 270 |
+
"CreateAuthorizer", "GetAuthorizers", "GetAuthorizer",
|
| 271 |
+
"UpdateAuthorizer", "DeleteAuthorizer", "CreateModel", "GetModels",
|
| 272 |
+
"GetModel", "DeleteModel", "GetApiKeys", "CreateApiKey", "GetApiKey",
|
| 273 |
+
"DeleteApiKey", "GetUsagePlans", "CreateUsagePlan", "GetUsagePlan",
|
| 274 |
+
"DeleteUsagePlan", "GetUsagePlanKeys", "CreateUsagePlanKey",
|
| 275 |
+
"DeleteUsagePlanKey", "GetDomainNames", "CreateDomainName",
|
| 276 |
+
"GetDomainName", "DeleteDomainName", "GetTags", "TagResource",
|
| 277 |
+
"UntagResource",
|
| 278 |
+
]
|
| 279 |
+
|
| 280 |
+
|
| 281 |
+
# ---- Persistence hooks ----
|
| 282 |
+
|
| 283 |
+
def get_state():
|
| 284 |
+
"""Return full module state for persistence."""
|
| 285 |
+
return {
|
| 286 |
+
"rest_apis": _rest_apis,
|
| 287 |
+
"resources": _resources,
|
| 288 |
+
"stages_v1": _stages_v1,
|
| 289 |
+
"deployments_v1": _deployments_v1,
|
| 290 |
+
"authorizers_v1": _authorizers_v1,
|
| 291 |
+
"models": _models,
|
| 292 |
+
"api_keys": _api_keys,
|
| 293 |
+
"usage_plans": _usage_plans,
|
| 294 |
+
"usage_plan_keys": _usage_plan_keys,
|
| 295 |
+
"domain_names": _domain_names,
|
| 296 |
+
"base_path_mappings": _base_path_mappings,
|
| 297 |
+
"v1_tags": _v1_tags,
|
| 298 |
+
}
|
| 299 |
+
|
| 300 |
+
|
| 301 |
+
def load_persisted_state(data):
|
| 302 |
+
"""Restore module state from a previously persisted snapshot."""
|
| 303 |
+
_rest_apis.update(data.get("rest_apis", {}))
|
| 304 |
+
_resources.update(data.get("resources", {}))
|
| 305 |
+
_stages_v1.update(data.get("stages_v1", {}))
|
| 306 |
+
_deployments_v1.update(data.get("deployments_v1", {}))
|
| 307 |
+
_authorizers_v1.update(data.get("authorizers_v1", {}))
|
| 308 |
+
_models.update(data.get("models", {}))
|
| 309 |
+
_api_keys.update(data.get("api_keys", {}))
|
| 310 |
+
_usage_plans.update(data.get("usage_plans", {}))
|
| 311 |
+
_usage_plan_keys.update(data.get("usage_plan_keys", {}))
|
| 312 |
+
_domain_names.update(data.get("domain_names", {}))
|
| 313 |
+
_base_path_mappings.update(data.get("base_path_mappings", {}))
|
| 314 |
+
_v1_tags.update(data.get("v1_tags", {}))
|
| 315 |
+
|
| 316 |
+
|
| 317 |
+
def reset():
|
| 318 |
+
"""Clear all module state."""
|
| 319 |
+
_rest_apis.clear()
|
| 320 |
+
_resources.clear()
|
| 321 |
+
_stages_v1.clear()
|
| 322 |
+
_deployments_v1.clear()
|
| 323 |
+
_authorizers_v1.clear()
|
| 324 |
+
_models.clear()
|
| 325 |
+
_api_keys.clear()
|
| 326 |
+
_usage_plans.clear()
|
| 327 |
+
_usage_plan_keys.clear()
|
| 328 |
+
_domain_names.clear()
|
| 329 |
+
_base_path_mappings.clear()
|
| 330 |
+
_v1_tags.clear()
|
| 331 |
+
|
| 332 |
+
|
| 333 |
+
# ---- Control plane router ----
|
| 334 |
+
|
| 335 |
+
async def handle_request(method, path, headers, body, query_params):
|
| 336 |
+
"""Route API Gateway v1 REST API control plane requests."""
|
| 337 |
+
try:
|
| 338 |
+
data = json.loads(body) if body else {}
|
| 339 |
+
except json.JSONDecodeError:
|
| 340 |
+
data = {}
|
| 341 |
+
|
| 342 |
+
parts = [p for p in path.strip("/").split("/") if p]
|
| 343 |
+
|
| 344 |
+
if not parts:
|
| 345 |
+
return _v1_error("NotFoundException", f"Unknown path: {path}", 404)
|
| 346 |
+
|
| 347 |
+
top = parts[0]
|
| 348 |
+
|
| 349 |
+
if top == "tags":
|
| 350 |
+
# /tags/{resourceArn} — ARN may contain slashes
|
| 351 |
+
resource_arn = "/".join(parts[1:]) if len(parts) > 1 else ""
|
| 352 |
+
if method == "GET":
|
| 353 |
+
return _get_v1_tags(resource_arn)
|
| 354 |
+
if method in ("PUT", "POST"):
|
| 355 |
+
return _tag_v1_resource(resource_arn, data)
|
| 356 |
+
if method == "DELETE":
|
| 357 |
+
tag_keys = query_params.get("tagKeys", [])
|
| 358 |
+
if isinstance(tag_keys, str):
|
| 359 |
+
tag_keys = [tag_keys]
|
| 360 |
+
return _untag_v1_resource(resource_arn, tag_keys)
|
| 361 |
+
|
| 362 |
+
if top == "apikeys":
|
| 363 |
+
key_id = parts[1] if len(parts) > 1 else None
|
| 364 |
+
if not key_id:
|
| 365 |
+
if method == "GET":
|
| 366 |
+
return _get_api_keys()
|
| 367 |
+
if method == "POST":
|
| 368 |
+
return _create_api_key(data)
|
| 369 |
+
else:
|
| 370 |
+
if method == "GET":
|
| 371 |
+
return _get_api_key(key_id)
|
| 372 |
+
if method == "DELETE":
|
| 373 |
+
return _delete_api_key(key_id)
|
| 374 |
+
if method == "PATCH":
|
| 375 |
+
return _update_api_key(key_id, data)
|
| 376 |
+
|
| 377 |
+
if top == "usageplans":
|
| 378 |
+
plan_id = parts[1] if len(parts) > 1 else None
|
| 379 |
+
sub = parts[2] if len(parts) > 2 else None
|
| 380 |
+
sub_id = parts[3] if len(parts) > 3 else None
|
| 381 |
+
if not plan_id:
|
| 382 |
+
if method == "GET":
|
| 383 |
+
return _get_usage_plans()
|
| 384 |
+
if method == "POST":
|
| 385 |
+
return _create_usage_plan(data)
|
| 386 |
+
elif sub == "keys":
|
| 387 |
+
if not sub_id:
|
| 388 |
+
if method == "GET":
|
| 389 |
+
return _get_usage_plan_keys(plan_id)
|
| 390 |
+
if method == "POST":
|
| 391 |
+
return _create_usage_plan_key(plan_id, data)
|
| 392 |
+
else:
|
| 393 |
+
if method == "DELETE":
|
| 394 |
+
return _delete_usage_plan_key(plan_id, sub_id)
|
| 395 |
+
else:
|
| 396 |
+
if method == "GET":
|
| 397 |
+
return _get_usage_plan(plan_id)
|
| 398 |
+
if method == "DELETE":
|
| 399 |
+
return _delete_usage_plan(plan_id)
|
| 400 |
+
if method == "PATCH":
|
| 401 |
+
return _update_usage_plan(plan_id, data)
|
| 402 |
+
|
| 403 |
+
if top == "domainnames":
|
| 404 |
+
domain_name = parts[1] if len(parts) > 1 else None
|
| 405 |
+
sub = parts[2] if len(parts) > 2 else None
|
| 406 |
+
sub_id = parts[3] if len(parts) > 3 else None
|
| 407 |
+
if not domain_name:
|
| 408 |
+
if method == "GET":
|
| 409 |
+
return _get_domain_names()
|
| 410 |
+
if method == "POST":
|
| 411 |
+
return _create_domain_name(data)
|
| 412 |
+
elif sub == "basepathmappings":
|
| 413 |
+
base_path = sub_id
|
| 414 |
+
if not base_path:
|
| 415 |
+
if method == "GET":
|
| 416 |
+
return _get_base_path_mappings(domain_name)
|
| 417 |
+
if method == "POST":
|
| 418 |
+
return _create_base_path_mapping(domain_name, data)
|
| 419 |
+
else:
|
| 420 |
+
if method == "GET":
|
| 421 |
+
return _get_base_path_mapping(domain_name, base_path)
|
| 422 |
+
if method == "DELETE":
|
| 423 |
+
return _delete_base_path_mapping(domain_name, base_path)
|
| 424 |
+
else:
|
| 425 |
+
if method == "GET":
|
| 426 |
+
return _get_domain_name(domain_name)
|
| 427 |
+
if method == "DELETE":
|
| 428 |
+
return _delete_domain_name(domain_name)
|
| 429 |
+
|
| 430 |
+
if top == "restapis":
|
| 431 |
+
# /restapis
|
| 432 |
+
if len(parts) == 1:
|
| 433 |
+
if method == "POST":
|
| 434 |
+
return _create_rest_api(data)
|
| 435 |
+
if method == "GET":
|
| 436 |
+
return _get_rest_apis()
|
| 437 |
+
|
| 438 |
+
api_id = parts[1]
|
| 439 |
+
|
| 440 |
+
# /restapis/{id}
|
| 441 |
+
if len(parts) == 2:
|
| 442 |
+
if method == "GET":
|
| 443 |
+
return _get_rest_api(api_id)
|
| 444 |
+
if method == "DELETE":
|
| 445 |
+
return _delete_rest_api(api_id)
|
| 446 |
+
if method == "PATCH":
|
| 447 |
+
return _update_rest_api(api_id, data)
|
| 448 |
+
|
| 449 |
+
sub = parts[2] if len(parts) > 2 else None
|
| 450 |
+
|
| 451 |
+
# /restapis/{id}/resources[/{resourceId}[/...]]
|
| 452 |
+
if sub == "resources":
|
| 453 |
+
resource_id = parts[3] if len(parts) > 3 else None
|
| 454 |
+
method_part = parts[4] if len(parts) > 4 else None
|
| 455 |
+
http_method = parts[5] if len(parts) > 5 else None
|
| 456 |
+
after_method = parts[6] if len(parts) > 6 else None
|
| 457 |
+
after_method_id = parts[7] if len(parts) > 7 else None
|
| 458 |
+
|
| 459 |
+
if not resource_id:
|
| 460 |
+
# GET /restapis/{id}/resources
|
| 461 |
+
if method == "GET":
|
| 462 |
+
return _get_resources(api_id)
|
| 463 |
+
|
| 464 |
+
elif method_part is None:
|
| 465 |
+
# /restapis/{id}/resources/{resourceId}
|
| 466 |
+
if method == "GET":
|
| 467 |
+
return _get_resource(api_id, resource_id)
|
| 468 |
+
if method == "POST":
|
| 469 |
+
# CreateResource: POST /restapis/{id}/resources/{parentId}
|
| 470 |
+
return _create_resource(api_id, resource_id, data)
|
| 471 |
+
if method == "PATCH":
|
| 472 |
+
return _update_resource(api_id, resource_id, data)
|
| 473 |
+
if method == "DELETE":
|
| 474 |
+
return _delete_resource(api_id, resource_id)
|
| 475 |
+
|
| 476 |
+
elif method_part == "methods":
|
| 477 |
+
if http_method is None:
|
| 478 |
+
return _v1_error("NotFoundException", "Method not specified", 404)
|
| 479 |
+
|
| 480 |
+
if after_method is None:
|
| 481 |
+
# /restapis/{id}/resources/{resourceId}/methods/{httpMethod}
|
| 482 |
+
if method == "PUT":
|
| 483 |
+
return _put_method(api_id, resource_id, http_method, data)
|
| 484 |
+
if method == "GET":
|
| 485 |
+
return _get_method(api_id, resource_id, http_method)
|
| 486 |
+
if method == "DELETE":
|
| 487 |
+
return _delete_method(api_id, resource_id, http_method)
|
| 488 |
+
if method == "PATCH":
|
| 489 |
+
return _update_method(api_id, resource_id, http_method, data)
|
| 490 |
+
|
| 491 |
+
elif after_method == "responses":
|
| 492 |
+
status_code = after_method_id
|
| 493 |
+
if not status_code:
|
| 494 |
+
return _v1_error("NotFoundException", "Status code not specified", 404)
|
| 495 |
+
if method == "PUT":
|
| 496 |
+
return _put_method_response(api_id, resource_id, http_method, status_code, data)
|
| 497 |
+
if method == "GET":
|
| 498 |
+
return _get_method_response(api_id, resource_id, http_method, status_code)
|
| 499 |
+
if method == "DELETE":
|
| 500 |
+
return _delete_method_response(api_id, resource_id, http_method, status_code)
|
| 501 |
+
|
| 502 |
+
elif after_method == "integration":
|
| 503 |
+
# Check for integration/responses/{statusCode}
|
| 504 |
+
int_sub = parts[7] if len(parts) > 7 else None
|
| 505 |
+
int_sub_id = parts[8] if len(parts) > 8 else None
|
| 506 |
+
|
| 507 |
+
if after_method_id is None and int_sub is None:
|
| 508 |
+
# /.../{httpMethod}/integration
|
| 509 |
+
if method == "PUT":
|
| 510 |
+
return _put_integration(api_id, resource_id, http_method, data)
|
| 511 |
+
if method == "GET":
|
| 512 |
+
return _get_integration(api_id, resource_id, http_method)
|
| 513 |
+
if method == "DELETE":
|
| 514 |
+
return _delete_integration(api_id, resource_id, http_method)
|
| 515 |
+
if method == "PATCH":
|
| 516 |
+
return _update_integration(api_id, resource_id, http_method, data)
|
| 517 |
+
elif after_method_id == "responses":
|
| 518 |
+
status_code = int_sub_id
|
| 519 |
+
if not status_code:
|
| 520 |
+
return _v1_error("NotFoundException", "Status code not specified", 404)
|
| 521 |
+
if method == "PUT":
|
| 522 |
+
return _put_integration_response(api_id, resource_id, http_method, status_code, data)
|
| 523 |
+
if method == "GET":
|
| 524 |
+
return _get_integration_response(api_id, resource_id, http_method, status_code)
|
| 525 |
+
if method == "DELETE":
|
| 526 |
+
return _delete_integration_response(api_id, resource_id, http_method, status_code)
|
| 527 |
+
|
| 528 |
+
# /restapis/{id}/deployments[/{deploymentId}]
|
| 529 |
+
elif sub == "deployments":
|
| 530 |
+
deployment_id = parts[3] if len(parts) > 3 else None
|
| 531 |
+
if not deployment_id:
|
| 532 |
+
if method == "POST":
|
| 533 |
+
return _create_deployment(api_id, data)
|
| 534 |
+
if method == "GET":
|
| 535 |
+
return _get_deployments(api_id)
|
| 536 |
+
else:
|
| 537 |
+
if method == "GET":
|
| 538 |
+
return _get_deployment(api_id, deployment_id)
|
| 539 |
+
if method == "PATCH":
|
| 540 |
+
return _update_deployment(api_id, deployment_id, data)
|
| 541 |
+
if method == "DELETE":
|
| 542 |
+
return _delete_deployment(api_id, deployment_id)
|
| 543 |
+
|
| 544 |
+
# /restapis/{id}/stages[/{stageName}]
|
| 545 |
+
elif sub == "stages":
|
| 546 |
+
stage_name = parts[3] if len(parts) > 3 else None
|
| 547 |
+
if not stage_name:
|
| 548 |
+
if method == "POST":
|
| 549 |
+
return _create_stage(api_id, data)
|
| 550 |
+
if method == "GET":
|
| 551 |
+
return _get_stages(api_id)
|
| 552 |
+
else:
|
| 553 |
+
if method == "GET":
|
| 554 |
+
return _get_stage(api_id, stage_name)
|
| 555 |
+
if method == "PATCH":
|
| 556 |
+
return _update_stage(api_id, stage_name, data)
|
| 557 |
+
if method == "DELETE":
|
| 558 |
+
return _delete_stage(api_id, stage_name)
|
| 559 |
+
|
| 560 |
+
# /restapis/{id}/authorizers[/{authorizerId}]
|
| 561 |
+
elif sub == "authorizers":
|
| 562 |
+
auth_id = parts[3] if len(parts) > 3 else None
|
| 563 |
+
if not auth_id:
|
| 564 |
+
if method == "POST":
|
| 565 |
+
return _create_authorizer(api_id, data)
|
| 566 |
+
if method == "GET":
|
| 567 |
+
return _get_authorizers(api_id)
|
| 568 |
+
else:
|
| 569 |
+
if method == "GET":
|
| 570 |
+
return _get_authorizer(api_id, auth_id)
|
| 571 |
+
if method == "PATCH":
|
| 572 |
+
return _update_authorizer(api_id, auth_id, data)
|
| 573 |
+
if method == "DELETE":
|
| 574 |
+
return _delete_authorizer(api_id, auth_id)
|
| 575 |
+
|
| 576 |
+
# /restapis/{id}/models[/{modelName}]
|
| 577 |
+
elif sub == "models":
|
| 578 |
+
model_name = parts[3] if len(parts) > 3 else None
|
| 579 |
+
if not model_name:
|
| 580 |
+
if method == "POST":
|
| 581 |
+
return _create_model(api_id, data)
|
| 582 |
+
if method == "GET":
|
| 583 |
+
return _get_models(api_id)
|
| 584 |
+
else:
|
| 585 |
+
if method == "GET":
|
| 586 |
+
return _get_model(api_id, model_name)
|
| 587 |
+
if method == "DELETE":
|
| 588 |
+
return _delete_model(api_id, model_name)
|
| 589 |
+
|
| 590 |
+
return _v1_error("NotFoundException", f"Unknown API Gateway v1 path: {path}", 404)
|
| 591 |
+
|
| 592 |
+
|
| 593 |
+
# ---- Data plane ----
|
| 594 |
+
|
| 595 |
+
async def handle_execute(api_id, stage_name, method, path, headers, body, query_params):
|
| 596 |
+
"""Execute a v1 REST API request through a deployed stage (data plane)."""
|
| 597 |
+
api = _rest_apis.get(api_id)
|
| 598 |
+
if not api:
|
| 599 |
+
return 404, {"Content-Type": "application/json"}, json.dumps({"message": "Not Found"}).encode()
|
| 600 |
+
|
| 601 |
+
stage = _stages_v1.get(api_id, {}).get(stage_name)
|
| 602 |
+
if not stage:
|
| 603 |
+
return 404, {"Content-Type": "application/json"}, json.dumps({"message": f"Stage '{stage_name}' not found"}).encode()
|
| 604 |
+
|
| 605 |
+
# Match path against resource tree
|
| 606 |
+
segments = [s for s in path.strip("/").split("/") if s]
|
| 607 |
+
resource, path_params = _match_resource_tree(api_id, segments)
|
| 608 |
+
|
| 609 |
+
if not resource:
|
| 610 |
+
return 404, {"Content-Type": "application/json"}, json.dumps({"message": "Missing Authentication Token"}).encode()
|
| 611 |
+
|
| 612 |
+
# Look up method
|
| 613 |
+
resource_methods = resource.get("resourceMethods", {})
|
| 614 |
+
method_obj = resource_methods.get(method) or resource_methods.get("ANY")
|
| 615 |
+
if not method_obj:
|
| 616 |
+
return 405, {"Content-Type": "application/json"}, json.dumps({"message": "Method Not Allowed"}).encode()
|
| 617 |
+
|
| 618 |
+
integration = method_obj.get("methodIntegration")
|
| 619 |
+
if not integration:
|
| 620 |
+
return 500, {"Content-Type": "application/json"}, json.dumps({"message": "No integration configured"}).encode()
|
| 621 |
+
|
| 622 |
+
int_type = integration.get("type", "")
|
| 623 |
+
|
| 624 |
+
if int_type in ("AWS_PROXY", "AWS"):
|
| 625 |
+
return await _invoke_lambda_proxy_v1(
|
| 626 |
+
integration, api_id, stage_name, stage, resource, path, method,
|
| 627 |
+
headers, body, query_params, path_params
|
| 628 |
+
)
|
| 629 |
+
elif int_type in ("HTTP_PROXY", "HTTP"):
|
| 630 |
+
return await _invoke_http_proxy_v1(integration, path, method, headers, body, query_params)
|
| 631 |
+
elif int_type == "MOCK":
|
| 632 |
+
return _invoke_mock_v1(integration)
|
| 633 |
+
else:
|
| 634 |
+
return 500, {"Content-Type": "application/json"}, json.dumps({"message": f"Unsupported integration type: {int_type}"}).encode()
|
| 635 |
+
|
| 636 |
+
|
| 637 |
+
async def _invoke_lambda_proxy_v1(integration, api_id, stage_name, stage, resource, request_path, method, headers, body, query_params, path_params):
|
| 638 |
+
"""Invoke Lambda with API Gateway v1 payload format 1.0."""
|
| 639 |
+
uri = integration.get("uri", "")
|
| 640 |
+
# Supported URI formats:
|
| 641 |
+
# 1. arn:aws:apigateway:{region}:lambda:path/2015-03-31/functions/arn:aws:lambda:{region}:{acct}:function:{name}[:{qualifier}]/invocations
|
| 642 |
+
# 2. arn:aws:lambda:{region}:{acct}:function:{name}[:{qualifier}]
|
| 643 |
+
# 3. plain function name: MyFunction[:{qualifier}]
|
| 644 |
+
from ministack.services import lambda_svc as _lambda_svc
|
| 645 |
+
if "function:" in uri:
|
| 646 |
+
# Strip wrapper up through 'function:' and any trailing /invocations.
|
| 647 |
+
tail = uri.split("function:")[-1].split("/")[0]
|
| 648 |
+
# tail is now "<name>" or "<name>:<qualifier>".
|
| 649 |
+
func_name, qualifier = _lambda_svc._resolve_name_and_qualifier(tail)
|
| 650 |
+
else:
|
| 651 |
+
func_name, qualifier = _lambda_svc._resolve_name_and_qualifier(uri)
|
| 652 |
+
|
| 653 |
+
qs_params = {k: v[0] for k, v in query_params.items()} if query_params else None
|
| 654 |
+
mv_qs_params = {k: list(v) for k, v in query_params.items()} if query_params else None
|
| 655 |
+
|
| 656 |
+
# Build single and multi-value header dicts
|
| 657 |
+
single_headers = {k: v if isinstance(v, str) else v[-1] for k, v in headers.items()}
|
| 658 |
+
multi_headers = {k: [v] if isinstance(v, str) else list(v) for k, v in headers.items()}
|
| 659 |
+
|
| 660 |
+
now_epoch_ms = int(time.time() * 1000)
|
| 661 |
+
request_time = datetime.datetime.utcnow().strftime("%d/%b/%Y:%H:%M:%S +0000")
|
| 662 |
+
request_id = new_uuid()
|
| 663 |
+
|
| 664 |
+
event = {
|
| 665 |
+
"version": "1.0",
|
| 666 |
+
"resource": resource["path"],
|
| 667 |
+
"path": request_path,
|
| 668 |
+
"httpMethod": method,
|
| 669 |
+
"headers": single_headers,
|
| 670 |
+
"multiValueHeaders": multi_headers,
|
| 671 |
+
"queryStringParameters": qs_params or None,
|
| 672 |
+
"multiValueQueryStringParameters": mv_qs_params or None,
|
| 673 |
+
"pathParameters": path_params or None,
|
| 674 |
+
"stageVariables": stage.get("variables") or None,
|
| 675 |
+
"requestContext": {
|
| 676 |
+
"accountId": get_account_id(),
|
| 677 |
+
"resourceId": resource["id"],
|
| 678 |
+
"stage": stage_name,
|
| 679 |
+
"requestId": request_id,
|
| 680 |
+
"extendedRequestId": request_id,
|
| 681 |
+
"requestTime": request_time,
|
| 682 |
+
"requestTimeEpoch": now_epoch_ms,
|
| 683 |
+
"path": f"/{stage_name}{request_path}",
|
| 684 |
+
"protocol": "HTTP/1.1",
|
| 685 |
+
"identity": {
|
| 686 |
+
"sourceIp": headers.get("x-forwarded-for", "127.0.0.1").split(",")[0].strip()
|
| 687 |
+
if isinstance(headers.get("x-forwarded-for", ""), str)
|
| 688 |
+
else "127.0.0.1",
|
| 689 |
+
"userAgent": headers.get("user-agent", ""),
|
| 690 |
+
},
|
| 691 |
+
"resourcePath": resource["path"],
|
| 692 |
+
"httpMethod": method,
|
| 693 |
+
"apiId": api_id,
|
| 694 |
+
},
|
| 695 |
+
"body": body.decode("utf-8", errors="replace") if body else None,
|
| 696 |
+
"isBase64Encoded": False,
|
| 697 |
+
}
|
| 698 |
+
|
| 699 |
+
lambda_response, err = await _call_lambda(func_name, event, qualifier=qualifier)
|
| 700 |
+
if err:
|
| 701 |
+
return 502, {"Content-Type": "application/json"}, json.dumps({"message": err}).encode()
|
| 702 |
+
|
| 703 |
+
status = lambda_response.get("statusCode", 200)
|
| 704 |
+
resp_headers = {"Content-Type": "application/json"}
|
| 705 |
+
resp_headers.update(lambda_response.get("headers", {}))
|
| 706 |
+
resp_body = lambda_response.get("body", "")
|
| 707 |
+
if isinstance(resp_body, str):
|
| 708 |
+
resp_body = resp_body.encode("utf-8")
|
| 709 |
+
elif isinstance(resp_body, dict):
|
| 710 |
+
resp_body = json.dumps(resp_body, ensure_ascii=False).encode("utf-8")
|
| 711 |
+
|
| 712 |
+
return status, resp_headers, resp_body
|
| 713 |
+
|
| 714 |
+
|
| 715 |
+
async def _invoke_http_proxy_v1(integration, path, method, headers, body, query_params):
|
| 716 |
+
"""Forward a request to an HTTP backend."""
|
| 717 |
+
uri = integration.get("uri", "")
|
| 718 |
+
url = uri.rstrip("/") + path
|
| 719 |
+
|
| 720 |
+
req = urllib.request.Request(url, data=body or None, method=method)
|
| 721 |
+
for k, v in headers.items():
|
| 722 |
+
if k.lower() not in ("host", "content-length"):
|
| 723 |
+
req.add_header(k, v)
|
| 724 |
+
try:
|
| 725 |
+
with urllib.request.urlopen(req, timeout=30) as resp:
|
| 726 |
+
resp_body = resp.read()
|
| 727 |
+
resp_headers = {"Content-Type": resp.headers.get("Content-Type", "application/json")}
|
| 728 |
+
return resp.status, resp_headers, resp_body
|
| 729 |
+
except urllib.error.HTTPError as e:
|
| 730 |
+
return e.code, {"Content-Type": "application/json"}, e.read()
|
| 731 |
+
except Exception as ex:
|
| 732 |
+
return 502, {"Content-Type": "application/json"}, json.dumps({"message": str(ex)}).encode()
|
| 733 |
+
|
| 734 |
+
|
| 735 |
+
def _invoke_mock_v1(integration):
|
| 736 |
+
"""Return a MOCK integration response.
|
| 737 |
+
|
| 738 |
+
Selection: iterate integrationResponses in status-code order; the first
|
| 739 |
+
entry whose selectionPattern is empty (default) or matches "200" is used,
|
| 740 |
+
matching AWS behaviour for MOCK where the input is always treated as
|
| 741 |
+
successful (statusCode 200).
|
| 742 |
+
"""
|
| 743 |
+
int_responses = integration.get("integrationResponses", {})
|
| 744 |
+
if not int_responses:
|
| 745 |
+
return 200, {"Content-Type": "application/json"}, b"{}"
|
| 746 |
+
|
| 747 |
+
# AWS selects the response whose selectionPattern matches the integration
|
| 748 |
+
# status code. For MOCK the "status" is always 200 (success path).
|
| 749 |
+
selected = None
|
| 750 |
+
# Prefer an explicit "200" entry first
|
| 751 |
+
if "200" in int_responses:
|
| 752 |
+
selected = int_responses["200"]
|
| 753 |
+
else:
|
| 754 |
+
# Fall back to the entry with an empty / catch-all selectionPattern
|
| 755 |
+
for resp in int_responses.values():
|
| 756 |
+
pattern = resp.get("selectionPattern", "")
|
| 757 |
+
if not pattern:
|
| 758 |
+
selected = resp
|
| 759 |
+
break
|
| 760 |
+
if not selected:
|
| 761 |
+
selected = next(iter(int_responses.values()))
|
| 762 |
+
|
| 763 |
+
status = int(selected.get("statusCode", 200))
|
| 764 |
+
resp_headers = {"Content-Type": "application/json"}
|
| 765 |
+
|
| 766 |
+
# Apply responseParameters: map integration values to method response headers
|
| 767 |
+
for dest, src in selected.get("responseParameters", {}).items():
|
| 768 |
+
# dest: "method.response.header.X-Custom-Header"
|
| 769 |
+
if dest.startswith("method.response.header."):
|
| 770 |
+
header_name = dest[len("method.response.header."):]
|
| 771 |
+
# src is a static string value (quoted) or integration reference
|
| 772 |
+
value = src.strip("'") if src.startswith("'") else src
|
| 773 |
+
resp_headers[header_name] = value
|
| 774 |
+
|
| 775 |
+
body_template = selected.get("responseTemplates", {}).get("application/json", "")
|
| 776 |
+
if body_template:
|
| 777 |
+
return status, resp_headers, body_template.encode()
|
| 778 |
+
return status, resp_headers, b"{}"
|
| 779 |
+
|
| 780 |
+
|
| 781 |
+
# ---- Control plane: REST APIs ----
|
| 782 |
+
|
| 783 |
+
def _resolve_custom_rest_api_id(tags: dict) -> tuple[str | None, tuple | None]:
|
| 784 |
+
"""Return (api_id_or_None, error_response_or_None).
|
| 785 |
+
|
| 786 |
+
Reads the ministack-native ``ms-custom-id`` tag (issue #400). If the
|
| 787 |
+
LocalStack ``ls-custom-id`` tag is set (and ``ms-custom-id`` is not), the
|
| 788 |
+
caller gets a clear ``BadRequestException`` so the ministack-native key is
|
| 789 |
+
the only supported contract."""
|
| 790 |
+
if not isinstance(tags, dict):
|
| 791 |
+
return None, None
|
| 792 |
+
if "ls-custom-id" in tags and "ms-custom-id" not in tags:
|
| 793 |
+
return None, _v1_error(
|
| 794 |
+
"BadRequestException",
|
| 795 |
+
"ls-custom-id tag is not supported; use 'ms-custom-id' instead",
|
| 796 |
+
400,
|
| 797 |
+
)
|
| 798 |
+
custom = tags.get("ms-custom-id")
|
| 799 |
+
if not custom:
|
| 800 |
+
return None, None
|
| 801 |
+
if custom in _rest_apis:
|
| 802 |
+
return None, _v1_error(
|
| 803 |
+
"ConflictException",
|
| 804 |
+
f"REST API id '{custom}' (from ms-custom-id tag) is already in use",
|
| 805 |
+
409,
|
| 806 |
+
)
|
| 807 |
+
return str(custom), None
|
| 808 |
+
|
| 809 |
+
|
| 810 |
+
def _create_rest_api(data):
|
| 811 |
+
tags = data.get("tags", {})
|
| 812 |
+
custom_id, err = _resolve_custom_rest_api_id(tags)
|
| 813 |
+
if err is not None:
|
| 814 |
+
return err
|
| 815 |
+
api_id = custom_id or _new_id()[:8]
|
| 816 |
+
api = {
|
| 817 |
+
"id": api_id,
|
| 818 |
+
"name": data.get("name", "unnamed"),
|
| 819 |
+
"description": data.get("description", ""),
|
| 820 |
+
"createdDate": _now_unix(),
|
| 821 |
+
"version": data.get("version", ""),
|
| 822 |
+
"binaryMediaTypes": data.get("binaryMediaTypes", []),
|
| 823 |
+
"minimumCompressionSize": data.get("minimumCompressionSize"),
|
| 824 |
+
"apiKeySource": data.get("apiKeySource", "HEADER"),
|
| 825 |
+
"endpointConfiguration": data.get("endpointConfiguration", {"types": ["REGIONAL"]}),
|
| 826 |
+
"policy": data.get("policy"),
|
| 827 |
+
"tags": data.get("tags", {}),
|
| 828 |
+
"disableExecuteApiEndpoint": data.get("disableExecuteApiEndpoint", False),
|
| 829 |
+
}
|
| 830 |
+
_rest_apis[api_id] = api
|
| 831 |
+
_resources[api_id] = {}
|
| 832 |
+
_stages_v1[api_id] = {}
|
| 833 |
+
_deployments_v1[api_id] = {}
|
| 834 |
+
_authorizers_v1[api_id] = {}
|
| 835 |
+
_models[api_id] = {}
|
| 836 |
+
|
| 837 |
+
# Create root resource "/"
|
| 838 |
+
root_id = _new_id()[:8]
|
| 839 |
+
root_resource = {
|
| 840 |
+
"id": root_id,
|
| 841 |
+
"parentId": None,
|
| 842 |
+
"pathPart": "",
|
| 843 |
+
"path": "/",
|
| 844 |
+
"resourceMethods": {},
|
| 845 |
+
}
|
| 846 |
+
_resources[api_id][root_id] = root_resource
|
| 847 |
+
|
| 848 |
+
_v1_tags[_rest_api_arn(api_id)] = dict(data.get("tags", {}))
|
| 849 |
+
return _v1_response(api, 201)
|
| 850 |
+
|
| 851 |
+
|
| 852 |
+
def _get_rest_api(api_id):
|
| 853 |
+
api = _rest_apis.get(api_id)
|
| 854 |
+
if not api:
|
| 855 |
+
return _v1_error("NotFoundException", "Invalid API identifier specified", 404)
|
| 856 |
+
return _v1_response(api)
|
| 857 |
+
|
| 858 |
+
|
| 859 |
+
def _get_rest_apis():
|
| 860 |
+
return _v1_response({"item": list(_rest_apis.values()), "nextToken": None})
|
| 861 |
+
|
| 862 |
+
|
| 863 |
+
def _update_rest_api(api_id, data):
|
| 864 |
+
api = _rest_apis.get(api_id)
|
| 865 |
+
if not api:
|
| 866 |
+
return _v1_error("NotFoundException", "Invalid API identifier specified", 404)
|
| 867 |
+
patch_ops = data.get("patchOperations", [])
|
| 868 |
+
_apply_patch(api, patch_ops)
|
| 869 |
+
return _v1_response(api)
|
| 870 |
+
|
| 871 |
+
|
| 872 |
+
def _delete_rest_api(api_id):
|
| 873 |
+
if api_id not in _rest_apis:
|
| 874 |
+
return _v1_error("NotFoundException", "Invalid API identifier specified", 404)
|
| 875 |
+
_rest_apis.pop(api_id, None)
|
| 876 |
+
_resources.pop(api_id, None)
|
| 877 |
+
_stages_v1.pop(api_id, None)
|
| 878 |
+
_deployments_v1.pop(api_id, None)
|
| 879 |
+
_authorizers_v1.pop(api_id, None)
|
| 880 |
+
_models.pop(api_id, None)
|
| 881 |
+
_v1_tags.pop(_rest_api_arn(api_id), None)
|
| 882 |
+
return 202, {}, b""
|
| 883 |
+
|
| 884 |
+
|
| 885 |
+
# ---- Control plane: Resources ----
|
| 886 |
+
|
| 887 |
+
def _get_resources(api_id):
|
| 888 |
+
if api_id not in _rest_apis:
|
| 889 |
+
return _v1_error("NotFoundException", "Invalid API identifier specified", 404)
|
| 890 |
+
return _v1_response({"item": list(_resources.get(api_id, {}).values())})
|
| 891 |
+
|
| 892 |
+
|
| 893 |
+
def _get_resource(api_id, resource_id):
|
| 894 |
+
resource = _resources.get(api_id, {}).get(resource_id)
|
| 895 |
+
if not resource:
|
| 896 |
+
return _v1_error("NotFoundException", "Invalid Resource identifier specified", 404)
|
| 897 |
+
return _v1_response(resource)
|
| 898 |
+
|
| 899 |
+
|
| 900 |
+
def _create_resource(api_id, parent_id, data):
|
| 901 |
+
if api_id not in _rest_apis:
|
| 902 |
+
return _v1_error("NotFoundException", "Invalid API identifier specified", 404)
|
| 903 |
+
if parent_id not in _resources.get(api_id, {}):
|
| 904 |
+
return _v1_error("NotFoundException", "Invalid Resource identifier specified", 404)
|
| 905 |
+
path_part = data.get("pathPart", "")
|
| 906 |
+
# Check for duplicate pathPart under same parent
|
| 907 |
+
for r in _resources.get(api_id, {}).values():
|
| 908 |
+
if r.get("parentId") == parent_id and r.get("pathPart") == path_part:
|
| 909 |
+
return _v1_error("ConflictException",
|
| 910 |
+
f"Another resource with the same parent already has this name: {path_part}", 409)
|
| 911 |
+
resource_id = _new_id()[:8]
|
| 912 |
+
resource = {
|
| 913 |
+
"id": resource_id,
|
| 914 |
+
"parentId": parent_id,
|
| 915 |
+
"pathPart": path_part,
|
| 916 |
+
"path": "",
|
| 917 |
+
"resourceMethods": {},
|
| 918 |
+
}
|
| 919 |
+
_resources[api_id][resource_id] = resource
|
| 920 |
+
# Compute the full path
|
| 921 |
+
resource["path"] = _compute_path(api_id, resource_id)
|
| 922 |
+
return _v1_response(resource, 201)
|
| 923 |
+
|
| 924 |
+
|
| 925 |
+
def _update_resource(api_id, resource_id, data):
|
| 926 |
+
resource = _resources.get(api_id, {}).get(resource_id)
|
| 927 |
+
if not resource:
|
| 928 |
+
return _v1_error("NotFoundException", "Invalid Resource identifier specified", 404)
|
| 929 |
+
patch_ops = data.get("patchOperations", [])
|
| 930 |
+
_apply_patch(resource, patch_ops)
|
| 931 |
+
# Recompute path if pathPart changed
|
| 932 |
+
resource["path"] = _compute_path(api_id, resource_id)
|
| 933 |
+
return _v1_response(resource)
|
| 934 |
+
|
| 935 |
+
|
| 936 |
+
def _delete_resource(api_id, resource_id):
|
| 937 |
+
if resource_id not in _resources.get(api_id, {}):
|
| 938 |
+
return _v1_error("NotFoundException", "Invalid Resource identifier specified", 404)
|
| 939 |
+
_resources[api_id].pop(resource_id, None)
|
| 940 |
+
return 202, {}, b""
|
| 941 |
+
|
| 942 |
+
|
| 943 |
+
# ---- Control plane: Methods ----
|
| 944 |
+
|
| 945 |
+
def _put_method(api_id, resource_id, http_method, data):
|
| 946 |
+
resource = _resources.get(api_id, {}).get(resource_id)
|
| 947 |
+
if not resource:
|
| 948 |
+
return _v1_error("NotFoundException", "Invalid Resource identifier specified", 404)
|
| 949 |
+
method_obj = {
|
| 950 |
+
"httpMethod": http_method,
|
| 951 |
+
"authorizationType": data.get("authorizationType", "NONE"),
|
| 952 |
+
"authorizerId": data.get("authorizerId"),
|
| 953 |
+
"apiKeyRequired": data.get("apiKeyRequired", False),
|
| 954 |
+
"operationName": data.get("operationName", ""),
|
| 955 |
+
"requestParameters": data.get("requestParameters", {}),
|
| 956 |
+
"requestModels": data.get("requestModels", {}),
|
| 957 |
+
"methodResponses": {},
|
| 958 |
+
"methodIntegration": None,
|
| 959 |
+
}
|
| 960 |
+
resource["resourceMethods"][http_method] = method_obj
|
| 961 |
+
return _v1_response(method_obj, 201)
|
| 962 |
+
|
| 963 |
+
|
| 964 |
+
def _get_method(api_id, resource_id, http_method):
|
| 965 |
+
resource = _resources.get(api_id, {}).get(resource_id)
|
| 966 |
+
if not resource:
|
| 967 |
+
return _v1_error("NotFoundException", "Invalid Resource identifier specified", 404)
|
| 968 |
+
method_obj = resource["resourceMethods"].get(http_method)
|
| 969 |
+
if not method_obj:
|
| 970 |
+
return _v1_error("NotFoundException", "Invalid Method identifier specified", 404)
|
| 971 |
+
return _v1_response(method_obj)
|
| 972 |
+
|
| 973 |
+
|
| 974 |
+
def _delete_method(api_id, resource_id, http_method):
|
| 975 |
+
resource = _resources.get(api_id, {}).get(resource_id)
|
| 976 |
+
if not resource:
|
| 977 |
+
return _v1_error("NotFoundException", "Invalid Resource identifier specified", 404)
|
| 978 |
+
resource["resourceMethods"].pop(http_method, None)
|
| 979 |
+
return 204, {}, b""
|
| 980 |
+
|
| 981 |
+
|
| 982 |
+
def _update_method(api_id, resource_id, http_method, data):
|
| 983 |
+
resource = _resources.get(api_id, {}).get(resource_id)
|
| 984 |
+
if not resource:
|
| 985 |
+
return _v1_error("NotFoundException", "Invalid Resource identifier specified", 404)
|
| 986 |
+
method_obj = resource["resourceMethods"].get(http_method)
|
| 987 |
+
if not method_obj:
|
| 988 |
+
return _v1_error("NotFoundException", "Invalid Method identifier specified", 404)
|
| 989 |
+
patch_ops = data.get("patchOperations", [])
|
| 990 |
+
_apply_patch(method_obj, patch_ops)
|
| 991 |
+
return _v1_response(method_obj)
|
| 992 |
+
|
| 993 |
+
|
| 994 |
+
# ---- Control plane: Method Responses ----
|
| 995 |
+
|
| 996 |
+
def _put_method_response(api_id, resource_id, http_method, status_code, data):
|
| 997 |
+
resource = _resources.get(api_id, {}).get(resource_id)
|
| 998 |
+
if not resource:
|
| 999 |
+
return _v1_error("NotFoundException", "Invalid Resource identifier specified", 404)
|
| 1000 |
+
method_obj = resource["resourceMethods"].get(http_method)
|
| 1001 |
+
if not method_obj:
|
| 1002 |
+
return _v1_error("NotFoundException", "Invalid Method identifier specified", 404)
|
| 1003 |
+
method_response = {
|
| 1004 |
+
"statusCode": status_code,
|
| 1005 |
+
"responseParameters": data.get("responseParameters", {}),
|
| 1006 |
+
"responseModels": data.get("responseModels", {}),
|
| 1007 |
+
}
|
| 1008 |
+
method_obj["methodResponses"][status_code] = method_response
|
| 1009 |
+
return _v1_response(method_response)
|
| 1010 |
+
|
| 1011 |
+
|
| 1012 |
+
def _get_method_response(api_id, resource_id, http_method, status_code):
|
| 1013 |
+
resource = _resources.get(api_id, {}).get(resource_id)
|
| 1014 |
+
if not resource:
|
| 1015 |
+
return _v1_error("NotFoundException", "Invalid Resource identifier specified", 404)
|
| 1016 |
+
method_obj = resource["resourceMethods"].get(http_method)
|
| 1017 |
+
if not method_obj:
|
| 1018 |
+
return _v1_error("NotFoundException", "Invalid Method identifier specified", 404)
|
| 1019 |
+
resp = method_obj["methodResponses"].get(status_code)
|
| 1020 |
+
if not resp:
|
| 1021 |
+
return _v1_error("NotFoundException", "Invalid Response status code specified", 404)
|
| 1022 |
+
return _v1_response(resp)
|
| 1023 |
+
|
| 1024 |
+
|
| 1025 |
+
def _delete_method_response(api_id, resource_id, http_method, status_code):
|
| 1026 |
+
resource = _resources.get(api_id, {}).get(resource_id)
|
| 1027 |
+
if not resource:
|
| 1028 |
+
return _v1_error("NotFoundException", "Invalid Resource identifier specified", 404)
|
| 1029 |
+
method_obj = resource["resourceMethods"].get(http_method)
|
| 1030 |
+
if method_obj:
|
| 1031 |
+
method_obj["methodResponses"].pop(status_code, None)
|
| 1032 |
+
return 204, {}, b""
|
| 1033 |
+
|
| 1034 |
+
|
| 1035 |
+
# ---- Control plane: Integration ----
|
| 1036 |
+
|
| 1037 |
+
def _put_integration(api_id, resource_id, http_method, data):
|
| 1038 |
+
resource = _resources.get(api_id, {}).get(resource_id)
|
| 1039 |
+
if not resource:
|
| 1040 |
+
return _v1_error("NotFoundException", "Invalid Resource identifier specified", 404)
|
| 1041 |
+
method_obj = resource["resourceMethods"].get(http_method)
|
| 1042 |
+
if not method_obj:
|
| 1043 |
+
return _v1_error("NotFoundException", "Invalid Method identifier specified", 404)
|
| 1044 |
+
integration = {
|
| 1045 |
+
"type": data.get("type", "AWS_PROXY"),
|
| 1046 |
+
"httpMethod": data.get("httpMethod", "POST"),
|
| 1047 |
+
"uri": data.get("uri", ""),
|
| 1048 |
+
"connectionType": data.get("connectionType", "INTERNET"),
|
| 1049 |
+
"credentials": data.get("credentials"),
|
| 1050 |
+
"requestParameters": data.get("requestParameters", {}),
|
| 1051 |
+
"requestTemplates": data.get("requestTemplates", {}),
|
| 1052 |
+
"passthroughBehavior": data.get("passthroughBehavior", "WHEN_NO_MATCH"),
|
| 1053 |
+
"timeoutInMillis": data.get("timeoutInMillis", 29000),
|
| 1054 |
+
"cacheNamespace": resource_id,
|
| 1055 |
+
"cacheKeyParameters": data.get("cacheKeyParameters", []),
|
| 1056 |
+
"integrationResponses": {},
|
| 1057 |
+
}
|
| 1058 |
+
method_obj["methodIntegration"] = integration
|
| 1059 |
+
return _v1_response(integration)
|
| 1060 |
+
|
| 1061 |
+
|
| 1062 |
+
def _get_integration(api_id, resource_id, http_method):
|
| 1063 |
+
resource = _resources.get(api_id, {}).get(resource_id)
|
| 1064 |
+
if not resource:
|
| 1065 |
+
return _v1_error("NotFoundException", "Invalid Resource identifier specified", 404)
|
| 1066 |
+
method_obj = resource["resourceMethods"].get(http_method)
|
| 1067 |
+
if not method_obj:
|
| 1068 |
+
return _v1_error("NotFoundException", "Invalid Method identifier specified", 404)
|
| 1069 |
+
integration = method_obj.get("methodIntegration")
|
| 1070 |
+
if not integration:
|
| 1071 |
+
return _v1_error("NotFoundException", "Invalid Integration identifier specified", 404)
|
| 1072 |
+
return _v1_response(integration)
|
| 1073 |
+
|
| 1074 |
+
|
| 1075 |
+
def _delete_integration(api_id, resource_id, http_method):
|
| 1076 |
+
resource = _resources.get(api_id, {}).get(resource_id)
|
| 1077 |
+
if not resource:
|
| 1078 |
+
return _v1_error("NotFoundException", "Invalid Resource identifier specified", 404)
|
| 1079 |
+
method_obj = resource["resourceMethods"].get(http_method)
|
| 1080 |
+
if method_obj:
|
| 1081 |
+
method_obj["methodIntegration"] = None
|
| 1082 |
+
return 204, {}, b""
|
| 1083 |
+
|
| 1084 |
+
|
| 1085 |
+
def _update_integration(api_id, resource_id, http_method, data):
|
| 1086 |
+
resource = _resources.get(api_id, {}).get(resource_id)
|
| 1087 |
+
if not resource:
|
| 1088 |
+
return _v1_error("NotFoundException", "Invalid Resource identifier specified", 404)
|
| 1089 |
+
method_obj = resource["resourceMethods"].get(http_method)
|
| 1090 |
+
if not method_obj:
|
| 1091 |
+
return _v1_error("NotFoundException", "Invalid Method identifier specified", 404)
|
| 1092 |
+
integration = method_obj.get("methodIntegration")
|
| 1093 |
+
if not integration:
|
| 1094 |
+
return _v1_error("NotFoundException", "Invalid Integration identifier specified", 404)
|
| 1095 |
+
patch_ops = data.get("patchOperations", [])
|
| 1096 |
+
_apply_patch(integration, patch_ops)
|
| 1097 |
+
return _v1_response(integration)
|
| 1098 |
+
|
| 1099 |
+
|
| 1100 |
+
# ---- Control plane: Integration Responses ----
|
| 1101 |
+
|
| 1102 |
+
def _put_integration_response(api_id, resource_id, http_method, status_code, data):
|
| 1103 |
+
resource = _resources.get(api_id, {}).get(resource_id)
|
| 1104 |
+
if not resource:
|
| 1105 |
+
return _v1_error("NotFoundException", "Invalid Resource identifier specified", 404)
|
| 1106 |
+
method_obj = resource["resourceMethods"].get(http_method)
|
| 1107 |
+
if not method_obj:
|
| 1108 |
+
return _v1_error("NotFoundException", "Invalid Method identifier specified", 404)
|
| 1109 |
+
integration = method_obj.get("methodIntegration")
|
| 1110 |
+
if not integration:
|
| 1111 |
+
return _v1_error("NotFoundException", "Invalid Integration identifier specified", 404)
|
| 1112 |
+
int_response = {
|
| 1113 |
+
"statusCode": status_code,
|
| 1114 |
+
"selectionPattern": data.get("selectionPattern", ""),
|
| 1115 |
+
"responseParameters": data.get("responseParameters", {}),
|
| 1116 |
+
"responseTemplates": data.get("responseTemplates", {}),
|
| 1117 |
+
"contentHandling": data.get("contentHandling"),
|
| 1118 |
+
}
|
| 1119 |
+
integration["integrationResponses"][status_code] = int_response
|
| 1120 |
+
return _v1_response(int_response)
|
| 1121 |
+
|
| 1122 |
+
|
| 1123 |
+
def _get_integration_response(api_id, resource_id, http_method, status_code):
|
| 1124 |
+
resource = _resources.get(api_id, {}).get(resource_id)
|
| 1125 |
+
if not resource:
|
| 1126 |
+
return _v1_error("NotFoundException", "Invalid Resource identifier specified", 404)
|
| 1127 |
+
method_obj = resource["resourceMethods"].get(http_method)
|
| 1128 |
+
if not method_obj:
|
| 1129 |
+
return _v1_error("NotFoundException", "Invalid Method identifier specified", 404)
|
| 1130 |
+
integration = method_obj.get("methodIntegration")
|
| 1131 |
+
if not integration:
|
| 1132 |
+
return _v1_error("NotFoundException", "Invalid Integration identifier specified", 404)
|
| 1133 |
+
resp = integration["integrationResponses"].get(status_code)
|
| 1134 |
+
if not resp:
|
| 1135 |
+
return _v1_error("NotFoundException", "Invalid Response status code specified", 404)
|
| 1136 |
+
return _v1_response(resp)
|
| 1137 |
+
|
| 1138 |
+
|
| 1139 |
+
def _delete_integration_response(api_id, resource_id, http_method, status_code):
|
| 1140 |
+
resource = _resources.get(api_id, {}).get(resource_id)
|
| 1141 |
+
if not resource:
|
| 1142 |
+
return _v1_error("NotFoundException", "Invalid Resource identifier specified", 404)
|
| 1143 |
+
method_obj = resource["resourceMethods"].get(http_method)
|
| 1144 |
+
if method_obj and method_obj.get("methodIntegration"):
|
| 1145 |
+
method_obj["methodIntegration"]["integrationResponses"].pop(status_code, None)
|
| 1146 |
+
return 204, {}, b""
|
| 1147 |
+
|
| 1148 |
+
|
| 1149 |
+
# ---- Helpers ----
|
| 1150 |
+
|
| 1151 |
+
def _build_api_summary(api_id):
|
| 1152 |
+
"""Build the apiSummary structure: {path: {httpMethod: {authorizationScopes, apiKeyRequired}}}."""
|
| 1153 |
+
summary = {}
|
| 1154 |
+
for resource in _resources.get(api_id, {}).values():
|
| 1155 |
+
path = resource.get("path", "/")
|
| 1156 |
+
for http_method, method_obj in resource.get("resourceMethods", {}).items():
|
| 1157 |
+
if path not in summary:
|
| 1158 |
+
summary[path] = {}
|
| 1159 |
+
summary[path][http_method] = {
|
| 1160 |
+
"authorizationScopes": [],
|
| 1161 |
+
"apiKeyRequired": method_obj.get("apiKeyRequired", False),
|
| 1162 |
+
}
|
| 1163 |
+
return summary
|
| 1164 |
+
|
| 1165 |
+
|
| 1166 |
+
# ---- Control plane: Deployments ----
|
| 1167 |
+
|
| 1168 |
+
def _create_deployment(api_id, data):
|
| 1169 |
+
if api_id not in _rest_apis:
|
| 1170 |
+
return _v1_error("NotFoundException", "Invalid API identifier specified", 404)
|
| 1171 |
+
deployment_id = _new_id()[:8]
|
| 1172 |
+
deployment = {
|
| 1173 |
+
"id": deployment_id,
|
| 1174 |
+
"description": data.get("description", ""),
|
| 1175 |
+
"createdDate": _now_unix(),
|
| 1176 |
+
"apiSummary": _build_api_summary(api_id),
|
| 1177 |
+
}
|
| 1178 |
+
_deployments_v1.setdefault(api_id, {})[deployment_id] = deployment
|
| 1179 |
+
|
| 1180 |
+
# If stageName is provided, create/update the stage automatically
|
| 1181 |
+
stage_name = data.get("stageName")
|
| 1182 |
+
if stage_name:
|
| 1183 |
+
existing_stage = _stages_v1.get(api_id, {}).get(stage_name)
|
| 1184 |
+
if existing_stage:
|
| 1185 |
+
existing_stage["deploymentId"] = deployment_id
|
| 1186 |
+
existing_stage["lastUpdatedDate"] = _now_unix()
|
| 1187 |
+
else:
|
| 1188 |
+
stage = {
|
| 1189 |
+
"stageName": stage_name,
|
| 1190 |
+
"deploymentId": deployment_id,
|
| 1191 |
+
"description": data.get("stageDescription", ""),
|
| 1192 |
+
"createdDate": _now_unix(),
|
| 1193 |
+
"lastUpdatedDate": _now_unix(),
|
| 1194 |
+
"variables": data.get("variables", {}),
|
| 1195 |
+
"methodSettings": {},
|
| 1196 |
+
"accessLogSettings": {},
|
| 1197 |
+
"cacheClusterEnabled": False,
|
| 1198 |
+
"cacheClusterSize": None,
|
| 1199 |
+
"tracingEnabled": False,
|
| 1200 |
+
"tags": {},
|
| 1201 |
+
"documentationVersion": None,
|
| 1202 |
+
}
|
| 1203 |
+
_stages_v1.setdefault(api_id, {})[stage_name] = stage
|
| 1204 |
+
|
| 1205 |
+
return _v1_response(deployment, 201)
|
| 1206 |
+
|
| 1207 |
+
|
| 1208 |
+
def _get_deployments(api_id):
|
| 1209 |
+
if api_id not in _rest_apis:
|
| 1210 |
+
return _v1_error("NotFoundException", "Invalid API identifier specified", 404)
|
| 1211 |
+
return _v1_response({"item": list(_deployments_v1.get(api_id, {}).values())})
|
| 1212 |
+
|
| 1213 |
+
|
| 1214 |
+
def _get_deployment(api_id, deployment_id):
|
| 1215 |
+
deployment = _deployments_v1.get(api_id, {}).get(deployment_id)
|
| 1216 |
+
if not deployment:
|
| 1217 |
+
return _v1_error("NotFoundException", "Invalid Deployment identifier specified", 404)
|
| 1218 |
+
return _v1_response(deployment)
|
| 1219 |
+
|
| 1220 |
+
|
| 1221 |
+
def _update_deployment(api_id, deployment_id, data):
|
| 1222 |
+
deployment = _deployments_v1.get(api_id, {}).get(deployment_id)
|
| 1223 |
+
if not deployment:
|
| 1224 |
+
return _v1_error("NotFoundException", "Invalid Deployment identifier specified", 404)
|
| 1225 |
+
patch_ops = data.get("patchOperations", [])
|
| 1226 |
+
_apply_patch(deployment, patch_ops)
|
| 1227 |
+
return _v1_response(deployment)
|
| 1228 |
+
|
| 1229 |
+
|
| 1230 |
+
def _delete_deployment(api_id, deployment_id):
|
| 1231 |
+
if deployment_id not in _deployments_v1.get(api_id, {}):
|
| 1232 |
+
return _v1_error("NotFoundException", "Invalid Deployment identifier specified", 404)
|
| 1233 |
+
_deployments_v1[api_id].pop(deployment_id, None)
|
| 1234 |
+
return 202, {}, b""
|
| 1235 |
+
|
| 1236 |
+
|
| 1237 |
+
# ---- Control plane: Stages ----
|
| 1238 |
+
|
| 1239 |
+
def _create_stage(api_id, data):
|
| 1240 |
+
if api_id not in _rest_apis:
|
| 1241 |
+
return _v1_error("NotFoundException", "Invalid API identifier specified", 404)
|
| 1242 |
+
stage_name = data.get("stageName", "")
|
| 1243 |
+
if not stage_name:
|
| 1244 |
+
return _v1_error("BadRequestException", "Stage name is required", 400)
|
| 1245 |
+
stage = {
|
| 1246 |
+
"stageName": stage_name,
|
| 1247 |
+
"deploymentId": data.get("deploymentId", ""),
|
| 1248 |
+
"description": data.get("description", ""),
|
| 1249 |
+
"createdDate": _now_unix(),
|
| 1250 |
+
"lastUpdatedDate": _now_unix(),
|
| 1251 |
+
"variables": data.get("variables", {}),
|
| 1252 |
+
"methodSettings": data.get("methodSettings", {}),
|
| 1253 |
+
"accessLogSettings": data.get("accessLogSettings", {}),
|
| 1254 |
+
"cacheClusterEnabled": data.get("cacheClusterEnabled", False),
|
| 1255 |
+
"cacheClusterSize": data.get("cacheClusterSize"),
|
| 1256 |
+
"tracingEnabled": data.get("tracingEnabled", False),
|
| 1257 |
+
"tags": data.get("tags", {}),
|
| 1258 |
+
"documentationVersion": data.get("documentationVersion"),
|
| 1259 |
+
}
|
| 1260 |
+
_stages_v1.setdefault(api_id, {})[stage_name] = stage
|
| 1261 |
+
return _v1_response(stage, 201)
|
| 1262 |
+
|
| 1263 |
+
|
| 1264 |
+
def _get_stages(api_id):
|
| 1265 |
+
if api_id not in _rest_apis:
|
| 1266 |
+
return _v1_error("NotFoundException", "Invalid API identifier specified", 404)
|
| 1267 |
+
return _v1_response({"item": list(_stages_v1.get(api_id, {}).values())})
|
| 1268 |
+
|
| 1269 |
+
|
| 1270 |
+
def _get_stage(api_id, stage_name):
|
| 1271 |
+
stage = _stages_v1.get(api_id, {}).get(stage_name)
|
| 1272 |
+
if not stage:
|
| 1273 |
+
return _v1_error("NotFoundException", "Invalid Stage identifier specified", 404)
|
| 1274 |
+
return _v1_response(stage)
|
| 1275 |
+
|
| 1276 |
+
|
| 1277 |
+
def _update_stage(api_id, stage_name, data):
|
| 1278 |
+
stage = _stages_v1.get(api_id, {}).get(stage_name)
|
| 1279 |
+
if not stage:
|
| 1280 |
+
return _v1_error("NotFoundException", "Invalid Stage identifier specified", 404)
|
| 1281 |
+
patch_ops = data.get("patchOperations", [])
|
| 1282 |
+
_apply_patch(stage, patch_ops)
|
| 1283 |
+
stage["lastUpdatedDate"] = _now_unix()
|
| 1284 |
+
return _v1_response(stage)
|
| 1285 |
+
|
| 1286 |
+
|
| 1287 |
+
def _delete_stage(api_id, stage_name):
|
| 1288 |
+
if stage_name not in _stages_v1.get(api_id, {}):
|
| 1289 |
+
return _v1_error("NotFoundException", "Invalid Stage identifier specified", 404)
|
| 1290 |
+
_stages_v1[api_id].pop(stage_name, None)
|
| 1291 |
+
return 202, {}, b""
|
| 1292 |
+
|
| 1293 |
+
|
| 1294 |
+
# ---- Control plane: Authorizers ----
|
| 1295 |
+
|
| 1296 |
+
def _create_authorizer(api_id, data):
|
| 1297 |
+
if api_id not in _rest_apis:
|
| 1298 |
+
return _v1_error("NotFoundException", "Invalid API identifier specified", 404)
|
| 1299 |
+
auth_id = _new_id()[:8]
|
| 1300 |
+
authorizer = {
|
| 1301 |
+
"id": auth_id,
|
| 1302 |
+
"name": data.get("name", ""),
|
| 1303 |
+
"type": data.get("type", "TOKEN"),
|
| 1304 |
+
"authorizerUri": data.get("authorizerUri", ""),
|
| 1305 |
+
"authorizerCredentials": data.get("authorizerCredentials"),
|
| 1306 |
+
"identitySource": data.get("identitySource", "method.request.header.Authorization"),
|
| 1307 |
+
"identityValidationExpression": data.get("identityValidationExpression", ""),
|
| 1308 |
+
"authorizerResultTtlInSeconds": data.get("authorizerResultTtlInSeconds", 300),
|
| 1309 |
+
"providerARNs": data.get("providerARNs", []),
|
| 1310 |
+
}
|
| 1311 |
+
_authorizers_v1.setdefault(api_id, {})[auth_id] = authorizer
|
| 1312 |
+
return _v1_response(authorizer, 201)
|
| 1313 |
+
|
| 1314 |
+
|
| 1315 |
+
def _get_authorizers(api_id):
|
| 1316 |
+
if api_id not in _rest_apis:
|
| 1317 |
+
return _v1_error("NotFoundException", "Invalid API identifier specified", 404)
|
| 1318 |
+
return _v1_response({"item": list(_authorizers_v1.get(api_id, {}).values())})
|
| 1319 |
+
|
| 1320 |
+
|
| 1321 |
+
def _get_authorizer(api_id, auth_id):
|
| 1322 |
+
authorizer = _authorizers_v1.get(api_id, {}).get(auth_id)
|
| 1323 |
+
if not authorizer:
|
| 1324 |
+
return _v1_error("NotFoundException", "Invalid Authorizer identifier specified", 404)
|
| 1325 |
+
return _v1_response(authorizer)
|
| 1326 |
+
|
| 1327 |
+
|
| 1328 |
+
def _update_authorizer(api_id, auth_id, data):
|
| 1329 |
+
authorizer = _authorizers_v1.get(api_id, {}).get(auth_id)
|
| 1330 |
+
if not authorizer:
|
| 1331 |
+
return _v1_error("NotFoundException", "Invalid Authorizer identifier specified", 404)
|
| 1332 |
+
patch_ops = data.get("patchOperations", [])
|
| 1333 |
+
_apply_patch(authorizer, patch_ops)
|
| 1334 |
+
return _v1_response(authorizer)
|
| 1335 |
+
|
| 1336 |
+
|
| 1337 |
+
def _delete_authorizer(api_id, auth_id):
|
| 1338 |
+
if auth_id not in _authorizers_v1.get(api_id, {}):
|
| 1339 |
+
return _v1_error("NotFoundException", "Invalid Authorizer identifier specified", 404)
|
| 1340 |
+
_authorizers_v1[api_id].pop(auth_id, None)
|
| 1341 |
+
return 202, {}, b""
|
| 1342 |
+
|
| 1343 |
+
|
| 1344 |
+
# ---- Control plane: Models ----
|
| 1345 |
+
|
| 1346 |
+
def _create_model(api_id, data):
|
| 1347 |
+
if api_id not in _rest_apis:
|
| 1348 |
+
return _v1_error("NotFoundException", "Invalid API identifier specified", 404)
|
| 1349 |
+
model_name = data.get("name", "")
|
| 1350 |
+
if not model_name:
|
| 1351 |
+
return _v1_error("BadRequestException", "Model name is required", 400)
|
| 1352 |
+
model = {
|
| 1353 |
+
"id": _new_id()[:8],
|
| 1354 |
+
"name": model_name,
|
| 1355 |
+
"description": data.get("description", ""),
|
| 1356 |
+
"schema": data.get("schema", ""),
|
| 1357 |
+
"contentType": data.get("contentType", "application/json"),
|
| 1358 |
+
}
|
| 1359 |
+
_models.setdefault(api_id, {})[model_name] = model
|
| 1360 |
+
return _v1_response(model, 201)
|
| 1361 |
+
|
| 1362 |
+
|
| 1363 |
+
def _get_models(api_id):
|
| 1364 |
+
if api_id not in _rest_apis:
|
| 1365 |
+
return _v1_error("NotFoundException", "Invalid API identifier specified", 404)
|
| 1366 |
+
return _v1_response({"item": list(_models.get(api_id, {}).values())})
|
| 1367 |
+
|
| 1368 |
+
|
| 1369 |
+
def _get_model(api_id, model_name):
|
| 1370 |
+
model = _models.get(api_id, {}).get(model_name)
|
| 1371 |
+
if not model:
|
| 1372 |
+
return _v1_error("NotFoundException", "Invalid Model identifier specified", 404)
|
| 1373 |
+
return _v1_response(model)
|
| 1374 |
+
|
| 1375 |
+
|
| 1376 |
+
def _delete_model(api_id, model_name):
|
| 1377 |
+
if model_name not in _models.get(api_id, {}):
|
| 1378 |
+
return _v1_error("NotFoundException", "Invalid Model identifier specified", 404)
|
| 1379 |
+
_models[api_id].pop(model_name, None)
|
| 1380 |
+
return 202, {}, b""
|
| 1381 |
+
|
| 1382 |
+
|
| 1383 |
+
# ---- Control plane: API Keys ----
|
| 1384 |
+
|
| 1385 |
+
def _create_api_key(data):
|
| 1386 |
+
key_id = _new_id()[:8]
|
| 1387 |
+
key_value = new_uuid().replace("-", "")
|
| 1388 |
+
api_key = {
|
| 1389 |
+
"id": key_id,
|
| 1390 |
+
"name": data.get("name", ""),
|
| 1391 |
+
"description": data.get("description", ""),
|
| 1392 |
+
"enabled": data.get("enabled", True),
|
| 1393 |
+
"createdDate": _now_unix(),
|
| 1394 |
+
"lastUpdatedDate": _now_unix(),
|
| 1395 |
+
"value": key_value,
|
| 1396 |
+
"stageKeys": data.get("stageKeys", []),
|
| 1397 |
+
"tags": data.get("tags", {}),
|
| 1398 |
+
}
|
| 1399 |
+
_api_keys[key_id] = api_key
|
| 1400 |
+
return _v1_response(api_key, 201)
|
| 1401 |
+
|
| 1402 |
+
|
| 1403 |
+
def _get_api_keys():
|
| 1404 |
+
return _v1_response({"item": list(_api_keys.values())})
|
| 1405 |
+
|
| 1406 |
+
|
| 1407 |
+
def _get_api_key(key_id):
|
| 1408 |
+
key = _api_keys.get(key_id)
|
| 1409 |
+
if not key:
|
| 1410 |
+
return _v1_error("NotFoundException", "Invalid API Key identifier specified", 404)
|
| 1411 |
+
return _v1_response(key)
|
| 1412 |
+
|
| 1413 |
+
|
| 1414 |
+
def _update_api_key(key_id, data):
|
| 1415 |
+
key = _api_keys.get(key_id)
|
| 1416 |
+
if not key:
|
| 1417 |
+
return _v1_error("NotFoundException", "Invalid API Key identifier specified", 404)
|
| 1418 |
+
patch_ops = data.get("patchOperations", [])
|
| 1419 |
+
_apply_patch(key, patch_ops)
|
| 1420 |
+
key["lastUpdatedDate"] = _now_unix()
|
| 1421 |
+
return _v1_response(key)
|
| 1422 |
+
|
| 1423 |
+
|
| 1424 |
+
def _delete_api_key(key_id):
|
| 1425 |
+
if key_id not in _api_keys:
|
| 1426 |
+
return _v1_error("NotFoundException", "Invalid API Key identifier specified", 404)
|
| 1427 |
+
_api_keys.pop(key_id, None)
|
| 1428 |
+
return 202, {}, b""
|
| 1429 |
+
|
| 1430 |
+
|
| 1431 |
+
# ---- Control plane: Usage Plans ----
|
| 1432 |
+
|
| 1433 |
+
def _create_usage_plan(data):
|
| 1434 |
+
plan_id = _new_id()[:8]
|
| 1435 |
+
plan = {
|
| 1436 |
+
"id": plan_id,
|
| 1437 |
+
"name": data.get("name", ""),
|
| 1438 |
+
"description": data.get("description", ""),
|
| 1439 |
+
"apiStages": data.get("apiStages", []),
|
| 1440 |
+
"throttle": data.get("throttle", {}),
|
| 1441 |
+
"quota": data.get("quota", {}),
|
| 1442 |
+
"tags": data.get("tags", {}),
|
| 1443 |
+
}
|
| 1444 |
+
_usage_plans[plan_id] = plan
|
| 1445 |
+
_usage_plan_keys[plan_id] = {}
|
| 1446 |
+
return _v1_response(plan, 201)
|
| 1447 |
+
|
| 1448 |
+
|
| 1449 |
+
def _get_usage_plans():
|
| 1450 |
+
return _v1_response({"item": list(_usage_plans.values())})
|
| 1451 |
+
|
| 1452 |
+
|
| 1453 |
+
def _get_usage_plan(plan_id):
|
| 1454 |
+
plan = _usage_plans.get(plan_id)
|
| 1455 |
+
if not plan:
|
| 1456 |
+
return _v1_error("NotFoundException", "Invalid Usage Plan identifier specified", 404)
|
| 1457 |
+
return _v1_response(plan)
|
| 1458 |
+
|
| 1459 |
+
|
| 1460 |
+
def _update_usage_plan(plan_id, data):
|
| 1461 |
+
plan = _usage_plans.get(plan_id)
|
| 1462 |
+
if not plan:
|
| 1463 |
+
return _v1_error("NotFoundException", "Invalid Usage Plan identifier specified", 404)
|
| 1464 |
+
patch_ops = data.get("patchOperations", [])
|
| 1465 |
+
_apply_patch(plan, patch_ops)
|
| 1466 |
+
return _v1_response(plan)
|
| 1467 |
+
|
| 1468 |
+
|
| 1469 |
+
def _delete_usage_plan(plan_id):
|
| 1470 |
+
if plan_id not in _usage_plans:
|
| 1471 |
+
return _v1_error("NotFoundException", "Invalid Usage Plan identifier specified", 404)
|
| 1472 |
+
_usage_plans.pop(plan_id, None)
|
| 1473 |
+
_usage_plan_keys.pop(plan_id, None)
|
| 1474 |
+
return 202, {}, b""
|
| 1475 |
+
|
| 1476 |
+
|
| 1477 |
+
def _create_usage_plan_key(plan_id, data):
|
| 1478 |
+
if plan_id not in _usage_plans:
|
| 1479 |
+
return _v1_error("NotFoundException", "Invalid Usage Plan identifier specified", 404)
|
| 1480 |
+
key_id = data.get("keyId", "")
|
| 1481 |
+
key_type = data.get("keyType", "API_KEY")
|
| 1482 |
+
plan_key = {
|
| 1483 |
+
"id": key_id,
|
| 1484 |
+
"type": key_type,
|
| 1485 |
+
"name": _api_keys.get(key_id, {}).get("name", ""),
|
| 1486 |
+
"value": _api_keys.get(key_id, {}).get("value", ""),
|
| 1487 |
+
}
|
| 1488 |
+
_usage_plan_keys.setdefault(plan_id, {})[key_id] = plan_key
|
| 1489 |
+
return _v1_response(plan_key, 201)
|
| 1490 |
+
|
| 1491 |
+
|
| 1492 |
+
def _get_usage_plan_keys(plan_id):
|
| 1493 |
+
if plan_id not in _usage_plans:
|
| 1494 |
+
return _v1_error("NotFoundException", "Invalid Usage Plan identifier specified", 404)
|
| 1495 |
+
return _v1_response({"item": list(_usage_plan_keys.get(plan_id, {}).values())})
|
| 1496 |
+
|
| 1497 |
+
|
| 1498 |
+
def _delete_usage_plan_key(plan_id, key_id):
|
| 1499 |
+
if plan_id not in _usage_plans:
|
| 1500 |
+
return _v1_error("NotFoundException", "Invalid Usage Plan identifier specified", 404)
|
| 1501 |
+
_usage_plan_keys.get(plan_id, {}).pop(key_id, None)
|
| 1502 |
+
return 202, {}, b""
|
| 1503 |
+
|
| 1504 |
+
|
| 1505 |
+
# ---- Control plane: Domain Names ----
|
| 1506 |
+
|
| 1507 |
+
def _create_domain_name(data):
|
| 1508 |
+
domain_name = data.get("domainName", "")
|
| 1509 |
+
if not domain_name:
|
| 1510 |
+
return _v1_error("BadRequestException", "Domain name is required", 400)
|
| 1511 |
+
dn = {
|
| 1512 |
+
"domainName": domain_name,
|
| 1513 |
+
"certificateName": data.get("certificateName", ""),
|
| 1514 |
+
"certificateArn": data.get("certificateArn", ""),
|
| 1515 |
+
"distributionDomainName": f"{domain_name}.cloudfront.net",
|
| 1516 |
+
"regionalDomainName": f"{domain_name}.execute-api.{get_region()}.amazonaws.com",
|
| 1517 |
+
"regionalHostedZoneId": "Z1UJRXOUMOOFQ8",
|
| 1518 |
+
"endpointConfiguration": data.get("endpointConfiguration", {"types": ["REGIONAL"]}),
|
| 1519 |
+
"tags": data.get("tags", {}),
|
| 1520 |
+
}
|
| 1521 |
+
_domain_names[domain_name] = dn
|
| 1522 |
+
_base_path_mappings[domain_name] = {}
|
| 1523 |
+
return _v1_response(dn, 201)
|
| 1524 |
+
|
| 1525 |
+
|
| 1526 |
+
def _get_domain_names():
|
| 1527 |
+
return _v1_response({"item": list(_domain_names.values())})
|
| 1528 |
+
|
| 1529 |
+
|
| 1530 |
+
def _get_domain_name(domain_name):
|
| 1531 |
+
dn = _domain_names.get(domain_name)
|
| 1532 |
+
if not dn:
|
| 1533 |
+
return _v1_error("NotFoundException", "Invalid domain name identifier specified", 404)
|
| 1534 |
+
return _v1_response(dn)
|
| 1535 |
+
|
| 1536 |
+
|
| 1537 |
+
def _delete_domain_name(domain_name):
|
| 1538 |
+
if domain_name not in _domain_names:
|
| 1539 |
+
return _v1_error("NotFoundException", "Invalid domain name identifier specified", 404)
|
| 1540 |
+
_domain_names.pop(domain_name, None)
|
| 1541 |
+
_base_path_mappings.pop(domain_name, None)
|
| 1542 |
+
return 202, {}, b""
|
| 1543 |
+
|
| 1544 |
+
|
| 1545 |
+
def _create_base_path_mapping(domain_name, data):
|
| 1546 |
+
if domain_name not in _domain_names:
|
| 1547 |
+
return _v1_error("NotFoundException", "Invalid domain name identifier specified", 404)
|
| 1548 |
+
base_path = data.get("basePath", "(none)")
|
| 1549 |
+
mapping = {
|
| 1550 |
+
"basePath": base_path,
|
| 1551 |
+
"restApiId": data.get("restApiId", ""),
|
| 1552 |
+
"stage": data.get("stage", ""),
|
| 1553 |
+
}
|
| 1554 |
+
_base_path_mappings.setdefault(domain_name, {})[base_path] = mapping
|
| 1555 |
+
return _v1_response(mapping, 201)
|
| 1556 |
+
|
| 1557 |
+
|
| 1558 |
+
def _get_base_path_mappings(domain_name):
|
| 1559 |
+
if domain_name not in _domain_names:
|
| 1560 |
+
return _v1_error("NotFoundException", "Invalid domain name identifier specified", 404)
|
| 1561 |
+
return _v1_response({"item": list(_base_path_mappings.get(domain_name, {}).values())})
|
| 1562 |
+
|
| 1563 |
+
|
| 1564 |
+
def _get_base_path_mapping(domain_name, base_path):
|
| 1565 |
+
mapping = _base_path_mappings.get(domain_name, {}).get(base_path)
|
| 1566 |
+
if not mapping:
|
| 1567 |
+
return _v1_error("NotFoundException", "Invalid base path mapping identifier specified", 404)
|
| 1568 |
+
return _v1_response(mapping)
|
| 1569 |
+
|
| 1570 |
+
|
| 1571 |
+
def _delete_base_path_mapping(domain_name, base_path):
|
| 1572 |
+
_base_path_mappings.get(domain_name, {}).pop(base_path, None)
|
| 1573 |
+
return 202, {}, b""
|
| 1574 |
+
|
| 1575 |
+
|
| 1576 |
+
# ---- Control plane: Tags ----
|
| 1577 |
+
|
| 1578 |
+
def _get_v1_tags(resource_arn):
|
| 1579 |
+
tags = _v1_tags.get(resource_arn, {})
|
| 1580 |
+
return _v1_response({"tags": tags})
|
| 1581 |
+
|
| 1582 |
+
|
| 1583 |
+
def _tag_v1_resource(resource_arn, data):
|
| 1584 |
+
tags = data.get("tags", {})
|
| 1585 |
+
_v1_tags.setdefault(resource_arn, {}).update(tags)
|
| 1586 |
+
return 204, {}, b""
|
| 1587 |
+
|
| 1588 |
+
|
| 1589 |
+
def _untag_v1_resource(resource_arn, tag_keys):
|
| 1590 |
+
existing = _v1_tags.get(resource_arn, {})
|
| 1591 |
+
for key in tag_keys:
|
| 1592 |
+
existing.pop(key, None)
|
| 1593 |
+
return 204, {}, b""
|
| 1594 |
+
|
| 1595 |
+
def get_state_summary() -> dict:
|
| 1596 |
+
return {
|
| 1597 |
+
"rest_apis": {"count": len(_rest_apis), "ids": list(_rest_apis.keys())},
|
| 1598 |
+
"resources": {"count": sum(len(r) for r in _resources.values()) if _resources else 0},
|
| 1599 |
+
"api_keys": {"count": len(_api_keys), "ids": list(_api_keys.keys())},
|
| 1600 |
+
"usage_plans": {"count": len(_usage_plans), "ids": list(_usage_plans.keys())},
|
| 1601 |
+
"domain_names": {"count": len(_domain_names), "names": list(_domain_names.keys())},
|
| 1602 |
+
}
|
aws_infra/ministack/services/appconfig.py
ADDED
|
@@ -0,0 +1,852 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
AppConfig Service Emulator.
|
| 3 |
+
REST/JSON protocol — path-based routing.
|
| 4 |
+
|
| 5 |
+
Control Plane (appconfig):
|
| 6 |
+
Applications: CreateApplication, GetApplication, ListApplications,
|
| 7 |
+
UpdateApplication, DeleteApplication
|
| 8 |
+
Environments: CreateEnvironment, GetEnvironment, ListEnvironments,
|
| 9 |
+
UpdateEnvironment, DeleteEnvironment
|
| 10 |
+
Configuration Profiles: CreateConfigurationProfile, GetConfigurationProfile,
|
| 11 |
+
ListConfigurationProfiles, UpdateConfigurationProfile,
|
| 12 |
+
DeleteConfigurationProfile
|
| 13 |
+
Hosted Configuration Versions: CreateHostedConfigurationVersion,
|
| 14 |
+
GetHostedConfigurationVersion,
|
| 15 |
+
ListHostedConfigurationVersions,
|
| 16 |
+
DeleteHostedConfigurationVersion
|
| 17 |
+
Deployment Strategies: CreateDeploymentStrategy, GetDeploymentStrategy,
|
| 18 |
+
ListDeploymentStrategies, UpdateDeploymentStrategy,
|
| 19 |
+
DeleteDeploymentStrategy
|
| 20 |
+
Deployments: StartDeployment, GetDeployment, ListDeployments,
|
| 21 |
+
StopDeployment
|
| 22 |
+
Tags: TagResource, UntagResource, ListTagsForResource
|
| 23 |
+
|
| 24 |
+
Data Plane (appconfigdata):
|
| 25 |
+
StartConfigurationSession, GetLatestConfiguration
|
| 26 |
+
"""
|
| 27 |
+
|
| 28 |
+
import copy
|
| 29 |
+
import json
|
| 30 |
+
import logging
|
| 31 |
+
import os
|
| 32 |
+
import re
|
| 33 |
+
import time
|
| 34 |
+
import uuid
|
| 35 |
+
|
| 36 |
+
from ministack.core.persistence import PERSIST_STATE, load_state
|
| 37 |
+
from ministack.core.responses import AccountScopedDict, get_account_id, get_region
|
| 38 |
+
|
| 39 |
+
logger = logging.getLogger("appconfig")
|
| 40 |
+
|
| 41 |
+
REGION = os.environ.get("MINISTACK_REGION", "us-east-1")
|
| 42 |
+
|
| 43 |
+
# ---------------------------------------------------------------------------
|
| 44 |
+
# State
|
| 45 |
+
# ---------------------------------------------------------------------------
|
| 46 |
+
|
| 47 |
+
_applications = AccountScopedDict()
|
| 48 |
+
_environments = AccountScopedDict() # "{app_id}/{env_id}" -> record
|
| 49 |
+
_config_profiles = AccountScopedDict() # "{app_id}/{profile_id}" -> record
|
| 50 |
+
_hosted_versions = AccountScopedDict() # "{app_id}/{profile_id}/{version}" -> record
|
| 51 |
+
_deployment_strategies = AccountScopedDict()
|
| 52 |
+
_deployments = AccountScopedDict() # "{app_id}/{env_id}/{deploy_num}" -> record
|
| 53 |
+
_tags = AccountScopedDict() # arn -> {key: value}
|
| 54 |
+
_sessions = AccountScopedDict() # token -> session record
|
| 55 |
+
|
| 56 |
+
# ---------------------------------------------------------------------------
|
| 57 |
+
# Persistence
|
| 58 |
+
# ---------------------------------------------------------------------------
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def get_state():
|
| 62 |
+
return copy.deepcopy({
|
| 63 |
+
"applications": _applications,
|
| 64 |
+
"environments": _environments,
|
| 65 |
+
"config_profiles": _config_profiles,
|
| 66 |
+
"hosted_versions": _hosted_versions,
|
| 67 |
+
"deployment_strategies": _deployment_strategies,
|
| 68 |
+
"deployments": _deployments,
|
| 69 |
+
"tags": _tags,
|
| 70 |
+
})
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
def restore_state(data):
|
| 74 |
+
_applications.update(data.get("applications", {}))
|
| 75 |
+
_environments.update(data.get("environments", {}))
|
| 76 |
+
_config_profiles.update(data.get("config_profiles", {}))
|
| 77 |
+
_hosted_versions.update(data.get("hosted_versions", {}))
|
| 78 |
+
_deployment_strategies.update(data.get("deployment_strategies", {}))
|
| 79 |
+
_deployments.update(data.get("deployments", {}))
|
| 80 |
+
_tags.update(data.get("tags", {}))
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
_restored = load_state("appconfig")
|
| 84 |
+
if _restored:
|
| 85 |
+
restore_state(_restored)
|
| 86 |
+
|
| 87 |
+
# ---------------------------------------------------------------------------
|
| 88 |
+
# Helpers
|
| 89 |
+
# ---------------------------------------------------------------------------
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
def _gen_id():
|
| 93 |
+
return uuid.uuid4().hex[:7]
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
def _now_iso():
|
| 97 |
+
return time.strftime("%Y-%m-%dT%H:%M:%S.000Z", time.gmtime())
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
def _app_arn(app_id):
|
| 101 |
+
return f"arn:aws:appconfig:{get_region()}:{get_account_id()}:application/{app_id}"
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
def _env_arn(app_id, env_id):
|
| 105 |
+
return f"arn:aws:appconfig:{get_region()}:{get_account_id()}:application/{app_id}/environment/{env_id}"
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
def _profile_arn(app_id, profile_id):
|
| 109 |
+
return f"arn:aws:appconfig:{get_region()}:{get_account_id()}:application/{app_id}/configurationprofile/{profile_id}"
|
| 110 |
+
|
| 111 |
+
|
| 112 |
+
def _strategy_arn(strategy_id):
|
| 113 |
+
return f"arn:aws:appconfig:{get_region()}:{get_account_id()}:deploymentstrategy/{strategy_id}"
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
def _deployment_arn(app_id, env_id, deploy_num):
|
| 117 |
+
return (
|
| 118 |
+
f"arn:aws:appconfig:{get_region()}:{get_account_id()}:"
|
| 119 |
+
f"application/{app_id}/environment/{env_id}/deployment/{deploy_num}"
|
| 120 |
+
)
|
| 121 |
+
|
| 122 |
+
|
| 123 |
+
# ---------------------------------------------------------------------------
|
| 124 |
+
# Applications
|
| 125 |
+
# ---------------------------------------------------------------------------
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
def _create_application(body):
|
| 129 |
+
name = body.get("Name")
|
| 130 |
+
if not name:
|
| 131 |
+
return _error(400, "BadRequestException", "Name is required")
|
| 132 |
+
app_id = _gen_id()
|
| 133 |
+
record = {
|
| 134 |
+
"Id": app_id,
|
| 135 |
+
"Name": name,
|
| 136 |
+
"Description": body.get("Description", ""),
|
| 137 |
+
}
|
| 138 |
+
_applications[app_id] = record
|
| 139 |
+
_apply_tags(_app_arn(app_id), body.get("Tags", {}))
|
| 140 |
+
logger.info("CreateApplication: %s (%s)", name, app_id)
|
| 141 |
+
return _json(201, record)
|
| 142 |
+
|
| 143 |
+
|
| 144 |
+
def _get_application(app_id):
|
| 145 |
+
app = _applications.get(app_id)
|
| 146 |
+
if not app:
|
| 147 |
+
return _error(404, "ResourceNotFoundException", f"Application {app_id} not found")
|
| 148 |
+
return _json(200, app)
|
| 149 |
+
|
| 150 |
+
|
| 151 |
+
def _list_applications(query):
|
| 152 |
+
max_results = int(query.get("max_results", 50))
|
| 153 |
+
items = list(_applications.values())
|
| 154 |
+
return _json(200, {"Items": items[:max_results]})
|
| 155 |
+
|
| 156 |
+
|
| 157 |
+
def _update_application(app_id, body):
|
| 158 |
+
app = _applications.get(app_id)
|
| 159 |
+
if not app:
|
| 160 |
+
return _error(404, "ResourceNotFoundException", f"Application {app_id} not found")
|
| 161 |
+
if "Name" in body:
|
| 162 |
+
app["Name"] = body["Name"]
|
| 163 |
+
if "Description" in body:
|
| 164 |
+
app["Description"] = body["Description"]
|
| 165 |
+
return _json(200, app)
|
| 166 |
+
|
| 167 |
+
|
| 168 |
+
def _delete_application(app_id):
|
| 169 |
+
if app_id not in _applications:
|
| 170 |
+
return _error(404, "ResourceNotFoundException", f"Application {app_id} not found")
|
| 171 |
+
del _applications[app_id]
|
| 172 |
+
_tags.pop(_app_arn(app_id), None)
|
| 173 |
+
keys_to_remove = [k for k in _environments if k.startswith(f"{app_id}/")]
|
| 174 |
+
for k in keys_to_remove:
|
| 175 |
+
_environments.pop(k, None)
|
| 176 |
+
keys_to_remove = [k for k in _config_profiles if k.startswith(f"{app_id}/")]
|
| 177 |
+
for k in keys_to_remove:
|
| 178 |
+
_config_profiles.pop(k, None)
|
| 179 |
+
keys_to_remove = [k for k in _hosted_versions if k.startswith(f"{app_id}/")]
|
| 180 |
+
for k in keys_to_remove:
|
| 181 |
+
_hosted_versions.pop(k, None)
|
| 182 |
+
keys_to_remove = [k for k in _deployments if k.startswith(f"{app_id}/")]
|
| 183 |
+
for k in keys_to_remove:
|
| 184 |
+
_deployments.pop(k, None)
|
| 185 |
+
return _json(204, {})
|
| 186 |
+
|
| 187 |
+
|
| 188 |
+
# ---------------------------------------------------------------------------
|
| 189 |
+
# Environments
|
| 190 |
+
# ---------------------------------------------------------------------------
|
| 191 |
+
|
| 192 |
+
|
| 193 |
+
def _create_environment(app_id, body):
|
| 194 |
+
if app_id not in _applications:
|
| 195 |
+
return _error(404, "ResourceNotFoundException", f"Application {app_id} not found")
|
| 196 |
+
name = body.get("Name")
|
| 197 |
+
if not name:
|
| 198 |
+
return _error(400, "BadRequestException", "Name is required")
|
| 199 |
+
env_id = _gen_id()
|
| 200 |
+
record = {
|
| 201 |
+
"ApplicationId": app_id,
|
| 202 |
+
"Id": env_id,
|
| 203 |
+
"Name": name,
|
| 204 |
+
"Description": body.get("Description", ""),
|
| 205 |
+
"State": "READY_FOR_DEPLOYMENT",
|
| 206 |
+
"Monitors": body.get("Monitors", []),
|
| 207 |
+
}
|
| 208 |
+
_environments[f"{app_id}/{env_id}"] = record
|
| 209 |
+
_apply_tags(_env_arn(app_id, env_id), body.get("Tags", {}))
|
| 210 |
+
logger.info("CreateEnvironment: %s/%s (%s)", app_id, name, env_id)
|
| 211 |
+
return _json(201, record)
|
| 212 |
+
|
| 213 |
+
|
| 214 |
+
def _get_environment(app_id, env_id):
|
| 215 |
+
env = _environments.get(f"{app_id}/{env_id}")
|
| 216 |
+
if not env:
|
| 217 |
+
return _error(404, "ResourceNotFoundException", f"Environment {env_id} not found")
|
| 218 |
+
return _json(200, env)
|
| 219 |
+
|
| 220 |
+
|
| 221 |
+
def _list_environments(app_id, query):
|
| 222 |
+
if app_id not in _applications:
|
| 223 |
+
return _error(404, "ResourceNotFoundException", f"Application {app_id} not found")
|
| 224 |
+
max_results = int(query.get("max_results", 50))
|
| 225 |
+
items = [e for e in _environments.values() if e["ApplicationId"] == app_id]
|
| 226 |
+
return _json(200, {"Items": items[:max_results]})
|
| 227 |
+
|
| 228 |
+
|
| 229 |
+
def _update_environment(app_id, env_id, body):
|
| 230 |
+
env = _environments.get(f"{app_id}/{env_id}")
|
| 231 |
+
if not env:
|
| 232 |
+
return _error(404, "ResourceNotFoundException", f"Environment {env_id} not found")
|
| 233 |
+
if "Name" in body:
|
| 234 |
+
env["Name"] = body["Name"]
|
| 235 |
+
if "Description" in body:
|
| 236 |
+
env["Description"] = body["Description"]
|
| 237 |
+
if "Monitors" in body:
|
| 238 |
+
env["Monitors"] = body["Monitors"]
|
| 239 |
+
return _json(200, env)
|
| 240 |
+
|
| 241 |
+
|
| 242 |
+
def _delete_environment(app_id, env_id):
|
| 243 |
+
key = f"{app_id}/{env_id}"
|
| 244 |
+
if key not in _environments:
|
| 245 |
+
return _error(404, "ResourceNotFoundException", f"Environment {env_id} not found")
|
| 246 |
+
del _environments[key]
|
| 247 |
+
_tags.pop(_env_arn(app_id, env_id), None)
|
| 248 |
+
return _json(204, {})
|
| 249 |
+
|
| 250 |
+
|
| 251 |
+
# ---------------------------------------------------------------------------
|
| 252 |
+
# Configuration Profiles
|
| 253 |
+
# ---------------------------------------------------------------------------
|
| 254 |
+
|
| 255 |
+
|
| 256 |
+
def _create_configuration_profile(app_id, body):
|
| 257 |
+
if app_id not in _applications:
|
| 258 |
+
return _error(404, "ResourceNotFoundException", f"Application {app_id} not found")
|
| 259 |
+
name = body.get("Name")
|
| 260 |
+
if not name:
|
| 261 |
+
return _error(400, "BadRequestException", "Name is required")
|
| 262 |
+
location_uri = body.get("LocationUri", "hosted")
|
| 263 |
+
profile_id = _gen_id()
|
| 264 |
+
record = {
|
| 265 |
+
"ApplicationId": app_id,
|
| 266 |
+
"Id": profile_id,
|
| 267 |
+
"Name": name,
|
| 268 |
+
"Description": body.get("Description", ""),
|
| 269 |
+
"LocationUri": location_uri,
|
| 270 |
+
"RetrievalRoleArn": body.get("RetrievalRoleArn", ""),
|
| 271 |
+
"Validators": body.get("Validators", []),
|
| 272 |
+
"Type": body.get("Type", "AWS.Freeform"),
|
| 273 |
+
}
|
| 274 |
+
_config_profiles[f"{app_id}/{profile_id}"] = record
|
| 275 |
+
_apply_tags(_profile_arn(app_id, profile_id), body.get("Tags", {}))
|
| 276 |
+
logger.info("CreateConfigurationProfile: %s/%s (%s)", app_id, name, profile_id)
|
| 277 |
+
return _json(201, record)
|
| 278 |
+
|
| 279 |
+
|
| 280 |
+
def _get_configuration_profile(app_id, profile_id):
|
| 281 |
+
profile = _config_profiles.get(f"{app_id}/{profile_id}")
|
| 282 |
+
if not profile:
|
| 283 |
+
return _error(404, "ResourceNotFoundException", f"Configuration profile {profile_id} not found")
|
| 284 |
+
return _json(200, profile)
|
| 285 |
+
|
| 286 |
+
|
| 287 |
+
def _list_configuration_profiles(app_id, query):
|
| 288 |
+
if app_id not in _applications:
|
| 289 |
+
return _error(404, "ResourceNotFoundException", f"Application {app_id} not found")
|
| 290 |
+
max_results = int(query.get("max_results", 50))
|
| 291 |
+
items = [p for p in _config_profiles.values() if p["ApplicationId"] == app_id]
|
| 292 |
+
return _json(200, {"Items": items[:max_results]})
|
| 293 |
+
|
| 294 |
+
|
| 295 |
+
def _update_configuration_profile(app_id, profile_id, body):
|
| 296 |
+
profile = _config_profiles.get(f"{app_id}/{profile_id}")
|
| 297 |
+
if not profile:
|
| 298 |
+
return _error(404, "ResourceNotFoundException", f"Configuration profile {profile_id} not found")
|
| 299 |
+
for field in ("Name", "Description", "RetrievalRoleArn", "Validators"):
|
| 300 |
+
if field in body:
|
| 301 |
+
profile[field] = body[field]
|
| 302 |
+
return _json(200, profile)
|
| 303 |
+
|
| 304 |
+
|
| 305 |
+
def _delete_configuration_profile(app_id, profile_id):
|
| 306 |
+
key = f"{app_id}/{profile_id}"
|
| 307 |
+
if key not in _config_profiles:
|
| 308 |
+
return _error(404, "ResourceNotFoundException", f"Configuration profile {profile_id} not found")
|
| 309 |
+
del _config_profiles[key]
|
| 310 |
+
_tags.pop(_profile_arn(app_id, profile_id), None)
|
| 311 |
+
keys_to_remove = [k for k in _hosted_versions if k.startswith(f"{app_id}/{profile_id}/")]
|
| 312 |
+
for k in keys_to_remove:
|
| 313 |
+
_hosted_versions.pop(k, None)
|
| 314 |
+
return _json(204, {})
|
| 315 |
+
|
| 316 |
+
|
| 317 |
+
# ---------------------------------------------------------------------------
|
| 318 |
+
# Hosted Configuration Versions
|
| 319 |
+
# ---------------------------------------------------------------------------
|
| 320 |
+
|
| 321 |
+
|
| 322 |
+
def _create_hosted_configuration_version(app_id, profile_id, body, content_type):
|
| 323 |
+
if f"{app_id}/{profile_id}" not in _config_profiles:
|
| 324 |
+
return _error(404, "ResourceNotFoundException", f"Configuration profile {profile_id} not found")
|
| 325 |
+
|
| 326 |
+
existing = [
|
| 327 |
+
v for k, v in _hosted_versions.items()
|
| 328 |
+
if k.startswith(f"{app_id}/{profile_id}/")
|
| 329 |
+
]
|
| 330 |
+
version_number = len(existing) + 1
|
| 331 |
+
|
| 332 |
+
record = {
|
| 333 |
+
"ApplicationId": app_id,
|
| 334 |
+
"ConfigurationProfileId": profile_id,
|
| 335 |
+
"VersionNumber": version_number,
|
| 336 |
+
"ContentType": content_type,
|
| 337 |
+
"Content": body,
|
| 338 |
+
"Description": "",
|
| 339 |
+
}
|
| 340 |
+
_hosted_versions[f"{app_id}/{profile_id}/{version_number}"] = record
|
| 341 |
+
logger.info("CreateHostedConfigurationVersion: %s/%s v%d", app_id, profile_id, version_number)
|
| 342 |
+
|
| 343 |
+
resp_headers = {
|
| 344 |
+
"Content-Type": content_type,
|
| 345 |
+
"Application-Id": app_id,
|
| 346 |
+
"Configuration-Profile-Id": profile_id,
|
| 347 |
+
"Version-Number": str(version_number),
|
| 348 |
+
}
|
| 349 |
+
return 201, resp_headers, body if isinstance(body, bytes) else body.encode("utf-8")
|
| 350 |
+
|
| 351 |
+
|
| 352 |
+
def _get_hosted_configuration_version(app_id, profile_id, version_number):
|
| 353 |
+
key = f"{app_id}/{profile_id}/{version_number}"
|
| 354 |
+
record = _hosted_versions.get(key)
|
| 355 |
+
if not record:
|
| 356 |
+
return _error(404, "ResourceNotFoundException",
|
| 357 |
+
f"Hosted configuration version {version_number} not found")
|
| 358 |
+
content = record["Content"]
|
| 359 |
+
resp_headers = {
|
| 360 |
+
"Content-Type": record["ContentType"],
|
| 361 |
+
"Application-Id": app_id,
|
| 362 |
+
"Configuration-Profile-Id": profile_id,
|
| 363 |
+
"Version-Number": str(version_number),
|
| 364 |
+
}
|
| 365 |
+
return 200, resp_headers, content if isinstance(content, bytes) else content.encode("utf-8")
|
| 366 |
+
|
| 367 |
+
|
| 368 |
+
def _list_hosted_configuration_versions(app_id, profile_id, query):
|
| 369 |
+
if f"{app_id}/{profile_id}" not in _config_profiles:
|
| 370 |
+
return _error(404, "ResourceNotFoundException", f"Configuration profile {profile_id} not found")
|
| 371 |
+
max_results = int(query.get("max_results", 50))
|
| 372 |
+
items = []
|
| 373 |
+
for k, v in _hosted_versions.items():
|
| 374 |
+
if k.startswith(f"{app_id}/{profile_id}/"):
|
| 375 |
+
items.append({
|
| 376 |
+
"ApplicationId": app_id,
|
| 377 |
+
"ConfigurationProfileId": profile_id,
|
| 378 |
+
"VersionNumber": v["VersionNumber"],
|
| 379 |
+
"ContentType": v["ContentType"],
|
| 380 |
+
"Description": v.get("Description", ""),
|
| 381 |
+
})
|
| 382 |
+
return _json(200, {"Items": items[:max_results]})
|
| 383 |
+
|
| 384 |
+
|
| 385 |
+
def _delete_hosted_configuration_version(app_id, profile_id, version_number):
|
| 386 |
+
key = f"{app_id}/{profile_id}/{version_number}"
|
| 387 |
+
if key not in _hosted_versions:
|
| 388 |
+
return _error(404, "ResourceNotFoundException",
|
| 389 |
+
f"Hosted configuration version {version_number} not found")
|
| 390 |
+
del _hosted_versions[key]
|
| 391 |
+
return _json(204, {})
|
| 392 |
+
|
| 393 |
+
|
| 394 |
+
# ---------------------------------------------------------------------------
|
| 395 |
+
# Deployment Strategies
|
| 396 |
+
# ---------------------------------------------------------------------------
|
| 397 |
+
|
| 398 |
+
|
| 399 |
+
def _create_deployment_strategy(body):
|
| 400 |
+
name = body.get("Name")
|
| 401 |
+
if not name:
|
| 402 |
+
return _error(400, "BadRequestException", "Name is required")
|
| 403 |
+
strategy_id = _gen_id()
|
| 404 |
+
record = {
|
| 405 |
+
"Id": strategy_id,
|
| 406 |
+
"Name": name,
|
| 407 |
+
"Description": body.get("Description", ""),
|
| 408 |
+
"DeploymentDurationInMinutes": body.get("DeploymentDurationInMinutes", 0),
|
| 409 |
+
"GrowthType": body.get("GrowthType", "LINEAR"),
|
| 410 |
+
"GrowthFactor": body.get("GrowthFactor", 100.0),
|
| 411 |
+
"FinalBakeTimeInMinutes": body.get("FinalBakeTimeInMinutes", 0),
|
| 412 |
+
"ReplicateTo": body.get("ReplicateTo", "NONE"),
|
| 413 |
+
}
|
| 414 |
+
_deployment_strategies[strategy_id] = record
|
| 415 |
+
_apply_tags(_strategy_arn(strategy_id), body.get("Tags", {}))
|
| 416 |
+
logger.info("CreateDeploymentStrategy: %s (%s)", name, strategy_id)
|
| 417 |
+
return _json(201, record)
|
| 418 |
+
|
| 419 |
+
|
| 420 |
+
def _get_deployment_strategy(strategy_id):
|
| 421 |
+
strategy = _deployment_strategies.get(strategy_id)
|
| 422 |
+
if not strategy:
|
| 423 |
+
return _error(404, "ResourceNotFoundException", f"Deployment strategy {strategy_id} not found")
|
| 424 |
+
return _json(200, strategy)
|
| 425 |
+
|
| 426 |
+
|
| 427 |
+
def _list_deployment_strategies(query):
|
| 428 |
+
max_results = int(query.get("max_results", 50))
|
| 429 |
+
items = list(_deployment_strategies.values())
|
| 430 |
+
return _json(200, {"Items": items[:max_results]})
|
| 431 |
+
|
| 432 |
+
|
| 433 |
+
def _update_deployment_strategy(strategy_id, body):
|
| 434 |
+
strategy = _deployment_strategies.get(strategy_id)
|
| 435 |
+
if not strategy:
|
| 436 |
+
return _error(404, "ResourceNotFoundException", f"Deployment strategy {strategy_id} not found")
|
| 437 |
+
for field in ("Description", "DeploymentDurationInMinutes", "GrowthType",
|
| 438 |
+
"GrowthFactor", "FinalBakeTimeInMinutes"):
|
| 439 |
+
if field in body:
|
| 440 |
+
strategy[field] = body[field]
|
| 441 |
+
return _json(200, strategy)
|
| 442 |
+
|
| 443 |
+
|
| 444 |
+
def _delete_deployment_strategy(strategy_id):
|
| 445 |
+
if strategy_id not in _deployment_strategies:
|
| 446 |
+
return _error(404, "ResourceNotFoundException", f"Deployment strategy {strategy_id} not found")
|
| 447 |
+
del _deployment_strategies[strategy_id]
|
| 448 |
+
_tags.pop(_strategy_arn(strategy_id), None)
|
| 449 |
+
return _json(204, {})
|
| 450 |
+
|
| 451 |
+
|
| 452 |
+
# ---------------------------------------------------------------------------
|
| 453 |
+
# Deployments
|
| 454 |
+
# ---------------------------------------------------------------------------
|
| 455 |
+
|
| 456 |
+
|
| 457 |
+
def _start_deployment(app_id, env_id, body):
|
| 458 |
+
if app_id not in _applications:
|
| 459 |
+
return _error(404, "ResourceNotFoundException", f"Application {app_id} not found")
|
| 460 |
+
if f"{app_id}/{env_id}" not in _environments:
|
| 461 |
+
return _error(404, "ResourceNotFoundException", f"Environment {env_id} not found")
|
| 462 |
+
|
| 463 |
+
strategy_id = body.get("DeploymentStrategyId", "")
|
| 464 |
+
profile_id = body.get("ConfigurationProfileId", "")
|
| 465 |
+
version = body.get("ConfigurationVersion", "")
|
| 466 |
+
|
| 467 |
+
if profile_id and f"{app_id}/{profile_id}" not in _config_profiles:
|
| 468 |
+
return _error(404, "ResourceNotFoundException", f"Configuration profile {profile_id} not found")
|
| 469 |
+
|
| 470 |
+
existing = [
|
| 471 |
+
v for k, v in _deployments.items()
|
| 472 |
+
if k.startswith(f"{app_id}/{env_id}/")
|
| 473 |
+
]
|
| 474 |
+
deploy_num = len(existing) + 1
|
| 475 |
+
|
| 476 |
+
now = _now_iso()
|
| 477 |
+
record = {
|
| 478 |
+
"ApplicationId": app_id,
|
| 479 |
+
"EnvironmentId": env_id,
|
| 480 |
+
"DeploymentStrategyId": strategy_id,
|
| 481 |
+
"ConfigurationProfileId": profile_id,
|
| 482 |
+
"DeploymentNumber": deploy_num,
|
| 483 |
+
"ConfigurationName": _config_profiles.get(f"{app_id}/{profile_id}", {}).get("Name", ""),
|
| 484 |
+
"ConfigurationLocationUri": "hosted",
|
| 485 |
+
"ConfigurationVersion": version,
|
| 486 |
+
"Description": body.get("Description", ""),
|
| 487 |
+
"DeploymentDurationInMinutes": 0,
|
| 488 |
+
"GrowthType": "LINEAR",
|
| 489 |
+
"GrowthFactor": 100.0,
|
| 490 |
+
"FinalBakeTimeInMinutes": 0,
|
| 491 |
+
"State": "COMPLETE",
|
| 492 |
+
"PercentageComplete": 100.0,
|
| 493 |
+
"StartedAt": now,
|
| 494 |
+
"CompletedAt": now,
|
| 495 |
+
}
|
| 496 |
+
_deployments[f"{app_id}/{env_id}/{deploy_num}"] = record
|
| 497 |
+
logger.info("StartDeployment: %s/%s #%d (profile=%s, version=%s)",
|
| 498 |
+
app_id, env_id, deploy_num, profile_id, version)
|
| 499 |
+
return _json(201, record)
|
| 500 |
+
|
| 501 |
+
|
| 502 |
+
def _get_deployment(app_id, env_id, deploy_num):
|
| 503 |
+
record = _deployments.get(f"{app_id}/{env_id}/{deploy_num}")
|
| 504 |
+
if not record:
|
| 505 |
+
return _error(404, "ResourceNotFoundException", f"Deployment {deploy_num} not found")
|
| 506 |
+
return _json(200, record)
|
| 507 |
+
|
| 508 |
+
|
| 509 |
+
def _list_deployments(app_id, env_id, query):
|
| 510 |
+
if f"{app_id}/{env_id}" not in _environments:
|
| 511 |
+
return _error(404, "ResourceNotFoundException", f"Environment {env_id} not found")
|
| 512 |
+
max_results = int(query.get("max_results", 50))
|
| 513 |
+
items = []
|
| 514 |
+
for k, v in _deployments.items():
|
| 515 |
+
if k.startswith(f"{app_id}/{env_id}/"):
|
| 516 |
+
items.append({
|
| 517 |
+
"DeploymentNumber": v["DeploymentNumber"],
|
| 518 |
+
"ConfigurationName": v.get("ConfigurationName", ""),
|
| 519 |
+
"ConfigurationVersion": v.get("ConfigurationVersion", ""),
|
| 520 |
+
"DeploymentDurationInMinutes": v.get("DeploymentDurationInMinutes", 0),
|
| 521 |
+
"GrowthType": v.get("GrowthType", "LINEAR"),
|
| 522 |
+
"GrowthFactor": v.get("GrowthFactor", 100.0),
|
| 523 |
+
"FinalBakeTimeInMinutes": v.get("FinalBakeTimeInMinutes", 0),
|
| 524 |
+
"State": v.get("State", "COMPLETE"),
|
| 525 |
+
"PercentageComplete": v.get("PercentageComplete", 100.0),
|
| 526 |
+
"StartedAt": v.get("StartedAt", ""),
|
| 527 |
+
"CompletedAt": v.get("CompletedAt", ""),
|
| 528 |
+
})
|
| 529 |
+
return _json(200, {"Items": items[:max_results]})
|
| 530 |
+
|
| 531 |
+
|
| 532 |
+
def _stop_deployment(app_id, env_id, deploy_num):
|
| 533 |
+
record = _deployments.get(f"{app_id}/{env_id}/{deploy_num}")
|
| 534 |
+
if not record:
|
| 535 |
+
return _error(404, "ResourceNotFoundException", f"Deployment {deploy_num} not found")
|
| 536 |
+
record["State"] = "ROLLED_BACK"
|
| 537 |
+
return _json(202, record)
|
| 538 |
+
|
| 539 |
+
|
| 540 |
+
# ---------------------------------------------------------------------------
|
| 541 |
+
# Tags
|
| 542 |
+
# ---------------------------------------------------------------------------
|
| 543 |
+
|
| 544 |
+
|
| 545 |
+
def _apply_tags(arn, tags_dict):
|
| 546 |
+
if tags_dict:
|
| 547 |
+
if arn not in _tags:
|
| 548 |
+
_tags[arn] = {}
|
| 549 |
+
_tags[arn].update(tags_dict)
|
| 550 |
+
|
| 551 |
+
|
| 552 |
+
def _tag_resource(resource_arn, body):
|
| 553 |
+
tags_dict = body.get("Tags", {})
|
| 554 |
+
_apply_tags(resource_arn, tags_dict)
|
| 555 |
+
return _json(204, {})
|
| 556 |
+
|
| 557 |
+
|
| 558 |
+
def _untag_resource(resource_arn, tag_keys):
|
| 559 |
+
if resource_arn in _tags:
|
| 560 |
+
for key in tag_keys:
|
| 561 |
+
_tags[resource_arn].pop(key, None)
|
| 562 |
+
return _json(204, {})
|
| 563 |
+
|
| 564 |
+
|
| 565 |
+
def _list_tags_for_resource(resource_arn):
|
| 566 |
+
return _json(200, {"Tags": _tags.get(resource_arn, {})})
|
| 567 |
+
|
| 568 |
+
|
| 569 |
+
# ---------------------------------------------------------------------------
|
| 570 |
+
# Data Plane — StartConfigurationSession / GetLatestConfiguration
|
| 571 |
+
# ---------------------------------------------------------------------------
|
| 572 |
+
|
| 573 |
+
|
| 574 |
+
def _start_configuration_session(body):
|
| 575 |
+
app_id = body.get("ApplicationIdentifier", "")
|
| 576 |
+
env_id = body.get("EnvironmentIdentifier", "")
|
| 577 |
+
profile_id = body.get("ConfigurationProfileIdentifier", "")
|
| 578 |
+
|
| 579 |
+
if not app_id or not env_id or not profile_id:
|
| 580 |
+
return _error(400, "BadRequestException",
|
| 581 |
+
"ApplicationIdentifier, EnvironmentIdentifier, and "
|
| 582 |
+
"ConfigurationProfileIdentifier are required")
|
| 583 |
+
|
| 584 |
+
token = uuid.uuid4().hex
|
| 585 |
+
_sessions[token] = {
|
| 586 |
+
"ApplicationIdentifier": app_id,
|
| 587 |
+
"EnvironmentIdentifier": env_id,
|
| 588 |
+
"ConfigurationProfileIdentifier": profile_id,
|
| 589 |
+
}
|
| 590 |
+
logger.info("StartConfigurationSession: app=%s env=%s profile=%s", app_id, env_id, profile_id)
|
| 591 |
+
return _json(201, {"InitialConfigurationToken": token})
|
| 592 |
+
|
| 593 |
+
|
| 594 |
+
def _get_latest_configuration(token):
|
| 595 |
+
session = _sessions.get(token)
|
| 596 |
+
if not session:
|
| 597 |
+
return _error(400, "BadRequestException", "Invalid or expired configuration token")
|
| 598 |
+
|
| 599 |
+
# Remove the used token
|
| 600 |
+
del _sessions[token]
|
| 601 |
+
|
| 602 |
+
app_id = session["ApplicationIdentifier"]
|
| 603 |
+
env_id = session["EnvironmentIdentifier"]
|
| 604 |
+
profile_id = session["ConfigurationProfileIdentifier"]
|
| 605 |
+
|
| 606 |
+
# Find the latest completed deployment for this app/env
|
| 607 |
+
latest_deploy = None
|
| 608 |
+
for k, v in _deployments.items():
|
| 609 |
+
if (k.startswith(f"{app_id}/{env_id}/")
|
| 610 |
+
and v.get("State") == "COMPLETE"
|
| 611 |
+
and v.get("ConfigurationProfileId") == profile_id):
|
| 612 |
+
if latest_deploy is None or v["DeploymentNumber"] > latest_deploy["DeploymentNumber"]:
|
| 613 |
+
latest_deploy = v
|
| 614 |
+
|
| 615 |
+
content = b""
|
| 616 |
+
content_type = "application/octet-stream"
|
| 617 |
+
if latest_deploy:
|
| 618 |
+
cfg_version = latest_deploy.get("ConfigurationVersion", "")
|
| 619 |
+
version_key = f"{app_id}/{profile_id}/{cfg_version}"
|
| 620 |
+
version_record = _hosted_versions.get(version_key)
|
| 621 |
+
if version_record:
|
| 622 |
+
raw = version_record["Content"]
|
| 623 |
+
content = raw if isinstance(raw, bytes) else raw.encode("utf-8")
|
| 624 |
+
content_type = version_record.get("ContentType", "application/octet-stream")
|
| 625 |
+
|
| 626 |
+
next_token = uuid.uuid4().hex
|
| 627 |
+
_sessions[next_token] = session.copy()
|
| 628 |
+
|
| 629 |
+
resp_headers = {
|
| 630 |
+
"Content-Type": content_type,
|
| 631 |
+
"Next-Poll-Configuration-Token": next_token,
|
| 632 |
+
"Next-Poll-Interval-In-Seconds": "30",
|
| 633 |
+
}
|
| 634 |
+
return 200, resp_headers, content
|
| 635 |
+
|
| 636 |
+
|
| 637 |
+
# ---------------------------------------------------------------------------
|
| 638 |
+
# Request router
|
| 639 |
+
# ---------------------------------------------------------------------------
|
| 640 |
+
|
| 641 |
+
|
| 642 |
+
async def handle_request(method, path, headers, body_bytes, query_params):
|
| 643 |
+
query = {k: (v[0] if isinstance(v, list) else v) for k, v in query_params.items()}
|
| 644 |
+
|
| 645 |
+
# --- Data plane paths ---
|
| 646 |
+
if path == "/configurationsessions" and method == "POST":
|
| 647 |
+
try:
|
| 648 |
+
data = json.loads(body_bytes) if body_bytes else {}
|
| 649 |
+
except json.JSONDecodeError:
|
| 650 |
+
return _error(400, "BadRequestException", "Invalid JSON")
|
| 651 |
+
return await _a(_start_configuration_session(data))
|
| 652 |
+
|
| 653 |
+
if path == "/configuration" and method == "GET":
|
| 654 |
+
token = query.get("configuration_token", "")
|
| 655 |
+
if not token:
|
| 656 |
+
return _error(400, "BadRequestException", "configuration_token is required")
|
| 657 |
+
return await _a(_get_latest_configuration(token))
|
| 658 |
+
|
| 659 |
+
# --- Control plane: tags ---
|
| 660 |
+
m = re.fullmatch(r"/tags/(.+)", path)
|
| 661 |
+
if m:
|
| 662 |
+
resource_arn = m.group(1)
|
| 663 |
+
if method == "POST":
|
| 664 |
+
try:
|
| 665 |
+
data = json.loads(body_bytes) if body_bytes else {}
|
| 666 |
+
except json.JSONDecodeError:
|
| 667 |
+
return _error(400, "BadRequestException", "Invalid JSON")
|
| 668 |
+
return await _a(_tag_resource(resource_arn, data))
|
| 669 |
+
if method == "GET":
|
| 670 |
+
return await _a(_list_tags_for_resource(resource_arn))
|
| 671 |
+
if method == "DELETE":
|
| 672 |
+
tag_keys = query.get("tagKeys", "")
|
| 673 |
+
keys = [k.strip() for k in tag_keys.split(",") if k.strip()] if tag_keys else []
|
| 674 |
+
return await _a(_untag_resource(resource_arn, keys))
|
| 675 |
+
|
| 676 |
+
# --- Control plane: parse JSON body for non-hosted-version routes ---
|
| 677 |
+
content_type = headers.get("content-type", "")
|
| 678 |
+
|
| 679 |
+
# Hosted configuration versions — body is raw content, not JSON
|
| 680 |
+
m = re.fullmatch(
|
| 681 |
+
r"/applications/([^/]+)/configurationprofiles/([^/]+)/hostedconfigurationversions",
|
| 682 |
+
path,
|
| 683 |
+
)
|
| 684 |
+
if m and method == "POST":
|
| 685 |
+
app_id, profile_id = m.group(1), m.group(2)
|
| 686 |
+
ct = content_type or "application/octet-stream"
|
| 687 |
+
return await _a(_create_hosted_configuration_version(app_id, profile_id, body_bytes, ct))
|
| 688 |
+
|
| 689 |
+
m = re.fullmatch(
|
| 690 |
+
r"/applications/([^/]+)/configurationprofiles/([^/]+)/hostedconfigurationversions/(\d+)",
|
| 691 |
+
path,
|
| 692 |
+
)
|
| 693 |
+
if m:
|
| 694 |
+
app_id, profile_id, ver = m.group(1), m.group(2), int(m.group(3))
|
| 695 |
+
if method == "GET":
|
| 696 |
+
return await _a(_get_hosted_configuration_version(app_id, profile_id, ver))
|
| 697 |
+
if method == "DELETE":
|
| 698 |
+
return await _a(_delete_hosted_configuration_version(app_id, profile_id, ver))
|
| 699 |
+
|
| 700 |
+
m = re.fullmatch(
|
| 701 |
+
r"/applications/([^/]+)/configurationprofiles/([^/]+)/hostedconfigurationversions",
|
| 702 |
+
path,
|
| 703 |
+
)
|
| 704 |
+
if m and method == "GET":
|
| 705 |
+
app_id, profile_id = m.group(1), m.group(2)
|
| 706 |
+
return await _a(_list_hosted_configuration_versions(app_id, profile_id, query))
|
| 707 |
+
|
| 708 |
+
# JSON body for remaining routes
|
| 709 |
+
try:
|
| 710 |
+
body = json.loads(body_bytes) if body_bytes else {}
|
| 711 |
+
except json.JSONDecodeError:
|
| 712 |
+
body = {}
|
| 713 |
+
|
| 714 |
+
# --- Applications ---
|
| 715 |
+
if path == "/applications":
|
| 716 |
+
if method == "POST":
|
| 717 |
+
return await _a(_create_application(body))
|
| 718 |
+
if method == "GET":
|
| 719 |
+
return await _a(_list_applications(query))
|
| 720 |
+
|
| 721 |
+
m = re.fullmatch(r"/applications/([^/]+)", path)
|
| 722 |
+
if m:
|
| 723 |
+
app_id = m.group(1)
|
| 724 |
+
if method == "GET":
|
| 725 |
+
return await _a(_get_application(app_id))
|
| 726 |
+
if method == "PATCH":
|
| 727 |
+
return await _a(_update_application(app_id, body))
|
| 728 |
+
if method == "DELETE":
|
| 729 |
+
return await _a(_delete_application(app_id))
|
| 730 |
+
|
| 731 |
+
# --- Deployments (must be checked before environments) ---
|
| 732 |
+
m = re.fullmatch(r"/applications/([^/]+)/environments/([^/]+)/deployments", path)
|
| 733 |
+
if m:
|
| 734 |
+
app_id, env_id = m.group(1), m.group(2)
|
| 735 |
+
if method == "POST":
|
| 736 |
+
return await _a(_start_deployment(app_id, env_id, body))
|
| 737 |
+
if method == "GET":
|
| 738 |
+
return await _a(_list_deployments(app_id, env_id, query))
|
| 739 |
+
|
| 740 |
+
m = re.fullmatch(r"/applications/([^/]+)/environments/([^/]+)/deployments/(\d+)", path)
|
| 741 |
+
if m:
|
| 742 |
+
app_id, env_id, deploy_num = m.group(1), m.group(2), int(m.group(3))
|
| 743 |
+
if method == "GET":
|
| 744 |
+
return await _a(_get_deployment(app_id, env_id, deploy_num))
|
| 745 |
+
if method == "DELETE":
|
| 746 |
+
return await _a(_stop_deployment(app_id, env_id, deploy_num))
|
| 747 |
+
|
| 748 |
+
# --- Environments ---
|
| 749 |
+
m = re.fullmatch(r"/applications/([^/]+)/environments", path)
|
| 750 |
+
if m:
|
| 751 |
+
app_id = m.group(1)
|
| 752 |
+
if method == "POST":
|
| 753 |
+
return await _a(_create_environment(app_id, body))
|
| 754 |
+
if method == "GET":
|
| 755 |
+
return await _a(_list_environments(app_id, query))
|
| 756 |
+
|
| 757 |
+
m = re.fullmatch(r"/applications/([^/]+)/environments/([^/]+)", path)
|
| 758 |
+
if m:
|
| 759 |
+
app_id, env_id = m.group(1), m.group(2)
|
| 760 |
+
if method == "GET":
|
| 761 |
+
return await _a(_get_environment(app_id, env_id))
|
| 762 |
+
if method == "PATCH":
|
| 763 |
+
return await _a(_update_environment(app_id, env_id, body))
|
| 764 |
+
if method == "DELETE":
|
| 765 |
+
return await _a(_delete_environment(app_id, env_id))
|
| 766 |
+
|
| 767 |
+
# --- Configuration Profiles ---
|
| 768 |
+
m = re.fullmatch(r"/applications/([^/]+)/configurationprofiles", path)
|
| 769 |
+
if m:
|
| 770 |
+
app_id = m.group(1)
|
| 771 |
+
if method == "POST":
|
| 772 |
+
return await _a(_create_configuration_profile(app_id, body))
|
| 773 |
+
if method == "GET":
|
| 774 |
+
return await _a(_list_configuration_profiles(app_id, query))
|
| 775 |
+
|
| 776 |
+
m = re.fullmatch(r"/applications/([^/]+)/configurationprofiles/([^/]+)", path)
|
| 777 |
+
if m:
|
| 778 |
+
app_id, profile_id = m.group(1), m.group(2)
|
| 779 |
+
if method == "GET":
|
| 780 |
+
return await _a(_get_configuration_profile(app_id, profile_id))
|
| 781 |
+
if method == "PATCH":
|
| 782 |
+
return await _a(_update_configuration_profile(app_id, profile_id, body))
|
| 783 |
+
if method == "DELETE":
|
| 784 |
+
return await _a(_delete_configuration_profile(app_id, profile_id))
|
| 785 |
+
|
| 786 |
+
# --- Deployment Strategies ---
|
| 787 |
+
# botocore's model uses the misspelled path "/deployementstrategies" for
|
| 788 |
+
# DeleteDeploymentStrategy (and possibly others), so accept both spellings.
|
| 789 |
+
if path in ("/deploymentstrategies", "/deployementstrategies"):
|
| 790 |
+
if method == "POST":
|
| 791 |
+
return await _a(_create_deployment_strategy(body))
|
| 792 |
+
if method == "GET":
|
| 793 |
+
return await _a(_list_deployment_strategies(query))
|
| 794 |
+
|
| 795 |
+
m = re.fullmatch(r"/deploy(?:e?)mentstrategies/([^/]+)", path)
|
| 796 |
+
if m:
|
| 797 |
+
strategy_id = m.group(1)
|
| 798 |
+
if method == "GET":
|
| 799 |
+
return await _a(_get_deployment_strategy(strategy_id))
|
| 800 |
+
if method == "PATCH":
|
| 801 |
+
return await _a(_update_deployment_strategy(strategy_id, body))
|
| 802 |
+
if method == "DELETE":
|
| 803 |
+
return await _a(_delete_deployment_strategy(strategy_id))
|
| 804 |
+
|
| 805 |
+
return _error(400, "BadRequestException", f"Unknown AppConfig path: {method} {path}")
|
| 806 |
+
|
| 807 |
+
|
| 808 |
+
async def _a(result):
|
| 809 |
+
return result
|
| 810 |
+
|
| 811 |
+
|
| 812 |
+
# ---------------------------------------------------------------------------
|
| 813 |
+
# Response helpers
|
| 814 |
+
# ---------------------------------------------------------------------------
|
| 815 |
+
|
| 816 |
+
|
| 817 |
+
def _json(status, data):
|
| 818 |
+
if status == 204:
|
| 819 |
+
return status, {}, b""
|
| 820 |
+
body = json.dumps(data).encode("utf-8")
|
| 821 |
+
return status, {"Content-Type": "application/json"}, body
|
| 822 |
+
|
| 823 |
+
|
| 824 |
+
def _error(status, code, message):
|
| 825 |
+
body = json.dumps({"Message": message, "Code": code}).encode("utf-8")
|
| 826 |
+
return status, {"Content-Type": "application/json", "x-amzn-errortype": code}, body
|
| 827 |
+
|
| 828 |
+
|
| 829 |
+
# ---------------------------------------------------------------------------
|
| 830 |
+
# Reset
|
| 831 |
+
# ---------------------------------------------------------------------------
|
| 832 |
+
|
| 833 |
+
|
| 834 |
+
def reset():
|
| 835 |
+
_applications.clear()
|
| 836 |
+
_environments.clear()
|
| 837 |
+
_config_profiles.clear()
|
| 838 |
+
_hosted_versions.clear()
|
| 839 |
+
_deployment_strategies.clear()
|
| 840 |
+
_deployments.clear()
|
| 841 |
+
_tags.clear()
|
| 842 |
+
_sessions.clear()
|
| 843 |
+
|
| 844 |
+
def get_state_summary() -> dict:
|
| 845 |
+
return {
|
| 846 |
+
"applications": {"count": len(_applications), "names": list(_applications.keys())},
|
| 847 |
+
"environments": {"count": len(_environments), "names": list(_environments.keys())},
|
| 848 |
+
"config_profiles": {"count": len(_config_profiles), "names": list(_config_profiles.keys())},
|
| 849 |
+
"hosted_versions": {"count": len(_hosted_versions), "names": list(_hosted_versions.keys())},
|
| 850 |
+
"deployment_strategies": {"count": len(_deployment_strategies), "names": list(_deployment_strategies.keys())},
|
| 851 |
+
"deployments": {"count": len(_deployments), "names": list(_deployments.keys())},
|
| 852 |
+
}
|
aws_infra/ministack/services/appsync.py
ADDED
|
@@ -0,0 +1,1001 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
AWS AppSync Service Emulator.
|
| 3 |
+
|
| 4 |
+
GraphQL API management service — REST/JSON protocol via /v1/apis/* paths.
|
| 5 |
+
|
| 6 |
+
Supports:
|
| 7 |
+
GraphQL APIs: CreateGraphQLApi, GetGraphQLApi, ListGraphQLApis,
|
| 8 |
+
UpdateGraphQLApi, DeleteGraphQLApi
|
| 9 |
+
API Keys: CreateApiKey, ListApiKeys, DeleteApiKey
|
| 10 |
+
Data Sources: CreateDataSource, GetDataSource, ListDataSources, DeleteDataSource
|
| 11 |
+
Resolvers: CreateResolver, GetResolver, ListResolvers, DeleteResolver
|
| 12 |
+
Types: CreateType, ListTypes, GetType
|
| 13 |
+
Tags: TagResource, UntagResource, ListTagsForResource
|
| 14 |
+
|
| 15 |
+
Wire protocol:
|
| 16 |
+
REST/JSON — path-based routing under /v1/apis.
|
| 17 |
+
Credential scope: appsync
|
| 18 |
+
"""
|
| 19 |
+
|
| 20 |
+
import copy
|
| 21 |
+
import json
|
| 22 |
+
import logging
|
| 23 |
+
import os
|
| 24 |
+
import re
|
| 25 |
+
import time
|
| 26 |
+
|
| 27 |
+
from ministack.core.persistence import PERSIST_STATE, load_state
|
| 28 |
+
from ministack.core.responses import AccountScopedDict, get_account_id, error_response_json, json_response, new_uuid, get_region
|
| 29 |
+
|
| 30 |
+
logger = logging.getLogger("appsync")
|
| 31 |
+
|
| 32 |
+
REGION = os.environ.get("MINISTACK_REGION", "us-east-1")
|
| 33 |
+
|
| 34 |
+
# ---------------------------------------------------------------------------
|
| 35 |
+
# In-memory state
|
| 36 |
+
# ---------------------------------------------------------------------------
|
| 37 |
+
|
| 38 |
+
_apis = AccountScopedDict() # apiId -> api record
|
| 39 |
+
_api_keys = AccountScopedDict() # apiId -> {keyId -> key record}
|
| 40 |
+
_data_sources = AccountScopedDict() # apiId -> {name -> data source record}
|
| 41 |
+
_resolvers = AccountScopedDict() # apiId -> {typeName -> {fieldName -> resolver record}}
|
| 42 |
+
_types = AccountScopedDict() # apiId -> {typeName -> type record}
|
| 43 |
+
_tags = AccountScopedDict() # resource_arn -> {key: value}
|
| 44 |
+
|
| 45 |
+
# ---------------------------------------------------------------------------
|
| 46 |
+
# Persistence
|
| 47 |
+
# ---------------------------------------------------------------------------
|
| 48 |
+
|
| 49 |
+
def _load_persisted():
|
| 50 |
+
if not PERSIST_STATE:
|
| 51 |
+
return
|
| 52 |
+
data = load_state("appsync")
|
| 53 |
+
if data:
|
| 54 |
+
restore_state(data)
|
| 55 |
+
logger.info("Loaded persisted state for appsync")
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
# ---------------------------------------------------------------------------
|
| 59 |
+
# Helpers
|
| 60 |
+
# ---------------------------------------------------------------------------
|
| 61 |
+
|
| 62 |
+
def _now():
|
| 63 |
+
return int(time.time())
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
def _api_arn(api_id):
|
| 67 |
+
return f"arn:aws:appsync:{get_region()}:{get_account_id()}:apis/{api_id}"
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def _json(status, body):
|
| 71 |
+
return json_response(body, status)
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
# ---------------------------------------------------------------------------
|
| 75 |
+
# GraphQL APIs
|
| 76 |
+
# ---------------------------------------------------------------------------
|
| 77 |
+
|
| 78 |
+
def _create_graphql_api(body):
|
| 79 |
+
api_id = new_uuid().replace("-", "")[:26]
|
| 80 |
+
name = body.get("name", "")
|
| 81 |
+
auth_type = body.get("authenticationType", "API_KEY")
|
| 82 |
+
additional_auth = body.get("additionalAuthenticationProviders", [])
|
| 83 |
+
log_config = body.get("logConfig")
|
| 84 |
+
user_pool_config = body.get("userPoolConfig")
|
| 85 |
+
openid_config = body.get("openIDConnectConfig")
|
| 86 |
+
xray = body.get("xrayEnabled", False)
|
| 87 |
+
tags = body.get("tags", {})
|
| 88 |
+
lambda_auth = body.get("lambdaAuthorizerConfig")
|
| 89 |
+
|
| 90 |
+
arn = _api_arn(api_id)
|
| 91 |
+
now = _now()
|
| 92 |
+
|
| 93 |
+
record = {
|
| 94 |
+
"apiId": api_id,
|
| 95 |
+
"name": name,
|
| 96 |
+
"authenticationType": auth_type,
|
| 97 |
+
"arn": arn,
|
| 98 |
+
"uris": {
|
| 99 |
+
"GRAPHQL": f"https://{api_id}.appsync-api.{get_region()}.amazonaws.com/graphql",
|
| 100 |
+
"REALTIME": f"wss://{api_id}.appsync-realtime-api.{get_region()}.amazonaws.com/graphql",
|
| 101 |
+
},
|
| 102 |
+
"additionalAuthenticationProviders": additional_auth,
|
| 103 |
+
"xrayEnabled": xray,
|
| 104 |
+
"wafWebAclArn": body.get("wafWebAclArn"),
|
| 105 |
+
"createdAt": now,
|
| 106 |
+
"lastUpdatedAt": now,
|
| 107 |
+
}
|
| 108 |
+
if log_config:
|
| 109 |
+
record["logConfig"] = log_config
|
| 110 |
+
if user_pool_config:
|
| 111 |
+
record["userPoolConfig"] = user_pool_config
|
| 112 |
+
if openid_config:
|
| 113 |
+
record["openIDConnectConfig"] = openid_config
|
| 114 |
+
if lambda_auth:
|
| 115 |
+
record["lambdaAuthorizerConfig"] = lambda_auth
|
| 116 |
+
|
| 117 |
+
_apis[api_id] = record
|
| 118 |
+
_api_keys[api_id] = {}
|
| 119 |
+
_data_sources[api_id] = {}
|
| 120 |
+
_resolvers[api_id] = {}
|
| 121 |
+
_types[api_id] = {}
|
| 122 |
+
|
| 123 |
+
if tags:
|
| 124 |
+
_tags[arn] = tags
|
| 125 |
+
|
| 126 |
+
return _json(200, {"graphqlApi": record})
|
| 127 |
+
|
| 128 |
+
|
| 129 |
+
def _get_graphql_api(api_id):
|
| 130 |
+
api = _apis.get(api_id)
|
| 131 |
+
if not api:
|
| 132 |
+
return error_response_json("NotFoundException", f"GraphQL API {api_id} not found", 404)
|
| 133 |
+
return _json(200, {"graphqlApi": api})
|
| 134 |
+
|
| 135 |
+
|
| 136 |
+
def _list_graphql_apis(query_params):
|
| 137 |
+
apis = list(_apis.values())
|
| 138 |
+
return _json(200, {"graphqlApis": apis})
|
| 139 |
+
|
| 140 |
+
|
| 141 |
+
def _update_graphql_api(api_id, body):
|
| 142 |
+
api = _apis.get(api_id)
|
| 143 |
+
if not api:
|
| 144 |
+
return error_response_json("NotFoundException", f"GraphQL API {api_id} not found", 404)
|
| 145 |
+
|
| 146 |
+
if "name" in body:
|
| 147 |
+
api["name"] = body["name"]
|
| 148 |
+
if "authenticationType" in body:
|
| 149 |
+
api["authenticationType"] = body["authenticationType"]
|
| 150 |
+
if "additionalAuthenticationProviders" in body:
|
| 151 |
+
api["additionalAuthenticationProviders"] = body["additionalAuthenticationProviders"]
|
| 152 |
+
if "logConfig" in body:
|
| 153 |
+
api["logConfig"] = body["logConfig"]
|
| 154 |
+
if "userPoolConfig" in body:
|
| 155 |
+
api["userPoolConfig"] = body["userPoolConfig"]
|
| 156 |
+
if "openIDConnectConfig" in body:
|
| 157 |
+
api["openIDConnectConfig"] = body["openIDConnectConfig"]
|
| 158 |
+
if "xrayEnabled" in body:
|
| 159 |
+
api["xrayEnabled"] = body["xrayEnabled"]
|
| 160 |
+
if "lambdaAuthorizerConfig" in body:
|
| 161 |
+
api["lambdaAuthorizerConfig"] = body["lambdaAuthorizerConfig"]
|
| 162 |
+
|
| 163 |
+
api["lastUpdatedAt"] = _now()
|
| 164 |
+
return _json(200, {"graphqlApi": api})
|
| 165 |
+
|
| 166 |
+
|
| 167 |
+
def _delete_graphql_api(api_id):
|
| 168 |
+
if api_id not in _apis:
|
| 169 |
+
return error_response_json("NotFoundException", f"GraphQL API {api_id} not found", 404)
|
| 170 |
+
|
| 171 |
+
arn = _apis[api_id]["arn"]
|
| 172 |
+
del _apis[api_id]
|
| 173 |
+
_api_keys.pop(api_id, None)
|
| 174 |
+
_data_sources.pop(api_id, None)
|
| 175 |
+
_resolvers.pop(api_id, None)
|
| 176 |
+
_types.pop(api_id, None)
|
| 177 |
+
_tags.pop(arn, None)
|
| 178 |
+
|
| 179 |
+
return _json(200, {})
|
| 180 |
+
|
| 181 |
+
|
| 182 |
+
# ---------------------------------------------------------------------------
|
| 183 |
+
# API Keys
|
| 184 |
+
# ---------------------------------------------------------------------------
|
| 185 |
+
|
| 186 |
+
def _create_api_key(api_id, body):
|
| 187 |
+
if api_id not in _apis:
|
| 188 |
+
return error_response_json("NotFoundException", f"GraphQL API {api_id} not found", 404)
|
| 189 |
+
|
| 190 |
+
key_id = "da2-" + new_uuid()[:26]
|
| 191 |
+
now = _now()
|
| 192 |
+
expires = body.get("expires", now + 604800) # default 7 days
|
| 193 |
+
description = body.get("description", "")
|
| 194 |
+
|
| 195 |
+
record = {
|
| 196 |
+
"id": key_id,
|
| 197 |
+
"description": description,
|
| 198 |
+
"expires": expires,
|
| 199 |
+
"createdAt": now,
|
| 200 |
+
"lastUpdatedAt": now,
|
| 201 |
+
"deletes": expires + 5184000, # 60 days after expiry
|
| 202 |
+
}
|
| 203 |
+
|
| 204 |
+
_api_keys.setdefault(api_id, {})[key_id] = record
|
| 205 |
+
return _json(200, {"apiKey": record})
|
| 206 |
+
|
| 207 |
+
|
| 208 |
+
def _list_api_keys(api_id):
|
| 209 |
+
if api_id not in _apis:
|
| 210 |
+
return error_response_json("NotFoundException", f"GraphQL API {api_id} not found", 404)
|
| 211 |
+
|
| 212 |
+
keys = list(_api_keys.get(api_id, {}).values())
|
| 213 |
+
return _json(200, {"apiKeys": keys})
|
| 214 |
+
|
| 215 |
+
|
| 216 |
+
def _delete_api_key(api_id, key_id):
|
| 217 |
+
if api_id not in _apis:
|
| 218 |
+
return error_response_json("NotFoundException", f"GraphQL API {api_id} not found", 404)
|
| 219 |
+
|
| 220 |
+
keys = _api_keys.get(api_id, {})
|
| 221 |
+
if key_id not in keys:
|
| 222 |
+
return error_response_json("NotFoundException", f"API key {key_id} not found", 404)
|
| 223 |
+
|
| 224 |
+
del keys[key_id]
|
| 225 |
+
return _json(200, {})
|
| 226 |
+
|
| 227 |
+
|
| 228 |
+
# ---------------------------------------------------------------------------
|
| 229 |
+
# Data Sources
|
| 230 |
+
# ---------------------------------------------------------------------------
|
| 231 |
+
|
| 232 |
+
def _create_data_source(api_id, body):
|
| 233 |
+
if api_id not in _apis:
|
| 234 |
+
return error_response_json("NotFoundException", f"GraphQL API {api_id} not found", 404)
|
| 235 |
+
|
| 236 |
+
name = body.get("name", "")
|
| 237 |
+
ds_type = body.get("type", "NONE")
|
| 238 |
+
description = body.get("description", "")
|
| 239 |
+
service_role_arn = body.get("serviceRoleArn", "")
|
| 240 |
+
|
| 241 |
+
arn = f"{_apis[api_id]['arn']}/datasources/{name}"
|
| 242 |
+
|
| 243 |
+
record = {
|
| 244 |
+
"dataSourceArn": arn,
|
| 245 |
+
"name": name,
|
| 246 |
+
"type": ds_type,
|
| 247 |
+
"description": description,
|
| 248 |
+
"serviceRoleArn": service_role_arn,
|
| 249 |
+
"createdAt": _now(),
|
| 250 |
+
"lastUpdatedAt": _now(),
|
| 251 |
+
}
|
| 252 |
+
|
| 253 |
+
if ds_type == "AMAZON_DYNAMODB":
|
| 254 |
+
record["dynamodbConfig"] = body.get("dynamodbConfig", {})
|
| 255 |
+
elif ds_type == "AWS_LAMBDA":
|
| 256 |
+
record["lambdaConfig"] = body.get("lambdaConfig", {})
|
| 257 |
+
elif ds_type == "AMAZON_ELASTICSEARCH" or ds_type == "AMAZON_OPENSEARCH_SERVICE":
|
| 258 |
+
record["elasticsearchConfig"] = body.get("elasticsearchConfig", {})
|
| 259 |
+
elif ds_type == "HTTP":
|
| 260 |
+
record["httpConfig"] = body.get("httpConfig", {})
|
| 261 |
+
elif ds_type == "RELATIONAL_DATABASE":
|
| 262 |
+
record["relationalDatabaseConfig"] = body.get("relationalDatabaseConfig", {})
|
| 263 |
+
|
| 264 |
+
_data_sources.setdefault(api_id, {})[name] = record
|
| 265 |
+
return _json(200, {"dataSource": record})
|
| 266 |
+
|
| 267 |
+
|
| 268 |
+
def _get_data_source(api_id, name):
|
| 269 |
+
if api_id not in _apis:
|
| 270 |
+
return error_response_json("NotFoundException", f"GraphQL API {api_id} not found", 404)
|
| 271 |
+
|
| 272 |
+
ds = _data_sources.get(api_id, {}).get(name)
|
| 273 |
+
if not ds:
|
| 274 |
+
return error_response_json("NotFoundException", f"Data source {name} not found", 404)
|
| 275 |
+
|
| 276 |
+
return _json(200, {"dataSource": ds})
|
| 277 |
+
|
| 278 |
+
|
| 279 |
+
def _list_data_sources(api_id):
|
| 280 |
+
if api_id not in _apis:
|
| 281 |
+
return error_response_json("NotFoundException", f"GraphQL API {api_id} not found", 404)
|
| 282 |
+
|
| 283 |
+
sources = list(_data_sources.get(api_id, {}).values())
|
| 284 |
+
return _json(200, {"dataSources": sources})
|
| 285 |
+
|
| 286 |
+
|
| 287 |
+
def _delete_data_source(api_id, name):
|
| 288 |
+
if api_id not in _apis:
|
| 289 |
+
return error_response_json("NotFoundException", f"GraphQL API {api_id} not found", 404)
|
| 290 |
+
|
| 291 |
+
sources = _data_sources.get(api_id, {})
|
| 292 |
+
if name not in sources:
|
| 293 |
+
return error_response_json("NotFoundException", f"Data source {name} not found", 404)
|
| 294 |
+
|
| 295 |
+
del sources[name]
|
| 296 |
+
return _json(200, {})
|
| 297 |
+
|
| 298 |
+
|
| 299 |
+
# ---------------------------------------------------------------------------
|
| 300 |
+
# Resolvers
|
| 301 |
+
# ---------------------------------------------------------------------------
|
| 302 |
+
|
| 303 |
+
def _create_resolver(api_id, type_name, body):
|
| 304 |
+
if api_id not in _apis:
|
| 305 |
+
return error_response_json("NotFoundException", f"GraphQL API {api_id} not found", 404)
|
| 306 |
+
|
| 307 |
+
field_name = body.get("fieldName", "")
|
| 308 |
+
data_source_name = body.get("dataSourceName")
|
| 309 |
+
request_template = body.get("requestMappingTemplate", "")
|
| 310 |
+
response_template = body.get("responseMappingTemplate", "")
|
| 311 |
+
kind = body.get("kind", "UNIT")
|
| 312 |
+
pipeline_config = body.get("pipelineConfig")
|
| 313 |
+
caching_config = body.get("cachingConfig")
|
| 314 |
+
runtime = body.get("runtime")
|
| 315 |
+
code = body.get("code")
|
| 316 |
+
|
| 317 |
+
arn = f"{_apis[api_id]['arn']}/types/{type_name}/resolvers/{field_name}"
|
| 318 |
+
|
| 319 |
+
record = {
|
| 320 |
+
"typeName": type_name,
|
| 321 |
+
"fieldName": field_name,
|
| 322 |
+
"dataSourceName": data_source_name,
|
| 323 |
+
"resolverArn": arn,
|
| 324 |
+
"requestMappingTemplate": request_template,
|
| 325 |
+
"responseMappingTemplate": response_template,
|
| 326 |
+
"kind": kind,
|
| 327 |
+
"createdAt": _now(),
|
| 328 |
+
"lastUpdatedAt": _now(),
|
| 329 |
+
}
|
| 330 |
+
if pipeline_config:
|
| 331 |
+
record["pipelineConfig"] = pipeline_config
|
| 332 |
+
if caching_config:
|
| 333 |
+
record["cachingConfig"] = caching_config
|
| 334 |
+
if runtime:
|
| 335 |
+
record["runtime"] = runtime
|
| 336 |
+
if code:
|
| 337 |
+
record["code"] = code
|
| 338 |
+
|
| 339 |
+
_resolvers.setdefault(api_id, {}).setdefault(type_name, {})[field_name] = record
|
| 340 |
+
return _json(200, {"resolver": record})
|
| 341 |
+
|
| 342 |
+
|
| 343 |
+
def _get_resolver(api_id, type_name, field_name):
|
| 344 |
+
if api_id not in _apis:
|
| 345 |
+
return error_response_json("NotFoundException", f"GraphQL API {api_id} not found", 404)
|
| 346 |
+
|
| 347 |
+
resolver = _resolvers.get(api_id, {}).get(type_name, {}).get(field_name)
|
| 348 |
+
if not resolver:
|
| 349 |
+
return error_response_json("NotFoundException",
|
| 350 |
+
f"Resolver {type_name}.{field_name} not found", 404)
|
| 351 |
+
|
| 352 |
+
return _json(200, {"resolver": resolver})
|
| 353 |
+
|
| 354 |
+
|
| 355 |
+
def _list_resolvers(api_id, type_name):
|
| 356 |
+
if api_id not in _apis:
|
| 357 |
+
return error_response_json("NotFoundException", f"GraphQL API {api_id} not found", 404)
|
| 358 |
+
|
| 359 |
+
resolvers = list(_resolvers.get(api_id, {}).get(type_name, {}).values())
|
| 360 |
+
return _json(200, {"resolvers": resolvers})
|
| 361 |
+
|
| 362 |
+
|
| 363 |
+
def _delete_resolver(api_id, type_name, field_name):
|
| 364 |
+
if api_id not in _apis:
|
| 365 |
+
return error_response_json("NotFoundException", f"GraphQL API {api_id} not found", 404)
|
| 366 |
+
|
| 367 |
+
type_resolvers = _resolvers.get(api_id, {}).get(type_name, {})
|
| 368 |
+
if field_name not in type_resolvers:
|
| 369 |
+
return error_response_json("NotFoundException",
|
| 370 |
+
f"Resolver {type_name}.{field_name} not found", 404)
|
| 371 |
+
|
| 372 |
+
del type_resolvers[field_name]
|
| 373 |
+
return _json(200, {})
|
| 374 |
+
|
| 375 |
+
|
| 376 |
+
# ---------------------------------------------------------------------------
|
| 377 |
+
# Types
|
| 378 |
+
# ---------------------------------------------------------------------------
|
| 379 |
+
|
| 380 |
+
def _create_type(api_id, body):
|
| 381 |
+
if api_id not in _apis:
|
| 382 |
+
return error_response_json("NotFoundException", f"GraphQL API {api_id} not found", 404)
|
| 383 |
+
|
| 384 |
+
definition = body.get("definition", "")
|
| 385 |
+
fmt = body.get("format", "SDL")
|
| 386 |
+
|
| 387 |
+
# Extract type name from SDL definition (e.g. "type Query { ... }" -> "Query")
|
| 388 |
+
name_match = re.search(r"(?:type|input|enum|interface|union|scalar)\s+(\w+)", definition)
|
| 389 |
+
type_name = name_match.group(1) if name_match else "Unknown"
|
| 390 |
+
|
| 391 |
+
arn = f"{_apis[api_id]['arn']}/types/{type_name}"
|
| 392 |
+
|
| 393 |
+
record = {
|
| 394 |
+
"name": type_name,
|
| 395 |
+
"description": body.get("description", ""),
|
| 396 |
+
"arn": arn,
|
| 397 |
+
"definition": definition,
|
| 398 |
+
"format": fmt,
|
| 399 |
+
"createdAt": _now(),
|
| 400 |
+
"lastUpdatedAt": _now(),
|
| 401 |
+
}
|
| 402 |
+
|
| 403 |
+
_types.setdefault(api_id, {})[type_name] = record
|
| 404 |
+
return _json(200, {"type": record})
|
| 405 |
+
|
| 406 |
+
|
| 407 |
+
def _get_type(api_id, type_name, query_params):
|
| 408 |
+
if api_id not in _apis:
|
| 409 |
+
return error_response_json("NotFoundException", f"GraphQL API {api_id} not found", 404)
|
| 410 |
+
|
| 411 |
+
fmt = "SDL"
|
| 412 |
+
if query_params.get("format"):
|
| 413 |
+
fmt_val = query_params["format"]
|
| 414 |
+
fmt = fmt_val[0] if isinstance(fmt_val, list) else fmt_val
|
| 415 |
+
|
| 416 |
+
t = _types.get(api_id, {}).get(type_name)
|
| 417 |
+
if not t:
|
| 418 |
+
return error_response_json("NotFoundException", f"Type {type_name} not found", 404)
|
| 419 |
+
|
| 420 |
+
return _json(200, {"type": t})
|
| 421 |
+
|
| 422 |
+
|
| 423 |
+
def _list_types(api_id, query_params):
|
| 424 |
+
if api_id not in _apis:
|
| 425 |
+
return error_response_json("NotFoundException", f"GraphQL API {api_id} not found", 404)
|
| 426 |
+
|
| 427 |
+
types = list(_types.get(api_id, {}).values())
|
| 428 |
+
return _json(200, {"types": types})
|
| 429 |
+
|
| 430 |
+
|
| 431 |
+
# ---------------------------------------------------------------------------
|
| 432 |
+
# Tags
|
| 433 |
+
# ---------------------------------------------------------------------------
|
| 434 |
+
|
| 435 |
+
def _tag_resource(body):
|
| 436 |
+
arn = body.get("resourceArn", "")
|
| 437 |
+
tags = body.get("tags", {})
|
| 438 |
+
_tags.setdefault(arn, {}).update(tags)
|
| 439 |
+
return _json(200, {})
|
| 440 |
+
|
| 441 |
+
|
| 442 |
+
def _untag_resource(arn, query_params):
|
| 443 |
+
tag_keys = query_params.get("tagKeys", [])
|
| 444 |
+
if isinstance(tag_keys, str):
|
| 445 |
+
tag_keys = [tag_keys]
|
| 446 |
+
existing = _tags.get(arn, {})
|
| 447 |
+
for k in tag_keys:
|
| 448 |
+
existing.pop(k, None)
|
| 449 |
+
return _json(200, {})
|
| 450 |
+
|
| 451 |
+
|
| 452 |
+
def _list_tags_for_resource(arn):
|
| 453 |
+
tags = _tags.get(arn, {})
|
| 454 |
+
return _json(200, {"tags": tags})
|
| 455 |
+
|
| 456 |
+
|
| 457 |
+
# ---------------------------------------------------------------------------
|
| 458 |
+
# Request router
|
| 459 |
+
# ---------------------------------------------------------------------------
|
| 460 |
+
|
| 461 |
+
# Path patterns for routing
|
| 462 |
+
_PATH_RE = re.compile(r"^/v1/apis(?:/([^/]+))?(?:/([^/]+))?(?:/([^/]+))?(?:/([^/]+))?(?:/([^/]+))?")
|
| 463 |
+
# /v1/apis -> groups: (None, None, None, None, None)
|
| 464 |
+
# /v1/apis/{apiId} -> groups: (apiId, None, None, None, None)
|
| 465 |
+
# /v1/apis/{apiId}/apikeys -> groups: (apiId, "apikeys", None, None, None)
|
| 466 |
+
# /v1/apis/{apiId}/apikeys/{id} -> groups: (apiId, "apikeys", id, None, None)
|
| 467 |
+
# /v1/apis/{apiId}/datasources -> groups: (apiId, "datasources", None, None, None)
|
| 468 |
+
# /v1/apis/{apiId}/datasources/{n} -> groups: (apiId, "datasources", name, None, None)
|
| 469 |
+
# /v1/apis/{apiId}/types -> groups: (apiId, "types", None, None, None)
|
| 470 |
+
# /v1/apis/{apiId}/types/{t}/resolvers -> (apiId, "types", t, "resolvers", None)
|
| 471 |
+
# /v1/apis/{apiId}/types/{t}/resolvers/{field} -> (apiId, "types", t, "resolvers", field)
|
| 472 |
+
|
| 473 |
+
|
| 474 |
+
async def handle_request(method, path, headers, body, query_params):
|
| 475 |
+
"""Main entry point — route AppSync REST requests."""
|
| 476 |
+
|
| 477 |
+
# Tags endpoint: /v1/tags/{resourceArn}
|
| 478 |
+
if path.startswith("/v1/tags/"):
|
| 479 |
+
from urllib.parse import unquote
|
| 480 |
+
arn = unquote(path[len("/v1/tags/"):])
|
| 481 |
+
if method == "POST":
|
| 482 |
+
data = json.loads(body) if body else {}
|
| 483 |
+
data["resourceArn"] = arn
|
| 484 |
+
return _tag_resource(data)
|
| 485 |
+
elif method == "DELETE":
|
| 486 |
+
return _untag_resource(arn, query_params)
|
| 487 |
+
else: # GET
|
| 488 |
+
return _list_tags_for_resource(arn)
|
| 489 |
+
|
| 490 |
+
# GraphQL data plane: POST /graphql or POST /v1/apis/{apiId}/graphql
|
| 491 |
+
if path == "/graphql" and method == "POST":
|
| 492 |
+
api_key = headers.get("x-api-key", "")
|
| 493 |
+
api_id = _resolve_api_by_key(api_key)
|
| 494 |
+
if not api_id:
|
| 495 |
+
return error_response_json("UnauthorizedException", "Valid API key required", 401)
|
| 496 |
+
data = json.loads(body) if body else {}
|
| 497 |
+
return _execute_graphql(api_id, data)
|
| 498 |
+
|
| 499 |
+
if path.startswith("/v1/apis/") and path.endswith("/graphql") and method == "POST":
|
| 500 |
+
parts = path.split("/")
|
| 501 |
+
if len(parts) >= 5:
|
| 502 |
+
api_id = parts[3]
|
| 503 |
+
data = json.loads(body) if body else {}
|
| 504 |
+
return _execute_graphql(api_id, data)
|
| 505 |
+
|
| 506 |
+
m = _PATH_RE.match(path)
|
| 507 |
+
if not m:
|
| 508 |
+
return error_response_json("NotFoundException", f"Unknown path: {path}", 404)
|
| 509 |
+
|
| 510 |
+
api_id, sub1, sub2, sub3, sub4 = m.groups()
|
| 511 |
+
|
| 512 |
+
data = {}
|
| 513 |
+
if body:
|
| 514 |
+
try:
|
| 515 |
+
data = json.loads(body)
|
| 516 |
+
except (json.JSONDecodeError, UnicodeDecodeError):
|
| 517 |
+
data = {}
|
| 518 |
+
|
| 519 |
+
# POST /v1/apis — CreateGraphQLApi
|
| 520 |
+
if api_id is None and sub1 is None:
|
| 521 |
+
if method == "POST":
|
| 522 |
+
return _create_graphql_api(data)
|
| 523 |
+
elif method == "GET":
|
| 524 |
+
return _list_graphql_apis(query_params)
|
| 525 |
+
|
| 526 |
+
# /v1/apis/{apiId}
|
| 527 |
+
if api_id and sub1 is None:
|
| 528 |
+
if method == "GET":
|
| 529 |
+
return _get_graphql_api(api_id)
|
| 530 |
+
elif method == "POST":
|
| 531 |
+
return _update_graphql_api(api_id, data)
|
| 532 |
+
elif method == "DELETE":
|
| 533 |
+
return _delete_graphql_api(api_id)
|
| 534 |
+
|
| 535 |
+
# /v1/apis/{apiId}/apikeys
|
| 536 |
+
if sub1 == "apikeys":
|
| 537 |
+
if sub2 is None:
|
| 538 |
+
if method == "POST":
|
| 539 |
+
return _create_api_key(api_id, data)
|
| 540 |
+
elif method == "GET":
|
| 541 |
+
return _list_api_keys(api_id)
|
| 542 |
+
else:
|
| 543 |
+
# /v1/apis/{apiId}/apikeys/{keyId}
|
| 544 |
+
if method == "DELETE":
|
| 545 |
+
return _delete_api_key(api_id, sub2)
|
| 546 |
+
|
| 547 |
+
# /v1/apis/{apiId}/datasources
|
| 548 |
+
if sub1 == "datasources":
|
| 549 |
+
if sub2 is None:
|
| 550 |
+
if method == "POST":
|
| 551 |
+
return _create_data_source(api_id, data)
|
| 552 |
+
elif method == "GET":
|
| 553 |
+
return _list_data_sources(api_id)
|
| 554 |
+
else:
|
| 555 |
+
# /v1/apis/{apiId}/datasources/{name}
|
| 556 |
+
if method == "GET":
|
| 557 |
+
return _get_data_source(api_id, sub2)
|
| 558 |
+
elif method == "DELETE":
|
| 559 |
+
return _delete_data_source(api_id, sub2)
|
| 560 |
+
|
| 561 |
+
# /v1/apis/{apiId}/types
|
| 562 |
+
if sub1 == "types":
|
| 563 |
+
if sub2 is None:
|
| 564 |
+
if method == "POST":
|
| 565 |
+
return _create_type(api_id, data)
|
| 566 |
+
elif method == "GET":
|
| 567 |
+
return _list_types(api_id, query_params)
|
| 568 |
+
elif sub3 == "resolvers":
|
| 569 |
+
# /v1/apis/{apiId}/types/{typeName}/resolvers
|
| 570 |
+
type_name = sub2
|
| 571 |
+
if sub4 is None:
|
| 572 |
+
if method == "POST":
|
| 573 |
+
return _create_resolver(api_id, type_name, data)
|
| 574 |
+
elif method == "GET":
|
| 575 |
+
return _list_resolvers(api_id, type_name)
|
| 576 |
+
else:
|
| 577 |
+
# /v1/apis/{apiId}/types/{typeName}/resolvers/{fieldName}
|
| 578 |
+
field_name = sub4
|
| 579 |
+
if method == "GET":
|
| 580 |
+
return _get_resolver(api_id, type_name, field_name)
|
| 581 |
+
elif method == "DELETE":
|
| 582 |
+
return _delete_resolver(api_id, type_name, field_name)
|
| 583 |
+
else:
|
| 584 |
+
# /v1/apis/{apiId}/types/{typeName} — GetType
|
| 585 |
+
if sub3 is None and method == "GET":
|
| 586 |
+
return _get_type(api_id, sub2, query_params)
|
| 587 |
+
|
| 588 |
+
return error_response_json("BadRequestException", f"Unsupported route: {method} {path}")
|
| 589 |
+
|
| 590 |
+
|
| 591 |
+
# ---------------------------------------------------------------------------
|
| 592 |
+
# State management
|
| 593 |
+
# ---------------------------------------------------------------------------
|
| 594 |
+
|
| 595 |
+
def reset():
|
| 596 |
+
"""Clear all in-memory state."""
|
| 597 |
+
_apis.clear()
|
| 598 |
+
_api_keys.clear()
|
| 599 |
+
_data_sources.clear()
|
| 600 |
+
_resolvers.clear()
|
| 601 |
+
_types.clear()
|
| 602 |
+
_tags.clear()
|
| 603 |
+
|
| 604 |
+
|
| 605 |
+
def get_state():
|
| 606 |
+
"""Return a deep copy of all state for persistence."""
|
| 607 |
+
return copy.deepcopy({
|
| 608 |
+
"apis": _apis,
|
| 609 |
+
"api_keys": _api_keys,
|
| 610 |
+
"data_sources": _data_sources,
|
| 611 |
+
"resolvers": _resolvers,
|
| 612 |
+
"types": _types,
|
| 613 |
+
"tags": _tags,
|
| 614 |
+
})
|
| 615 |
+
|
| 616 |
+
|
| 617 |
+
def restore_state(data):
|
| 618 |
+
"""Restore state from persisted data."""
|
| 619 |
+
_apis.update(data.get("apis", {}))
|
| 620 |
+
_api_keys.update(data.get("api_keys", {}))
|
| 621 |
+
_data_sources.update(data.get("data_sources", {}))
|
| 622 |
+
_resolvers.update(data.get("resolvers", {}))
|
| 623 |
+
_types.update(data.get("types", {}))
|
| 624 |
+
_tags.update(data.get("tags", {}))
|
| 625 |
+
|
| 626 |
+
|
| 627 |
+
# ---------------------------------------------------------------------------
|
| 628 |
+
# GraphQL Data Plane — parse and execute queries against DynamoDB
|
| 629 |
+
# ---------------------------------------------------------------------------
|
| 630 |
+
|
| 631 |
+
import re as _re
|
| 632 |
+
|
| 633 |
+
# Simple GraphQL parser — handles queries/mutations that Amplify generates
|
| 634 |
+
_GQL_OP_RE = _re.compile(
|
| 635 |
+
r'(?:query|mutation|subscription)\s+(\w+)?\s*(?:\(([^)]*)\))?\s*\{(.*)\}',
|
| 636 |
+
_re.DOTALL,
|
| 637 |
+
)
|
| 638 |
+
_GQL_FIELD_RE = _re.compile(r'(\w+)\s*(?:\(([^)]*)\))?\s*(?:\{([^}]*)\})?')
|
| 639 |
+
|
| 640 |
+
|
| 641 |
+
def _resolve_api_by_key(api_key_value):
|
| 642 |
+
"""Find the API ID that owns this API key."""
|
| 643 |
+
for api_id, keys in _api_keys.items():
|
| 644 |
+
for kid, key in keys.items():
|
| 645 |
+
if kid == api_key_value or key.get("id") == api_key_value:
|
| 646 |
+
return api_id
|
| 647 |
+
# Fallback: if only one API exists, use it
|
| 648 |
+
if len(_apis) == 1:
|
| 649 |
+
return next(iter(_apis))
|
| 650 |
+
return None
|
| 651 |
+
|
| 652 |
+
|
| 653 |
+
def _execute_graphql(api_id, data):
|
| 654 |
+
"""Execute a GraphQL query/mutation against the configured resolvers."""
|
| 655 |
+
query = data.get("query", "")
|
| 656 |
+
variables = data.get("variables", {})
|
| 657 |
+
operation_name = data.get("operationName")
|
| 658 |
+
|
| 659 |
+
if not query.strip():
|
| 660 |
+
return _json(400, {"errors": [{"message": "Query is required"}]})
|
| 661 |
+
|
| 662 |
+
if api_id not in _apis:
|
| 663 |
+
return _json(404, {"errors": [{"message": f"API {api_id} not found"}]})
|
| 664 |
+
|
| 665 |
+
# Parse the top-level operation
|
| 666 |
+
# Strip __typename fields — Amplify adds these everywhere
|
| 667 |
+
query_clean = _re.sub(r'__typename\s*', '', query)
|
| 668 |
+
|
| 669 |
+
m = _GQL_OP_RE.search(query_clean)
|
| 670 |
+
if not m:
|
| 671 |
+
# Try bare field query: { getUser(id: "1") { name } }
|
| 672 |
+
inner = query_clean.strip().strip("{}")
|
| 673 |
+
fields = _parse_fields(inner, variables)
|
| 674 |
+
else:
|
| 675 |
+
op_name, op_args, body = m.groups()
|
| 676 |
+
fields = _parse_fields(body, variables)
|
| 677 |
+
|
| 678 |
+
# Determine operation type
|
| 679 |
+
is_mutation = query_clean.strip().startswith("mutation")
|
| 680 |
+
|
| 681 |
+
results = {}
|
| 682 |
+
errors = []
|
| 683 |
+
for field_name, args, sub_fields in fields:
|
| 684 |
+
resolver = _find_resolver(api_id, "Mutation" if is_mutation else "Query", field_name)
|
| 685 |
+
if resolver:
|
| 686 |
+
try:
|
| 687 |
+
result = _resolve_field(api_id, resolver, args, sub_fields, variables)
|
| 688 |
+
results[field_name] = result
|
| 689 |
+
except Exception as e:
|
| 690 |
+
errors.append({"message": str(e), "path": [field_name]})
|
| 691 |
+
results[field_name] = None
|
| 692 |
+
else:
|
| 693 |
+
# No resolver — return mock empty result
|
| 694 |
+
results[field_name] = None
|
| 695 |
+
|
| 696 |
+
response = {"data": results}
|
| 697 |
+
if errors:
|
| 698 |
+
response["errors"] = errors
|
| 699 |
+
return _json(200, response)
|
| 700 |
+
|
| 701 |
+
|
| 702 |
+
def _parse_fields(body, variables):
|
| 703 |
+
"""Parse GraphQL field selections into (name, args_dict, sub_fields) tuples."""
|
| 704 |
+
fields = []
|
| 705 |
+
for m in _GQL_FIELD_RE.finditer(body.strip()):
|
| 706 |
+
name = m.group(1)
|
| 707 |
+
args_str = m.group(2) or ""
|
| 708 |
+
sub = m.group(3) or ""
|
| 709 |
+
args = _parse_args(args_str, variables)
|
| 710 |
+
sub_fields = [s.strip() for s in sub.split() if s.strip() and s.strip() != "__typename"]
|
| 711 |
+
fields.append((name, args, sub_fields))
|
| 712 |
+
return fields
|
| 713 |
+
|
| 714 |
+
|
| 715 |
+
def _parse_args(args_str, variables):
|
| 716 |
+
"""Parse GraphQL arguments like (id: "1") or (id: $id) into a dict."""
|
| 717 |
+
args = {}
|
| 718 |
+
if not args_str.strip():
|
| 719 |
+
return args
|
| 720 |
+
# Match key: value pairs
|
| 721 |
+
for pair in _re.finditer(r'(\w+)\s*:\s*("(?:[^"\\]|\\.)*"|\$\w+|\d+(?:\.\d+)?|true|false|null|\{[^}]*\}|\[[^\]]*\])', args_str):
|
| 722 |
+
key = pair.group(1)
|
| 723 |
+
val = pair.group(2)
|
| 724 |
+
if val.startswith("$"):
|
| 725 |
+
val = variables.get(val[1:], val)
|
| 726 |
+
elif val.startswith('"') and val.endswith('"'):
|
| 727 |
+
val = val[1:-1]
|
| 728 |
+
elif val == "true":
|
| 729 |
+
val = True
|
| 730 |
+
elif val == "false":
|
| 731 |
+
val = False
|
| 732 |
+
elif val == "null":
|
| 733 |
+
val = None
|
| 734 |
+
elif val.startswith("{") and val.endswith("}"):
|
| 735 |
+
val = _parse_args(val[1:-1], variables)
|
| 736 |
+
elif val.startswith("[") and val.endswith("]"):
|
| 737 |
+
val = val # Keep as string for now
|
| 738 |
+
elif val.replace(".", "").isdigit():
|
| 739 |
+
val = float(val) if "." in val else int(val)
|
| 740 |
+
args[key] = val
|
| 741 |
+
return args
|
| 742 |
+
|
| 743 |
+
|
| 744 |
+
def _find_resolver(api_id, type_name, field_name):
|
| 745 |
+
"""Find a resolver for Query.fieldName or Mutation.fieldName."""
|
| 746 |
+
resolvers = _resolvers.get(api_id, {})
|
| 747 |
+
# Try exact match
|
| 748 |
+
if type_name in resolvers and field_name in resolvers[type_name]:
|
| 749 |
+
return resolvers[type_name][field_name]
|
| 750 |
+
# Try generic match (some setups use "Query" or "Mutation" type)
|
| 751 |
+
for tn in resolvers:
|
| 752 |
+
if field_name in resolvers[tn]:
|
| 753 |
+
return resolvers[tn][field_name]
|
| 754 |
+
return None
|
| 755 |
+
|
| 756 |
+
|
| 757 |
+
def _resolve_field(api_id, resolver, args, sub_fields, variables):
|
| 758 |
+
"""Execute a resolver against its data source (DynamoDB)."""
|
| 759 |
+
ds_name = resolver.get("dataSourceName", "")
|
| 760 |
+
data_source = _data_sources.get(api_id, {}).get(ds_name)
|
| 761 |
+
|
| 762 |
+
if not data_source:
|
| 763 |
+
# No data source — return args as mock
|
| 764 |
+
return args or {}
|
| 765 |
+
|
| 766 |
+
ds_type = data_source.get("type", "NONE")
|
| 767 |
+
|
| 768 |
+
if ds_type == "AMAZON_DYNAMODB":
|
| 769 |
+
return _resolve_dynamodb(data_source, resolver, args, sub_fields)
|
| 770 |
+
elif ds_type == "AWS_LAMBDA":
|
| 771 |
+
return _resolve_lambda(data_source, args)
|
| 772 |
+
else:
|
| 773 |
+
return args or {}
|
| 774 |
+
|
| 775 |
+
|
| 776 |
+
def _resolve_dynamodb(data_source, resolver, args, sub_fields):
|
| 777 |
+
"""Execute a DynamoDB resolver — auto-detect operation from field name and args."""
|
| 778 |
+
import ministack.services.dynamodb as _ddb
|
| 779 |
+
|
| 780 |
+
config = data_source.get("dynamodbConfig", {})
|
| 781 |
+
table_name = config.get("tableName", "")
|
| 782 |
+
if not table_name:
|
| 783 |
+
return None
|
| 784 |
+
|
| 785 |
+
table = _ddb._tables.get(table_name)
|
| 786 |
+
if not table:
|
| 787 |
+
return None
|
| 788 |
+
|
| 789 |
+
field_name = resolver.get("fieldName", "")
|
| 790 |
+
|
| 791 |
+
# Auto-detect: get* → GetItem, list* → Scan, create*/update*/put* → PutItem, delete* ��� DeleteItem
|
| 792 |
+
if field_name.startswith("get") or "id" in args:
|
| 793 |
+
return _ddb_get_item(table, table_name, args, sub_fields)
|
| 794 |
+
elif field_name.startswith("list"):
|
| 795 |
+
return _ddb_scan(table, table_name, args, sub_fields)
|
| 796 |
+
elif field_name.startswith("create") or field_name.startswith("put"):
|
| 797 |
+
return _ddb_put_item(table, table_name, args)
|
| 798 |
+
elif field_name.startswith("update"):
|
| 799 |
+
return _ddb_update_item(table, table_name, args)
|
| 800 |
+
elif field_name.startswith("delete"):
|
| 801 |
+
return _ddb_delete_item(table, table_name, args)
|
| 802 |
+
else:
|
| 803 |
+
# Default: try scan
|
| 804 |
+
return _ddb_scan(table, table_name, args, sub_fields)
|
| 805 |
+
|
| 806 |
+
|
| 807 |
+
def _ddb_get_item(table, table_name, args, sub_fields):
|
| 808 |
+
"""Get a single item by primary key."""
|
| 809 |
+
pk_name = table["pk_name"]
|
| 810 |
+
sk_name = table.get("sk_name")
|
| 811 |
+
|
| 812 |
+
pk_val = args.get("id") or args.get(pk_name) or next(iter(args.values()), None)
|
| 813 |
+
if pk_val is None:
|
| 814 |
+
return None
|
| 815 |
+
|
| 816 |
+
items = table["items"]
|
| 817 |
+
pk_bucket = items.get(str(pk_val), {})
|
| 818 |
+
|
| 819 |
+
if sk_name:
|
| 820 |
+
sk_val = args.get(sk_name, "")
|
| 821 |
+
item = pk_bucket.get(str(sk_val))
|
| 822 |
+
else:
|
| 823 |
+
# No sort key — get the single item
|
| 824 |
+
item = next(iter(pk_bucket.values()), None) if pk_bucket else None
|
| 825 |
+
|
| 826 |
+
if not item:
|
| 827 |
+
return None
|
| 828 |
+
|
| 829 |
+
return _strip_ddb_types(item, sub_fields)
|
| 830 |
+
|
| 831 |
+
|
| 832 |
+
def _ddb_scan(table, table_name, args, sub_fields):
|
| 833 |
+
"""Scan/list items, optionally with filters and pagination."""
|
| 834 |
+
items = []
|
| 835 |
+
limit = args.get("limit", 100)
|
| 836 |
+
next_token = args.get("nextToken")
|
| 837 |
+
|
| 838 |
+
count = 0
|
| 839 |
+
for pk in sorted(table["items"].keys()):
|
| 840 |
+
for sk in sorted(table["items"][pk].keys()):
|
| 841 |
+
if count >= limit:
|
| 842 |
+
break
|
| 843 |
+
items.append(_strip_ddb_types(table["items"][pk][sk], sub_fields))
|
| 844 |
+
count += 1
|
| 845 |
+
|
| 846 |
+
# Filter if filter arg provided
|
| 847 |
+
filter_arg = args.get("filter", {})
|
| 848 |
+
if filter_arg and isinstance(filter_arg, dict):
|
| 849 |
+
filtered = []
|
| 850 |
+
for item in items:
|
| 851 |
+
match = True
|
| 852 |
+
for fk, fv in filter_arg.items():
|
| 853 |
+
if isinstance(fv, dict) and "eq" in fv:
|
| 854 |
+
if item.get(fk) != fv["eq"]:
|
| 855 |
+
match = False
|
| 856 |
+
elif item.get(fk) != fv:
|
| 857 |
+
match = False
|
| 858 |
+
if match:
|
| 859 |
+
filtered.append(item)
|
| 860 |
+
items = filtered
|
| 861 |
+
|
| 862 |
+
return {"items": items, "nextToken": None}
|
| 863 |
+
|
| 864 |
+
|
| 865 |
+
def _ddb_put_item(table, table_name, args):
|
| 866 |
+
"""Create/put an item."""
|
| 867 |
+
import ministack.services.dynamodb as _ddb
|
| 868 |
+
from collections import defaultdict
|
| 869 |
+
|
| 870 |
+
input_data = args.get("input", args)
|
| 871 |
+
pk_name = table["pk_name"]
|
| 872 |
+
sk_name = table.get("sk_name")
|
| 873 |
+
|
| 874 |
+
# Build DynamoDB-typed item
|
| 875 |
+
ddb_item = {}
|
| 876 |
+
for k, v in input_data.items():
|
| 877 |
+
if isinstance(v, str):
|
| 878 |
+
ddb_item[k] = {"S": v}
|
| 879 |
+
elif isinstance(v, (int, float)):
|
| 880 |
+
ddb_item[k] = {"N": str(v)}
|
| 881 |
+
elif isinstance(v, bool):
|
| 882 |
+
ddb_item[k] = {"BOOL": v}
|
| 883 |
+
elif isinstance(v, list):
|
| 884 |
+
ddb_item[k] = {"L": [{"S": str(i)} for i in v]}
|
| 885 |
+
elif v is None:
|
| 886 |
+
ddb_item[k] = {"NULL": True}
|
| 887 |
+
else:
|
| 888 |
+
ddb_item[k] = {"S": str(v)}
|
| 889 |
+
|
| 890 |
+
# Auto-generate ID if not provided
|
| 891 |
+
if pk_name not in ddb_item and "id" not in ddb_item:
|
| 892 |
+
ddb_item["id" if pk_name == "id" else pk_name] = {"S": new_uuid()}
|
| 893 |
+
|
| 894 |
+
pk_val = _ddb._extract_key_val(ddb_item.get(pk_name, {}))
|
| 895 |
+
sk_val = _ddb._extract_key_val(ddb_item.get(sk_name, {})) if sk_name else ""
|
| 896 |
+
|
| 897 |
+
if not isinstance(table["items"], defaultdict):
|
| 898 |
+
table["items"] = defaultdict(dict, table["items"])
|
| 899 |
+
|
| 900 |
+
table["items"][pk_val][sk_val] = ddb_item
|
| 901 |
+
table["ItemCount"] = sum(len(v) for v in table["items"].values())
|
| 902 |
+
|
| 903 |
+
return _strip_ddb_types(ddb_item, [])
|
| 904 |
+
|
| 905 |
+
|
| 906 |
+
def _ddb_update_item(table, table_name, args):
|
| 907 |
+
"""Update an existing item — merge input fields."""
|
| 908 |
+
input_data = args.get("input", args)
|
| 909 |
+
pk_name = table["pk_name"]
|
| 910 |
+
pk_val = str(input_data.get("id") or input_data.get(pk_name, ""))
|
| 911 |
+
|
| 912 |
+
if pk_val in table["items"]:
|
| 913 |
+
sk = next(iter(table["items"][pk_val]), "")
|
| 914 |
+
existing = table["items"][pk_val].get(sk, {})
|
| 915 |
+
for k, v in input_data.items():
|
| 916 |
+
if isinstance(v, str):
|
| 917 |
+
existing[k] = {"S": v}
|
| 918 |
+
elif isinstance(v, (int, float)):
|
| 919 |
+
existing[k] = {"N": str(v)}
|
| 920 |
+
elif isinstance(v, bool):
|
| 921 |
+
existing[k] = {"BOOL": v}
|
| 922 |
+
return _strip_ddb_types(existing, [])
|
| 923 |
+
return None
|
| 924 |
+
|
| 925 |
+
|
| 926 |
+
def _ddb_delete_item(table, table_name, args):
|
| 927 |
+
"""Delete an item and return it."""
|
| 928 |
+
input_data = args.get("input", args)
|
| 929 |
+
pk_name = table["pk_name"]
|
| 930 |
+
pk_val = str(input_data.get("id") or input_data.get(pk_name, ""))
|
| 931 |
+
|
| 932 |
+
if pk_val in table["items"]:
|
| 933 |
+
sk = next(iter(table["items"][pk_val]), "")
|
| 934 |
+
item = table["items"][pk_val].pop(sk, None)
|
| 935 |
+
if not table["items"][pk_val]:
|
| 936 |
+
table["items"].pop(pk_val, None)
|
| 937 |
+
if item:
|
| 938 |
+
return _strip_ddb_types(item, [])
|
| 939 |
+
return None
|
| 940 |
+
|
| 941 |
+
|
| 942 |
+
def _strip_ddb_types(item, sub_fields):
|
| 943 |
+
"""Convert DynamoDB typed attributes to plain values for GraphQL response."""
|
| 944 |
+
if not item:
|
| 945 |
+
return None
|
| 946 |
+
result = {}
|
| 947 |
+
for k, v in item.items():
|
| 948 |
+
if isinstance(v, dict):
|
| 949 |
+
if "S" in v:
|
| 950 |
+
result[k] = v["S"]
|
| 951 |
+
elif "N" in v:
|
| 952 |
+
val = v["N"]
|
| 953 |
+
result[k] = int(val) if "." not in val else float(val)
|
| 954 |
+
elif "BOOL" in v:
|
| 955 |
+
result[k] = v["BOOL"]
|
| 956 |
+
elif "NULL" in v:
|
| 957 |
+
result[k] = None
|
| 958 |
+
elif "L" in v:
|
| 959 |
+
result[k] = [_strip_ddb_types(i, []) if isinstance(i, dict) and not any(t in i for t in ("S", "N", "BOOL")) else (i.get("S") or i.get("N") or i.get("BOOL")) for i in v["L"]]
|
| 960 |
+
elif "M" in v:
|
| 961 |
+
result[k] = _strip_ddb_types(v["M"], [])
|
| 962 |
+
else:
|
| 963 |
+
result[k] = v
|
| 964 |
+
else:
|
| 965 |
+
result[k] = v
|
| 966 |
+
if sub_fields:
|
| 967 |
+
result = {k: v for k, v in result.items() if k in sub_fields or k == "id" or k == "__typename"}
|
| 968 |
+
return result
|
| 969 |
+
|
| 970 |
+
|
| 971 |
+
def _resolve_lambda(data_source, args):
|
| 972 |
+
"""Execute a Lambda resolver."""
|
| 973 |
+
config = data_source.get("lambdaConfig", {})
|
| 974 |
+
func_arn = config.get("lambdaFunctionArn", "")
|
| 975 |
+
if not func_arn:
|
| 976 |
+
return args
|
| 977 |
+
|
| 978 |
+
import ministack.services.lambda_svc as _lambda_svc
|
| 979 |
+
func_name = func_arn.rsplit(":", 1)[-1]
|
| 980 |
+
func = _lambda_svc._functions.get(func_name)
|
| 981 |
+
if not func:
|
| 982 |
+
return args
|
| 983 |
+
|
| 984 |
+
result = _lambda_svc._execute_function(func, args)
|
| 985 |
+
body = result.get("body")
|
| 986 |
+
if isinstance(body, dict):
|
| 987 |
+
return body
|
| 988 |
+
if isinstance(body, (str, bytes)):
|
| 989 |
+
try:
|
| 990 |
+
return json.loads(body)
|
| 991 |
+
except Exception:
|
| 992 |
+
return {"result": body}
|
| 993 |
+
return args
|
| 994 |
+
|
| 995 |
+
# Load persisted state (must be after restore_state is defined)
|
| 996 |
+
_load_persisted()
|
| 997 |
+
|
| 998 |
+
def get_state_summary() -> dict:
|
| 999 |
+
return {
|
| 1000 |
+
"apis": {"count": len(_apis), "ids": list(_apis.keys())},
|
| 1001 |
+
}
|
aws_infra/ministack/services/athena.py
ADDED
|
@@ -0,0 +1,938 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Athena Service Emulator.
|
| 3 |
+
JSON-based API via X-Amz-Target (AmazonAthena).
|
| 4 |
+
Uses DuckDB to actually execute SQL queries against S3 data (CSV/JSON/Parquet).
|
| 5 |
+
Supports: StartQueryExecution, GetQueryExecution, GetQueryResults,
|
| 6 |
+
StopQueryExecution, ListQueryExecutions,
|
| 7 |
+
CreateWorkGroup, DeleteWorkGroup, GetWorkGroup, ListWorkGroups, UpdateWorkGroup,
|
| 8 |
+
CreateNamedQuery, DeleteNamedQuery, GetNamedQuery, ListNamedQueries,
|
| 9 |
+
BatchGetNamedQuery, BatchGetQueryExecution,
|
| 10 |
+
CreateDataCatalog, GetDataCatalog, ListDataCatalogs, DeleteDataCatalog, UpdateDataCatalog,
|
| 11 |
+
CreatePreparedStatement, GetPreparedStatement, DeletePreparedStatement, ListPreparedStatements,
|
| 12 |
+
GetTableMetadata, ListTableMetadata,
|
| 13 |
+
TagResource, UntagResource, ListTagsForResource.
|
| 14 |
+
"""
|
| 15 |
+
|
| 16 |
+
import copy
|
| 17 |
+
import json
|
| 18 |
+
import logging
|
| 19 |
+
import os
|
| 20 |
+
import re
|
| 21 |
+
import threading
|
| 22 |
+
import time
|
| 23 |
+
|
| 24 |
+
from ministack.core.persistence import PERSIST_STATE, load_state
|
| 25 |
+
from ministack.core.responses import AccountScopedDict, get_account_id, error_response_json, json_response, new_uuid, get_region
|
| 26 |
+
|
| 27 |
+
logger = logging.getLogger("athena")
|
| 28 |
+
|
| 29 |
+
REGION = os.environ.get("MINISTACK_REGION", "us-east-1")
|
| 30 |
+
S3_DATA_DIR = os.environ.get("S3_DATA_DIR", "/tmp/ministack-data/s3")
|
| 31 |
+
ATHENA_ENGINE = os.environ.get("ATHENA_ENGINE", "auto") # "auto" | "duckdb" | "mock"
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def get_athena_engine():
|
| 35 |
+
"""Resolve the effective SQL engine. Reads module-level ATHENA_ENGINE which
|
| 36 |
+
can be overridden at runtime via POST /_ministack/config."""
|
| 37 |
+
engine = ATHENA_ENGINE
|
| 38 |
+
if engine == "auto":
|
| 39 |
+
engine = "duckdb" if _duckdb_available else "mock"
|
| 40 |
+
logger.debug("Athena engine: %s (ATHENA_ENGINE=%s)", engine, ATHENA_ENGINE)
|
| 41 |
+
return engine
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
_executions = AccountScopedDict()
|
| 45 |
+
# Per-account workgroups / data catalogs. AWS's "primary" workgroup and
|
| 46 |
+
# "AwsDataCatalog" exist in every account — we lazily seed them per-tenant
|
| 47 |
+
# on first access so two accounts never share the same workgroup or catalog
|
| 48 |
+
# state (creation times, configs, etc.).
|
| 49 |
+
_workgroups = AccountScopedDict()
|
| 50 |
+
_named_queries = AccountScopedDict()
|
| 51 |
+
_data_catalogs = AccountScopedDict()
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def _ensure_default_workgroup():
|
| 55 |
+
if "primary" not in _workgroups:
|
| 56 |
+
_workgroups["primary"] = {
|
| 57 |
+
"Name": "primary",
|
| 58 |
+
"State": "ENABLED",
|
| 59 |
+
"Description": "Primary workgroup",
|
| 60 |
+
"CreationTime": int(time.time()),
|
| 61 |
+
"Configuration": {
|
| 62 |
+
"ResultConfiguration": {"OutputLocation": "s3://athena-results/"}
|
| 63 |
+
},
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
def _ensure_default_data_catalog():
|
| 68 |
+
if "AwsDataCatalog" not in _data_catalogs:
|
| 69 |
+
_data_catalogs["AwsDataCatalog"] = {
|
| 70 |
+
"Name": "AwsDataCatalog",
|
| 71 |
+
"Description": "AWS Glue Data Catalog",
|
| 72 |
+
"Type": "GLUE",
|
| 73 |
+
"Parameters": {},
|
| 74 |
+
}
|
| 75 |
+
_prepared_statements = AccountScopedDict() # "workgroup/name" -> statement dict
|
| 76 |
+
_tags = AccountScopedDict() # arn -> {key: value, ...}
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
def get_state():
|
| 80 |
+
return copy.deepcopy(
|
| 81 |
+
{
|
| 82 |
+
"_executions": _executions,
|
| 83 |
+
"_workgroups": _workgroups,
|
| 84 |
+
"_named_queries": _named_queries,
|
| 85 |
+
"_data_catalogs": _data_catalogs,
|
| 86 |
+
"_prepared_statements": _prepared_statements,
|
| 87 |
+
"_tags": _tags,
|
| 88 |
+
}
|
| 89 |
+
)
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
def restore_state(data):
|
| 93 |
+
# AccountScopedDicts are mutated in-place — no module-level reassignment.
|
| 94 |
+
_executions.clear()
|
| 95 |
+
_executions.update(data.get("_executions", {}))
|
| 96 |
+
_workgroups.clear()
|
| 97 |
+
_workgroups.update(data.get("_workgroups", {}))
|
| 98 |
+
_named_queries.clear()
|
| 99 |
+
_named_queries.update(data.get("_named_queries", {}))
|
| 100 |
+
_data_catalogs.clear()
|
| 101 |
+
_data_catalogs.update(data.get("_data_catalogs", {}))
|
| 102 |
+
_prepared_statements.clear()
|
| 103 |
+
_prepared_statements.update(data.get("_prepared_statements", {}))
|
| 104 |
+
_tags.clear()
|
| 105 |
+
_tags.update(data.get("_tags", {}))
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
_restored = load_state("athena")
|
| 109 |
+
if _restored:
|
| 110 |
+
restore_state(_restored)
|
| 111 |
+
|
| 112 |
+
try:
|
| 113 |
+
import duckdb
|
| 114 |
+
|
| 115 |
+
_duckdb_available = True
|
| 116 |
+
except ImportError:
|
| 117 |
+
_duckdb_available = False
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
|
| 121 |
+
_DUCKDB_TYPE_MAP = {
|
| 122 |
+
"BOOLEAN": "boolean",
|
| 123 |
+
"TINYINT": "tinyint",
|
| 124 |
+
"SMALLINT": "smallint",
|
| 125 |
+
"INTEGER": "integer",
|
| 126 |
+
"INT": "integer",
|
| 127 |
+
"BIGINT": "bigint",
|
| 128 |
+
"HUGEINT": "bigint",
|
| 129 |
+
"FLOAT": "float",
|
| 130 |
+
"REAL": "float",
|
| 131 |
+
"DOUBLE": "double",
|
| 132 |
+
"DECIMAL": "decimal",
|
| 133 |
+
"VARCHAR": "varchar",
|
| 134 |
+
"BLOB": "varbinary",
|
| 135 |
+
"DATE": "date",
|
| 136 |
+
"TIME": "time",
|
| 137 |
+
"TIMESTAMP": "timestamp",
|
| 138 |
+
"TIMESTAMP WITH TIME ZONE": "timestamp",
|
| 139 |
+
"INTERVAL": "varchar",
|
| 140 |
+
"LIST": "array",
|
| 141 |
+
"STRUCT": "row",
|
| 142 |
+
"MAP": "map",
|
| 143 |
+
}
|
| 144 |
+
|
| 145 |
+
|
| 146 |
+
def _arn_workgroup(name):
|
| 147 |
+
return f"arn:aws:athena:{get_region()}:{get_account_id()}:workgroup/{name}"
|
| 148 |
+
|
| 149 |
+
|
| 150 |
+
def _arn_datacatalog(name):
|
| 151 |
+
return f"arn:aws:athena:{get_region()}:{get_account_id()}:datacatalog/{name}"
|
| 152 |
+
|
| 153 |
+
|
| 154 |
+
async def handle_request(method, path, headers, body, query_params):
|
| 155 |
+
# AWS pre-provisions "primary" workgroup + "AwsDataCatalog" in every
|
| 156 |
+
# account. Seed them lazily per-tenant on first access.
|
| 157 |
+
_ensure_default_workgroup()
|
| 158 |
+
_ensure_default_data_catalog()
|
| 159 |
+
|
| 160 |
+
target = headers.get("x-amz-target", "")
|
| 161 |
+
action = target.split(".")[-1] if "." in target else ""
|
| 162 |
+
|
| 163 |
+
try:
|
| 164 |
+
data = json.loads(body) if body else {}
|
| 165 |
+
except json.JSONDecodeError:
|
| 166 |
+
return error_response_json("SerializationException", "Invalid JSON", 400)
|
| 167 |
+
|
| 168 |
+
handlers = {
|
| 169 |
+
"StartQueryExecution": _start_query_execution,
|
| 170 |
+
"GetQueryExecution": _get_query_execution,
|
| 171 |
+
"GetQueryResults": _get_query_results,
|
| 172 |
+
"StopQueryExecution": _stop_query_execution,
|
| 173 |
+
"ListQueryExecutions": _list_query_executions,
|
| 174 |
+
"CreateWorkGroup": _create_workgroup,
|
| 175 |
+
"DeleteWorkGroup": _delete_workgroup,
|
| 176 |
+
"GetWorkGroup": _get_workgroup,
|
| 177 |
+
"ListWorkGroups": _list_workgroups,
|
| 178 |
+
"UpdateWorkGroup": _update_workgroup,
|
| 179 |
+
"CreateNamedQuery": _create_named_query,
|
| 180 |
+
"DeleteNamedQuery": _delete_named_query,
|
| 181 |
+
"GetNamedQuery": _get_named_query,
|
| 182 |
+
"ListNamedQueries": _list_named_queries,
|
| 183 |
+
"BatchGetNamedQuery": _batch_get_named_query,
|
| 184 |
+
"BatchGetQueryExecution": _batch_get_query_execution,
|
| 185 |
+
# Data Catalogs
|
| 186 |
+
"CreateDataCatalog": _create_data_catalog,
|
| 187 |
+
"GetDataCatalog": _get_data_catalog,
|
| 188 |
+
"ListDataCatalogs": _list_data_catalogs,
|
| 189 |
+
"DeleteDataCatalog": _delete_data_catalog,
|
| 190 |
+
"UpdateDataCatalog": _update_data_catalog,
|
| 191 |
+
# Prepared Statements
|
| 192 |
+
"CreatePreparedStatement": _create_prepared_statement,
|
| 193 |
+
"GetPreparedStatement": _get_prepared_statement,
|
| 194 |
+
"DeletePreparedStatement": _delete_prepared_statement,
|
| 195 |
+
"ListPreparedStatements": _list_prepared_statements,
|
| 196 |
+
# Table Metadata
|
| 197 |
+
"GetTableMetadata": _get_table_metadata,
|
| 198 |
+
"ListTableMetadata": _list_table_metadata,
|
| 199 |
+
# Tags
|
| 200 |
+
"TagResource": _tag_resource,
|
| 201 |
+
"UntagResource": _untag_resource,
|
| 202 |
+
"ListTagsForResource": _list_tags_for_resource,
|
| 203 |
+
}
|
| 204 |
+
|
| 205 |
+
handler = handlers.get(action)
|
| 206 |
+
if not handler:
|
| 207 |
+
return error_response_json(
|
| 208 |
+
"InvalidAction", f"Unknown Athena action: {action}", 400
|
| 209 |
+
)
|
| 210 |
+
return handler(data)
|
| 211 |
+
|
| 212 |
+
|
| 213 |
+
# ---- Query Execution ----
|
| 214 |
+
|
| 215 |
+
|
| 216 |
+
def _start_query_execution(data):
|
| 217 |
+
query = data.get("QueryString", "")
|
| 218 |
+
query_id = new_uuid()
|
| 219 |
+
workgroup = data.get("WorkGroup", "primary")
|
| 220 |
+
output_location = data.get("ResultConfiguration", {}).get(
|
| 221 |
+
"OutputLocation"
|
| 222 |
+
) or _workgroups.get(workgroup, {}).get("Configuration", {}).get(
|
| 223 |
+
"ResultConfiguration", {}
|
| 224 |
+
).get("OutputLocation", "s3://athena-results/")
|
| 225 |
+
db = data.get("QueryExecutionContext", {}).get("Database", "default")
|
| 226 |
+
catalog = data.get("QueryExecutionContext", {}).get("Catalog", "AwsDataCatalog")
|
| 227 |
+
|
| 228 |
+
execution = {
|
| 229 |
+
"QueryExecutionId": query_id,
|
| 230 |
+
"Query": query,
|
| 231 |
+
"StatementType": _detect_statement_type(query),
|
| 232 |
+
"ResultConfiguration": {"OutputLocation": f"{output_location}{query_id}.csv"},
|
| 233 |
+
"QueryExecutionContext": {"Database": db, "Catalog": catalog},
|
| 234 |
+
"Status": {
|
| 235 |
+
"State": "QUEUED",
|
| 236 |
+
"SubmissionDateTime": int(time.time()),
|
| 237 |
+
"CompletionDateTime": None,
|
| 238 |
+
"StateChangeReason": "",
|
| 239 |
+
},
|
| 240 |
+
"Statistics": {
|
| 241 |
+
"EngineExecutionTimeInMillis": 0,
|
| 242 |
+
"DataScannedInBytes": 0,
|
| 243 |
+
"DataManifestLocation": "",
|
| 244 |
+
"TotalExecutionTimeInMillis": 0,
|
| 245 |
+
"QueryQueueTimeInMillis": 0,
|
| 246 |
+
"QueryPlanningTimeInMillis": 0,
|
| 247 |
+
"ServiceProcessingTimeInMillis": 0,
|
| 248 |
+
},
|
| 249 |
+
"WorkGroup": workgroup,
|
| 250 |
+
"EngineVersion": {
|
| 251 |
+
"SelectedEngineVersion": "Athena engine version 3",
|
| 252 |
+
"EffectiveEngineVersion": "Athena engine version 3",
|
| 253 |
+
},
|
| 254 |
+
"_results": None,
|
| 255 |
+
"_column_types": None,
|
| 256 |
+
"_error": None,
|
| 257 |
+
}
|
| 258 |
+
_executions[query_id] = execution
|
| 259 |
+
|
| 260 |
+
thread = threading.Thread(
|
| 261 |
+
target=_execute_query, args=(query_id, query, db), daemon=True
|
| 262 |
+
)
|
| 263 |
+
thread.start()
|
| 264 |
+
|
| 265 |
+
return json_response({"QueryExecutionId": query_id})
|
| 266 |
+
|
| 267 |
+
|
| 268 |
+
def _execute_query(query_id, query, database):
|
| 269 |
+
execution = _executions.get(query_id)
|
| 270 |
+
if not execution:
|
| 271 |
+
return
|
| 272 |
+
|
| 273 |
+
execution["Status"]["State"] = "RUNNING"
|
| 274 |
+
start = time.time()
|
| 275 |
+
|
| 276 |
+
try:
|
| 277 |
+
engine = get_athena_engine()
|
| 278 |
+
|
| 279 |
+
if engine == "duckdb":
|
| 280 |
+
results = _run_duckdb(query, database)
|
| 281 |
+
else:
|
| 282 |
+
results = _mock_query_results(query)
|
| 283 |
+
|
| 284 |
+
execution["_results"] = {"columns": results["columns"], "rows": results["rows"]}
|
| 285 |
+
execution["_column_types"] = results.get(
|
| 286 |
+
"column_types", ["varchar"] * len(results["columns"])
|
| 287 |
+
)
|
| 288 |
+
execution["Status"]["State"] = "SUCCEEDED"
|
| 289 |
+
elapsed_ms = int((time.time() - start) * 1000)
|
| 290 |
+
execution["Statistics"]["EngineExecutionTimeInMillis"] = elapsed_ms
|
| 291 |
+
execution["Statistics"]["TotalExecutionTimeInMillis"] = elapsed_ms + 50
|
| 292 |
+
execution["Statistics"]["QueryPlanningTimeInMillis"] = min(20, elapsed_ms)
|
| 293 |
+
execution["Statistics"]["ServiceProcessingTimeInMillis"] = 30
|
| 294 |
+
execution["Statistics"]["DataScannedInBytes"] = sum(
|
| 295 |
+
len(str(row)) for row in results.get("rows", [])
|
| 296 |
+
)
|
| 297 |
+
except Exception as e:
|
| 298 |
+
logger.error("Athena query %s failed: %s", query_id, e)
|
| 299 |
+
execution["Status"]["State"] = "FAILED"
|
| 300 |
+
execution["Status"]["StateChangeReason"] = str(e)[:2000]
|
| 301 |
+
execution["_error"] = str(e)
|
| 302 |
+
|
| 303 |
+
execution["Status"]["CompletionDateTime"] = int(time.time())
|
| 304 |
+
|
| 305 |
+
|
| 306 |
+
def _run_duckdb(query, database):
|
| 307 |
+
import duckdb
|
| 308 |
+
|
| 309 |
+
conn = duckdb.connect(":memory:")
|
| 310 |
+
rewritten = _rewrite_s3_paths(query)
|
| 311 |
+
|
| 312 |
+
try:
|
| 313 |
+
result = conn.execute(rewritten)
|
| 314 |
+
columns = []
|
| 315 |
+
column_types = []
|
| 316 |
+
if result.description:
|
| 317 |
+
for desc in result.description:
|
| 318 |
+
columns.append(desc[0])
|
| 319 |
+
raw_type = desc[1] if len(desc) > 1 else "VARCHAR"
|
| 320 |
+
if isinstance(raw_type, str):
|
| 321 |
+
type_key = raw_type.upper().split("(")[0].strip()
|
| 322 |
+
else:
|
| 323 |
+
type_key = str(raw_type).upper().split("(")[0].strip()
|
| 324 |
+
athena_type = _DUCKDB_TYPE_MAP.get(type_key, "varchar")
|
| 325 |
+
column_types.append(athena_type)
|
| 326 |
+
rows = result.fetchall()
|
| 327 |
+
conn.close()
|
| 328 |
+
return {
|
| 329 |
+
"columns": columns,
|
| 330 |
+
"column_types": column_types,
|
| 331 |
+
"rows": [list(r) for r in rows],
|
| 332 |
+
}
|
| 333 |
+
except Exception as e:
|
| 334 |
+
conn.close()
|
| 335 |
+
raise e
|
| 336 |
+
|
| 337 |
+
|
| 338 |
+
def _rewrite_s3_paths(query):
|
| 339 |
+
"""Replace s3://bucket/key references with local file paths.
|
| 340 |
+
Handles: quoted strings, read_csv/read_parquet/read_json function args,
|
| 341 |
+
and FROM clauses with s3 paths.
|
| 342 |
+
"""
|
| 343 |
+
|
| 344 |
+
def replace_s3(match):
|
| 345 |
+
prefix = match.group(1)
|
| 346 |
+
s3_uri = match.group(2)
|
| 347 |
+
suffix = match.group(3)
|
| 348 |
+
stripped = s3_uri
|
| 349 |
+
if stripped.startswith("s3://"):
|
| 350 |
+
stripped = stripped[5:]
|
| 351 |
+
elif stripped.startswith("s3a://"):
|
| 352 |
+
stripped = stripped[6:]
|
| 353 |
+
parts = stripped.split("/", 1)
|
| 354 |
+
bucket = parts[0]
|
| 355 |
+
key = parts[1] if len(parts) > 1 else ""
|
| 356 |
+
local_path = os.path.join(S3_DATA_DIR, bucket, key)
|
| 357 |
+
return f"{prefix}{local_path}{suffix}"
|
| 358 |
+
|
| 359 |
+
result = re.sub(
|
| 360 |
+
r"""(["'])(s3a?://[^"']+)(["'])""",
|
| 361 |
+
replace_s3,
|
| 362 |
+
query,
|
| 363 |
+
)
|
| 364 |
+
result = re.sub(
|
| 365 |
+
r"(FROM\s+)(s3a?://\S+)(\s|;|$)",
|
| 366 |
+
replace_s3,
|
| 367 |
+
result,
|
| 368 |
+
flags=re.IGNORECASE,
|
| 369 |
+
)
|
| 370 |
+
return result
|
| 371 |
+
|
| 372 |
+
|
| 373 |
+
def _mock_query_results(query):
|
| 374 |
+
query_upper = query.strip().upper()
|
| 375 |
+
if query_upper.startswith("SELECT"):
|
| 376 |
+
match = re.match(r"SELECT\s+'([^']*)'", query.strip(), re.IGNORECASE)
|
| 377 |
+
if match:
|
| 378 |
+
val = match.group(1)
|
| 379 |
+
return {"columns": [val], "column_types": ["varchar"], "rows": [[val]]}
|
| 380 |
+
alias_pattern = re.findall(
|
| 381 |
+
r"""(?:(\d+(?:\.\d+)?)|'([^']*)')\s+AS\s+(\w+)""",
|
| 382 |
+
query.strip(),
|
| 383 |
+
re.IGNORECASE,
|
| 384 |
+
)
|
| 385 |
+
if alias_pattern:
|
| 386 |
+
cols = [m[2] for m in alias_pattern]
|
| 387 |
+
types = ["integer" if m[0] else "varchar" for m in alias_pattern]
|
| 388 |
+
vals = [m[0] if m[0] else m[1] for m in alias_pattern]
|
| 389 |
+
return {"columns": cols, "column_types": types, "rows": [vals]}
|
| 390 |
+
return {
|
| 391 |
+
"columns": ["result"],
|
| 392 |
+
"column_types": ["varchar"],
|
| 393 |
+
"rows": [["mock_value"]],
|
| 394 |
+
}
|
| 395 |
+
return {"columns": [], "column_types": [], "rows": []}
|
| 396 |
+
|
| 397 |
+
|
| 398 |
+
def _detect_statement_type(query):
|
| 399 |
+
q = query.strip().upper()
|
| 400 |
+
if q.startswith("SELECT") or q.startswith("WITH"):
|
| 401 |
+
return "DML"
|
| 402 |
+
if q.startswith(("CREATE", "DROP", "ALTER")):
|
| 403 |
+
return "DDL"
|
| 404 |
+
if q.startswith(("INSERT", "DELETE", "UPDATE", "MERGE")):
|
| 405 |
+
return "DML"
|
| 406 |
+
return "UTILITY"
|
| 407 |
+
|
| 408 |
+
|
| 409 |
+
def _get_query_execution(data):
|
| 410 |
+
query_id = data.get("QueryExecutionId")
|
| 411 |
+
execution = _executions.get(query_id)
|
| 412 |
+
if not execution:
|
| 413 |
+
return error_response_json(
|
| 414 |
+
"InvalidRequestException", f"Query {query_id} not found", 400
|
| 415 |
+
)
|
| 416 |
+
return json_response({"QueryExecution": _execution_out(execution)})
|
| 417 |
+
|
| 418 |
+
|
| 419 |
+
def _get_query_results(data):
|
| 420 |
+
query_id = data.get("QueryExecutionId")
|
| 421 |
+
max_results = data.get("MaxResults", 1000)
|
| 422 |
+
next_token = data.get("NextToken")
|
| 423 |
+
execution = _executions.get(query_id)
|
| 424 |
+
if not execution:
|
| 425 |
+
return error_response_json(
|
| 426 |
+
"InvalidRequestException", f"Query {query_id} not found", 400
|
| 427 |
+
)
|
| 428 |
+
|
| 429 |
+
state = execution["Status"]["State"]
|
| 430 |
+
if state == "FAILED":
|
| 431 |
+
return error_response_json(
|
| 432 |
+
"InvalidRequestException",
|
| 433 |
+
f"Query has failed: {execution['Status'].get('StateChangeReason', 'Unknown')}",
|
| 434 |
+
400,
|
| 435 |
+
)
|
| 436 |
+
if state != "SUCCEEDED":
|
| 437 |
+
return error_response_json(
|
| 438 |
+
"InvalidRequestException", f"Query is in state {state}", 400
|
| 439 |
+
)
|
| 440 |
+
|
| 441 |
+
results = execution.get("_results") or {"columns": [], "rows": []}
|
| 442 |
+
columns = results.get("columns", [])
|
| 443 |
+
rows = results.get("rows", [])
|
| 444 |
+
column_types = execution.get("_column_types") or ["varchar"] * len(columns)
|
| 445 |
+
|
| 446 |
+
start_idx = 0
|
| 447 |
+
if next_token:
|
| 448 |
+
try:
|
| 449 |
+
start_idx = int(next_token)
|
| 450 |
+
except ValueError:
|
| 451 |
+
pass
|
| 452 |
+
|
| 453 |
+
page_rows = rows[start_idx : start_idx + max_results]
|
| 454 |
+
|
| 455 |
+
result_rows = []
|
| 456 |
+
result_rows.append({"Data": [{"VarCharValue": col} for col in columns]})
|
| 457 |
+
for row in page_rows:
|
| 458 |
+
result_rows.append(
|
| 459 |
+
{"Data": [{"VarCharValue": str(v) if v is not None else ""} for v in row]}
|
| 460 |
+
)
|
| 461 |
+
|
| 462 |
+
column_info = []
|
| 463 |
+
for i, col in enumerate(columns):
|
| 464 |
+
ctype = column_types[i] if i < len(column_types) else "varchar"
|
| 465 |
+
precision, scale = _type_precision_scale(ctype)
|
| 466 |
+
column_info.append(
|
| 467 |
+
{
|
| 468 |
+
"CatalogName": "hive",
|
| 469 |
+
"SchemaName": "",
|
| 470 |
+
"TableName": "",
|
| 471 |
+
"Name": col,
|
| 472 |
+
"Label": col,
|
| 473 |
+
"Type": ctype,
|
| 474 |
+
"Precision": precision,
|
| 475 |
+
"Scale": scale,
|
| 476 |
+
"Nullable": "UNKNOWN",
|
| 477 |
+
"CaseSensitive": ctype == "varchar",
|
| 478 |
+
}
|
| 479 |
+
)
|
| 480 |
+
|
| 481 |
+
response = {
|
| 482 |
+
"ResultSet": {
|
| 483 |
+
"Rows": result_rows,
|
| 484 |
+
"ResultSetMetadata": {"ColumnInfo": column_info},
|
| 485 |
+
},
|
| 486 |
+
"UpdateCount": 0,
|
| 487 |
+
}
|
| 488 |
+
|
| 489 |
+
end_idx = start_idx + max_results
|
| 490 |
+
if end_idx < len(rows):
|
| 491 |
+
response["NextToken"] = str(end_idx)
|
| 492 |
+
|
| 493 |
+
return json_response(response)
|
| 494 |
+
|
| 495 |
+
|
| 496 |
+
def _type_precision_scale(athena_type):
|
| 497 |
+
if athena_type in ("integer", "int"):
|
| 498 |
+
return 10, 0
|
| 499 |
+
if athena_type == "bigint":
|
| 500 |
+
return 19, 0
|
| 501 |
+
if athena_type == "smallint":
|
| 502 |
+
return 5, 0
|
| 503 |
+
if athena_type == "tinyint":
|
| 504 |
+
return 3, 0
|
| 505 |
+
if athena_type == "double":
|
| 506 |
+
return 17, 0
|
| 507 |
+
if athena_type == "float":
|
| 508 |
+
return 7, 0
|
| 509 |
+
if athena_type == "boolean":
|
| 510 |
+
return 0, 0
|
| 511 |
+
if athena_type == "decimal":
|
| 512 |
+
return 38, 0
|
| 513 |
+
return 0, 0
|
| 514 |
+
|
| 515 |
+
|
| 516 |
+
def _stop_query_execution(data):
|
| 517 |
+
query_id = data.get("QueryExecutionId")
|
| 518 |
+
execution = _executions.get(query_id)
|
| 519 |
+
if execution and execution["Status"]["State"] in ("QUEUED", "RUNNING"):
|
| 520 |
+
execution["Status"]["State"] = "CANCELLED"
|
| 521 |
+
execution["Status"]["StateChangeReason"] = "Query was cancelled by user"
|
| 522 |
+
execution["Status"]["CompletionDateTime"] = int(time.time())
|
| 523 |
+
return json_response({})
|
| 524 |
+
|
| 525 |
+
|
| 526 |
+
def _list_query_executions(data):
|
| 527 |
+
workgroup = data.get("WorkGroup", "primary")
|
| 528 |
+
ids = [qid for qid, ex in _executions.items() if ex.get("WorkGroup") == workgroup]
|
| 529 |
+
return json_response({"QueryExecutionIds": ids})
|
| 530 |
+
|
| 531 |
+
|
| 532 |
+
# ---- WorkGroups ----
|
| 533 |
+
|
| 534 |
+
|
| 535 |
+
def _create_workgroup(data):
|
| 536 |
+
name = data.get("Name")
|
| 537 |
+
if name in _workgroups:
|
| 538 |
+
return error_response_json(
|
| 539 |
+
"InvalidRequestException", f"WorkGroup {name} already exists", 400
|
| 540 |
+
)
|
| 541 |
+
_workgroups[name] = {
|
| 542 |
+
"Name": name,
|
| 543 |
+
"State": "ENABLED",
|
| 544 |
+
"Description": data.get("Description", ""),
|
| 545 |
+
"CreationTime": int(time.time()),
|
| 546 |
+
"Configuration": data.get("Configuration", {}),
|
| 547 |
+
}
|
| 548 |
+
tags = data.get("Tags", [])
|
| 549 |
+
if tags:
|
| 550 |
+
arn = _arn_workgroup(name)
|
| 551 |
+
_tags[arn] = {t["Key"]: t["Value"] for t in tags}
|
| 552 |
+
return json_response({})
|
| 553 |
+
|
| 554 |
+
|
| 555 |
+
def _delete_workgroup(data):
|
| 556 |
+
name = data.get("WorkGroup")
|
| 557 |
+
if name == "primary":
|
| 558 |
+
return error_response_json(
|
| 559 |
+
"InvalidRequestException", "Cannot delete primary workgroup", 400
|
| 560 |
+
)
|
| 561 |
+
_workgroups.pop(name, None)
|
| 562 |
+
_tags.pop(_arn_workgroup(name), None)
|
| 563 |
+
return json_response({})
|
| 564 |
+
|
| 565 |
+
|
| 566 |
+
def _get_workgroup(data):
|
| 567 |
+
name = data.get("WorkGroup")
|
| 568 |
+
wg = _workgroups.get(name)
|
| 569 |
+
if not wg:
|
| 570 |
+
return error_response_json(
|
| 571 |
+
"InvalidRequestException", f"WorkGroup {name} not found", 400
|
| 572 |
+
)
|
| 573 |
+
out = dict(wg)
|
| 574 |
+
out.setdefault("WorkGroupConfiguration", out.get("Configuration", {}))
|
| 575 |
+
return json_response({"WorkGroup": out})
|
| 576 |
+
|
| 577 |
+
|
| 578 |
+
def _list_workgroups(data):
|
| 579 |
+
return json_response(
|
| 580 |
+
{
|
| 581 |
+
"WorkGroups": [
|
| 582 |
+
{
|
| 583 |
+
"Name": wg["Name"],
|
| 584 |
+
"State": wg["State"],
|
| 585 |
+
"Description": wg.get("Description", ""),
|
| 586 |
+
"CreationTime": wg.get("CreationTime", 0),
|
| 587 |
+
}
|
| 588 |
+
for wg in _workgroups.values()
|
| 589 |
+
]
|
| 590 |
+
}
|
| 591 |
+
)
|
| 592 |
+
|
| 593 |
+
|
| 594 |
+
def _update_workgroup(data):
|
| 595 |
+
name = data.get("WorkGroup")
|
| 596 |
+
wg = _workgroups.get(name)
|
| 597 |
+
if not wg:
|
| 598 |
+
return error_response_json(
|
| 599 |
+
"InvalidRequestException", f"WorkGroup {name} not found", 400
|
| 600 |
+
)
|
| 601 |
+
if "ConfigurationUpdates" in data:
|
| 602 |
+
updates = data["ConfigurationUpdates"]
|
| 603 |
+
config = wg.setdefault("Configuration", {})
|
| 604 |
+
if "ResultConfigurationUpdates" in updates:
|
| 605 |
+
rc = config.setdefault("ResultConfiguration", {})
|
| 606 |
+
rcu = updates["ResultConfigurationUpdates"]
|
| 607 |
+
if "OutputLocation" in rcu:
|
| 608 |
+
rc["OutputLocation"] = rcu["OutputLocation"]
|
| 609 |
+
if "EncryptionConfiguration" in rcu:
|
| 610 |
+
rc["EncryptionConfiguration"] = rcu["EncryptionConfiguration"]
|
| 611 |
+
if rcu.get("RemoveOutputLocation"):
|
| 612 |
+
rc.pop("OutputLocation", None)
|
| 613 |
+
if rcu.get("RemoveEncryptionConfiguration"):
|
| 614 |
+
rc.pop("EncryptionConfiguration", None)
|
| 615 |
+
for ck in (
|
| 616 |
+
"EnforceWorkGroupConfiguration",
|
| 617 |
+
"PublishCloudWatchMetricsEnabled",
|
| 618 |
+
"BytesScannedCutoffPerQuery",
|
| 619 |
+
"RequesterPaysEnabled",
|
| 620 |
+
"EngineVersion",
|
| 621 |
+
):
|
| 622 |
+
if ck in updates:
|
| 623 |
+
config[ck] = updates[ck]
|
| 624 |
+
if "Description" in data:
|
| 625 |
+
wg["Description"] = data["Description"]
|
| 626 |
+
if "State" in data:
|
| 627 |
+
wg["State"] = data["State"]
|
| 628 |
+
return json_response({})
|
| 629 |
+
|
| 630 |
+
|
| 631 |
+
# ---- Named Queries ----
|
| 632 |
+
|
| 633 |
+
|
| 634 |
+
def _create_named_query(data):
|
| 635 |
+
query_id = new_uuid()
|
| 636 |
+
_named_queries[query_id] = {
|
| 637 |
+
"NamedQueryId": query_id,
|
| 638 |
+
"Name": data.get("Name", ""),
|
| 639 |
+
"Description": data.get("Description", ""),
|
| 640 |
+
"Database": data.get("Database", "default"),
|
| 641 |
+
"QueryString": data.get("QueryString", ""),
|
| 642 |
+
"WorkGroup": data.get("WorkGroup", "primary"),
|
| 643 |
+
}
|
| 644 |
+
return json_response({"NamedQueryId": query_id})
|
| 645 |
+
|
| 646 |
+
|
| 647 |
+
def _delete_named_query(data):
|
| 648 |
+
_named_queries.pop(data.get("NamedQueryId"), None)
|
| 649 |
+
return json_response({})
|
| 650 |
+
|
| 651 |
+
|
| 652 |
+
def _get_named_query(data):
|
| 653 |
+
query_id = data.get("NamedQueryId")
|
| 654 |
+
nq = _named_queries.get(query_id)
|
| 655 |
+
if not nq:
|
| 656 |
+
return error_response_json(
|
| 657 |
+
"InvalidRequestException", f"Named query {query_id} not found", 400
|
| 658 |
+
)
|
| 659 |
+
return json_response({"NamedQuery": nq})
|
| 660 |
+
|
| 661 |
+
|
| 662 |
+
def _list_named_queries(data):
|
| 663 |
+
workgroup = data.get("WorkGroup")
|
| 664 |
+
if workgroup:
|
| 665 |
+
ids = [qid for qid, nq in _named_queries.items() if nq.get("WorkGroup") == workgroup]
|
| 666 |
+
else:
|
| 667 |
+
ids = list(_named_queries.keys())
|
| 668 |
+
return json_response({"NamedQueryIds": ids})
|
| 669 |
+
|
| 670 |
+
|
| 671 |
+
def _batch_get_named_query(data):
|
| 672 |
+
ids = data.get("NamedQueryIds", [])
|
| 673 |
+
queries = [_named_queries[qid] for qid in ids if qid in _named_queries]
|
| 674 |
+
unprocessed = [
|
| 675 |
+
{
|
| 676 |
+
"NamedQueryId": qid,
|
| 677 |
+
"ErrorCode": "INTERNAL_FAILURE",
|
| 678 |
+
"ErrorMessage": "Not found",
|
| 679 |
+
}
|
| 680 |
+
for qid in ids
|
| 681 |
+
if qid not in _named_queries
|
| 682 |
+
]
|
| 683 |
+
return json_response(
|
| 684 |
+
{"NamedQueries": queries, "UnprocessedNamedQueryIds": unprocessed}
|
| 685 |
+
)
|
| 686 |
+
|
| 687 |
+
|
| 688 |
+
def _batch_get_query_execution(data):
|
| 689 |
+
ids = data.get("QueryExecutionIds", [])
|
| 690 |
+
execs = [_execution_out(_executions[qid]) for qid in ids if qid in _executions]
|
| 691 |
+
unprocessed = [
|
| 692 |
+
{
|
| 693 |
+
"QueryExecutionId": qid,
|
| 694 |
+
"ErrorCode": "INTERNAL_FAILURE",
|
| 695 |
+
"ErrorMessage": "Not found",
|
| 696 |
+
}
|
| 697 |
+
for qid in ids
|
| 698 |
+
if qid not in _executions
|
| 699 |
+
]
|
| 700 |
+
return json_response(
|
| 701 |
+
{"QueryExecutions": execs, "UnprocessedQueryExecutionIds": unprocessed}
|
| 702 |
+
)
|
| 703 |
+
|
| 704 |
+
|
| 705 |
+
# ---- Data Catalogs ----
|
| 706 |
+
|
| 707 |
+
|
| 708 |
+
def _create_data_catalog(data):
|
| 709 |
+
name = data.get("Name")
|
| 710 |
+
if not name:
|
| 711 |
+
return error_response_json("InvalidRequestException", "Name is required", 400)
|
| 712 |
+
if name in _data_catalogs:
|
| 713 |
+
return error_response_json(
|
| 714 |
+
"InvalidRequestException", f"Data catalog {name} already exists", 400
|
| 715 |
+
)
|
| 716 |
+
catalog_type = data.get("Type", "HIVE")
|
| 717 |
+
if catalog_type not in ("HIVE", "LAMBDA", "GLUE"):
|
| 718 |
+
return error_response_json(
|
| 719 |
+
"InvalidRequestException", f"Invalid catalog type: {catalog_type}", 400
|
| 720 |
+
)
|
| 721 |
+
_data_catalogs[name] = {
|
| 722 |
+
"Name": name,
|
| 723 |
+
"Description": data.get("Description", ""),
|
| 724 |
+
"Type": catalog_type,
|
| 725 |
+
"Parameters": data.get("Parameters", {}),
|
| 726 |
+
}
|
| 727 |
+
tags = data.get("Tags", [])
|
| 728 |
+
if tags:
|
| 729 |
+
arn = _arn_datacatalog(name)
|
| 730 |
+
_tags[arn] = {t["Key"]: t["Value"] for t in tags}
|
| 731 |
+
return json_response({})
|
| 732 |
+
|
| 733 |
+
|
| 734 |
+
def _get_data_catalog(data):
|
| 735 |
+
name = data.get("Name")
|
| 736 |
+
catalog = _data_catalogs.get(name)
|
| 737 |
+
if not catalog:
|
| 738 |
+
return error_response_json(
|
| 739 |
+
"InvalidRequestException", f"Data catalog {name} not found", 400
|
| 740 |
+
)
|
| 741 |
+
return json_response({"DataCatalog": catalog})
|
| 742 |
+
|
| 743 |
+
|
| 744 |
+
def _list_data_catalogs(data):
|
| 745 |
+
summaries = [
|
| 746 |
+
{"CatalogName": c["Name"], "Type": c["Type"]} for c in _data_catalogs.values()
|
| 747 |
+
]
|
| 748 |
+
return json_response({"DataCatalogsSummary": summaries})
|
| 749 |
+
|
| 750 |
+
|
| 751 |
+
def _delete_data_catalog(data):
|
| 752 |
+
name = data.get("Name")
|
| 753 |
+
if name == "AwsDataCatalog":
|
| 754 |
+
return error_response_json(
|
| 755 |
+
"InvalidRequestException", "Cannot delete the default AWS data catalog", 400
|
| 756 |
+
)
|
| 757 |
+
if name not in _data_catalogs:
|
| 758 |
+
return error_response_json(
|
| 759 |
+
"InvalidRequestException", f"Data catalog {name} not found", 400
|
| 760 |
+
)
|
| 761 |
+
del _data_catalogs[name]
|
| 762 |
+
_tags.pop(_arn_datacatalog(name), None)
|
| 763 |
+
return json_response({})
|
| 764 |
+
|
| 765 |
+
|
| 766 |
+
def _update_data_catalog(data):
|
| 767 |
+
name = data.get("Name")
|
| 768 |
+
catalog = _data_catalogs.get(name)
|
| 769 |
+
if not catalog:
|
| 770 |
+
return error_response_json(
|
| 771 |
+
"InvalidRequestException", f"Data catalog {name} not found", 400
|
| 772 |
+
)
|
| 773 |
+
if "Description" in data:
|
| 774 |
+
catalog["Description"] = data["Description"]
|
| 775 |
+
if "Type" in data:
|
| 776 |
+
catalog["Type"] = data["Type"]
|
| 777 |
+
if "Parameters" in data:
|
| 778 |
+
catalog["Parameters"] = data["Parameters"]
|
| 779 |
+
return json_response({})
|
| 780 |
+
|
| 781 |
+
|
| 782 |
+
# ---- Prepared Statements ----
|
| 783 |
+
|
| 784 |
+
|
| 785 |
+
def _create_prepared_statement(data):
|
| 786 |
+
name = data.get("StatementName")
|
| 787 |
+
workgroup = data.get("WorkGroup", "primary")
|
| 788 |
+
query = data.get("QueryStatement", "")
|
| 789 |
+
if not name:
|
| 790 |
+
return error_response_json(
|
| 791 |
+
"InvalidRequestException", "StatementName is required", 400
|
| 792 |
+
)
|
| 793 |
+
key = f"{workgroup}/{name}"
|
| 794 |
+
if key in _prepared_statements:
|
| 795 |
+
return error_response_json(
|
| 796 |
+
"InvalidRequestException",
|
| 797 |
+
f"Prepared statement {name} already exists in {workgroup}",
|
| 798 |
+
400,
|
| 799 |
+
)
|
| 800 |
+
_prepared_statements[key] = {
|
| 801 |
+
"StatementName": name,
|
| 802 |
+
"WorkGroupName": workgroup,
|
| 803 |
+
"QueryStatement": query,
|
| 804 |
+
"Description": data.get("Description", ""),
|
| 805 |
+
"LastModifiedTime": int(time.time()),
|
| 806 |
+
}
|
| 807 |
+
return json_response({})
|
| 808 |
+
|
| 809 |
+
|
| 810 |
+
def _get_prepared_statement(data):
|
| 811 |
+
name = data.get("StatementName")
|
| 812 |
+
workgroup = data.get("WorkGroup") or data.get("WorkGroupName", "primary")
|
| 813 |
+
key = f"{workgroup}/{name}"
|
| 814 |
+
stmt = _prepared_statements.get(key)
|
| 815 |
+
if not stmt:
|
| 816 |
+
return error_response_json(
|
| 817 |
+
"ResourceNotFoundException",
|
| 818 |
+
f"Prepared statement {name} not found in {workgroup}",
|
| 819 |
+
400,
|
| 820 |
+
)
|
| 821 |
+
return json_response({"PreparedStatement": stmt})
|
| 822 |
+
|
| 823 |
+
|
| 824 |
+
def _delete_prepared_statement(data):
|
| 825 |
+
name = data.get("StatementName")
|
| 826 |
+
workgroup = data.get("WorkGroup") or data.get("WorkGroupName", "primary")
|
| 827 |
+
key = f"{workgroup}/{name}"
|
| 828 |
+
if key not in _prepared_statements:
|
| 829 |
+
return error_response_json(
|
| 830 |
+
"ResourceNotFoundException", f"Prepared statement {name} not found", 400
|
| 831 |
+
)
|
| 832 |
+
del _prepared_statements[key]
|
| 833 |
+
return json_response({})
|
| 834 |
+
|
| 835 |
+
|
| 836 |
+
def _list_prepared_statements(data):
|
| 837 |
+
workgroup = data.get("WorkGroup") or data.get("WorkGroupName", "primary")
|
| 838 |
+
stmts = [
|
| 839 |
+
{"StatementName": s["StatementName"], "LastModifiedTime": s["LastModifiedTime"]}
|
| 840 |
+
for k, s in _prepared_statements.items()
|
| 841 |
+
if s.get("WorkGroupName") == workgroup
|
| 842 |
+
]
|
| 843 |
+
return json_response({"PreparedStatements": stmts})
|
| 844 |
+
|
| 845 |
+
|
| 846 |
+
# ---- Table Metadata (stubs) ----
|
| 847 |
+
|
| 848 |
+
|
| 849 |
+
def _get_table_metadata(data):
|
| 850 |
+
catalog = data.get("CatalogName", "AwsDataCatalog")
|
| 851 |
+
db = data.get("DatabaseName", "default")
|
| 852 |
+
table = data.get("TableName", "")
|
| 853 |
+
return json_response(
|
| 854 |
+
{
|
| 855 |
+
"TableMetadata": {
|
| 856 |
+
"Name": table,
|
| 857 |
+
"CreateTime": int(time.time()),
|
| 858 |
+
"LastAccessTime": int(time.time()),
|
| 859 |
+
"TableType": "EXTERNAL_TABLE",
|
| 860 |
+
"Columns": [],
|
| 861 |
+
"PartitionKeys": [],
|
| 862 |
+
"Parameters": {"classification": "csv"},
|
| 863 |
+
}
|
| 864 |
+
}
|
| 865 |
+
)
|
| 866 |
+
|
| 867 |
+
|
| 868 |
+
def _list_table_metadata(data):
|
| 869 |
+
return json_response({"TableMetadataList": []})
|
| 870 |
+
|
| 871 |
+
|
| 872 |
+
# ---- Tags ----
|
| 873 |
+
|
| 874 |
+
|
| 875 |
+
def _tag_resource(data):
|
| 876 |
+
arn = data.get("ResourceARN", "")
|
| 877 |
+
tags = data.get("Tags", [])
|
| 878 |
+
tag_dict = _tags.setdefault(arn, {})
|
| 879 |
+
for t in tags:
|
| 880 |
+
tag_dict[t["Key"]] = t["Value"]
|
| 881 |
+
return json_response({})
|
| 882 |
+
|
| 883 |
+
|
| 884 |
+
def _untag_resource(data):
|
| 885 |
+
arn = data.get("ResourceARN", "")
|
| 886 |
+
keys = data.get("TagKeys", [])
|
| 887 |
+
tag_dict = _tags.get(arn, {})
|
| 888 |
+
for k in keys:
|
| 889 |
+
tag_dict.pop(k, None)
|
| 890 |
+
return json_response({})
|
| 891 |
+
|
| 892 |
+
|
| 893 |
+
def _list_tags_for_resource(data):
|
| 894 |
+
arn = data.get("ResourceARN", "")
|
| 895 |
+
tag_dict = _tags.get(arn, {})
|
| 896 |
+
tags = [{"Key": k, "Value": v} for k, v in tag_dict.items()]
|
| 897 |
+
return json_response({"Tags": tags})
|
| 898 |
+
|
| 899 |
+
|
| 900 |
+
# ---- Helpers ----
|
| 901 |
+
|
| 902 |
+
|
| 903 |
+
def _execution_out(ex):
|
| 904 |
+
return {k: v for k, v in ex.items() if not k.startswith("_")}
|
| 905 |
+
|
| 906 |
+
|
| 907 |
+
SUPPORTED_ACTIONS = [
|
| 908 |
+
"StartQueryExecution", "GetQueryExecution", "GetQueryResults", "StopQueryExecution",
|
| 909 |
+
"ListQueryExecutions", "CreateWorkGroup", "DeleteWorkGroup", "GetWorkGroup",
|
| 910 |
+
"ListWorkGroups", "UpdateWorkGroup", "CreateNamedQuery", "DeleteNamedQuery",
|
| 911 |
+
"GetNamedQuery", "ListNamedQueries", "BatchGetNamedQuery", "BatchGetQueryExecution",
|
| 912 |
+
"CreateDataCatalog", "GetDataCatalog", "ListDataCatalogs", "DeleteDataCatalog",
|
| 913 |
+
"UpdateDataCatalog", "CreatePreparedStatement", "GetPreparedStatement",
|
| 914 |
+
"DeletePreparedStatement", "ListPreparedStatements", "GetTableMetadata",
|
| 915 |
+
"ListTableMetadata", "TagResource", "UntagResource", "ListTagsForResource",
|
| 916 |
+
]
|
| 917 |
+
|
| 918 |
+
|
| 919 |
+
def get_state_summary() -> dict:
|
| 920 |
+
return {
|
| 921 |
+
"workgroups": {"count": len(_workgroups), "names": list(_workgroups.keys())},
|
| 922 |
+
"named_queries": {"count": len(_named_queries), "ids": list(_named_queries.keys())},
|
| 923 |
+
"data_catalogs": {"count": len(_data_catalogs), "names": list(_data_catalogs.keys())},
|
| 924 |
+
"executions": {"count": len(_executions), "ids": list(_executions.keys())},
|
| 925 |
+
"prepared_statements": {"count": len(_prepared_statements), "keys": list(_prepared_statements.keys())},
|
| 926 |
+
"tags": {"count": len(_tags), "arns": list(_tags.keys())},
|
| 927 |
+
}
|
| 928 |
+
|
| 929 |
+
|
| 930 |
+
def reset():
|
| 931 |
+
_executions.clear()
|
| 932 |
+
_named_queries.clear()
|
| 933 |
+
_prepared_statements.clear()
|
| 934 |
+
_workgroups.clear()
|
| 935 |
+
_data_catalogs.clear()
|
| 936 |
+
_tags.clear()
|
| 937 |
+
# "primary" workgroup and "AwsDataCatalog" are seeded lazily per-account
|
| 938 |
+
# on next access via _ensure_default_workgroup() / _ensure_default_data_catalog().
|
aws_infra/ministack/services/autoscaling.py
ADDED
|
@@ -0,0 +1,554 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
AutoScaling Service Emulator.
|
| 3 |
+
Query API (Action=...) — groups, launch configs, policies, hooks, scheduled actions.
|
| 4 |
+
All in-memory, no actual instance scaling.
|
| 5 |
+
|
| 6 |
+
Supports:
|
| 7 |
+
ASG: CreateAutoScalingGroup, DescribeAutoScalingGroups, UpdateAutoScalingGroup,
|
| 8 |
+
DeleteAutoScalingGroup, DescribeAutoScalingInstances, DescribeScalingActivities
|
| 9 |
+
LC: CreateLaunchConfiguration, DescribeLaunchConfigurations, DeleteLaunchConfiguration
|
| 10 |
+
Policies: PutScalingPolicy, DescribePolicies, DeletePolicy
|
| 11 |
+
Hooks: PutLifecycleHook, DescribeLifecycleHooks, DeleteLifecycleHook,
|
| 12 |
+
CompleteLifecycleAction, RecordLifecycleActionHeartbeat
|
| 13 |
+
Schedule: PutScheduledUpdateGroupAction, DescribeScheduledActions, DeleteScheduledAction
|
| 14 |
+
Tags: CreateOrUpdateTags, DescribeTags, DeleteTags
|
| 15 |
+
"""
|
| 16 |
+
|
| 17 |
+
import logging
|
| 18 |
+
import os
|
| 19 |
+
import time
|
| 20 |
+
from collections import defaultdict
|
| 21 |
+
|
| 22 |
+
from ministack.core.responses import AccountScopedDict, get_account_id, new_uuid, now_iso, get_region
|
| 23 |
+
|
| 24 |
+
logger = logging.getLogger("autoscaling")
|
| 25 |
+
REGION = os.environ.get("MINISTACK_REGION", "us-east-1")
|
| 26 |
+
|
| 27 |
+
_asgs = AccountScopedDict()
|
| 28 |
+
_launch_configs = AccountScopedDict()
|
| 29 |
+
_policies = AccountScopedDict()
|
| 30 |
+
_hooks = AccountScopedDict()
|
| 31 |
+
_scheduled_actions = AccountScopedDict()
|
| 32 |
+
_tags = AccountScopedDict() # asg_name -> [{"Key":..., "Value":...}, ...]
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
import copy
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def get_state():
|
| 39 |
+
return {
|
| 40 |
+
"asgs": copy.deepcopy(_asgs),
|
| 41 |
+
"launch_configs": copy.deepcopy(_launch_configs),
|
| 42 |
+
"policies": copy.deepcopy(_policies),
|
| 43 |
+
"hooks": copy.deepcopy(_hooks),
|
| 44 |
+
"scheduled_actions": copy.deepcopy(_scheduled_actions),
|
| 45 |
+
"tags": copy.deepcopy(_tags),
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def restore_state(data):
|
| 50 |
+
if data:
|
| 51 |
+
_asgs.update(data.get("asgs", {}))
|
| 52 |
+
_launch_configs.update(data.get("launch_configs", {}))
|
| 53 |
+
_policies.update(data.get("policies", {}))
|
| 54 |
+
_hooks.update(data.get("hooks", {}))
|
| 55 |
+
_scheduled_actions.update(data.get("scheduled_actions", {}))
|
| 56 |
+
_tags.update(data.get("tags", {}))
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def reset():
|
| 60 |
+
_asgs.clear()
|
| 61 |
+
_launch_configs.clear()
|
| 62 |
+
_policies.clear()
|
| 63 |
+
_hooks.clear()
|
| 64 |
+
_scheduled_actions.clear()
|
| 65 |
+
_tags.clear()
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
def _p(params, key):
|
| 69 |
+
v = params.get(key, "")
|
| 70 |
+
return v[0] if isinstance(v, list) else v
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
def _parse_member_list(params, prefix):
|
| 74 |
+
items = []
|
| 75 |
+
i = 1
|
| 76 |
+
while True:
|
| 77 |
+
key = f"{prefix}.member.{i}"
|
| 78 |
+
val = _p(params, key)
|
| 79 |
+
if not val:
|
| 80 |
+
break
|
| 81 |
+
items.append(val)
|
| 82 |
+
i += 1
|
| 83 |
+
return items
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
def _xml(status, root_tag, inner):
|
| 87 |
+
body = (f'<?xml version="1.0" encoding="UTF-8"?>'
|
| 88 |
+
f'<{root_tag} xmlns="http://autoscaling.amazonaws.com/doc/2011-01-01/">'
|
| 89 |
+
f'{inner}'
|
| 90 |
+
f'<ResponseMetadata><RequestId>{new_uuid()}</RequestId></ResponseMetadata>'
|
| 91 |
+
f'</{root_tag}>').encode("utf-8")
|
| 92 |
+
return status, {"Content-Type": "application/xml"}, body
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
def _error(code, message, status=400):
|
| 96 |
+
return _xml(status, "ErrorResponse",
|
| 97 |
+
f'<Error><Type>Sender</Type><Code>{code}</Code><Message>{message}</Message></Error>')
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
def _asg_arn(name):
|
| 101 |
+
return f"arn:aws:autoscaling:{get_region()}:{get_account_id()}:autoScalingGroup:{new_uuid()}:autoScalingGroupName/{name}"
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
# ---------------------------------------------------------------------------
|
| 105 |
+
# AutoScalingGroup
|
| 106 |
+
# ---------------------------------------------------------------------------
|
| 107 |
+
|
| 108 |
+
def _create_asg(p):
|
| 109 |
+
name = _p(p, "AutoScalingGroupName")
|
| 110 |
+
if not name:
|
| 111 |
+
return _error("ValidationError", "AutoScalingGroupName is required")
|
| 112 |
+
if name in _asgs:
|
| 113 |
+
return _error("AlreadyExistsFault", f"AutoScalingGroup {name} already exists")
|
| 114 |
+
|
| 115 |
+
arn = _asg_arn(name)
|
| 116 |
+
_asgs[name] = {
|
| 117 |
+
"AutoScalingGroupName": name,
|
| 118 |
+
"AutoScalingGroupARN": arn,
|
| 119 |
+
"LaunchConfigurationName": _p(p, "LaunchConfigurationName"),
|
| 120 |
+
"LaunchTemplate": {},
|
| 121 |
+
"MinSize": int(_p(p, "MinSize") or 0),
|
| 122 |
+
"MaxSize": int(_p(p, "MaxSize") or 0),
|
| 123 |
+
"DesiredCapacity": int(_p(p, "DesiredCapacity") or _p(p, "MinSize") or 0),
|
| 124 |
+
"DefaultCooldown": int(_p(p, "DefaultCooldown") or 300),
|
| 125 |
+
"AvailabilityZones": _parse_member_list(p, "AvailabilityZones") or [f"{get_region()}a"],
|
| 126 |
+
"HealthCheckType": _p(p, "HealthCheckType") or "EC2",
|
| 127 |
+
"HealthCheckGracePeriod": int(_p(p, "HealthCheckGracePeriod") or 300),
|
| 128 |
+
"Instances": [],
|
| 129 |
+
"CreatedTime": now_iso(),
|
| 130 |
+
"VPCZoneIdentifier": _p(p, "VPCZoneIdentifier") or "",
|
| 131 |
+
"TerminationPolicies": _parse_member_list(p, "TerminationPolicies") or ["Default"],
|
| 132 |
+
"NewInstancesProtectedFromScaleIn": _p(p, "NewInstancesProtectedFromScaleIn") == "true",
|
| 133 |
+
"ServiceLinkedRoleARN": _p(p, "ServiceLinkedRoleARN") or "",
|
| 134 |
+
"Tags": [],
|
| 135 |
+
"Status": "",
|
| 136 |
+
}
|
| 137 |
+
|
| 138 |
+
# Parse launch template
|
| 139 |
+
lt_id = _p(p, "LaunchTemplate.LaunchTemplateId") or _p(p, "LaunchTemplate.LaunchTemplateName")
|
| 140 |
+
lt_ver = _p(p, "LaunchTemplate.Version") or "$Default"
|
| 141 |
+
if lt_id:
|
| 142 |
+
_asgs[name]["LaunchTemplate"] = {
|
| 143 |
+
"LaunchTemplateId": lt_id,
|
| 144 |
+
"LaunchTemplateName": lt_id,
|
| 145 |
+
"Version": lt_ver,
|
| 146 |
+
}
|
| 147 |
+
|
| 148 |
+
# Parse tags
|
| 149 |
+
i = 1
|
| 150 |
+
tags = []
|
| 151 |
+
while _p(p, f"Tags.member.{i}.Key"):
|
| 152 |
+
tags.append({
|
| 153 |
+
"Key": _p(p, f"Tags.member.{i}.Key"),
|
| 154 |
+
"Value": _p(p, f"Tags.member.{i}.Value"),
|
| 155 |
+
"ResourceId": name,
|
| 156 |
+
"ResourceType": "auto-scaling-group",
|
| 157 |
+
"PropagateAtLaunch": _p(p, f"Tags.member.{i}.PropagateAtLaunch") == "true",
|
| 158 |
+
})
|
| 159 |
+
i += 1
|
| 160 |
+
_asgs[name]["Tags"] = tags
|
| 161 |
+
_tags[name] = tags
|
| 162 |
+
|
| 163 |
+
logger.info("CreateAutoScalingGroup: %s", name)
|
| 164 |
+
return _xml(200, "CreateAutoScalingGroupResponse", "<CreateAutoScalingGroupResult/>")
|
| 165 |
+
|
| 166 |
+
|
| 167 |
+
def _describe_asgs(p):
|
| 168 |
+
names = _parse_member_list(p, "AutoScalingGroupNames")
|
| 169 |
+
members = ""
|
| 170 |
+
for name, asg in _asgs.items():
|
| 171 |
+
if names and name not in names:
|
| 172 |
+
continue
|
| 173 |
+
azs = "".join(f"<member>{az}</member>" for az in asg["AvailabilityZones"])
|
| 174 |
+
tp = "".join(f"<member>{t}</member>" for t in asg["TerminationPolicies"])
|
| 175 |
+
tags_xml = "".join(
|
| 176 |
+
f"<member><Key>{t['Key']}</Key><Value>{t['Value']}</Value>"
|
| 177 |
+
f"<ResourceId>{t['ResourceId']}</ResourceId><ResourceType>{t['ResourceType']}</ResourceType>"
|
| 178 |
+
f"<PropagateAtLaunch>{'true' if t.get('PropagateAtLaunch') else 'false'}</PropagateAtLaunch></member>"
|
| 179 |
+
for t in asg.get("Tags", [])
|
| 180 |
+
)
|
| 181 |
+
lt = asg.get("LaunchTemplate", {})
|
| 182 |
+
lt_xml = ""
|
| 183 |
+
if lt:
|
| 184 |
+
lt_xml = (f"<LaunchTemplate>"
|
| 185 |
+
f"<LaunchTemplateId>{lt.get('LaunchTemplateId', '')}</LaunchTemplateId>"
|
| 186 |
+
f"<LaunchTemplateName>{lt.get('LaunchTemplateName', '')}</LaunchTemplateName>"
|
| 187 |
+
f"<Version>{lt.get('Version', '')}</Version></LaunchTemplate>")
|
| 188 |
+
members += (f"<member>"
|
| 189 |
+
f"<AutoScalingGroupName>{name}</AutoScalingGroupName>"
|
| 190 |
+
f"<AutoScalingGroupARN>{asg['AutoScalingGroupARN']}</AutoScalingGroupARN>"
|
| 191 |
+
f"<MinSize>{asg['MinSize']}</MinSize>"
|
| 192 |
+
f"<MaxSize>{asg['MaxSize']}</MaxSize>"
|
| 193 |
+
f"<DesiredCapacity>{asg['DesiredCapacity']}</DesiredCapacity>"
|
| 194 |
+
f"<DefaultCooldown>{asg['DefaultCooldown']}</DefaultCooldown>"
|
| 195 |
+
f"<AvailabilityZones>{azs}</AvailabilityZones>"
|
| 196 |
+
f"<HealthCheckType>{asg['HealthCheckType']}</HealthCheckType>"
|
| 197 |
+
f"<HealthCheckGracePeriod>{asg['HealthCheckGracePeriod']}</HealthCheckGracePeriod>"
|
| 198 |
+
f"<CreatedTime>{asg['CreatedTime']}</CreatedTime>"
|
| 199 |
+
f"<VPCZoneIdentifier>{asg['VPCZoneIdentifier']}</VPCZoneIdentifier>"
|
| 200 |
+
f"<TerminationPolicies>{tp}</TerminationPolicies>"
|
| 201 |
+
f"<NewInstancesProtectedFromScaleIn>{'true' if asg['NewInstancesProtectedFromScaleIn'] else 'false'}</NewInstancesProtectedFromScaleIn>"
|
| 202 |
+
f"<Tags>{tags_xml}</Tags>"
|
| 203 |
+
f"<Instances/>"
|
| 204 |
+
f"{lt_xml}"
|
| 205 |
+
f"<LaunchConfigurationName>{asg.get('LaunchConfigurationName', '')}</LaunchConfigurationName>"
|
| 206 |
+
f"</member>")
|
| 207 |
+
return _xml(200, "DescribeAutoScalingGroupsResponse",
|
| 208 |
+
f"<DescribeAutoScalingGroupsResult><AutoScalingGroups>{members}</AutoScalingGroups></DescribeAutoScalingGroupsResult>")
|
| 209 |
+
|
| 210 |
+
|
| 211 |
+
def _update_asg(p):
|
| 212 |
+
name = _p(p, "AutoScalingGroupName")
|
| 213 |
+
asg = _asgs.get(name)
|
| 214 |
+
if not asg:
|
| 215 |
+
return _error("ValidationError", f"AutoScalingGroup {name} not found")
|
| 216 |
+
for k, pk in [("MinSize", "MinSize"), ("MaxSize", "MaxSize"), ("DesiredCapacity", "DesiredCapacity"),
|
| 217 |
+
("DefaultCooldown", "DefaultCooldown"), ("HealthCheckGracePeriod", "HealthCheckGracePeriod")]:
|
| 218 |
+
v = _p(p, pk)
|
| 219 |
+
if v:
|
| 220 |
+
asg[k] = int(v)
|
| 221 |
+
if _p(p, "HealthCheckType"):
|
| 222 |
+
asg["HealthCheckType"] = _p(p, "HealthCheckType")
|
| 223 |
+
if _p(p, "VPCZoneIdentifier"):
|
| 224 |
+
asg["VPCZoneIdentifier"] = _p(p, "VPCZoneIdentifier")
|
| 225 |
+
return _xml(200, "UpdateAutoScalingGroupResponse", "<UpdateAutoScalingGroupResult/>")
|
| 226 |
+
|
| 227 |
+
|
| 228 |
+
def _delete_asg(p):
|
| 229 |
+
name = _p(p, "AutoScalingGroupName")
|
| 230 |
+
_asgs.pop(name, None)
|
| 231 |
+
_tags.pop(name, None)
|
| 232 |
+
# Remove associated hooks
|
| 233 |
+
keys_to_del = [k for k in _hooks if k.startswith(f"{name}/")]
|
| 234 |
+
for k in keys_to_del:
|
| 235 |
+
del _hooks[k]
|
| 236 |
+
return _xml(200, "DeleteAutoScalingGroupResponse", "<DeleteAutoScalingGroupResult/>")
|
| 237 |
+
|
| 238 |
+
|
| 239 |
+
def _describe_asg_instances(p):
|
| 240 |
+
return _xml(200, "DescribeAutoScalingInstancesResponse",
|
| 241 |
+
"<DescribeAutoScalingInstancesResult><AutoScalingInstances/></DescribeAutoScalingInstancesResult>")
|
| 242 |
+
|
| 243 |
+
|
| 244 |
+
def _describe_scaling_activities(p):
|
| 245 |
+
return _xml(200, "DescribeScalingActivitiesResponse",
|
| 246 |
+
"<DescribeScalingActivitiesResult><Activities/></DescribeScalingActivitiesResult>")
|
| 247 |
+
|
| 248 |
+
|
| 249 |
+
# ---------------------------------------------------------------------------
|
| 250 |
+
# LaunchConfiguration
|
| 251 |
+
# ---------------------------------------------------------------------------
|
| 252 |
+
|
| 253 |
+
def _create_lc(p):
|
| 254 |
+
name = _p(p, "LaunchConfigurationName")
|
| 255 |
+
if not name:
|
| 256 |
+
return _error("ValidationError", "LaunchConfigurationName is required")
|
| 257 |
+
if name in _launch_configs:
|
| 258 |
+
return _error("AlreadyExistsFault", f"LaunchConfiguration {name} already exists")
|
| 259 |
+
arn = f"arn:aws:autoscaling:{get_region()}:{get_account_id()}:launchConfiguration:{new_uuid()}:launchConfigurationName/{name}"
|
| 260 |
+
_launch_configs[name] = {
|
| 261 |
+
"LaunchConfigurationName": name,
|
| 262 |
+
"LaunchConfigurationARN": arn,
|
| 263 |
+
"ImageId": _p(p, "ImageId") or "ami-00000000",
|
| 264 |
+
"InstanceType": _p(p, "InstanceType") or "t2.micro",
|
| 265 |
+
"KeyName": _p(p, "KeyName") or "",
|
| 266 |
+
"SecurityGroups": _parse_member_list(p, "SecurityGroups"),
|
| 267 |
+
"UserData": _p(p, "UserData") or "",
|
| 268 |
+
"CreatedTime": now_iso(),
|
| 269 |
+
}
|
| 270 |
+
return _xml(200, "CreateLaunchConfigurationResponse", "<CreateLaunchConfigurationResult/>")
|
| 271 |
+
|
| 272 |
+
|
| 273 |
+
def _describe_lcs(p):
|
| 274 |
+
names = _parse_member_list(p, "LaunchConfigurationNames")
|
| 275 |
+
members = ""
|
| 276 |
+
for name, lc in _launch_configs.items():
|
| 277 |
+
if names and name not in names:
|
| 278 |
+
continue
|
| 279 |
+
sgs = "".join(f"<member>{sg}</member>" for sg in lc.get("SecurityGroups", []))
|
| 280 |
+
members += (f"<member>"
|
| 281 |
+
f"<LaunchConfigurationName>{name}</LaunchConfigurationName>"
|
| 282 |
+
f"<LaunchConfigurationARN>{lc['LaunchConfigurationARN']}</LaunchConfigurationARN>"
|
| 283 |
+
f"<ImageId>{lc['ImageId']}</ImageId>"
|
| 284 |
+
f"<InstanceType>{lc['InstanceType']}</InstanceType>"
|
| 285 |
+
f"<CreatedTime>{lc['CreatedTime']}</CreatedTime>"
|
| 286 |
+
f"<SecurityGroups>{sgs}</SecurityGroups>"
|
| 287 |
+
f"</member>")
|
| 288 |
+
return _xml(200, "DescribeLaunchConfigurationsResponse",
|
| 289 |
+
f"<DescribeLaunchConfigurationsResult><LaunchConfigurations>{members}</LaunchConfigurations></DescribeLaunchConfigurationsResult>")
|
| 290 |
+
|
| 291 |
+
|
| 292 |
+
def _delete_lc(p):
|
| 293 |
+
name = _p(p, "LaunchConfigurationName")
|
| 294 |
+
_launch_configs.pop(name, None)
|
| 295 |
+
return _xml(200, "DeleteLaunchConfigurationResponse", "<DeleteLaunchConfigurationResult/>")
|
| 296 |
+
|
| 297 |
+
|
| 298 |
+
# ---------------------------------------------------------------------------
|
| 299 |
+
# Scaling Policy
|
| 300 |
+
# ---------------------------------------------------------------------------
|
| 301 |
+
|
| 302 |
+
def _put_scaling_policy(p):
|
| 303 |
+
asg_name = _p(p, "AutoScalingGroupName")
|
| 304 |
+
policy_name = _p(p, "PolicyName")
|
| 305 |
+
if not policy_name:
|
| 306 |
+
return _error("ValidationError", "PolicyName is required")
|
| 307 |
+
arn = f"arn:aws:autoscaling:{get_region()}:{get_account_id()}:scalingPolicy:{new_uuid()}:autoScalingGroupName/{asg_name}:policyName/{policy_name}"
|
| 308 |
+
key = f"{asg_name}/{policy_name}"
|
| 309 |
+
_policies[key] = {
|
| 310 |
+
"PolicyARN": arn,
|
| 311 |
+
"PolicyName": policy_name,
|
| 312 |
+
"AutoScalingGroupName": asg_name,
|
| 313 |
+
"PolicyType": _p(p, "PolicyType") or "SimpleScaling",
|
| 314 |
+
"AdjustmentType": _p(p, "AdjustmentType") or "ChangeInCapacity",
|
| 315 |
+
"ScalingAdjustment": int(_p(p, "ScalingAdjustment") or 0),
|
| 316 |
+
"Cooldown": int(_p(p, "Cooldown") or 300),
|
| 317 |
+
}
|
| 318 |
+
return _xml(200, "PutScalingPolicyResponse",
|
| 319 |
+
f"<PutScalingPolicyResult><PolicyARN>{arn}</PolicyARN></PutScalingPolicyResult>")
|
| 320 |
+
|
| 321 |
+
|
| 322 |
+
def _describe_policies(p):
|
| 323 |
+
asg_name = _p(p, "AutoScalingGroupName")
|
| 324 |
+
members = ""
|
| 325 |
+
for key, pol in _policies.items():
|
| 326 |
+
if asg_name and pol["AutoScalingGroupName"] != asg_name:
|
| 327 |
+
continue
|
| 328 |
+
members += (f"<member>"
|
| 329 |
+
f"<PolicyARN>{pol['PolicyARN']}</PolicyARN>"
|
| 330 |
+
f"<PolicyName>{pol['PolicyName']}</PolicyName>"
|
| 331 |
+
f"<AutoScalingGroupName>{pol['AutoScalingGroupName']}</AutoScalingGroupName>"
|
| 332 |
+
f"<PolicyType>{pol['PolicyType']}</PolicyType>"
|
| 333 |
+
f"<AdjustmentType>{pol.get('AdjustmentType', '')}</AdjustmentType>"
|
| 334 |
+
f"<ScalingAdjustment>{pol.get('ScalingAdjustment', 0)}</ScalingAdjustment>"
|
| 335 |
+
f"<Cooldown>{pol.get('Cooldown', 300)}</Cooldown>"
|
| 336 |
+
f"</member>")
|
| 337 |
+
return _xml(200, "DescribePoliciesResponse",
|
| 338 |
+
f"<DescribePoliciesResult><ScalingPolicies>{members}</ScalingPolicies></DescribePoliciesResult>")
|
| 339 |
+
|
| 340 |
+
|
| 341 |
+
def _delete_policy(p):
|
| 342 |
+
policy_name = _p(p, "PolicyName")
|
| 343 |
+
asg_name = _p(p, "AutoScalingGroupName")
|
| 344 |
+
key = f"{asg_name}/{policy_name}"
|
| 345 |
+
_policies.pop(key, None)
|
| 346 |
+
return _xml(200, "DeletePolicyResponse", "<DeletePolicyResult/>")
|
| 347 |
+
|
| 348 |
+
|
| 349 |
+
# ---------------------------------------------------------------------------
|
| 350 |
+
# Lifecycle Hook
|
| 351 |
+
# ---------------------------------------------------------------------------
|
| 352 |
+
|
| 353 |
+
def _put_lifecycle_hook(p):
|
| 354 |
+
asg_name = _p(p, "AutoScalingGroupName")
|
| 355 |
+
hook_name = _p(p, "LifecycleHookName")
|
| 356 |
+
key = f"{asg_name}/{hook_name}"
|
| 357 |
+
_hooks[key] = {
|
| 358 |
+
"LifecycleHookName": hook_name,
|
| 359 |
+
"AutoScalingGroupName": asg_name,
|
| 360 |
+
"LifecycleTransition": _p(p, "LifecycleTransition") or "autoscaling:EC2_INSTANCE_LAUNCHING",
|
| 361 |
+
"HeartbeatTimeout": int(_p(p, "HeartbeatTimeout") or 3600),
|
| 362 |
+
"DefaultResult": _p(p, "DefaultResult") or "ABANDON",
|
| 363 |
+
"NotificationTargetARN": _p(p, "NotificationTargetARN") or "",
|
| 364 |
+
"RoleARN": _p(p, "RoleARN") or "",
|
| 365 |
+
}
|
| 366 |
+
return _xml(200, "PutLifecycleHookResponse", "<PutLifecycleHookResult/>")
|
| 367 |
+
|
| 368 |
+
|
| 369 |
+
def _describe_lifecycle_hooks(p):
|
| 370 |
+
asg_name = _p(p, "AutoScalingGroupName")
|
| 371 |
+
members = ""
|
| 372 |
+
for key, hook in _hooks.items():
|
| 373 |
+
if hook["AutoScalingGroupName"] != asg_name:
|
| 374 |
+
continue
|
| 375 |
+
members += (f"<member>"
|
| 376 |
+
f"<LifecycleHookName>{hook['LifecycleHookName']}</LifecycleHookName>"
|
| 377 |
+
f"<AutoScalingGroupName>{hook['AutoScalingGroupName']}</AutoScalingGroupName>"
|
| 378 |
+
f"<LifecycleTransition>{hook['LifecycleTransition']}</LifecycleTransition>"
|
| 379 |
+
f"<HeartbeatTimeout>{hook['HeartbeatTimeout']}</HeartbeatTimeout>"
|
| 380 |
+
f"<DefaultResult>{hook['DefaultResult']}</DefaultResult>"
|
| 381 |
+
f"</member>")
|
| 382 |
+
return _xml(200, "DescribeLifecycleHooksResponse",
|
| 383 |
+
f"<DescribeLifecycleHooksResult><LifecycleHooks>{members}</LifecycleHooks></DescribeLifecycleHooksResult>")
|
| 384 |
+
|
| 385 |
+
|
| 386 |
+
def _delete_lifecycle_hook(p):
|
| 387 |
+
asg_name = _p(p, "AutoScalingGroupName")
|
| 388 |
+
hook_name = _p(p, "LifecycleHookName")
|
| 389 |
+
_hooks.pop(f"{asg_name}/{hook_name}", None)
|
| 390 |
+
return _xml(200, "DeleteLifecycleHookResponse", "<DeleteLifecycleHookResult/>")
|
| 391 |
+
|
| 392 |
+
|
| 393 |
+
def _complete_lifecycle_action(p):
|
| 394 |
+
return _xml(200, "CompleteLifecycleActionResponse", "<CompleteLifecycleActionResult/>")
|
| 395 |
+
|
| 396 |
+
|
| 397 |
+
def _record_lifecycle_heartbeat(p):
|
| 398 |
+
return _xml(200, "RecordLifecycleActionHeartbeatResponse", "<RecordLifecycleActionHeartbeatResult/>")
|
| 399 |
+
|
| 400 |
+
|
| 401 |
+
# ---------------------------------------------------------------------------
|
| 402 |
+
# Scheduled Action
|
| 403 |
+
# ---------------------------------------------------------------------------
|
| 404 |
+
|
| 405 |
+
def _put_scheduled_action(p):
|
| 406 |
+
asg_name = _p(p, "AutoScalingGroupName")
|
| 407 |
+
action_name = _p(p, "ScheduledActionName")
|
| 408 |
+
key = f"{asg_name}/{action_name}"
|
| 409 |
+
arn = f"arn:aws:autoscaling:{get_region()}:{get_account_id()}:scheduledUpdateGroupAction:{new_uuid()}:autoScalingGroupName/{asg_name}:scheduledActionName/{action_name}"
|
| 410 |
+
_scheduled_actions[key] = {
|
| 411 |
+
"ScheduledActionARN": arn,
|
| 412 |
+
"ScheduledActionName": action_name,
|
| 413 |
+
"AutoScalingGroupName": asg_name,
|
| 414 |
+
"Recurrence": _p(p, "Recurrence") or "",
|
| 415 |
+
"MinSize": int(_p(p, "MinSize") or -1),
|
| 416 |
+
"MaxSize": int(_p(p, "MaxSize") or -1),
|
| 417 |
+
"DesiredCapacity": int(_p(p, "DesiredCapacity") or -1),
|
| 418 |
+
}
|
| 419 |
+
return _xml(200, "PutScheduledUpdateGroupActionResponse", "<PutScheduledUpdateGroupActionResult/>")
|
| 420 |
+
|
| 421 |
+
|
| 422 |
+
def _describe_scheduled_actions(p):
|
| 423 |
+
asg_name = _p(p, "AutoScalingGroupName")
|
| 424 |
+
members = ""
|
| 425 |
+
for key, sa in _scheduled_actions.items():
|
| 426 |
+
if asg_name and sa["AutoScalingGroupName"] != asg_name:
|
| 427 |
+
continue
|
| 428 |
+
members += (f"<member>"
|
| 429 |
+
f"<ScheduledActionARN>{sa['ScheduledActionARN']}</ScheduledActionARN>"
|
| 430 |
+
f"<ScheduledActionName>{sa['ScheduledActionName']}</ScheduledActionName>"
|
| 431 |
+
f"<AutoScalingGroupName>{sa['AutoScalingGroupName']}</AutoScalingGroupName>"
|
| 432 |
+
f"</member>")
|
| 433 |
+
return _xml(200, "DescribeScheduledActionsResponse",
|
| 434 |
+
f"<DescribeScheduledActionsResult><ScheduledUpdateGroupActions>{members}</ScheduledUpdateGroupActions></DescribeScheduledActionsResult>")
|
| 435 |
+
|
| 436 |
+
|
| 437 |
+
def _delete_scheduled_action(p):
|
| 438 |
+
asg_name = _p(p, "AutoScalingGroupName")
|
| 439 |
+
action_name = _p(p, "ScheduledActionName")
|
| 440 |
+
_scheduled_actions.pop(f"{asg_name}/{action_name}", None)
|
| 441 |
+
return _xml(200, "DeleteScheduledActionResponse", "<DeleteScheduledActionResult/>")
|
| 442 |
+
|
| 443 |
+
|
| 444 |
+
# ---------------------------------------------------------------------------
|
| 445 |
+
# Tags
|
| 446 |
+
# ---------------------------------------------------------------------------
|
| 447 |
+
|
| 448 |
+
def _create_or_update_tags(p):
|
| 449 |
+
i = 1
|
| 450 |
+
while _p(p, f"Tags.member.{i}.Key"):
|
| 451 |
+
asg_name = _p(p, f"Tags.member.{i}.ResourceId")
|
| 452 |
+
tag = {
|
| 453 |
+
"Key": _p(p, f"Tags.member.{i}.Key"),
|
| 454 |
+
"Value": _p(p, f"Tags.member.{i}.Value"),
|
| 455 |
+
"ResourceId": asg_name,
|
| 456 |
+
"ResourceType": "auto-scaling-group",
|
| 457 |
+
"PropagateAtLaunch": _p(p, f"Tags.member.{i}.PropagateAtLaunch") == "true",
|
| 458 |
+
}
|
| 459 |
+
existing = _tags.setdefault(asg_name, [])
|
| 460 |
+
existing = [t for t in existing if t["Key"] != tag["Key"]]
|
| 461 |
+
existing.append(tag)
|
| 462 |
+
_tags[asg_name] = existing
|
| 463 |
+
if asg_name in _asgs:
|
| 464 |
+
_asgs[asg_name]["Tags"] = existing
|
| 465 |
+
i += 1
|
| 466 |
+
return _xml(200, "CreateOrUpdateTagsResponse", "<CreateOrUpdateTagsResult/>")
|
| 467 |
+
|
| 468 |
+
|
| 469 |
+
def _describe_tags(p):
|
| 470 |
+
members = ""
|
| 471 |
+
for asg_name, tag_list in _tags.items():
|
| 472 |
+
for t in tag_list:
|
| 473 |
+
members += (f"<member>"
|
| 474 |
+
f"<Key>{t['Key']}</Key><Value>{t['Value']}</Value>"
|
| 475 |
+
f"<ResourceId>{t['ResourceId']}</ResourceId>"
|
| 476 |
+
f"<ResourceType>{t['ResourceType']}</ResourceType>"
|
| 477 |
+
f"<PropagateAtLaunch>{'true' if t.get('PropagateAtLaunch') else 'false'}</PropagateAtLaunch>"
|
| 478 |
+
f"</member>")
|
| 479 |
+
return _xml(200, "DescribeTagsResponse",
|
| 480 |
+
f"<DescribeTagsResult><Tags>{members}</Tags></DescribeTagsResult>")
|
| 481 |
+
|
| 482 |
+
|
| 483 |
+
def _delete_tags(p):
|
| 484 |
+
i = 1
|
| 485 |
+
while _p(p, f"Tags.member.{i}.Key"):
|
| 486 |
+
asg_name = _p(p, f"Tags.member.{i}.ResourceId")
|
| 487 |
+
key = _p(p, f"Tags.member.{i}.Key")
|
| 488 |
+
existing = _tags.get(asg_name, [])
|
| 489 |
+
_tags[asg_name] = [t for t in existing if t["Key"] != key]
|
| 490 |
+
if asg_name in _asgs:
|
| 491 |
+
_asgs[asg_name]["Tags"] = _tags[asg_name]
|
| 492 |
+
i += 1
|
| 493 |
+
return _xml(200, "DeleteTagsResponse", "<DeleteTagsResult/>")
|
| 494 |
+
|
| 495 |
+
|
| 496 |
+
# ---------------------------------------------------------------------------
|
| 497 |
+
# Request handler
|
| 498 |
+
# ---------------------------------------------------------------------------
|
| 499 |
+
|
| 500 |
+
_ACTION_MAP = {
|
| 501 |
+
"CreateAutoScalingGroup": _create_asg,
|
| 502 |
+
"DescribeAutoScalingGroups": _describe_asgs,
|
| 503 |
+
"UpdateAutoScalingGroup": _update_asg,
|
| 504 |
+
"DeleteAutoScalingGroup": _delete_asg,
|
| 505 |
+
"DescribeAutoScalingInstances": _describe_asg_instances,
|
| 506 |
+
"DescribeScalingActivities": _describe_scaling_activities,
|
| 507 |
+
"CreateLaunchConfiguration": _create_lc,
|
| 508 |
+
"DescribeLaunchConfigurations": _describe_lcs,
|
| 509 |
+
"DeleteLaunchConfiguration": _delete_lc,
|
| 510 |
+
"PutScalingPolicy": _put_scaling_policy,
|
| 511 |
+
"DescribePolicies": _describe_policies,
|
| 512 |
+
"DeletePolicy": _delete_policy,
|
| 513 |
+
"PutLifecycleHook": _put_lifecycle_hook,
|
| 514 |
+
"DescribeLifecycleHooks": _describe_lifecycle_hooks,
|
| 515 |
+
"DeleteLifecycleHook": _delete_lifecycle_hook,
|
| 516 |
+
"CompleteLifecycleAction": _complete_lifecycle_action,
|
| 517 |
+
"RecordLifecycleActionHeartbeat": _record_lifecycle_heartbeat,
|
| 518 |
+
"PutScheduledUpdateGroupAction": _put_scheduled_action,
|
| 519 |
+
"DescribeScheduledActions": _describe_scheduled_actions,
|
| 520 |
+
"DeleteScheduledAction": _delete_scheduled_action,
|
| 521 |
+
"CreateOrUpdateTags": _create_or_update_tags,
|
| 522 |
+
"DescribeTags": _describe_tags,
|
| 523 |
+
"DeleteTags": _delete_tags,
|
| 524 |
+
}
|
| 525 |
+
|
| 526 |
+
|
| 527 |
+
async def handle_request(method, path, headers, body, query_params):
|
| 528 |
+
from urllib.parse import parse_qs
|
| 529 |
+
if body:
|
| 530 |
+
params = parse_qs(body.decode("utf-8", errors="replace"), keep_blank_values=True)
|
| 531 |
+
params = {k: v[0] if len(v) == 1 else v for k, v in params.items()}
|
| 532 |
+
else:
|
| 533 |
+
params = dict(query_params) if query_params else {}
|
| 534 |
+
|
| 535 |
+
action = params.get("Action", "")
|
| 536 |
+
if isinstance(action, list):
|
| 537 |
+
action = action[0]
|
| 538 |
+
|
| 539 |
+
handler = _ACTION_MAP.get(action)
|
| 540 |
+
if not handler:
|
| 541 |
+
s, h, b = _error("InvalidAction", f"Unknown AutoScaling action: {action}")
|
| 542 |
+
return s, h, b
|
| 543 |
+
|
| 544 |
+
s, h, b = handler(params)
|
| 545 |
+
return s, h, b
|
| 546 |
+
|
| 547 |
+
def get_state_summary() -> dict:
|
| 548 |
+
return {
|
| 549 |
+
"asgs": {"count": len(_asgs), "names": list(_asgs.keys())},
|
| 550 |
+
"launch_configs": {"count": len(_launch_configs), "names": list(_launch_configs.keys())},
|
| 551 |
+
"policies": {"count": len(_policies), "names": list(_policies.keys())},
|
| 552 |
+
"hooks": {"count": len(_hooks), "names": list(_hooks.keys())},
|
| 553 |
+
"scheduled_actions": {"count": len(_scheduled_actions), "names": list(_scheduled_actions.keys())},
|
| 554 |
+
}
|
aws_infra/ministack/services/cloudformation/__init__.py
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
CloudFormation Service Emulator -- AWS-compatible.
|
| 3 |
+
Supports: CreateStack, UpdateStack, DeleteStack, DescribeStacks, ListStacks,
|
| 4 |
+
DescribeStackEvents, DescribeStackResource, DescribeStackResources,
|
| 5 |
+
ListStackResources, GetTemplate, ValidateTemplate, ListExports,
|
| 6 |
+
CreateChangeSet, DescribeChangeSet, ExecuteChangeSet,
|
| 7 |
+
DeleteChangeSet, ListChangeSets,
|
| 8 |
+
GetTemplateSummary.
|
| 9 |
+
Uses Query API (Action=...) with form-encoded body.
|
| 10 |
+
"""
|
| 11 |
+
|
| 12 |
+
import json
|
| 13 |
+
import logging
|
| 14 |
+
import os
|
| 15 |
+
from urllib.parse import parse_qs
|
| 16 |
+
|
| 17 |
+
from ministack.core.responses import AccountScopedDict
|
| 18 |
+
|
| 19 |
+
logger = logging.getLogger("cloudformation")
|
| 20 |
+
|
| 21 |
+
REGION = os.environ.get("MINISTACK_REGION", "us-east-1")
|
| 22 |
+
|
| 23 |
+
# In-memory state (shared across all submodules)
|
| 24 |
+
_stacks = AccountScopedDict() # stack_name -> stack dict
|
| 25 |
+
_stack_events = AccountScopedDict() # stack_id -> [event list]
|
| 26 |
+
_exports = AccountScopedDict() # export_name -> {StackId, Name, Value}
|
| 27 |
+
_change_sets = AccountScopedDict() # cs_id -> change set dict
|
| 28 |
+
|
| 29 |
+
# Re-exports for compatibility
|
| 30 |
+
from .engine import ( # noqa: E402
|
| 31 |
+
_parse_template,
|
| 32 |
+
_resolve_parameters,
|
| 33 |
+
_evaluate_conditions,
|
| 34 |
+
_resolve_refs,
|
| 35 |
+
_extract_deps,
|
| 36 |
+
_topological_sort,
|
| 37 |
+
_NO_VALUE,
|
| 38 |
+
)
|
| 39 |
+
|
| 40 |
+
from .helpers import _p # noqa: E402
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
async def handle_request(method: str, path: str, headers: dict,
|
| 44 |
+
body: bytes, query_params: dict) -> tuple:
|
| 45 |
+
params = dict(query_params)
|
| 46 |
+
content_type = headers.get("content-type", "")
|
| 47 |
+
target = headers.get("x-amz-target", "")
|
| 48 |
+
|
| 49 |
+
# JSON protocol (newer SDKs): X-Amz-Target: CloudFormation_20100515.ActionName
|
| 50 |
+
if "amz-json" in content_type and target.startswith("CloudFormation_20100515."):
|
| 51 |
+
action_name = target.split(".")[-1]
|
| 52 |
+
params["Action"] = [action_name]
|
| 53 |
+
if body:
|
| 54 |
+
try:
|
| 55 |
+
json_body = json.loads(body)
|
| 56 |
+
for k, v in json_body.items():
|
| 57 |
+
params[k] = [str(v)] if not isinstance(v, list) else v
|
| 58 |
+
except (json.JSONDecodeError, TypeError):
|
| 59 |
+
pass
|
| 60 |
+
elif method == "POST" and body:
|
| 61 |
+
form_params = parse_qs(body.decode("utf-8", errors="replace"))
|
| 62 |
+
for k, v in form_params.items():
|
| 63 |
+
params[k] = v
|
| 64 |
+
|
| 65 |
+
action = _p(params, "Action")
|
| 66 |
+
handler = _ACTION_HANDLERS.get(action)
|
| 67 |
+
if not handler:
|
| 68 |
+
from .helpers import _error
|
| 69 |
+
return _error("InvalidAction", f"Unknown action: {action}", 400)
|
| 70 |
+
return handler(params)
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
# ---------------------------------------------------------------------------
|
| 74 |
+
# Supported Actions
|
| 75 |
+
# ---------------------------------------------------------------------------
|
| 76 |
+
|
| 77 |
+
SUPPORTED_ACTIONS = [
|
| 78 |
+
"CreateStack", "UpdateStack", "DeleteStack", "DescribeStacks",
|
| 79 |
+
"ListStacks", "DescribeStackEvents", "DescribeStackResource",
|
| 80 |
+
"DescribeStackResources", "GetTemplate", "ValidateTemplate",
|
| 81 |
+
"ListExports", "CreateChangeSet", "DescribeChangeSet",
|
| 82 |
+
"ExecuteChangeSet", "DeleteChangeSet", "ListChangeSets",
|
| 83 |
+
"GetTemplateSummary",
|
| 84 |
+
]
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
# ---------------------------------------------------------------------------
|
| 88 |
+
# State
|
| 89 |
+
# ---------------------------------------------------------------------------
|
| 90 |
+
|
| 91 |
+
def get_state_summary() -> dict:
|
| 92 |
+
return {
|
| 93 |
+
"stacks": {"count": len(_stacks), "names": list(_stacks.keys())},
|
| 94 |
+
"change_sets": {"count": len(_change_sets), "ids": list(_change_sets.keys())},
|
| 95 |
+
"stack_events": {"count": len(_stack_events), "ids": list(_stack_events.keys())},
|
| 96 |
+
"exports": {"count": len(_exports), "names": list(_exports.keys())},
|
| 97 |
+
}
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
def reset():
|
| 101 |
+
_stacks.clear()
|
| 102 |
+
_stack_events.clear()
|
| 103 |
+
_exports.clear()
|
| 104 |
+
_change_sets.clear()
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
# Must be last — handlers imports from this module
|
| 108 |
+
from .handlers import _ACTION_HANDLERS, _validate_template # noqa: E402
|
| 109 |
+
from ministack.core.responses import AccountScopedDict, get_account_id
|
aws_infra/ministack/services/cloudformation/changesets.py
ADDED
|
@@ -0,0 +1,323 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
CloudFormation change set handlers — Create, Describe, Execute, Delete, List change sets.
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
import asyncio
|
| 6 |
+
import copy
|
| 7 |
+
import json
|
| 8 |
+
import logging
|
| 9 |
+
|
| 10 |
+
from ministack.core.responses import get_account_id, new_uuid, now_iso
|
| 11 |
+
|
| 12 |
+
from .engine import (
|
| 13 |
+
_evaluate_conditions, _parse_template, _resolve_parameters,
|
| 14 |
+
_resolve_refs, _NO_VALUE,
|
| 15 |
+
)
|
| 16 |
+
from .stacks import _add_event, _deploy_stack_async, _diff_resources
|
| 17 |
+
from .provisioners import REGION
|
| 18 |
+
from .helpers import _xml, _error, _p, _esc, _extract_members, _resolve_template, CFN_NS
|
| 19 |
+
|
| 20 |
+
logger = logging.getLogger("cloudformation")
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def _find_change_set(cs_name, stack_name=""):
|
| 24 |
+
"""Look up a change set by ID or by name+stack. Returns (cs_id, cs_dict) or (None, None)."""
|
| 25 |
+
from ministack.services.cloudformation import _change_sets
|
| 26 |
+
if cs_name in _change_sets:
|
| 27 |
+
return cs_name, _change_sets[cs_name]
|
| 28 |
+
for cid, c in _change_sets.items():
|
| 29 |
+
if c["ChangeSetName"] == cs_name:
|
| 30 |
+
if not stack_name or c["StackName"] == stack_name:
|
| 31 |
+
return cid, c
|
| 32 |
+
return None, None
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
# --- CreateChangeSet ---
|
| 36 |
+
|
| 37 |
+
def _create_change_set(params):
|
| 38 |
+
from ministack.services.cloudformation import _stacks, _stack_events, _change_sets
|
| 39 |
+
stack_name = _p(params, "StackName")
|
| 40 |
+
cs_name = _p(params, "ChangeSetName")
|
| 41 |
+
cs_type = _p(params, "ChangeSetType", "UPDATE")
|
| 42 |
+
|
| 43 |
+
if not stack_name:
|
| 44 |
+
return _error("ValidationError", "StackName is required")
|
| 45 |
+
if not cs_name:
|
| 46 |
+
return _error("ValidationError", "ChangeSetName is required")
|
| 47 |
+
|
| 48 |
+
template_body, resolve_err = _resolve_template(params)
|
| 49 |
+
if resolve_err:
|
| 50 |
+
return resolve_err
|
| 51 |
+
|
| 52 |
+
provided_params = _extract_members(params, "Parameters")
|
| 53 |
+
tags = _extract_members(params, "Tags")
|
| 54 |
+
|
| 55 |
+
stack = _stacks.get(stack_name)
|
| 56 |
+
|
| 57 |
+
if cs_type == "CREATE":
|
| 58 |
+
if stack and stack.get("StackStatus") not in (
|
| 59 |
+
"DELETE_COMPLETE", "ROLLBACK_COMPLETE", "REVIEW_IN_PROGRESS"
|
| 60 |
+
):
|
| 61 |
+
return _error("AlreadyExistsException",
|
| 62 |
+
f"Stack [{stack_name}] already exists")
|
| 63 |
+
if not template_body:
|
| 64 |
+
return _error("ValidationError", "TemplateBody or TemplateURL is required")
|
| 65 |
+
|
| 66 |
+
# Create a placeholder stack in REVIEW_IN_PROGRESS
|
| 67 |
+
stack_id = (
|
| 68 |
+
f"arn:aws:cloudformation:{REGION}:{get_account_id()}:"
|
| 69 |
+
f"stack/{stack_name}/{new_uuid()}"
|
| 70 |
+
)
|
| 71 |
+
stack = {
|
| 72 |
+
"StackName": stack_name,
|
| 73 |
+
"StackId": stack_id,
|
| 74 |
+
"StackStatus": "REVIEW_IN_PROGRESS",
|
| 75 |
+
"StackStatusReason": "",
|
| 76 |
+
"CreationTime": now_iso(),
|
| 77 |
+
"LastUpdatedTime": now_iso(),
|
| 78 |
+
"Description": "",
|
| 79 |
+
"Parameters": [],
|
| 80 |
+
"Tags": tags,
|
| 81 |
+
"Outputs": [],
|
| 82 |
+
"DisableRollback": False,
|
| 83 |
+
"_resources": {},
|
| 84 |
+
"_template": {},
|
| 85 |
+
"_template_body": "",
|
| 86 |
+
"_resolved_params": {},
|
| 87 |
+
"_conditions": {},
|
| 88 |
+
}
|
| 89 |
+
_stacks[stack_name] = stack
|
| 90 |
+
_stack_events[stack_id] = []
|
| 91 |
+
else:
|
| 92 |
+
if not stack:
|
| 93 |
+
return _error("ValidationError",
|
| 94 |
+
f"Stack [{stack_name}] does not exist")
|
| 95 |
+
stack_id = stack["StackId"]
|
| 96 |
+
if not template_body:
|
| 97 |
+
template_body = stack.get("_template_body", "{}")
|
| 98 |
+
|
| 99 |
+
try:
|
| 100 |
+
template = _parse_template(template_body)
|
| 101 |
+
except Exception as e:
|
| 102 |
+
return _error("ValidationError", f"Template format error: {e}")
|
| 103 |
+
|
| 104 |
+
try:
|
| 105 |
+
param_values = _resolve_parameters(template, provided_params)
|
| 106 |
+
except ValueError as exc:
|
| 107 |
+
return _error("ValidationError", str(exc))
|
| 108 |
+
|
| 109 |
+
# Compute changes
|
| 110 |
+
old_template = stack.get("_template", {}) if cs_type == "UPDATE" else {}
|
| 111 |
+
changes = _diff_resources(old_template, template)
|
| 112 |
+
|
| 113 |
+
cs_id = (
|
| 114 |
+
f"arn:aws:cloudformation:{REGION}:{get_account_id()}:"
|
| 115 |
+
f"changeSet/{cs_name}/{new_uuid()}"
|
| 116 |
+
)
|
| 117 |
+
|
| 118 |
+
change_set = {
|
| 119 |
+
"ChangeSetId": cs_id,
|
| 120 |
+
"ChangeSetName": cs_name,
|
| 121 |
+
"StackId": stack_id,
|
| 122 |
+
"StackName": stack_name,
|
| 123 |
+
"Status": "CREATE_COMPLETE",
|
| 124 |
+
"ExecutionStatus": "AVAILABLE",
|
| 125 |
+
"CreationTime": now_iso(),
|
| 126 |
+
"Description": _p(params, "Description", ""),
|
| 127 |
+
"ChangeSetType": cs_type,
|
| 128 |
+
"Changes": changes,
|
| 129 |
+
"Parameters": [
|
| 130 |
+
{"ParameterKey": k, "ParameterValue": v["Value"]}
|
| 131 |
+
for k, v in param_values.items()
|
| 132 |
+
],
|
| 133 |
+
"Tags": tags,
|
| 134 |
+
"_template": template,
|
| 135 |
+
"_template_body": template_body,
|
| 136 |
+
"_resolved_params": param_values,
|
| 137 |
+
}
|
| 138 |
+
_change_sets[cs_id] = change_set
|
| 139 |
+
|
| 140 |
+
return _xml(200, "CreateChangeSetResponse",
|
| 141 |
+
f"<CreateChangeSetResult>"
|
| 142 |
+
f"<Id>{cs_id}</Id>"
|
| 143 |
+
f"<StackId>{stack_id}</StackId>"
|
| 144 |
+
f"</CreateChangeSetResult>")
|
| 145 |
+
|
| 146 |
+
|
| 147 |
+
# --- DescribeChangeSet ---
|
| 148 |
+
|
| 149 |
+
def _describe_change_set(params):
|
| 150 |
+
cs_name = _p(params, "ChangeSetName")
|
| 151 |
+
stack_name = _p(params, "StackName")
|
| 152 |
+
_, cs = _find_change_set(cs_name, stack_name)
|
| 153 |
+
if not cs:
|
| 154 |
+
return _error("ChangeSetNotFoundException",
|
| 155 |
+
f"ChangeSet [{cs_name}] does not exist")
|
| 156 |
+
|
| 157 |
+
params_xml = ""
|
| 158 |
+
for p in cs.get("Parameters", []):
|
| 159 |
+
params_xml += (
|
| 160 |
+
"<member>"
|
| 161 |
+
f"<ParameterKey>{_esc(p['ParameterKey'])}</ParameterKey>"
|
| 162 |
+
f"<ParameterValue>{_esc(str(p['ParameterValue']))}</ParameterValue>"
|
| 163 |
+
"</member>"
|
| 164 |
+
)
|
| 165 |
+
|
| 166 |
+
changes_xml = ""
|
| 167 |
+
for ch in cs.get("Changes", []):
|
| 168 |
+
rc = ch.get("ResourceChange", {})
|
| 169 |
+
changes_xml += (
|
| 170 |
+
"<member><ResourceChange>"
|
| 171 |
+
f"<Action>{rc.get('Action', '')}</Action>"
|
| 172 |
+
f"<LogicalResourceId>{_esc(rc.get('LogicalResourceId', ''))}</LogicalResourceId>"
|
| 173 |
+
f"<ResourceType>{_esc(rc.get('ResourceType', ''))}</ResourceType>"
|
| 174 |
+
f"<Replacement>{rc.get('Replacement', '')}</Replacement>"
|
| 175 |
+
"</ResourceChange></member>"
|
| 176 |
+
)
|
| 177 |
+
|
| 178 |
+
tags_xml = ""
|
| 179 |
+
for t in cs.get("Tags", []):
|
| 180 |
+
tags_xml += (
|
| 181 |
+
"<member>"
|
| 182 |
+
f"<Key>{_esc(t.get('Key', ''))}</Key>"
|
| 183 |
+
f"<Value>{_esc(t.get('Value', ''))}</Value>"
|
| 184 |
+
"</member>"
|
| 185 |
+
)
|
| 186 |
+
|
| 187 |
+
inner = (
|
| 188 |
+
f"<ChangeSetId>{_esc(cs['ChangeSetId'])}</ChangeSetId>"
|
| 189 |
+
f"<ChangeSetName>{_esc(cs['ChangeSetName'])}</ChangeSetName>"
|
| 190 |
+
f"<StackId>{_esc(cs['StackId'])}</StackId>"
|
| 191 |
+
f"<StackName>{_esc(cs['StackName'])}</StackName>"
|
| 192 |
+
f"<Status>{cs['Status']}</Status>"
|
| 193 |
+
f"<ExecutionStatus>{cs['ExecutionStatus']}</ExecutionStatus>"
|
| 194 |
+
f"<CreationTime>{cs['CreationTime']}</CreationTime>"
|
| 195 |
+
f"<Description>{_esc(cs.get('Description', ''))}</Description>"
|
| 196 |
+
f"<ChangeSetType>{cs.get('ChangeSetType', '')}</ChangeSetType>"
|
| 197 |
+
f"<Parameters>{params_xml}</Parameters>"
|
| 198 |
+
f"<Changes>{changes_xml}</Changes>"
|
| 199 |
+
f"<Tags>{tags_xml}</Tags>"
|
| 200 |
+
)
|
| 201 |
+
|
| 202 |
+
return _xml(200, "DescribeChangeSetResponse",
|
| 203 |
+
f"<DescribeChangeSetResult>{inner}</DescribeChangeSetResult>")
|
| 204 |
+
|
| 205 |
+
|
| 206 |
+
# --- ExecuteChangeSet ---
|
| 207 |
+
|
| 208 |
+
def _execute_change_set(params):
|
| 209 |
+
from ministack.services.cloudformation import _stacks
|
| 210 |
+
cs_name = _p(params, "ChangeSetName")
|
| 211 |
+
stack_name = _p(params, "StackName")
|
| 212 |
+
_, cs = _find_change_set(cs_name, stack_name)
|
| 213 |
+
if not cs:
|
| 214 |
+
return _error("ChangeSetNotFoundException",
|
| 215 |
+
f"ChangeSet [{cs_name}] does not exist")
|
| 216 |
+
|
| 217 |
+
if cs["ExecutionStatus"] != "AVAILABLE":
|
| 218 |
+
return _error("InvalidChangeSetStatusException",
|
| 219 |
+
f"ChangeSet [{cs_name}] is in {cs['ExecutionStatus']} status")
|
| 220 |
+
|
| 221 |
+
cs["ExecutionStatus"] = "EXECUTE_IN_PROGRESS"
|
| 222 |
+
real_stack_name = cs["StackName"]
|
| 223 |
+
stack = _stacks.get(real_stack_name)
|
| 224 |
+
if not stack:
|
| 225 |
+
return _error("ValidationError",
|
| 226 |
+
f"Stack [{real_stack_name}] does not exist")
|
| 227 |
+
|
| 228 |
+
stack_id = stack["StackId"]
|
| 229 |
+
template = cs["_template"]
|
| 230 |
+
template_body = cs["_template_body"]
|
| 231 |
+
param_values = cs["_resolved_params"]
|
| 232 |
+
tags = cs.get("Tags", [])
|
| 233 |
+
cs_type = cs.get("ChangeSetType", "UPDATE")
|
| 234 |
+
is_update = cs_type == "UPDATE"
|
| 235 |
+
|
| 236 |
+
if is_update:
|
| 237 |
+
previous_stack = {
|
| 238 |
+
"_resources": copy.deepcopy(stack.get("_resources", {})),
|
| 239 |
+
"_template": copy.deepcopy(stack.get("_template", {})),
|
| 240 |
+
"_template_body": stack.get("_template_body", ""),
|
| 241 |
+
"_resolved_params": copy.deepcopy(stack.get("_resolved_params", {})),
|
| 242 |
+
"Outputs": copy.deepcopy(stack.get("Outputs", [])),
|
| 243 |
+
}
|
| 244 |
+
else:
|
| 245 |
+
previous_stack = None
|
| 246 |
+
|
| 247 |
+
status_prefix = "UPDATE" if is_update else "CREATE"
|
| 248 |
+
stack["StackStatus"] = f"{status_prefix}_IN_PROGRESS"
|
| 249 |
+
stack["LastUpdatedTime"] = now_iso()
|
| 250 |
+
stack["_template_body"] = template_body
|
| 251 |
+
if tags:
|
| 252 |
+
stack["Tags"] = tags
|
| 253 |
+
stack["Parameters"] = [
|
| 254 |
+
{"ParameterKey": k, "ParameterValue": v["Value"], "NoEcho": v["NoEcho"]}
|
| 255 |
+
for k, v in param_values.items()
|
| 256 |
+
]
|
| 257 |
+
stack["_conditions"] = _evaluate_conditions(template, param_values)
|
| 258 |
+
|
| 259 |
+
_add_event(stack_id, real_stack_name, real_stack_name,
|
| 260 |
+
"AWS::CloudFormation::Stack", f"{status_prefix}_IN_PROGRESS",
|
| 261 |
+
physical_id=stack_id)
|
| 262 |
+
|
| 263 |
+
asyncio.get_event_loop().create_task(
|
| 264 |
+
_deploy_stack_async(real_stack_name, stack_id, template,
|
| 265 |
+
param_values, False, tags,
|
| 266 |
+
is_update=is_update,
|
| 267 |
+
previous_stack=previous_stack)
|
| 268 |
+
)
|
| 269 |
+
|
| 270 |
+
cs["ExecutionStatus"] = "EXECUTE_COMPLETE"
|
| 271 |
+
cs["Status"] = "EXECUTE_COMPLETE"
|
| 272 |
+
|
| 273 |
+
return _xml(200, "ExecuteChangeSetResponse",
|
| 274 |
+
"<ExecuteChangeSetResult></ExecuteChangeSetResult>")
|
| 275 |
+
|
| 276 |
+
|
| 277 |
+
# --- DeleteChangeSet ---
|
| 278 |
+
|
| 279 |
+
def _delete_change_set(params):
|
| 280 |
+
from ministack.services.cloudformation import _change_sets
|
| 281 |
+
cs_name = _p(params, "ChangeSetName")
|
| 282 |
+
stack_name = _p(params, "StackName")
|
| 283 |
+
cs_id, cs = _find_change_set(cs_name, stack_name)
|
| 284 |
+
if not cs_id:
|
| 285 |
+
return _error("ChangeSetNotFoundException",
|
| 286 |
+
f"ChangeSet [{cs_name}] does not exist")
|
| 287 |
+
_change_sets.pop(cs_id, None)
|
| 288 |
+
return _xml(200, "DeleteChangeSetResponse", "")
|
| 289 |
+
|
| 290 |
+
|
| 291 |
+
# --- ListChangeSets ---
|
| 292 |
+
|
| 293 |
+
def _list_change_sets(params):
|
| 294 |
+
from ministack.services.cloudformation import _stacks, _change_sets
|
| 295 |
+
stack_name = _p(params, "StackName")
|
| 296 |
+
if not stack_name:
|
| 297 |
+
return _error("ValidationError", "StackName is required")
|
| 298 |
+
|
| 299 |
+
members = ""
|
| 300 |
+
for cs in _change_sets.values():
|
| 301 |
+
if cs["StackName"] != stack_name:
|
| 302 |
+
continue
|
| 303 |
+
members += (
|
| 304 |
+
"<member>"
|
| 305 |
+
f"<ChangeSetId>{_esc(cs['ChangeSetId'])}</ChangeSetId>"
|
| 306 |
+
f"<ChangeSetName>{_esc(cs['ChangeSetName'])}</ChangeSetName>"
|
| 307 |
+
f"<StackId>{_esc(cs['StackId'])}</StackId>"
|
| 308 |
+
f"<StackName>{_esc(cs['StackName'])}</StackName>"
|
| 309 |
+
f"<Status>{cs['Status']}</Status>"
|
| 310 |
+
f"<ExecutionStatus>{cs['ExecutionStatus']}</ExecutionStatus>"
|
| 311 |
+
f"<CreationTime>{cs['CreationTime']}</CreationTime>"
|
| 312 |
+
f"<Description>{_esc(cs.get('Description', ''))}</Description>"
|
| 313 |
+
"</member>"
|
| 314 |
+
)
|
| 315 |
+
|
| 316 |
+
return _xml(200, "ListChangeSetsResponse",
|
| 317 |
+
f"<ListChangeSetsResult>"
|
| 318 |
+
f"<Summaries>{members}</Summaries>"
|
| 319 |
+
f"</ListChangeSetsResult>")
|
| 320 |
+
|
| 321 |
+
|
| 322 |
+
# --- GetTemplateSummary ---
|
| 323 |
+
|
aws_infra/ministack/services/cloudformation/engine.py
ADDED
|
@@ -0,0 +1,545 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
CloudFormation engine — pure functions for template parsing, parameter resolution,
|
| 3 |
+
condition evaluation, intrinsic function resolution, and topological sorting.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import base64
|
| 7 |
+
import os
|
| 8 |
+
import heapq
|
| 9 |
+
import json
|
| 10 |
+
import re
|
| 11 |
+
from collections import defaultdict
|
| 12 |
+
|
| 13 |
+
import yaml
|
| 14 |
+
|
| 15 |
+
from ministack.core.responses import get_account_id, get_region, new_uuid
|
| 16 |
+
|
| 17 |
+
# Sentinel for AWS::NoValue
|
| 18 |
+
_NO_VALUE = object()
|
| 19 |
+
|
| 20 |
+
# REGION kept for backwards compat with old imports; new code must prefer
|
| 21 |
+
# get_region() so AWS::Region reflects the caller's request region (#398).
|
| 22 |
+
REGION = os.environ.get("MINISTACK_REGION", "us-east-1")
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
# ===========================================================================
|
| 26 |
+
# YAML Parser -- CloudFormation tag support
|
| 27 |
+
# ===========================================================================
|
| 28 |
+
|
| 29 |
+
class CfnLoader(yaml.SafeLoader):
|
| 30 |
+
"""YAML loader that handles CloudFormation intrinsic function tags."""
|
| 31 |
+
pass
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def _construct_cfn_tag(tag_name):
|
| 35 |
+
"""Build a constructor that wraps the value in {tag_name: value}."""
|
| 36 |
+
def constructor(loader, node):
|
| 37 |
+
if isinstance(node, yaml.ScalarNode):
|
| 38 |
+
val = loader.construct_scalar(node)
|
| 39 |
+
elif isinstance(node, yaml.SequenceNode):
|
| 40 |
+
val = loader.construct_sequence(node, deep=True)
|
| 41 |
+
elif isinstance(node, yaml.MappingNode):
|
| 42 |
+
val = loader.construct_mapping(node, deep=True)
|
| 43 |
+
else:
|
| 44 |
+
val = loader.construct_scalar(node)
|
| 45 |
+
return {tag_name: val}
|
| 46 |
+
return constructor
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def _construct_getatt(loader, node):
|
| 50 |
+
"""!GetAtt -- scalar 'A.B' splits on first dot; sequence passes through."""
|
| 51 |
+
if isinstance(node, yaml.ScalarNode):
|
| 52 |
+
val = loader.construct_scalar(node)
|
| 53 |
+
parts = val.split(".", 1)
|
| 54 |
+
if len(parts) == 2:
|
| 55 |
+
return {"Fn::GetAtt": [parts[0], parts[1]]}
|
| 56 |
+
return {"Fn::GetAtt": [val, ""]}
|
| 57 |
+
if isinstance(node, yaml.SequenceNode):
|
| 58 |
+
val = loader.construct_sequence(node, deep=True)
|
| 59 |
+
return {"Fn::GetAtt": val}
|
| 60 |
+
val = loader.construct_scalar(node)
|
| 61 |
+
return {"Fn::GetAtt": [val, ""]}
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
def _construct_timestamp(loader, node):
|
| 65 |
+
"""Override timestamp to preserve date strings as plain strings."""
|
| 66 |
+
return loader.construct_scalar(node)
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
# Register all CFN tags
|
| 70 |
+
_SIMPLE_TAGS = {
|
| 71 |
+
"!Ref": "Ref",
|
| 72 |
+
"!Sub": "Fn::Sub",
|
| 73 |
+
"!Join": "Fn::Join",
|
| 74 |
+
"!Split": "Fn::Split",
|
| 75 |
+
"!Select": "Fn::Select",
|
| 76 |
+
"!If": "Fn::If",
|
| 77 |
+
"!Equals": "Fn::Equals",
|
| 78 |
+
"!And": "Fn::And",
|
| 79 |
+
"!Or": "Fn::Or",
|
| 80 |
+
"!Not": "Fn::Not",
|
| 81 |
+
"!Base64": "Fn::Base64",
|
| 82 |
+
"!FindInMap": "Fn::FindInMap",
|
| 83 |
+
"!ImportValue": "Fn::ImportValue",
|
| 84 |
+
"!GetAZs": "Fn::GetAZs",
|
| 85 |
+
"!Condition": "Condition",
|
| 86 |
+
"!Cidr": "Fn::Cidr",
|
| 87 |
+
}
|
| 88 |
+
|
| 89 |
+
for _tag, _fn_name in _SIMPLE_TAGS.items():
|
| 90 |
+
CfnLoader.add_constructor(_tag, _construct_cfn_tag(_fn_name))
|
| 91 |
+
|
| 92 |
+
CfnLoader.add_constructor("!GetAtt", _construct_getatt)
|
| 93 |
+
# Preserve date strings -- override the implicit timestamp resolver
|
| 94 |
+
CfnLoader.add_constructor("tag:yaml.org,2002:timestamp", _construct_timestamp)
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
def _parse_template(template_body: str) -> dict:
|
| 98 |
+
"""Parse a CFN template from JSON or YAML."""
|
| 99 |
+
template_body = template_body.strip()
|
| 100 |
+
if template_body.startswith("{"):
|
| 101 |
+
result = json.loads(template_body)
|
| 102 |
+
else:
|
| 103 |
+
result = yaml.load(template_body, Loader=CfnLoader)
|
| 104 |
+
if not isinstance(result, dict):
|
| 105 |
+
raise ValueError("Template must be a JSON or YAML mapping")
|
| 106 |
+
return result
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
# ===========================================================================
|
| 110 |
+
# Parameter Resolver
|
| 111 |
+
# ===========================================================================
|
| 112 |
+
|
| 113 |
+
_AWS_SPECIFIC_TYPES = {
|
| 114 |
+
"AWS::SSM::Parameter::Type",
|
| 115 |
+
"AWS::SSM::Parameter::Value<String>",
|
| 116 |
+
"AWS::SSM::Parameter::Value<List<String>>",
|
| 117 |
+
"AWS::SSM::Parameter::Value<AWS::EC2::Image::Id>",
|
| 118 |
+
"AWS::EC2::AvailabilityZone::Name",
|
| 119 |
+
"AWS::EC2::Image::Id",
|
| 120 |
+
"AWS::EC2::Instance::Id",
|
| 121 |
+
"AWS::EC2::KeyPair::KeyName",
|
| 122 |
+
"AWS::EC2::SecurityGroup::GroupName",
|
| 123 |
+
"AWS::EC2::SecurityGroup::Id",
|
| 124 |
+
"AWS::EC2::Subnet::Id",
|
| 125 |
+
"AWS::EC2::Volume::Id",
|
| 126 |
+
"AWS::EC2::VPC::Id",
|
| 127 |
+
"AWS::Route53::HostedZone::Id",
|
| 128 |
+
}
|
| 129 |
+
|
| 130 |
+
|
| 131 |
+
def _resolve_parameters(template: dict, provided_params: list[dict]) -> dict:
|
| 132 |
+
"""Resolve template parameters with provided values and defaults.
|
| 133 |
+
|
| 134 |
+
Returns dict of param_name -> {Value, NoEcho}.
|
| 135 |
+
"""
|
| 136 |
+
param_defs = template.get("Parameters", {})
|
| 137 |
+
provided_map = {p["Key"]: p["Value"] for p in provided_params if "Key" in p}
|
| 138 |
+
resolved = {}
|
| 139 |
+
|
| 140 |
+
for name, defn in param_defs.items():
|
| 141 |
+
ptype = defn.get("Type", "String")
|
| 142 |
+
no_echo = str(defn.get("NoEcho", "false")).lower() == "true"
|
| 143 |
+
|
| 144 |
+
if name in provided_map:
|
| 145 |
+
value = provided_map[name]
|
| 146 |
+
elif "Default" in defn:
|
| 147 |
+
value = defn["Default"]
|
| 148 |
+
else:
|
| 149 |
+
raise ValueError(f"Parameter '{name}' has no Default and was not provided")
|
| 150 |
+
|
| 151 |
+
value = str(value) if value is not None else ""
|
| 152 |
+
|
| 153 |
+
# Validate AllowedValues
|
| 154 |
+
allowed = defn.get("AllowedValues")
|
| 155 |
+
if allowed and value not in [str(a) for a in allowed]:
|
| 156 |
+
raise ValueError(
|
| 157 |
+
f"Parameter '{name}' value '{value}' is not in AllowedValues: {allowed}"
|
| 158 |
+
)
|
| 159 |
+
|
| 160 |
+
# Type coercion
|
| 161 |
+
if ptype == "Number":
|
| 162 |
+
# Validate it's numeric but keep as string for consistency
|
| 163 |
+
try:
|
| 164 |
+
float(value)
|
| 165 |
+
except ValueError:
|
| 166 |
+
raise ValueError(f"Parameter '{name}' value '{value}' is not a valid Number")
|
| 167 |
+
elif ptype == "CommaDelimitedList":
|
| 168 |
+
# Keep as string; Fn::Select will split
|
| 169 |
+
pass
|
| 170 |
+
# AWS-specific types treated as String -- no extra validation
|
| 171 |
+
|
| 172 |
+
resolved[name] = {"Value": value, "NoEcho": no_echo}
|
| 173 |
+
|
| 174 |
+
return resolved
|
| 175 |
+
|
| 176 |
+
|
| 177 |
+
# ===========================================================================
|
| 178 |
+
# Condition Evaluator
|
| 179 |
+
# ===========================================================================
|
| 180 |
+
|
| 181 |
+
def _evaluate_conditions(template: dict, params: dict) -> dict:
|
| 182 |
+
"""Evaluate all conditions in the template. Returns {name: bool}."""
|
| 183 |
+
cond_defs = template.get("Conditions", {})
|
| 184 |
+
evaluated: dict[str, bool] = {}
|
| 185 |
+
|
| 186 |
+
def _eval(expr):
|
| 187 |
+
if isinstance(expr, dict):
|
| 188 |
+
if "Fn::Equals" in expr:
|
| 189 |
+
args = expr["Fn::Equals"]
|
| 190 |
+
left = _resolve_cond_value(args[0])
|
| 191 |
+
right = _resolve_cond_value(args[1])
|
| 192 |
+
return str(left) == str(right)
|
| 193 |
+
if "Fn::And" in expr:
|
| 194 |
+
return all(_eval(c) for c in expr["Fn::And"])
|
| 195 |
+
if "Fn::Or" in expr:
|
| 196 |
+
return any(_eval(c) for c in expr["Fn::Or"])
|
| 197 |
+
if "Fn::Not" in expr:
|
| 198 |
+
return not _eval(expr["Fn::Not"][0])
|
| 199 |
+
if "Condition" in expr:
|
| 200 |
+
cname = expr["Condition"]
|
| 201 |
+
if cname not in evaluated:
|
| 202 |
+
evaluated[cname] = _eval(cond_defs[cname])
|
| 203 |
+
return evaluated[cname]
|
| 204 |
+
if "Ref" in expr:
|
| 205 |
+
return _resolve_cond_value(expr)
|
| 206 |
+
return bool(expr)
|
| 207 |
+
|
| 208 |
+
def _resolve_cond_value(val):
|
| 209 |
+
if isinstance(val, dict):
|
| 210 |
+
if "Ref" in val:
|
| 211 |
+
pname = val["Ref"]
|
| 212 |
+
if pname in params:
|
| 213 |
+
return params[pname]["Value"]
|
| 214 |
+
return pname
|
| 215 |
+
if "Fn::Equals" in val:
|
| 216 |
+
return _eval(val)
|
| 217 |
+
if "Condition" in val:
|
| 218 |
+
return _eval(val)
|
| 219 |
+
return val
|
| 220 |
+
|
| 221 |
+
for name, defn in cond_defs.items():
|
| 222 |
+
if name not in evaluated:
|
| 223 |
+
evaluated[name] = _eval(defn)
|
| 224 |
+
|
| 225 |
+
return evaluated
|
| 226 |
+
|
| 227 |
+
|
| 228 |
+
# ===========================================================================
|
| 229 |
+
# Intrinsic Function Resolver
|
| 230 |
+
# ===========================================================================
|
| 231 |
+
|
| 232 |
+
def _resolve_refs(value, resources, params, conditions, mappings,
|
| 233 |
+
stack_name, stack_id):
|
| 234 |
+
"""Recursively resolve CloudFormation intrinsic functions."""
|
| 235 |
+
if isinstance(value, str):
|
| 236 |
+
return value
|
| 237 |
+
|
| 238 |
+
if isinstance(value, list):
|
| 239 |
+
resolved = [
|
| 240 |
+
_resolve_refs(item, resources, params, conditions, mappings,
|
| 241 |
+
stack_name, stack_id)
|
| 242 |
+
for item in value
|
| 243 |
+
]
|
| 244 |
+
return [r for r in resolved if r is not _NO_VALUE]
|
| 245 |
+
|
| 246 |
+
if not isinstance(value, dict):
|
| 247 |
+
return value
|
| 248 |
+
|
| 249 |
+
# --- Ref ---
|
| 250 |
+
if "Ref" in value:
|
| 251 |
+
ref = value["Ref"]
|
| 252 |
+
# Pseudo-parameters
|
| 253 |
+
pseudo = {
|
| 254 |
+
"AWS::StackName": stack_name,
|
| 255 |
+
"AWS::StackId": stack_id,
|
| 256 |
+
"AWS::Region": get_region(),
|
| 257 |
+
"AWS::AccountId": get_account_id(),
|
| 258 |
+
"AWS::NoValue": _NO_VALUE,
|
| 259 |
+
"AWS::URLSuffix": "amazonaws.com",
|
| 260 |
+
"AWS::Partition": "aws",
|
| 261 |
+
"AWS::NotificationARNs": [],
|
| 262 |
+
}
|
| 263 |
+
if ref in pseudo:
|
| 264 |
+
return pseudo[ref]
|
| 265 |
+
if ref in params:
|
| 266 |
+
return params[ref]["Value"]
|
| 267 |
+
# Resource physical ID
|
| 268 |
+
if ref in resources and "PhysicalResourceId" in resources[ref]:
|
| 269 |
+
return resources[ref]["PhysicalResourceId"]
|
| 270 |
+
return ref
|
| 271 |
+
|
| 272 |
+
# --- Fn::GetAtt ---
|
| 273 |
+
if "Fn::GetAtt" in value:
|
| 274 |
+
args = value["Fn::GetAtt"]
|
| 275 |
+
if isinstance(args, str):
|
| 276 |
+
parts = args.split(".", 1)
|
| 277 |
+
logical_id = parts[0]
|
| 278 |
+
attr = parts[1] if len(parts) > 1 else ""
|
| 279 |
+
else:
|
| 280 |
+
logical_id = args[0]
|
| 281 |
+
attr = args[1] if len(args) > 1 else ""
|
| 282 |
+
res = resources.get(logical_id, {})
|
| 283 |
+
attrs = res.get("Attributes", {})
|
| 284 |
+
if attr in attrs:
|
| 285 |
+
return attrs[attr]
|
| 286 |
+
# Fallback: try PhysicalResourceId
|
| 287 |
+
return res.get("PhysicalResourceId", "")
|
| 288 |
+
|
| 289 |
+
# --- Fn::Join ---
|
| 290 |
+
if "Fn::Join" in value:
|
| 291 |
+
args = value["Fn::Join"]
|
| 292 |
+
delimiter = args[0]
|
| 293 |
+
items = _resolve_refs(args[1], resources, params, conditions,
|
| 294 |
+
mappings, stack_name, stack_id)
|
| 295 |
+
return delimiter.join(str(i) for i in items if i is not _NO_VALUE)
|
| 296 |
+
|
| 297 |
+
# --- Fn::Sub ---
|
| 298 |
+
if "Fn::Sub" in value:
|
| 299 |
+
sub_val = value["Fn::Sub"]
|
| 300 |
+
if isinstance(sub_val, list):
|
| 301 |
+
template_str = sub_val[0]
|
| 302 |
+
var_map = sub_val[1] if len(sub_val) > 1 else {}
|
| 303 |
+
# Resolve values in the var_map first
|
| 304 |
+
resolved_map = {}
|
| 305 |
+
for k, v in var_map.items():
|
| 306 |
+
resolved_map[k] = _resolve_refs(v, resources, params,
|
| 307 |
+
conditions, mappings,
|
| 308 |
+
stack_name, stack_id)
|
| 309 |
+
else:
|
| 310 |
+
template_str = sub_val
|
| 311 |
+
resolved_map = {}
|
| 312 |
+
|
| 313 |
+
def _sub_replace(match):
|
| 314 |
+
var = match.group(1)
|
| 315 |
+
# Check explicit var map first
|
| 316 |
+
if var in resolved_map:
|
| 317 |
+
return str(resolved_map[var])
|
| 318 |
+
# Pseudo-params
|
| 319 |
+
pseudo = {
|
| 320 |
+
"AWS::StackName": stack_name,
|
| 321 |
+
"AWS::StackId": stack_id,
|
| 322 |
+
"AWS::Region": get_region(),
|
| 323 |
+
"AWS::AccountId": get_account_id(),
|
| 324 |
+
"AWS::URLSuffix": "amazonaws.com",
|
| 325 |
+
"AWS::Partition": "aws",
|
| 326 |
+
}
|
| 327 |
+
if var in pseudo:
|
| 328 |
+
return str(pseudo[var])
|
| 329 |
+
# Param
|
| 330 |
+
if var in params:
|
| 331 |
+
return str(params[var]["Value"])
|
| 332 |
+
# Resource.Attr
|
| 333 |
+
if "." in var:
|
| 334 |
+
parts = var.split(".", 1)
|
| 335 |
+
res = resources.get(parts[0], {})
|
| 336 |
+
attrs = res.get("Attributes", {})
|
| 337 |
+
if parts[1] in attrs:
|
| 338 |
+
return str(attrs[parts[1]])
|
| 339 |
+
return str(res.get("PhysicalResourceId", var))
|
| 340 |
+
# Resource physical ID
|
| 341 |
+
if var in resources and "PhysicalResourceId" in resources[var]:
|
| 342 |
+
return str(resources[var]["PhysicalResourceId"])
|
| 343 |
+
return var
|
| 344 |
+
|
| 345 |
+
return re.sub(r"\$\{([^}]+)\}", _sub_replace, str(template_str))
|
| 346 |
+
|
| 347 |
+
# --- Fn::Select ---
|
| 348 |
+
if "Fn::Select" in value:
|
| 349 |
+
args = value["Fn::Select"]
|
| 350 |
+
index = int(_resolve_refs(args[0], resources, params, conditions,
|
| 351 |
+
mappings, stack_name, stack_id))
|
| 352 |
+
items = _resolve_refs(args[1], resources, params, conditions,
|
| 353 |
+
mappings, stack_name, stack_id)
|
| 354 |
+
if isinstance(items, str):
|
| 355 |
+
items = [s.strip() for s in items.split(",")]
|
| 356 |
+
if 0 <= index < len(items):
|
| 357 |
+
return items[index]
|
| 358 |
+
return ""
|
| 359 |
+
|
| 360 |
+
# --- Fn::Split ---
|
| 361 |
+
if "Fn::Split" in value:
|
| 362 |
+
args = value["Fn::Split"]
|
| 363 |
+
delimiter = args[0]
|
| 364 |
+
source = _resolve_refs(args[1], resources, params, conditions,
|
| 365 |
+
mappings, stack_name, stack_id)
|
| 366 |
+
return str(source).split(delimiter)
|
| 367 |
+
|
| 368 |
+
# --- Fn::If ---
|
| 369 |
+
if "Fn::If" in value:
|
| 370 |
+
args = value["Fn::If"]
|
| 371 |
+
cond_name = args[0]
|
| 372 |
+
cond_val = conditions.get(cond_name, False)
|
| 373 |
+
branch = args[1] if cond_val else args[2]
|
| 374 |
+
result = _resolve_refs(branch, resources, params, conditions,
|
| 375 |
+
mappings, stack_name, stack_id)
|
| 376 |
+
return result
|
| 377 |
+
|
| 378 |
+
# --- Fn::Base64 ---
|
| 379 |
+
if "Fn::Base64" in value:
|
| 380 |
+
inner = _resolve_refs(value["Fn::Base64"], resources, params,
|
| 381 |
+
conditions, mappings, stack_name, stack_id)
|
| 382 |
+
return base64.b64encode(str(inner).encode("utf-8")).decode("utf-8")
|
| 383 |
+
|
| 384 |
+
# --- Fn::FindInMap ---
|
| 385 |
+
if "Fn::FindInMap" in value:
|
| 386 |
+
args = value["Fn::FindInMap"]
|
| 387 |
+
map_name = _resolve_refs(args[0], resources, params, conditions,
|
| 388 |
+
mappings, stack_name, stack_id)
|
| 389 |
+
key1 = _resolve_refs(args[1], resources, params, conditions,
|
| 390 |
+
mappings, stack_name, stack_id)
|
| 391 |
+
key2 = _resolve_refs(args[2], resources, params, conditions,
|
| 392 |
+
mappings, stack_name, stack_id)
|
| 393 |
+
return mappings.get(str(map_name), {}).get(str(key1), {}).get(str(key2), "")
|
| 394 |
+
|
| 395 |
+
# --- Fn::ImportValue ---
|
| 396 |
+
if "Fn::ImportValue" in value:
|
| 397 |
+
from ministack.services.cloudformation import _exports
|
| 398 |
+
export_name = _resolve_refs(value["Fn::ImportValue"], resources,
|
| 399 |
+
params, conditions, mappings,
|
| 400 |
+
stack_name, stack_id)
|
| 401 |
+
export = _exports.get(str(export_name))
|
| 402 |
+
if export:
|
| 403 |
+
return export["Value"]
|
| 404 |
+
raise ValueError(f"Export '{export_name}' not found")
|
| 405 |
+
|
| 406 |
+
# --- Fn::GetAZs ---
|
| 407 |
+
if "Fn::GetAZs" in value:
|
| 408 |
+
region = _resolve_refs(value["Fn::GetAZs"], resources, params,
|
| 409 |
+
conditions, mappings, stack_name, stack_id)
|
| 410 |
+
if not region:
|
| 411 |
+
region = get_region()
|
| 412 |
+
return [f"{region}a", f"{region}b", f"{region}c"]
|
| 413 |
+
|
| 414 |
+
# --- Fn::Cidr ---
|
| 415 |
+
if "Fn::Cidr" in value:
|
| 416 |
+
args = value["Fn::Cidr"]
|
| 417 |
+
ip_block = _resolve_refs(args[0], resources, params, conditions,
|
| 418 |
+
mappings, stack_name, stack_id)
|
| 419 |
+
count = int(_resolve_refs(args[1], resources, params, conditions,
|
| 420 |
+
mappings, stack_name, stack_id))
|
| 421 |
+
cidr_bits = int(_resolve_refs(args[2], resources, params, conditions,
|
| 422 |
+
mappings, stack_name, stack_id))
|
| 423 |
+
# Simplified CIDR generation
|
| 424 |
+
return [f"10.0.{i}.0/{32 - cidr_bits}" for i in range(count)]
|
| 425 |
+
|
| 426 |
+
# --- Fn::Equals (condition-like in non-condition context) ---
|
| 427 |
+
if "Fn::Equals" in value:
|
| 428 |
+
args = value["Fn::Equals"]
|
| 429 |
+
left = _resolve_refs(args[0], resources, params, conditions,
|
| 430 |
+
mappings, stack_name, stack_id)
|
| 431 |
+
right = _resolve_refs(args[1], resources, params, conditions,
|
| 432 |
+
mappings, stack_name, stack_id)
|
| 433 |
+
return str(left) == str(right)
|
| 434 |
+
|
| 435 |
+
# --- Condition (reference) ---
|
| 436 |
+
if "Condition" in value and len(value) == 1:
|
| 437 |
+
return conditions.get(value["Condition"], False)
|
| 438 |
+
|
| 439 |
+
# Recurse into plain dicts
|
| 440 |
+
result = {}
|
| 441 |
+
for k, v in value.items():
|
| 442 |
+
resolved = _resolve_refs(v, resources, params, conditions,
|
| 443 |
+
mappings, stack_name, stack_id)
|
| 444 |
+
if resolved is not _NO_VALUE:
|
| 445 |
+
result[k] = resolved
|
| 446 |
+
return result
|
| 447 |
+
|
| 448 |
+
|
| 449 |
+
# ===========================================================================
|
| 450 |
+
# Dependency Extractor + Topological Sort
|
| 451 |
+
# ===========================================================================
|
| 452 |
+
|
| 453 |
+
def _extract_deps(resource_def: dict, all_resource_names: set) -> set:
|
| 454 |
+
"""Walk a resource definition and extract dependency logical IDs."""
|
| 455 |
+
deps = set()
|
| 456 |
+
|
| 457 |
+
def _walk(obj):
|
| 458 |
+
if isinstance(obj, dict):
|
| 459 |
+
if "Ref" in obj:
|
| 460 |
+
ref = obj["Ref"]
|
| 461 |
+
if ref in all_resource_names:
|
| 462 |
+
deps.add(ref)
|
| 463 |
+
if "Fn::GetAtt" in obj:
|
| 464 |
+
args = obj["Fn::GetAtt"]
|
| 465 |
+
if isinstance(args, list) and args:
|
| 466 |
+
if args[0] in all_resource_names:
|
| 467 |
+
deps.add(args[0])
|
| 468 |
+
elif isinstance(args, str):
|
| 469 |
+
logical = args.split(".")[0]
|
| 470 |
+
if logical in all_resource_names:
|
| 471 |
+
deps.add(logical)
|
| 472 |
+
if "Fn::Sub" in obj:
|
| 473 |
+
sub_val = obj["Fn::Sub"]
|
| 474 |
+
template_str = sub_val[0] if isinstance(sub_val, list) else sub_val
|
| 475 |
+
for match in re.finditer(r"\$\{([^}]+)\}", str(template_str)):
|
| 476 |
+
var = match.group(1)
|
| 477 |
+
base = var.split(".")[0]
|
| 478 |
+
if base in all_resource_names:
|
| 479 |
+
deps.add(base)
|
| 480 |
+
# Walk ALL branches of Fn::If
|
| 481 |
+
if "Fn::If" in obj:
|
| 482 |
+
args = obj["Fn::If"]
|
| 483 |
+
for branch in args[1:]:
|
| 484 |
+
_walk(branch)
|
| 485 |
+
for k, v in obj.items():
|
| 486 |
+
if k not in ("Ref", "Fn::GetAtt", "Fn::Sub", "Fn::If"):
|
| 487 |
+
_walk(v)
|
| 488 |
+
elif isinstance(obj, list):
|
| 489 |
+
for item in obj:
|
| 490 |
+
_walk(item)
|
| 491 |
+
|
| 492 |
+
# DependsOn
|
| 493 |
+
depends_on = resource_def.get("DependsOn", [])
|
| 494 |
+
if isinstance(depends_on, str):
|
| 495 |
+
depends_on = [depends_on]
|
| 496 |
+
for d in depends_on:
|
| 497 |
+
if d in all_resource_names:
|
| 498 |
+
deps.add(d)
|
| 499 |
+
|
| 500 |
+
# Walk Properties
|
| 501 |
+
_walk(resource_def.get("Properties", {}))
|
| 502 |
+
|
| 503 |
+
return deps
|
| 504 |
+
|
| 505 |
+
|
| 506 |
+
def _topological_sort(resources: dict, conditions: dict) -> list:
|
| 507 |
+
"""Kahn's algorithm for topological sort of resources."""
|
| 508 |
+
all_names = set(resources.keys())
|
| 509 |
+
# Filter out resources whose condition evaluates to false
|
| 510 |
+
active = set()
|
| 511 |
+
for name, defn in resources.items():
|
| 512 |
+
cond = defn.get("Condition")
|
| 513 |
+
if cond and not conditions.get(cond, True):
|
| 514 |
+
continue
|
| 515 |
+
active.add(name)
|
| 516 |
+
|
| 517 |
+
in_degree = {name: 0 for name in active}
|
| 518 |
+
adj: dict[str, list[str]] = {name: [] for name in active}
|
| 519 |
+
|
| 520 |
+
for name in active:
|
| 521 |
+
deps = _extract_deps(resources[name], active)
|
| 522 |
+
for dep in deps:
|
| 523 |
+
if dep in active and dep != name:
|
| 524 |
+
adj[dep].append(name)
|
| 525 |
+
in_degree[name] += 1
|
| 526 |
+
|
| 527 |
+
queue = sorted(n for n in active if in_degree[n] == 0)
|
| 528 |
+
heapq.heapify(queue)
|
| 529 |
+
result = []
|
| 530 |
+
|
| 531 |
+
while queue:
|
| 532 |
+
node = heapq.heappop(queue)
|
| 533 |
+
result.append(node)
|
| 534 |
+
for neighbor in adj[node]:
|
| 535 |
+
in_degree[neighbor] -= 1
|
| 536 |
+
if in_degree[neighbor] == 0:
|
| 537 |
+
heapq.heappush(queue, neighbor)
|
| 538 |
+
|
| 539 |
+
if len(result) != len(active):
|
| 540 |
+
remaining = active - set(result)
|
| 541 |
+
raise ValueError(
|
| 542 |
+
f"Circular dependency detected among resources: {', '.join(sorted(remaining))}"
|
| 543 |
+
)
|
| 544 |
+
|
| 545 |
+
return result
|
aws_infra/ministack/services/cloudformation/handlers.py
ADDED
|
@@ -0,0 +1,646 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
CloudFormation handlers — API action handlers for all supported CloudFormation actions.
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
import asyncio
|
| 6 |
+
import copy
|
| 7 |
+
import json
|
| 8 |
+
import logging
|
| 9 |
+
|
| 10 |
+
from ministack.core.responses import get_account_id, new_uuid, now_iso
|
| 11 |
+
|
| 12 |
+
from .engine import (
|
| 13 |
+
_evaluate_conditions, _parse_template, _resolve_parameters,
|
| 14 |
+
_resolve_refs, _NO_VALUE,
|
| 15 |
+
)
|
| 16 |
+
from .stacks import _add_event, _deploy_stack_async, _delete_stack_async, _diff_resources
|
| 17 |
+
from .provisioners import _provision_resource
|
| 18 |
+
from ministack.core.responses import get_region
|
| 19 |
+
from .helpers import _xml, _error, _p, _esc, _extract_members, _extract_stack_status_filters, _resolve_template, CFN_NS
|
| 20 |
+
from .changesets import (
|
| 21 |
+
_create_change_set, _describe_change_set, _execute_change_set,
|
| 22 |
+
_delete_change_set, _list_change_sets,
|
| 23 |
+
)
|
| 24 |
+
|
| 25 |
+
logger = logging.getLogger("cloudformation")
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
# --- CreateStack ---
|
| 29 |
+
|
| 30 |
+
def _create_stack(params):
|
| 31 |
+
from ministack.services.cloudformation import _stacks, _stack_events, _exports, _change_sets
|
| 32 |
+
stack_name = _p(params, "StackName")
|
| 33 |
+
if not stack_name:
|
| 34 |
+
return _error("ValidationError", "StackName is required")
|
| 35 |
+
|
| 36 |
+
template_body, resolve_err = _resolve_template(params)
|
| 37 |
+
if resolve_err:
|
| 38 |
+
return resolve_err
|
| 39 |
+
if not template_body:
|
| 40 |
+
return _error("ValidationError", "TemplateBody or TemplateURL is required")
|
| 41 |
+
|
| 42 |
+
# Check stack name uniqueness (active stacks)
|
| 43 |
+
existing = _stacks.get(stack_name)
|
| 44 |
+
if existing and existing.get("StackStatus", "") not in (
|
| 45 |
+
"DELETE_COMPLETE", "ROLLBACK_COMPLETE"
|
| 46 |
+
):
|
| 47 |
+
return _error("AlreadyExistsException",
|
| 48 |
+
f"Stack [{stack_name}] already exists")
|
| 49 |
+
|
| 50 |
+
try:
|
| 51 |
+
template = _parse_template(template_body)
|
| 52 |
+
except Exception as e:
|
| 53 |
+
return _error("ValidationError", f"Template format error: {e}")
|
| 54 |
+
provided_params = _extract_members(params, "Parameters")
|
| 55 |
+
tags = _extract_members(params, "Tags")
|
| 56 |
+
disable_rollback = _p(params, "DisableRollback", "false").lower() == "true"
|
| 57 |
+
|
| 58 |
+
# Resolve parameters
|
| 59 |
+
try:
|
| 60 |
+
param_values = _resolve_parameters(template, provided_params)
|
| 61 |
+
except ValueError as exc:
|
| 62 |
+
return _error("ValidationError", str(exc))
|
| 63 |
+
|
| 64 |
+
stack_id = (
|
| 65 |
+
f"arn:aws:cloudformation:{get_region()}:{get_account_id()}:"
|
| 66 |
+
f"stack/{stack_name}/{new_uuid()}"
|
| 67 |
+
)
|
| 68 |
+
|
| 69 |
+
stack = {
|
| 70 |
+
"StackName": stack_name,
|
| 71 |
+
"StackId": stack_id,
|
| 72 |
+
"StackStatus": "CREATE_IN_PROGRESS",
|
| 73 |
+
"StackStatusReason": "",
|
| 74 |
+
"CreationTime": now_iso(),
|
| 75 |
+
"LastUpdatedTime": now_iso(),
|
| 76 |
+
"Description": template.get("Description", ""),
|
| 77 |
+
"Parameters": [
|
| 78 |
+
{
|
| 79 |
+
"ParameterKey": k,
|
| 80 |
+
"ParameterValue": v["Value"],
|
| 81 |
+
"NoEcho": v["NoEcho"],
|
| 82 |
+
}
|
| 83 |
+
for k, v in param_values.items()
|
| 84 |
+
],
|
| 85 |
+
"Tags": tags,
|
| 86 |
+
"Outputs": [],
|
| 87 |
+
"DisableRollback": disable_rollback,
|
| 88 |
+
"_resources": {},
|
| 89 |
+
"_template": template,
|
| 90 |
+
"_template_body": template_body,
|
| 91 |
+
"_resolved_params": param_values,
|
| 92 |
+
"_conditions": _evaluate_conditions(template, param_values),
|
| 93 |
+
}
|
| 94 |
+
_stacks[stack_name] = stack
|
| 95 |
+
_stack_events[stack_id] = []
|
| 96 |
+
|
| 97 |
+
_add_event(stack_id, stack_name, stack_name,
|
| 98 |
+
"AWS::CloudFormation::Stack", "CREATE_IN_PROGRESS",
|
| 99 |
+
physical_id=stack_id)
|
| 100 |
+
|
| 101 |
+
asyncio.get_event_loop().create_task(
|
| 102 |
+
_deploy_stack_async(stack_name, stack_id, template,
|
| 103 |
+
param_values, disable_rollback, tags)
|
| 104 |
+
)
|
| 105 |
+
|
| 106 |
+
return _xml(200, "CreateStackResponse",
|
| 107 |
+
f"<CreateStackResult><StackId>{stack_id}</StackId></CreateStackResult>")
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
# --- DescribeStacks ---
|
| 111 |
+
|
| 112 |
+
def _describe_stacks(params):
|
| 113 |
+
from ministack.services.cloudformation import _stacks
|
| 114 |
+
stack_name = _p(params, "StackName")
|
| 115 |
+
|
| 116 |
+
if stack_name:
|
| 117 |
+
stack = _stacks.get(stack_name)
|
| 118 |
+
# Also try matching by stack ID
|
| 119 |
+
if not stack:
|
| 120 |
+
for s in _stacks.values():
|
| 121 |
+
if s.get("StackId") == stack_name:
|
| 122 |
+
stack = s
|
| 123 |
+
break
|
| 124 |
+
if not stack:
|
| 125 |
+
return _error("ValidationError",
|
| 126 |
+
f"Stack with id {stack_name} does not exist")
|
| 127 |
+
stacks_to_describe = [stack]
|
| 128 |
+
else:
|
| 129 |
+
# Return all stacks except DELETE_COMPLETE
|
| 130 |
+
stacks_to_describe = [
|
| 131 |
+
s for s in _stacks.values()
|
| 132 |
+
if s.get("StackStatus") != "DELETE_COMPLETE"
|
| 133 |
+
]
|
| 134 |
+
|
| 135 |
+
members = ""
|
| 136 |
+
for s in stacks_to_describe:
|
| 137 |
+
params_xml = ""
|
| 138 |
+
for p in s.get("Parameters", []):
|
| 139 |
+
val = "****" if p.get("NoEcho") else _esc(str(p.get("ParameterValue", "")))
|
| 140 |
+
params_xml += (
|
| 141 |
+
"<member>"
|
| 142 |
+
f"<ParameterKey>{_esc(p['ParameterKey'])}</ParameterKey>"
|
| 143 |
+
f"<ParameterValue>{val}</ParameterValue>"
|
| 144 |
+
"</member>"
|
| 145 |
+
)
|
| 146 |
+
|
| 147 |
+
outputs_xml = ""
|
| 148 |
+
for o in s.get("Outputs", []):
|
| 149 |
+
export_xml = ""
|
| 150 |
+
if o.get("ExportName"):
|
| 151 |
+
export_xml = f"<ExportName>{_esc(o['ExportName'])}</ExportName>"
|
| 152 |
+
outputs_xml += (
|
| 153 |
+
"<member>"
|
| 154 |
+
f"<OutputKey>{_esc(o['OutputKey'])}</OutputKey>"
|
| 155 |
+
f"<OutputValue>{_esc(str(o['OutputValue']))}</OutputValue>"
|
| 156 |
+
f"<Description>{_esc(o.get('Description', ''))}</Description>"
|
| 157 |
+
f"{export_xml}"
|
| 158 |
+
"</member>"
|
| 159 |
+
)
|
| 160 |
+
|
| 161 |
+
tags_xml = ""
|
| 162 |
+
for t in s.get("Tags", []):
|
| 163 |
+
tags_xml += (
|
| 164 |
+
"<member>"
|
| 165 |
+
f"<Key>{_esc(t.get('Key', ''))}</Key>"
|
| 166 |
+
f"<Value>{_esc(t.get('Value', ''))}</Value>"
|
| 167 |
+
"</member>"
|
| 168 |
+
)
|
| 169 |
+
|
| 170 |
+
members += (
|
| 171 |
+
"<member>"
|
| 172 |
+
f"<StackName>{_esc(s['StackName'])}</StackName>"
|
| 173 |
+
f"<StackId>{_esc(s['StackId'])}</StackId>"
|
| 174 |
+
f"<StackStatus>{s['StackStatus']}</StackStatus>"
|
| 175 |
+
f"<StackStatusReason>{_esc(s.get('StackStatusReason', ''))}</StackStatusReason>"
|
| 176 |
+
f"<CreationTime>{s.get('CreationTime', '')}</CreationTime>"
|
| 177 |
+
f"<LastUpdatedTime>{s.get('LastUpdatedTime', '')}</LastUpdatedTime>"
|
| 178 |
+
f"<Description>{_esc(s.get('Description', ''))}</Description>"
|
| 179 |
+
f"<DisableRollback>{str(s.get('DisableRollback', False)).lower()}</DisableRollback>"
|
| 180 |
+
f"<Parameters>{params_xml}</Parameters>"
|
| 181 |
+
f"<Outputs>{outputs_xml}</Outputs>"
|
| 182 |
+
f"<Tags>{tags_xml}</Tags>"
|
| 183 |
+
"</member>"
|
| 184 |
+
)
|
| 185 |
+
|
| 186 |
+
return _xml(200, "DescribeStacksResponse",
|
| 187 |
+
f"<DescribeStacksResult><Stacks>{members}</Stacks></DescribeStacksResult>")
|
| 188 |
+
|
| 189 |
+
|
| 190 |
+
# --- ListStacks ---
|
| 191 |
+
|
| 192 |
+
def _list_stacks(params):
|
| 193 |
+
from ministack.services.cloudformation import _stacks
|
| 194 |
+
status_filters = _extract_stack_status_filters(params)
|
| 195 |
+
|
| 196 |
+
summaries = ""
|
| 197 |
+
for s in _stacks.values():
|
| 198 |
+
status = s.get("StackStatus", "")
|
| 199 |
+
if status_filters and status not in status_filters:
|
| 200 |
+
continue
|
| 201 |
+
entry = (
|
| 202 |
+
"<member>"
|
| 203 |
+
f"<StackName>{_esc(s['StackName'])}</StackName>"
|
| 204 |
+
f"<StackId>{_esc(s['StackId'])}</StackId>"
|
| 205 |
+
f"<StackStatus>{status}</StackStatus>"
|
| 206 |
+
f"<CreationTime>{s.get('CreationTime', '')}</CreationTime>"
|
| 207 |
+
)
|
| 208 |
+
if s.get("LastUpdatedTime"):
|
| 209 |
+
entry += f"<LastUpdatedTime>{s['LastUpdatedTime']}</LastUpdatedTime>"
|
| 210 |
+
if s.get("StackStatusReason"):
|
| 211 |
+
entry += f"<StackStatusReason>{_esc(s['StackStatusReason'])}</StackStatusReason>"
|
| 212 |
+
if s.get("DeletionTime"):
|
| 213 |
+
entry += f"<DeletionTime>{s['DeletionTime']}</DeletionTime>"
|
| 214 |
+
entry += "</member>"
|
| 215 |
+
summaries += entry
|
| 216 |
+
|
| 217 |
+
return _xml(200, "ListStacksResponse",
|
| 218 |
+
f"<ListStacksResult><StackSummaries>{summaries}</StackSummaries></ListStacksResult>")
|
| 219 |
+
|
| 220 |
+
|
| 221 |
+
# --- DescribeStackEvents ---
|
| 222 |
+
|
| 223 |
+
def _describe_stack_events(params):
|
| 224 |
+
from ministack.services.cloudformation import _stacks, _stack_events
|
| 225 |
+
stack_name = _p(params, "StackName")
|
| 226 |
+
if not stack_name:
|
| 227 |
+
return _error("ValidationError", "StackName is required")
|
| 228 |
+
|
| 229 |
+
stack = _stacks.get(stack_name)
|
| 230 |
+
if not stack:
|
| 231 |
+
# Try by stack ID
|
| 232 |
+
for s in _stacks.values():
|
| 233 |
+
if s.get("StackId") == stack_name:
|
| 234 |
+
stack = s
|
| 235 |
+
break
|
| 236 |
+
if not stack:
|
| 237 |
+
return _error("ValidationError",
|
| 238 |
+
f"Stack [{stack_name}] does not exist")
|
| 239 |
+
|
| 240 |
+
stack_id = stack["StackId"]
|
| 241 |
+
events = _stack_events.get(stack_id, [])
|
| 242 |
+
# Newest first
|
| 243 |
+
events_sorted = sorted(events, key=lambda e: e.get("Timestamp", ""),
|
| 244 |
+
reverse=True)
|
| 245 |
+
|
| 246 |
+
members = ""
|
| 247 |
+
for e in events_sorted:
|
| 248 |
+
members += (
|
| 249 |
+
"<member>"
|
| 250 |
+
f"<StackId>{_esc(e.get('StackId', ''))}</StackId>"
|
| 251 |
+
f"<StackName>{_esc(e.get('StackName', ''))}</StackName>"
|
| 252 |
+
f"<EventId>{_esc(e.get('EventId', ''))}</EventId>"
|
| 253 |
+
f"<LogicalResourceId>{_esc(e.get('LogicalResourceId', ''))}</LogicalResourceId>"
|
| 254 |
+
f"<PhysicalResourceId>{_esc(e.get('PhysicalResourceId', ''))}</PhysicalResourceId>"
|
| 255 |
+
f"<ResourceType>{_esc(e.get('ResourceType', ''))}</ResourceType>"
|
| 256 |
+
f"<ResourceStatus>{e.get('ResourceStatus', '')}</ResourceStatus>"
|
| 257 |
+
f"<ResourceStatusReason>{_esc(e.get('ResourceStatusReason', ''))}</ResourceStatusReason>"
|
| 258 |
+
f"<Timestamp>{e.get('Timestamp', '')}</Timestamp>"
|
| 259 |
+
"</member>"
|
| 260 |
+
)
|
| 261 |
+
|
| 262 |
+
return _xml(200, "DescribeStackEventsResponse",
|
| 263 |
+
f"<DescribeStackEventsResult><StackEvents>{members}</StackEvents></DescribeStackEventsResult>")
|
| 264 |
+
|
| 265 |
+
|
| 266 |
+
# --- DescribeStackResource ---
|
| 267 |
+
|
| 268 |
+
def _describe_stack_resource(params):
|
| 269 |
+
from ministack.services.cloudformation import _stacks
|
| 270 |
+
stack_name = _p(params, "StackName")
|
| 271 |
+
logical_id = _p(params, "LogicalResourceId")
|
| 272 |
+
|
| 273 |
+
stack = _stacks.get(stack_name)
|
| 274 |
+
if not stack:
|
| 275 |
+
return _error("ValidationError",
|
| 276 |
+
f"Stack [{stack_name}] does not exist")
|
| 277 |
+
|
| 278 |
+
resources = stack.get("_resources", {})
|
| 279 |
+
res = resources.get(logical_id)
|
| 280 |
+
if not res:
|
| 281 |
+
return _error("ValidationError",
|
| 282 |
+
f"Resource [{logical_id}] does not exist in stack [{stack_name}]")
|
| 283 |
+
|
| 284 |
+
detail = (
|
| 285 |
+
f"<LogicalResourceId>{_esc(logical_id)}</LogicalResourceId>"
|
| 286 |
+
f"<PhysicalResourceId>{_esc(res.get('PhysicalResourceId', ''))}</PhysicalResourceId>"
|
| 287 |
+
f"<ResourceType>{_esc(res.get('ResourceType', ''))}</ResourceType>"
|
| 288 |
+
f"<ResourceStatus>{res.get('ResourceStatus', '')}</ResourceStatus>"
|
| 289 |
+
f"<Timestamp>{res.get('Timestamp', '')}</Timestamp>"
|
| 290 |
+
f"<StackName>{_esc(stack_name)}</StackName>"
|
| 291 |
+
f"<StackId>{_esc(stack['StackId'])}</StackId>"
|
| 292 |
+
)
|
| 293 |
+
|
| 294 |
+
return _xml(200, "DescribeStackResourceResponse",
|
| 295 |
+
f"<DescribeStackResourceResult>"
|
| 296 |
+
f"<StackResourceDetail>{detail}</StackResourceDetail>"
|
| 297 |
+
f"</DescribeStackResourceResult>")
|
| 298 |
+
|
| 299 |
+
|
| 300 |
+
# --- DescribeStackResources ---
|
| 301 |
+
|
| 302 |
+
def _describe_stack_resources(params):
|
| 303 |
+
from ministack.services.cloudformation import _stacks
|
| 304 |
+
stack_name = _p(params, "StackName")
|
| 305 |
+
|
| 306 |
+
stack = _stacks.get(stack_name)
|
| 307 |
+
if not stack:
|
| 308 |
+
return _error("ValidationError",
|
| 309 |
+
f"Stack [{stack_name}] does not exist")
|
| 310 |
+
|
| 311 |
+
resources = stack.get("_resources", {})
|
| 312 |
+
members = ""
|
| 313 |
+
for logical_id, res in resources.items():
|
| 314 |
+
members += (
|
| 315 |
+
"<member>"
|
| 316 |
+
f"<LogicalResourceId>{_esc(logical_id)}</LogicalResourceId>"
|
| 317 |
+
f"<PhysicalResourceId>{_esc(res.get('PhysicalResourceId', ''))}</PhysicalResourceId>"
|
| 318 |
+
f"<ResourceType>{_esc(res.get('ResourceType', ''))}</ResourceType>"
|
| 319 |
+
f"<ResourceStatus>{res.get('ResourceStatus', '')}</ResourceStatus>"
|
| 320 |
+
f"<Timestamp>{res.get('Timestamp', '')}</Timestamp>"
|
| 321 |
+
f"<StackName>{_esc(stack_name)}</StackName>"
|
| 322 |
+
f"<StackId>{_esc(stack['StackId'])}</StackId>"
|
| 323 |
+
"</member>"
|
| 324 |
+
)
|
| 325 |
+
|
| 326 |
+
return _xml(200, "DescribeStackResourcesResponse",
|
| 327 |
+
f"<DescribeStackResourcesResult>"
|
| 328 |
+
f"<StackResources>{members}</StackResources>"
|
| 329 |
+
f"</DescribeStackResourcesResult>")
|
| 330 |
+
|
| 331 |
+
|
| 332 |
+
# --- ListStackResources ---
|
| 333 |
+
|
| 334 |
+
def _list_stack_resources(params):
|
| 335 |
+
from ministack.services.cloudformation import _stacks
|
| 336 |
+
stack_name = _p(params, "StackName")
|
| 337 |
+
if not stack_name:
|
| 338 |
+
return _error("ValidationError", "StackName is required")
|
| 339 |
+
|
| 340 |
+
stack = _stacks.get(stack_name)
|
| 341 |
+
if not stack:
|
| 342 |
+
for s in _stacks.values():
|
| 343 |
+
if s.get("StackId") == stack_name:
|
| 344 |
+
stack = s
|
| 345 |
+
break
|
| 346 |
+
if not stack:
|
| 347 |
+
return _error("ValidationError",
|
| 348 |
+
f"Stack [{stack_name}] does not exist")
|
| 349 |
+
|
| 350 |
+
resources = stack.get("_resources", {})
|
| 351 |
+
members = ""
|
| 352 |
+
for logical_id, res in resources.items():
|
| 353 |
+
members += (
|
| 354 |
+
"<member>"
|
| 355 |
+
f"<LogicalResourceId>{_esc(logical_id)}</LogicalResourceId>"
|
| 356 |
+
f"<PhysicalResourceId>{_esc(res.get('PhysicalResourceId', ''))}</PhysicalResourceId>"
|
| 357 |
+
f"<ResourceType>{_esc(res.get('ResourceType', ''))}</ResourceType>"
|
| 358 |
+
f"<ResourceStatus>{res.get('ResourceStatus', '')}</ResourceStatus>"
|
| 359 |
+
f"<LastUpdatedTimestamp>{res.get('Timestamp', '')}</LastUpdatedTimestamp>"
|
| 360 |
+
"</member>"
|
| 361 |
+
)
|
| 362 |
+
|
| 363 |
+
return _xml(200, "ListStackResourcesResponse",
|
| 364 |
+
f"<ListStackResourcesResult>"
|
| 365 |
+
f"<StackResourceSummaries>{members}</StackResourceSummaries>"
|
| 366 |
+
f"</ListStackResourcesResult>")
|
| 367 |
+
|
| 368 |
+
|
| 369 |
+
# --- GetTemplate ---
|
| 370 |
+
|
| 371 |
+
def _get_template(params):
|
| 372 |
+
from ministack.services.cloudformation import _stacks
|
| 373 |
+
stack_name = _p(params, "StackName")
|
| 374 |
+
|
| 375 |
+
stack = _stacks.get(stack_name)
|
| 376 |
+
if not stack:
|
| 377 |
+
for s in _stacks.values():
|
| 378 |
+
if s.get("StackId") == stack_name:
|
| 379 |
+
stack = s
|
| 380 |
+
break
|
| 381 |
+
if not stack:
|
| 382 |
+
return _error("ValidationError",
|
| 383 |
+
f"Stack [{stack_name}] does not exist")
|
| 384 |
+
|
| 385 |
+
template_body = stack.get("_template_body", "{}")
|
| 386 |
+
return _xml(200, "GetTemplateResponse",
|
| 387 |
+
f"<GetTemplateResult>"
|
| 388 |
+
f"<TemplateBody>{_esc(template_body)}</TemplateBody>"
|
| 389 |
+
f"</GetTemplateResult>")
|
| 390 |
+
|
| 391 |
+
|
| 392 |
+
# --- DeleteStack ---
|
| 393 |
+
|
| 394 |
+
def _delete_stack(params):
|
| 395 |
+
from ministack.services.cloudformation import _stacks
|
| 396 |
+
stack_name = _p(params, "StackName")
|
| 397 |
+
if not stack_name:
|
| 398 |
+
return _error("ValidationError", "StackName is required")
|
| 399 |
+
|
| 400 |
+
stack = _stacks.get(stack_name)
|
| 401 |
+
if not stack:
|
| 402 |
+
# AWS returns success for deleting non-existent stacks
|
| 403 |
+
return _xml(200, "DeleteStackResponse", "")
|
| 404 |
+
|
| 405 |
+
if stack.get("StackStatus") == "DELETE_COMPLETE":
|
| 406 |
+
return _xml(200, "DeleteStackResponse", "")
|
| 407 |
+
|
| 408 |
+
# Check for active imports before deleting
|
| 409 |
+
stack_exports = [
|
| 410 |
+
out.get("ExportName") for out in stack.get("Outputs", [])
|
| 411 |
+
if out.get("ExportName")
|
| 412 |
+
]
|
| 413 |
+
for export_name in stack_exports:
|
| 414 |
+
for other_name, other_stack in _stacks.items():
|
| 415 |
+
if other_name == stack_name:
|
| 416 |
+
continue
|
| 417 |
+
other_status = other_stack.get("StackStatus", "")
|
| 418 |
+
if other_status.endswith("_COMPLETE") and "DELETE" not in other_status:
|
| 419 |
+
other_template = other_stack.get("_template", {})
|
| 420 |
+
if export_name in json.dumps(other_template):
|
| 421 |
+
return _error("ValidationError",
|
| 422 |
+
f"Export {export_name} is imported by stack {other_name}")
|
| 423 |
+
|
| 424 |
+
stack_id = stack["StackId"]
|
| 425 |
+
asyncio.get_event_loop().create_task(_delete_stack_async(stack_name, stack_id))
|
| 426 |
+
|
| 427 |
+
return _xml(200, "DeleteStackResponse", "")
|
| 428 |
+
|
| 429 |
+
|
| 430 |
+
# --- UpdateStack ---
|
| 431 |
+
|
| 432 |
+
def _update_stack(params):
|
| 433 |
+
from ministack.services.cloudformation import _stacks
|
| 434 |
+
stack_name = _p(params, "StackName")
|
| 435 |
+
if not stack_name:
|
| 436 |
+
return _error("ValidationError", "StackName is required")
|
| 437 |
+
|
| 438 |
+
stack = _stacks.get(stack_name)
|
| 439 |
+
if not stack:
|
| 440 |
+
return _error("ValidationError",
|
| 441 |
+
f"Stack [{stack_name}] does not exist")
|
| 442 |
+
|
| 443 |
+
current_status = stack.get("StackStatus", "")
|
| 444 |
+
if current_status not in ("CREATE_COMPLETE", "UPDATE_COMPLETE",
|
| 445 |
+
"UPDATE_ROLLBACK_COMPLETE"):
|
| 446 |
+
return _error("ValidationError",
|
| 447 |
+
f"Stack [{stack_name}] is in {current_status} state "
|
| 448 |
+
f"and cannot be updated")
|
| 449 |
+
|
| 450 |
+
template_body, resolve_err = _resolve_template(params)
|
| 451 |
+
if resolve_err:
|
| 452 |
+
return resolve_err
|
| 453 |
+
if not template_body:
|
| 454 |
+
# Use previous template if UsePreviousTemplate
|
| 455 |
+
if _p(params, "UsePreviousTemplate", "false").lower() == "true":
|
| 456 |
+
template_body = stack.get("_template_body", "{}")
|
| 457 |
+
else:
|
| 458 |
+
return _error("ValidationError", "TemplateBody or TemplateURL is required")
|
| 459 |
+
|
| 460 |
+
try:
|
| 461 |
+
template = _parse_template(template_body)
|
| 462 |
+
except Exception as e:
|
| 463 |
+
return _error("ValidationError", f"Template format error: {e}")
|
| 464 |
+
provided_params = _extract_members(params, "Parameters")
|
| 465 |
+
tags = _extract_members(params, "Tags")
|
| 466 |
+
disable_rollback = _p(params, "DisableRollback", "false").lower() == "true"
|
| 467 |
+
|
| 468 |
+
try:
|
| 469 |
+
param_values = _resolve_parameters(template, provided_params)
|
| 470 |
+
except ValueError as exc:
|
| 471 |
+
return _error("ValidationError", str(exc))
|
| 472 |
+
|
| 473 |
+
# Save previous state for rollback
|
| 474 |
+
previous_stack = {
|
| 475 |
+
"_resources": copy.deepcopy(stack.get("_resources", {})),
|
| 476 |
+
"_template": copy.deepcopy(stack.get("_template", {})),
|
| 477 |
+
"_template_body": stack.get("_template_body", ""),
|
| 478 |
+
"_resolved_params": copy.deepcopy(stack.get("_resolved_params", {})),
|
| 479 |
+
"Outputs": copy.deepcopy(stack.get("Outputs", [])),
|
| 480 |
+
}
|
| 481 |
+
|
| 482 |
+
stack_id = stack["StackId"]
|
| 483 |
+
stack["StackStatus"] = "UPDATE_IN_PROGRESS"
|
| 484 |
+
stack["LastUpdatedTime"] = now_iso()
|
| 485 |
+
stack["_template_body"] = template_body
|
| 486 |
+
if tags:
|
| 487 |
+
stack["Tags"] = tags
|
| 488 |
+
stack["Parameters"] = [
|
| 489 |
+
{"ParameterKey": k, "ParameterValue": v["Value"], "NoEcho": v["NoEcho"]}
|
| 490 |
+
for k, v in param_values.items()
|
| 491 |
+
]
|
| 492 |
+
stack["_conditions"] = _evaluate_conditions(template, param_values)
|
| 493 |
+
|
| 494 |
+
_add_event(stack_id, stack_name, stack_name,
|
| 495 |
+
"AWS::CloudFormation::Stack", "UPDATE_IN_PROGRESS",
|
| 496 |
+
physical_id=stack_id)
|
| 497 |
+
|
| 498 |
+
asyncio.get_event_loop().create_task(
|
| 499 |
+
_deploy_stack_async(stack_name, stack_id, template,
|
| 500 |
+
param_values, disable_rollback, tags,
|
| 501 |
+
is_update=True, previous_stack=previous_stack)
|
| 502 |
+
)
|
| 503 |
+
|
| 504 |
+
return _xml(200, "UpdateStackResponse",
|
| 505 |
+
f"<UpdateStackResult><StackId>{stack_id}</StackId></UpdateStackResult>")
|
| 506 |
+
|
| 507 |
+
|
| 508 |
+
# --- ValidateTemplate ---
|
| 509 |
+
|
| 510 |
+
def _validate_template(params):
|
| 511 |
+
template_body = _p(params, "TemplateBody")
|
| 512 |
+
if not template_body:
|
| 513 |
+
return _error("ValidationError", "TemplateBody is required")
|
| 514 |
+
|
| 515 |
+
try:
|
| 516 |
+
template = _parse_template(template_body)
|
| 517 |
+
except Exception as e:
|
| 518 |
+
return _error("ValidationError", f"Template format error: {e}")
|
| 519 |
+
if "Resources" not in template:
|
| 520 |
+
return _error("ValidationError",
|
| 521 |
+
"Template format error: At least one Resources member must be defined.")
|
| 522 |
+
description = template.get("Description", "")
|
| 523 |
+
param_defs = template.get("Parameters", {})
|
| 524 |
+
|
| 525 |
+
params_xml = ""
|
| 526 |
+
for name, defn in param_defs.items():
|
| 527 |
+
default = defn.get("Default", "")
|
| 528 |
+
no_echo = str(defn.get("NoEcho", "false")).lower()
|
| 529 |
+
ptype = defn.get("Type", "String")
|
| 530 |
+
desc = defn.get("Description", "")
|
| 531 |
+
params_xml += (
|
| 532 |
+
"<member>"
|
| 533 |
+
f"<ParameterKey>{_esc(name)}</ParameterKey>"
|
| 534 |
+
f"<DefaultValue>{_esc(str(default))}</DefaultValue>"
|
| 535 |
+
f"<NoEcho>{no_echo}</NoEcho>"
|
| 536 |
+
f"<ParameterType>{_esc(ptype)}</ParameterType>"
|
| 537 |
+
f"<Description>{_esc(desc)}</Description>"
|
| 538 |
+
"</member>"
|
| 539 |
+
)
|
| 540 |
+
|
| 541 |
+
return _xml(200, "ValidateTemplateResponse",
|
| 542 |
+
f"<ValidateTemplateResult>"
|
| 543 |
+
f"<Description>{_esc(description)}</Description>"
|
| 544 |
+
f"<Parameters>{params_xml}</Parameters>"
|
| 545 |
+
f"</ValidateTemplateResult>")
|
| 546 |
+
|
| 547 |
+
|
| 548 |
+
# --- ListExports ---
|
| 549 |
+
|
| 550 |
+
def _list_exports(params):
|
| 551 |
+
from ministack.services.cloudformation import _exports
|
| 552 |
+
members = ""
|
| 553 |
+
for name, exp in _exports.items():
|
| 554 |
+
members += (
|
| 555 |
+
"<member>"
|
| 556 |
+
f"<ExportingStackId>{_esc(exp.get('StackId', ''))}</ExportingStackId>"
|
| 557 |
+
f"<Name>{_esc(name)}</Name>"
|
| 558 |
+
f"<Value>{_esc(str(exp.get('Value', '')))}</Value>"
|
| 559 |
+
"</member>"
|
| 560 |
+
)
|
| 561 |
+
|
| 562 |
+
return _xml(200, "ListExportsResponse",
|
| 563 |
+
f"<ListExportsResult><Exports>{members}</Exports></ListExportsResult>")
|
| 564 |
+
# --- GetTemplateSummary ---
|
| 565 |
+
|
| 566 |
+
def _get_template_summary(params):
|
| 567 |
+
from ministack.services.cloudformation import _stacks
|
| 568 |
+
template_body, resolve_err = _resolve_template(params)
|
| 569 |
+
if resolve_err:
|
| 570 |
+
return resolve_err
|
| 571 |
+
stack_name = _p(params, "StackName")
|
| 572 |
+
|
| 573 |
+
if stack_name and not template_body:
|
| 574 |
+
stack = _stacks.get(stack_name)
|
| 575 |
+
if not stack:
|
| 576 |
+
return _error("ValidationError",
|
| 577 |
+
f"Stack [{stack_name}] does not exist")
|
| 578 |
+
template_body = stack.get("_template_body", "{}")
|
| 579 |
+
|
| 580 |
+
if not template_body:
|
| 581 |
+
return _error("ValidationError",
|
| 582 |
+
"Either TemplateBody, TemplateURL, or StackName must be provided")
|
| 583 |
+
|
| 584 |
+
try:
|
| 585 |
+
template = _parse_template(template_body)
|
| 586 |
+
except Exception as e:
|
| 587 |
+
return _error("ValidationError", f"Template format error: {e}")
|
| 588 |
+
description = template.get("Description", "")
|
| 589 |
+
resources = template.get("Resources", {})
|
| 590 |
+
param_defs = template.get("Parameters", {})
|
| 591 |
+
|
| 592 |
+
# Resource types
|
| 593 |
+
resource_types = sorted(set(
|
| 594 |
+
r.get("Type", "") for r in resources.values()
|
| 595 |
+
))
|
| 596 |
+
types_xml = "".join(f"<member>{_esc(t)}</member>" for t in resource_types)
|
| 597 |
+
|
| 598 |
+
# Parameters
|
| 599 |
+
params_xml = ""
|
| 600 |
+
for name, defn in param_defs.items():
|
| 601 |
+
default = defn.get("Default", "")
|
| 602 |
+
no_echo = str(defn.get("NoEcho", "false")).lower()
|
| 603 |
+
ptype = defn.get("Type", "String")
|
| 604 |
+
desc = defn.get("Description", "")
|
| 605 |
+
params_xml += (
|
| 606 |
+
"<member>"
|
| 607 |
+
f"<ParameterKey>{_esc(name)}</ParameterKey>"
|
| 608 |
+
f"<DefaultValue>{_esc(str(default))}</DefaultValue>"
|
| 609 |
+
f"<NoEcho>{no_echo}</NoEcho>"
|
| 610 |
+
f"<ParameterType>{_esc(ptype)}</ParameterType>"
|
| 611 |
+
f"<Description>{_esc(desc)}</Description>"
|
| 612 |
+
"</member>"
|
| 613 |
+
)
|
| 614 |
+
|
| 615 |
+
return _xml(200, "GetTemplateSummaryResponse",
|
| 616 |
+
f"<GetTemplateSummaryResult>"
|
| 617 |
+
f"<Description>{_esc(description)}</Description>"
|
| 618 |
+
f"<ResourceTypes>{types_xml}</ResourceTypes>"
|
| 619 |
+
f"<Parameters>{params_xml}</Parameters>"
|
| 620 |
+
f"</GetTemplateSummaryResult>")
|
| 621 |
+
|
| 622 |
+
|
| 623 |
+
# ===========================================================================
|
| 624 |
+
# Action Handler Registry
|
| 625 |
+
# ===========================================================================
|
| 626 |
+
|
| 627 |
+
_ACTION_HANDLERS = {
|
| 628 |
+
"CreateStack": _create_stack,
|
| 629 |
+
"DescribeStacks": _describe_stacks,
|
| 630 |
+
"ListStacks": _list_stacks,
|
| 631 |
+
"DeleteStack": _delete_stack,
|
| 632 |
+
"UpdateStack": _update_stack,
|
| 633 |
+
"DescribeStackEvents": _describe_stack_events,
|
| 634 |
+
"DescribeStackResource": _describe_stack_resource,
|
| 635 |
+
"DescribeStackResources": _describe_stack_resources,
|
| 636 |
+
"ListStackResources": _list_stack_resources,
|
| 637 |
+
"GetTemplate": _get_template,
|
| 638 |
+
"ValidateTemplate": _validate_template,
|
| 639 |
+
"ListExports": _list_exports,
|
| 640 |
+
"CreateChangeSet": _create_change_set,
|
| 641 |
+
"DescribeChangeSet": _describe_change_set,
|
| 642 |
+
"ExecuteChangeSet": _execute_change_set,
|
| 643 |
+
"DeleteChangeSet": _delete_change_set,
|
| 644 |
+
"ListChangeSets": _list_change_sets,
|
| 645 |
+
"GetTemplateSummary": _get_template_summary,
|
| 646 |
+
}
|
aws_infra/ministack/services/cloudformation/helpers.py
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
CloudFormation helpers — XML response formatting and parameter extraction utilities.
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
import logging
|
| 6 |
+
from html import escape as _esc
|
| 7 |
+
from urllib.parse import urlparse
|
| 8 |
+
|
| 9 |
+
from ministack.core.responses import new_uuid
|
| 10 |
+
|
| 11 |
+
logger = logging.getLogger("cloudformation")
|
| 12 |
+
|
| 13 |
+
CFN_NS = "http://cloudformation.amazonaws.com/doc/2010-05-08/"
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def _p(params, key, default=""):
|
| 17 |
+
"""Extract a single value from parsed query-string params."""
|
| 18 |
+
val = params.get(key, [default])
|
| 19 |
+
return val[0] if isinstance(val, list) else val
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def _xml(status, root_tag, inner):
|
| 23 |
+
body = (
|
| 24 |
+
f'<?xml version="1.0" encoding="UTF-8"?>'
|
| 25 |
+
f'<{root_tag} xmlns="{CFN_NS}">'
|
| 26 |
+
f'{inner}'
|
| 27 |
+
f'<ResponseMetadata><RequestId>{new_uuid()}</RequestId></ResponseMetadata>'
|
| 28 |
+
f'</{root_tag}>'
|
| 29 |
+
).encode("utf-8")
|
| 30 |
+
return status, {"Content-Type": "application/xml"}, body
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def _error(code, message, status=400):
|
| 34 |
+
t = "Sender" if status < 500 else "Receiver"
|
| 35 |
+
body = (
|
| 36 |
+
f'<?xml version="1.0" encoding="UTF-8"?>'
|
| 37 |
+
f'<ErrorResponse xmlns="{CFN_NS}">'
|
| 38 |
+
f'<Error><Type>{t}</Type><Code>{code}</Code>'
|
| 39 |
+
f'<Message>{_esc(message)}</Message></Error>'
|
| 40 |
+
f'<RequestId>{new_uuid()}</RequestId>'
|
| 41 |
+
f'</ErrorResponse>'
|
| 42 |
+
).encode("utf-8")
|
| 43 |
+
return status, {"Content-Type": "application/xml"}, body
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def _extract_members(params, prefix):
|
| 47 |
+
"""Extract Parameters.member.N.Key/Value or Tags.member.N.Key/Value."""
|
| 48 |
+
result = []
|
| 49 |
+
i = 1
|
| 50 |
+
while True:
|
| 51 |
+
key = (_p(params, f"{prefix}.member.{i}.ParameterKey")
|
| 52 |
+
or _p(params, f"{prefix}.member.{i}.Key"))
|
| 53 |
+
if not key:
|
| 54 |
+
break
|
| 55 |
+
value = (_p(params, f"{prefix}.member.{i}.ParameterValue")
|
| 56 |
+
or _p(params, f"{prefix}.member.{i}.Value"))
|
| 57 |
+
result.append({"Key": key, "Value": value or ""})
|
| 58 |
+
i += 1
|
| 59 |
+
return result
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def _resolve_template(params):
|
| 63 |
+
"""Resolve TemplateBody or TemplateURL to a template string.
|
| 64 |
+
If TemplateURL is provided, fetch the template from S3.
|
| 65 |
+
Returns (template_body, error_tuple) — error_tuple is None on success."""
|
| 66 |
+
template_body = _p(params, "TemplateBody")
|
| 67 |
+
template_url = _p(params, "TemplateURL")
|
| 68 |
+
|
| 69 |
+
if template_body:
|
| 70 |
+
return template_body, None
|
| 71 |
+
|
| 72 |
+
if template_url:
|
| 73 |
+
try:
|
| 74 |
+
from ministack.services import s3 as _s3
|
| 75 |
+
parsed = urlparse(template_url)
|
| 76 |
+
# Support formats:
|
| 77 |
+
# http://localhost:4566/bucket/key
|
| 78 |
+
# https://s3.amazonaws.com/bucket/key
|
| 79 |
+
# https://bucket.s3.amazonaws.com/key
|
| 80 |
+
path = parsed.path.lstrip("/")
|
| 81 |
+
parts = path.split("/", 1)
|
| 82 |
+
if len(parts) < 2:
|
| 83 |
+
return None, _error("ValidationError",
|
| 84 |
+
f"Invalid TemplateURL: {template_url}")
|
| 85 |
+
bucket_name, key = parts[0], parts[1]
|
| 86 |
+
obj_data = _s3._get_object_data(bucket_name, key)
|
| 87 |
+
if obj_data is None:
|
| 88 |
+
return None, _error("ValidationError",
|
| 89 |
+
f"Template not found at {template_url}")
|
| 90 |
+
return obj_data.decode("utf-8"), None
|
| 91 |
+
except Exception as e:
|
| 92 |
+
logger.warning("Failed to fetch TemplateURL %s: %s", template_url, e)
|
| 93 |
+
return None, _error("ValidationError",
|
| 94 |
+
f"Error fetching TemplateURL: {e}")
|
| 95 |
+
|
| 96 |
+
return None, None # neither provided
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
def _extract_stack_status_filters(params):
|
| 100 |
+
"""Extract StackStatusFilter.member.N values."""
|
| 101 |
+
filters = []
|
| 102 |
+
i = 1
|
| 103 |
+
while True:
|
| 104 |
+
val = _p(params, f"StackStatusFilter.member.{i}")
|
| 105 |
+
if not val:
|
| 106 |
+
break
|
| 107 |
+
filters.append(val)
|
| 108 |
+
i += 1
|
| 109 |
+
return filters
|
aws_infra/ministack/services/cloudformation/provisioners.py
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
aws_infra/ministack/services/cloudformation/stacks.py
ADDED
|
@@ -0,0 +1,343 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
CloudFormation stacks — async stack lifecycle (deploy, delete, update, diff).
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
import asyncio
|
| 6 |
+
import copy
|
| 7 |
+
import json
|
| 8 |
+
import logging
|
| 9 |
+
import time
|
| 10 |
+
|
| 11 |
+
from ministack.core.responses import get_account_id, new_uuid, now_iso
|
| 12 |
+
|
| 13 |
+
from .engine import (
|
| 14 |
+
_evaluate_conditions, _parse_template, _resolve_parameters,
|
| 15 |
+
_resolve_refs, _topological_sort, _NO_VALUE,
|
| 16 |
+
)
|
| 17 |
+
from .provisioners import _provision_resource, _delete_resource, REGION
|
| 18 |
+
|
| 19 |
+
logger = logging.getLogger("cloudformation")
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
# ===========================================================================
|
| 23 |
+
# Stack Events helper
|
| 24 |
+
# ===========================================================================
|
| 25 |
+
|
| 26 |
+
def _add_event(stack_id, stack_name, logical_id, resource_type, status,
|
| 27 |
+
reason="", physical_id=""):
|
| 28 |
+
"""Record a stack event."""
|
| 29 |
+
from ministack.services.cloudformation import _stack_events
|
| 30 |
+
event = {
|
| 31 |
+
"StackId": stack_id,
|
| 32 |
+
"StackName": stack_name,
|
| 33 |
+
"EventId": new_uuid(),
|
| 34 |
+
"LogicalResourceId": logical_id,
|
| 35 |
+
"PhysicalResourceId": physical_id,
|
| 36 |
+
"ResourceType": resource_type,
|
| 37 |
+
"ResourceStatus": status,
|
| 38 |
+
"ResourceStatusReason": reason,
|
| 39 |
+
"Timestamp": now_iso(),
|
| 40 |
+
}
|
| 41 |
+
if stack_id not in _stack_events:
|
| 42 |
+
_stack_events[stack_id] = []
|
| 43 |
+
_stack_events[stack_id].append(event)
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
# ===========================================================================
|
| 47 |
+
# Stack Deploy / Delete / Update Logic
|
| 48 |
+
# ===========================================================================
|
| 49 |
+
|
| 50 |
+
async def _deploy_stack_async(stack_name: str, stack_id: str, template: dict,
|
| 51 |
+
param_values: dict, disable_rollback: bool,
|
| 52 |
+
tags: list, is_update: bool = False,
|
| 53 |
+
previous_stack: dict | None = None):
|
| 54 |
+
"""Background task: provision resources and set final stack status."""
|
| 55 |
+
from ministack.services.cloudformation import _stacks, _exports
|
| 56 |
+
status_prefix = "UPDATE" if is_update else "CREATE"
|
| 57 |
+
stack = _stacks[stack_name]
|
| 58 |
+
|
| 59 |
+
mappings = template.get("Mappings", {})
|
| 60 |
+
conditions = _evaluate_conditions(template, param_values)
|
| 61 |
+
resources_defs = template.get("Resources", {})
|
| 62 |
+
outputs_defs = template.get("Outputs", {})
|
| 63 |
+
|
| 64 |
+
# Topological sort
|
| 65 |
+
try:
|
| 66 |
+
ordered = _topological_sort(resources_defs, conditions)
|
| 67 |
+
except ValueError as exc:
|
| 68 |
+
stack["StackStatus"] = f"{status_prefix}_FAILED"
|
| 69 |
+
stack["StackStatusReason"] = str(exc)
|
| 70 |
+
_add_event(stack_id, stack_name, stack_name,
|
| 71 |
+
"AWS::CloudFormation::Stack", f"{status_prefix}_FAILED",
|
| 72 |
+
str(exc), stack_id)
|
| 73 |
+
return
|
| 74 |
+
|
| 75 |
+
provisioned_resources: dict = stack.get("_resources", {})
|
| 76 |
+
created_in_this_run = []
|
| 77 |
+
|
| 78 |
+
# If update: figure out what to add/modify/remove
|
| 79 |
+
if is_update and previous_stack:
|
| 80 |
+
old_resource_names = set(previous_stack.get("_resources", {}).keys())
|
| 81 |
+
new_resource_names = set(ordered)
|
| 82 |
+
to_remove = old_resource_names - new_resource_names
|
| 83 |
+
else:
|
| 84 |
+
to_remove = set()
|
| 85 |
+
|
| 86 |
+
failed = False
|
| 87 |
+
fail_reason = ""
|
| 88 |
+
|
| 89 |
+
for logical_id in ordered:
|
| 90 |
+
res_def = resources_defs[logical_id]
|
| 91 |
+
cond = res_def.get("Condition")
|
| 92 |
+
if cond and not conditions.get(cond, True):
|
| 93 |
+
continue
|
| 94 |
+
|
| 95 |
+
resource_type = res_def.get("Type", "AWS::CloudFormation::CustomResource")
|
| 96 |
+
raw_props = res_def.get("Properties", {})
|
| 97 |
+
|
| 98 |
+
try:
|
| 99 |
+
# Resolve properties
|
| 100 |
+
resolved_props = _resolve_refs(
|
| 101 |
+
copy.deepcopy(raw_props), provisioned_resources, param_values,
|
| 102 |
+
conditions, mappings, stack_name, stack_id
|
| 103 |
+
)
|
| 104 |
+
# Filter out _NO_VALUE properties at top level
|
| 105 |
+
if isinstance(resolved_props, dict):
|
| 106 |
+
resolved_props = {
|
| 107 |
+
k: v for k, v in resolved_props.items() if v is not _NO_VALUE
|
| 108 |
+
}
|
| 109 |
+
|
| 110 |
+
_add_event(stack_id, stack_name, logical_id, resource_type,
|
| 111 |
+
f"{status_prefix}_IN_PROGRESS")
|
| 112 |
+
|
| 113 |
+
physical_id, attrs = _provision_resource(
|
| 114 |
+
resource_type, logical_id, resolved_props, stack_name
|
| 115 |
+
)
|
| 116 |
+
except Exception as exc:
|
| 117 |
+
logger.error("Failed to provision %s (%s): %s",
|
| 118 |
+
logical_id, resource_type, exc)
|
| 119 |
+
_add_event(stack_id, stack_name, logical_id, resource_type,
|
| 120 |
+
f"{status_prefix}_FAILED", str(exc))
|
| 121 |
+
failed = True
|
| 122 |
+
fail_reason = f"Resource {logical_id} failed: {exc}"
|
| 123 |
+
break
|
| 124 |
+
|
| 125 |
+
provisioned_resources[logical_id] = {
|
| 126 |
+
"PhysicalResourceId": physical_id,
|
| 127 |
+
"ResourceType": resource_type,
|
| 128 |
+
"ResourceStatus": f"{status_prefix}_COMPLETE",
|
| 129 |
+
"LogicalResourceId": logical_id,
|
| 130 |
+
"Properties": resolved_props,
|
| 131 |
+
"Attributes": attrs,
|
| 132 |
+
"Timestamp": now_iso(),
|
| 133 |
+
}
|
| 134 |
+
created_in_this_run.append(logical_id)
|
| 135 |
+
|
| 136 |
+
_add_event(stack_id, stack_name, logical_id, resource_type,
|
| 137 |
+
f"{status_prefix}_COMPLETE", physical_id=physical_id)
|
| 138 |
+
|
| 139 |
+
# Delete removed resources (update case)
|
| 140 |
+
if not failed and to_remove:
|
| 141 |
+
old_resources = previous_stack.get("_resources", {})
|
| 142 |
+
for logical_id in to_remove:
|
| 143 |
+
old_res = old_resources.get(logical_id, {})
|
| 144 |
+
rtype = old_res.get("ResourceType", "")
|
| 145 |
+
pid = old_res.get("PhysicalResourceId", "")
|
| 146 |
+
old_props = old_res.get("Properties", {})
|
| 147 |
+
try:
|
| 148 |
+
_delete_resource(rtype, pid, old_props)
|
| 149 |
+
except Exception as exc:
|
| 150 |
+
logger.warning("Failed to delete old resource %s: %s",
|
| 151 |
+
logical_id, exc)
|
| 152 |
+
provisioned_resources.pop(logical_id, None)
|
| 153 |
+
|
| 154 |
+
await asyncio.sleep(0)
|
| 155 |
+
|
| 156 |
+
if failed:
|
| 157 |
+
if disable_rollback:
|
| 158 |
+
stack["StackStatus"] = f"{status_prefix}_FAILED"
|
| 159 |
+
stack["StackStatusReason"] = fail_reason
|
| 160 |
+
_add_event(stack_id, stack_name, stack_name,
|
| 161 |
+
"AWS::CloudFormation::Stack", f"{status_prefix}_FAILED",
|
| 162 |
+
fail_reason, stack_id)
|
| 163 |
+
else:
|
| 164 |
+
# Rollback: delete resources created in this run in reverse order
|
| 165 |
+
stack["StackStatus"] = "ROLLBACK_IN_PROGRESS" if not is_update else "UPDATE_ROLLBACK_IN_PROGRESS"
|
| 166 |
+
_add_event(stack_id, stack_name, stack_name,
|
| 167 |
+
"AWS::CloudFormation::Stack", stack["StackStatus"],
|
| 168 |
+
"Rollback requested", stack_id)
|
| 169 |
+
|
| 170 |
+
for logical_id in reversed(created_in_this_run):
|
| 171 |
+
res = provisioned_resources.get(logical_id, {})
|
| 172 |
+
rtype = res.get("ResourceType", "")
|
| 173 |
+
pid = res.get("PhysicalResourceId", "")
|
| 174 |
+
res_props = res.get("Properties", {})
|
| 175 |
+
try:
|
| 176 |
+
_delete_resource(rtype, pid, res_props)
|
| 177 |
+
_add_event(stack_id, stack_name, logical_id, rtype,
|
| 178 |
+
"DELETE_COMPLETE", physical_id=pid)
|
| 179 |
+
except Exception as del_exc:
|
| 180 |
+
logger.warning("Rollback delete of %s failed: %s",
|
| 181 |
+
logical_id, del_exc)
|
| 182 |
+
_add_event(stack_id, stack_name, logical_id, rtype,
|
| 183 |
+
"DELETE_FAILED", str(del_exc), pid)
|
| 184 |
+
provisioned_resources.pop(logical_id, None)
|
| 185 |
+
|
| 186 |
+
if is_update and previous_stack:
|
| 187 |
+
# Restore previous resources
|
| 188 |
+
stack["_resources"] = previous_stack.get("_resources", {})
|
| 189 |
+
stack["_template"] = previous_stack.get("_template", {})
|
| 190 |
+
stack["_resolved_params"] = previous_stack.get("_resolved_params", {})
|
| 191 |
+
stack["Outputs"] = previous_stack.get("Outputs", [])
|
| 192 |
+
stack["StackStatus"] = "UPDATE_ROLLBACK_COMPLETE"
|
| 193 |
+
else:
|
| 194 |
+
stack["StackStatus"] = "ROLLBACK_COMPLETE"
|
| 195 |
+
_add_event(stack_id, stack_name, stack_name,
|
| 196 |
+
"AWS::CloudFormation::Stack", stack["StackStatus"],
|
| 197 |
+
"Rollback complete", stack_id)
|
| 198 |
+
return
|
| 199 |
+
|
| 200 |
+
# Success: resolve outputs
|
| 201 |
+
stack["_resources"] = provisioned_resources
|
| 202 |
+
stack["_template"] = template
|
| 203 |
+
stack["_resolved_params"] = param_values
|
| 204 |
+
|
| 205 |
+
resolved_outputs = []
|
| 206 |
+
for out_name, out_def in outputs_defs.items():
|
| 207 |
+
cond = out_def.get("Condition")
|
| 208 |
+
if cond and not conditions.get(cond, True):
|
| 209 |
+
continue
|
| 210 |
+
out_value = _resolve_refs(
|
| 211 |
+
copy.deepcopy(out_def.get("Value", "")),
|
| 212 |
+
provisioned_resources, param_values, conditions,
|
| 213 |
+
mappings, stack_name, stack_id
|
| 214 |
+
)
|
| 215 |
+
output = {
|
| 216 |
+
"OutputKey": out_name,
|
| 217 |
+
"OutputValue": str(out_value),
|
| 218 |
+
"Description": out_def.get("Description", ""),
|
| 219 |
+
}
|
| 220 |
+
export_def = out_def.get("Export", {})
|
| 221 |
+
if export_def:
|
| 222 |
+
export_name = _resolve_refs(
|
| 223 |
+
copy.deepcopy(export_def.get("Name", "")),
|
| 224 |
+
provisioned_resources, param_values, conditions,
|
| 225 |
+
mappings, stack_name, stack_id
|
| 226 |
+
)
|
| 227 |
+
output["ExportName"] = str(export_name)
|
| 228 |
+
_exports[str(export_name)] = {
|
| 229 |
+
"StackId": stack_id,
|
| 230 |
+
"Name": str(export_name),
|
| 231 |
+
"Value": str(out_value),
|
| 232 |
+
}
|
| 233 |
+
resolved_outputs.append(output)
|
| 234 |
+
|
| 235 |
+
stack["Outputs"] = resolved_outputs
|
| 236 |
+
stack["StackStatus"] = f"{status_prefix}_COMPLETE"
|
| 237 |
+
_add_event(stack_id, stack_name, stack_name,
|
| 238 |
+
"AWS::CloudFormation::Stack", f"{status_prefix}_COMPLETE",
|
| 239 |
+
physical_id=stack_id)
|
| 240 |
+
|
| 241 |
+
|
| 242 |
+
async def _delete_stack_async(stack_name: str, stack_id: str):
|
| 243 |
+
"""Background task: delete all resources and mark stack DELETE_COMPLETE."""
|
| 244 |
+
from ministack.services.cloudformation import _stacks, _exports
|
| 245 |
+
stack = _stacks.get(stack_name)
|
| 246 |
+
if not stack:
|
| 247 |
+
return
|
| 248 |
+
|
| 249 |
+
stack["StackStatus"] = "DELETE_IN_PROGRESS"
|
| 250 |
+
_add_event(stack_id, stack_name, stack_name,
|
| 251 |
+
"AWS::CloudFormation::Stack", "DELETE_IN_PROGRESS",
|
| 252 |
+
physical_id=stack_id)
|
| 253 |
+
|
| 254 |
+
# Export-in-use check already done synchronously in _delete_stack
|
| 255 |
+
|
| 256 |
+
resources = stack.get("_resources", {})
|
| 257 |
+
template = stack.get("_template", {})
|
| 258 |
+
res_defs = template.get("Resources", {}) if template else {}
|
| 259 |
+
conditions = stack.get("_conditions", {})
|
| 260 |
+
|
| 261 |
+
# Delete in reverse dependency order
|
| 262 |
+
try:
|
| 263 |
+
ordered = _topological_sort(res_defs, conditions) if res_defs else list(resources.keys())
|
| 264 |
+
except ValueError:
|
| 265 |
+
ordered = list(resources.keys())
|
| 266 |
+
|
| 267 |
+
for logical_id in reversed(ordered):
|
| 268 |
+
res = resources.get(logical_id)
|
| 269 |
+
if not res:
|
| 270 |
+
continue
|
| 271 |
+
rtype = res.get("ResourceType", "")
|
| 272 |
+
pid = res.get("PhysicalResourceId", "")
|
| 273 |
+
res_props = res.get("Properties", {})
|
| 274 |
+
|
| 275 |
+
_add_event(stack_id, stack_name, logical_id, rtype,
|
| 276 |
+
"DELETE_IN_PROGRESS", physical_id=pid)
|
| 277 |
+
try:
|
| 278 |
+
_delete_resource(rtype, pid, res_props)
|
| 279 |
+
_add_event(stack_id, stack_name, logical_id, rtype,
|
| 280 |
+
"DELETE_COMPLETE", physical_id=pid)
|
| 281 |
+
except Exception as exc:
|
| 282 |
+
logger.warning("Delete of %s (%s) failed: %s", logical_id, pid, exc)
|
| 283 |
+
_add_event(stack_id, stack_name, logical_id, rtype,
|
| 284 |
+
"DELETE_FAILED", str(exc), pid)
|
| 285 |
+
|
| 286 |
+
# Remove exports
|
| 287 |
+
for out in stack.get("Outputs", []):
|
| 288 |
+
export_name = out.get("ExportName")
|
| 289 |
+
if export_name:
|
| 290 |
+
_exports.pop(export_name, None)
|
| 291 |
+
|
| 292 |
+
await asyncio.sleep(0)
|
| 293 |
+
|
| 294 |
+
stack["StackStatus"] = "DELETE_COMPLETE"
|
| 295 |
+
_add_event(stack_id, stack_name, stack_name,
|
| 296 |
+
"AWS::CloudFormation::Stack", "DELETE_COMPLETE",
|
| 297 |
+
physical_id=stack_id)
|
| 298 |
+
|
| 299 |
+
|
| 300 |
+
# ===========================================================================
|
| 301 |
+
# Change Set Helpers
|
| 302 |
+
# ===========================================================================
|
| 303 |
+
|
| 304 |
+
def _diff_resources(old_template: dict, new_template: dict) -> list:
|
| 305 |
+
"""Diff two templates and return a list of change dicts."""
|
| 306 |
+
old_res = old_template.get("Resources", {})
|
| 307 |
+
new_res = new_template.get("Resources", {})
|
| 308 |
+
changes = []
|
| 309 |
+
|
| 310 |
+
all_keys = old_res.keys() | new_res.keys()
|
| 311 |
+
for key in sorted(all_keys):
|
| 312 |
+
if key not in old_res:
|
| 313 |
+
changes.append({
|
| 314 |
+
"ResourceChange": {
|
| 315 |
+
"Action": "Add",
|
| 316 |
+
"LogicalResourceId": key,
|
| 317 |
+
"ResourceType": new_res[key].get("Type", ""),
|
| 318 |
+
"Replacement": "False",
|
| 319 |
+
}
|
| 320 |
+
})
|
| 321 |
+
elif key not in new_res:
|
| 322 |
+
changes.append({
|
| 323 |
+
"ResourceChange": {
|
| 324 |
+
"Action": "Remove",
|
| 325 |
+
"LogicalResourceId": key,
|
| 326 |
+
"ResourceType": old_res[key].get("Type", ""),
|
| 327 |
+
"PhysicalResourceId": "",
|
| 328 |
+
"Replacement": "False",
|
| 329 |
+
}
|
| 330 |
+
})
|
| 331 |
+
else:
|
| 332 |
+
old_props = old_res[key].get("Properties", {})
|
| 333 |
+
new_props = new_res[key].get("Properties", {})
|
| 334 |
+
if old_props != new_props:
|
| 335 |
+
changes.append({
|
| 336 |
+
"ResourceChange": {
|
| 337 |
+
"Action": "Modify",
|
| 338 |
+
"LogicalResourceId": key,
|
| 339 |
+
"ResourceType": new_res[key].get("Type", ""),
|
| 340 |
+
"Replacement": "Conditional",
|
| 341 |
+
}
|
| 342 |
+
})
|
| 343 |
+
return changes
|