Sizzing commited on
Commit
c745a99
·
verified ·
1 Parent(s): 40056ec

Upload folder using huggingface_hub

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .gitattributes +28 -0
  2. Blog.MD +564 -0
  3. Dockerfile +119 -0
  4. Makefile +187 -0
  5. README.md +713 -5
  6. __init__.py +21 -0
  7. aws_infra/CHANGELOG.md +0 -0
  8. aws_infra/CONTRIBUTING.md +196 -0
  9. aws_infra/Dockerfile +70 -0
  10. aws_infra/LICENSE +21 -0
  11. aws_infra/Makefile +127 -0
  12. aws_infra/README.md +1163 -0
  13. aws_infra/SECURITY.md +28 -0
  14. aws_infra/Testcontainers/go-testcontainers/README.md +25 -0
  15. aws_infra/Testcontainers/go-testcontainers/go.mod +75 -0
  16. aws_infra/Testcontainers/go-testcontainers/go.sum +232 -0
  17. aws_infra/Testcontainers/go-testcontainers/ministack_test.go +297 -0
  18. aws_infra/Testcontainers/java-testcontainers/README.md +25 -0
  19. aws_infra/Testcontainers/java-testcontainers/pom.xml +80 -0
  20. aws_infra/Testcontainers/java-testcontainers/src/test/java/io/ministack/MiniStackTest.java +211 -0
  21. aws_infra/Testcontainers/python-testcontainers/README.md +23 -0
  22. aws_infra/Testcontainers/python-testcontainers/requirements.txt +3 -0
  23. aws_infra/Testcontainers/python-testcontainers/test_ministack.py +110 -0
  24. aws_infra/bin/awslocal +14 -0
  25. aws_infra/docker-compose.yml +58 -0
  26. aws_infra/ministack/__init__.py +0 -0
  27. aws_infra/ministack/__main__.py +3 -0
  28. aws_infra/ministack/app.py +1414 -0
  29. aws_infra/ministack/core/__init__.py +0 -0
  30. aws_infra/ministack/core/hypercorn_compat.py +43 -0
  31. aws_infra/ministack/core/lambda_runtime.py +629 -0
  32. aws_infra/ministack/core/persistence.py +94 -0
  33. aws_infra/ministack/core/responses.py +266 -0
  34. aws_infra/ministack/core/router.py +559 -0
  35. aws_infra/ministack/services/__init__.py +0 -0
  36. aws_infra/ministack/services/acm.py +278 -0
  37. aws_infra/ministack/services/alb.py +1169 -0
  38. aws_infra/ministack/services/apigateway.py +1456 -0
  39. aws_infra/ministack/services/apigateway_v1.py +1602 -0
  40. aws_infra/ministack/services/appconfig.py +852 -0
  41. aws_infra/ministack/services/appsync.py +1001 -0
  42. aws_infra/ministack/services/athena.py +938 -0
  43. aws_infra/ministack/services/autoscaling.py +554 -0
  44. aws_infra/ministack/services/cloudformation/__init__.py +109 -0
  45. aws_infra/ministack/services/cloudformation/changesets.py +323 -0
  46. aws_infra/ministack/services/cloudformation/engine.py +545 -0
  47. aws_infra/ministack/services/cloudformation/handlers.py +646 -0
  48. aws_infra/ministack/services/cloudformation/helpers.py +109 -0
  49. aws_infra/ministack/services/cloudformation/provisioners.py +0 -0
  50. 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
+ ![From Cloud Chaos to Capable Agents](docs/figures/blog_hero.png)
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
+ ![System architecture](docs/figures/architecture_diagram.png)
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
+ ![Curriculum tier pyramid](docs/figures/tier_pyramid.png)
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
+ ![Curriculum progression](docs/figures/curriculum_progression.png)
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
+ ![Reward components](docs/figures/reward_components.png)
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
+ ![Parallel rollout architecture](docs/figures/parallel_rollout_diagram.png)
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
+ ![8 simultaneous WebSocket sessions](docs/figures/env_init_screenshot.png)
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
+ ![SFT dataset composition](docs/figures/dataset_composition.png)
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
+ ![Top-4 candidate models on the held-out benchmark](docs/figures/model_eval_chart.png)
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
+ ![SFT loss curve](docs/figures/sft_loss_curve.png)
392
+
393
+ ![Optuna parameter importance](docs/figures/optuna_param_importance.png)
394
+ ![Optuna optimization history](docs/figures/optuna_history.png)
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
+ ![GRPO Optuna trials comparison](docs/figures/grpo_optuna_trials_comparison.png)
419
+ ![GRPO Optuna parameter importances](docs/figures/grpo_optuna_importances.png)
420
+ ![GRPO Optuna optimization history](docs/figures/grpo_optuna_history.png)
421
+
422
+ Final run: **35 GRPO steps, ~1.5 hours on Colab A10**.
423
+
424
+ ![GRPO per-step training signals](docs/figures/grpo_final_per_step.png)
425
+ ![GRPO env reward over training](docs/figures/grpo_reward_curve.png)
426
+ ![GRPO per-tier reward curve](docs/figures/grpo_per_tier_curve.png)
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
+ ![Base vs SFT eval-metrics comparison](docs/figures/base_vs_sft_success.png)
447
+ ![Single-step eval, base vs SFT](docs/figures/single_step_eval.png)
448
+ ![Dataset comparison: base vs SFT](docs/figures/compare_dataset.png)
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
+ ![SFT vs GRPO metrics grid](docs/figures/sft_vs_grpo_metrics_grid.png)
469
+ ![SFT vs GRPO by tier](docs/figures/sft_vs_grpo_by_tier.png)
470
+ ![SFT vs GRPO scalar comparison](docs/figures/sft_vs_grpo_scalar.png)
471
+ ![RL env comparison: base vs SFT (per-episode rewards)](docs/figures/compare_rl_env.png)
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
+ ![Qualitative rollouts on representative tasks](docs/figures/qualitative_rollouts.png)
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: Aws Rl Env
3
- emoji: 🦀
4
- colorFrom: green
5
- colorTo: red
6
  sdk: docker
7
  pinned: false
 
 
 
 
8
  ---
9
 
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ > ![System architecture](docs/figures/architecture_diagram.png)
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
+ > ![Curriculum progression: 5 tiers, priority scoring formula, mastery + spaced rep + fast-track](docs/figures/curriculum_progression.png)
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
+ > ![SFT loss curve](docs/figures/sft_loss_curve.png)
401
+ > ![GRPO mean reward over training](docs/figures/grpo_reward_curve.png)
402
+ > ![Per-rollout reward by curriculum tier](docs/figures/grpo_per_tier_curve.png)
403
+ > ![Optuna parameter importance](docs/figures/optuna_param_importance.png)
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
+ > ![Parallel rollout: 3 coordinated pool layers](docs/figures/parallel_rollout_diagram.png)
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
+ > ![Top 4 candidate models on the held-out benchmark](docs/figures/model_eval_chart.png)
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
+ > ![Base vs SFT eval-metrics comparison](docs/figures/base_vs_sft_success.png)
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
+ > ![Dataset comparison: base vs SFT (per-row scores)](docs/figures/compare_dataset.png)
488
+ > ![RL env comparison: base vs SFT (per-episode rewards)](docs/figures/compare_rl_env.png)
489
+
490
+ ### SFT training curves
491
+
492
+ > ![SFT loss curve over training](docs/figures/sft_loss_curve.png)
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
+ > ![Optuna parameter importances](docs/figures/optuna_param_importance.png)
499
+ > ![Optuna optimization history](docs/figures/optuna_history.png)
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
+ > ![SFT vs GRPO metrics grid](docs/figures/sft_vs_grpo_metrics_grid.png)
518
+ > ![SFT vs GRPO by tier](docs/figures/sft_vs_grpo_by_tier.png)
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
+ > ![GRPO final per-step training signals](docs/figures/grpo_final_per_step.png)
527
+ > ![GRPO env reward over training](docs/figures/grpo_reward_curve.png)
528
+
529
+ Optuna search across 4 trials picked the final config:
530
+
531
+ > ![GRPO Optuna trial comparison](docs/figures/grpo_optuna_trials_comparison.png)
532
+ > ![GRPO Optuna parameter importances](docs/figures/grpo_optuna_importances.png)
533
+ > ![GRPO Optuna optimization history](docs/figures/grpo_optuna_history.png)
534
+
535
+ ### Qualitative rollouts (post-GRPO)
536
+
537
+ One sample episode per tier:
538
+
539
+ > ![Qualitative rollouts on representative tasks](docs/figures/qualitative_rollouts.png)
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