microvm-ctl 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,40 @@
1
+ {
2
+ "pagination": {
3
+ "ListManagedMicrovmImageVersions": {
4
+ "input_token": "nextToken",
5
+ "output_token": "nextToken",
6
+ "limit_key": "maxResults",
7
+ "result_key": "items"
8
+ },
9
+ "ListManagedMicrovmImages": {
10
+ "input_token": "nextToken",
11
+ "output_token": "nextToken",
12
+ "limit_key": "maxResults",
13
+ "result_key": "items"
14
+ },
15
+ "ListMicrovmImageBuilds": {
16
+ "input_token": "nextToken",
17
+ "output_token": "nextToken",
18
+ "limit_key": "maxResults",
19
+ "result_key": "items"
20
+ },
21
+ "ListMicrovmImageVersions": {
22
+ "input_token": "nextToken",
23
+ "output_token": "nextToken",
24
+ "limit_key": "maxResults",
25
+ "result_key": "items"
26
+ },
27
+ "ListMicrovmImages": {
28
+ "input_token": "nextToken",
29
+ "output_token": "nextToken",
30
+ "limit_key": "maxResults",
31
+ "result_key": "items"
32
+ },
33
+ "ListMicrovms": {
34
+ "input_token": "nextToken",
35
+ "output_token": "nextToken",
36
+ "limit_key": "maxResults",
37
+ "result_key": "items"
38
+ }
39
+ }
40
+ }
microvm/endpoint.py ADDED
@@ -0,0 +1,136 @@
1
+ """Execution plane: authenticated HTTP into a microVM's dedicated endpoint.
2
+
3
+ Every microVM gets its own HTTPS endpoint
4
+ (`<microvm-id>.lambda-microvm.<region>.on.aws`). There is no unauthenticated
5
+ mode: each request carries a port-scoped, expiring JWE token in the
6
+ `X-aws-proxy-auth` header, minted via CreateMicrovmAuthToken. Non-default
7
+ ports are selected with `X-aws-proxy-port`.
8
+
9
+ This client mints tokens lazily, caches them until ~80% of their TTL, and
10
+ retries the two endpoint errors you must design for:
11
+ 429 — endpoint rate limit -> jittered backoff
12
+ 502 — app down or VM auto-resuming -> patient retry (first request after
13
+ suspend pays the resume; subsequent ones are warm)
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import random
19
+ import time
20
+
21
+ import requests
22
+
23
+ from microvm.client import microvm_client
24
+ from microvm.config import PORT_HEADER, PlaneConfig
25
+
26
+
27
+ class EndpointError(RuntimeError):
28
+ pass
29
+
30
+
31
+ class EndpointClient:
32
+ def __init__(
33
+ self,
34
+ config: PlaneConfig,
35
+ microvm_id: str,
36
+ endpoint: str | None = None,
37
+ *,
38
+ ports: list[int] | None = None,
39
+ all_ports: bool = False,
40
+ token_ttl_minutes: int = 15,
41
+ ):
42
+ self.cfg = config
43
+ self.api = microvm_client(config.region, config.profile)
44
+ self.microvm_id = microvm_id
45
+ self.endpoint = endpoint or self._discover_endpoint()
46
+ self.ports = ports or [8080]
47
+ self.all_ports = all_ports
48
+ self.ttl_minutes = token_ttl_minutes
49
+ self._token: dict[str, str] | None = None
50
+ self._token_expiry = 0.0
51
+ self.http = requests.Session()
52
+
53
+ def _discover_endpoint(self) -> str:
54
+ vm = self.api.get_microvm(microvmIdentifier=self.microvm_id)
55
+ return vm["endpoint"]
56
+
57
+ # -- token lifecycle ---------------------------------------------------------
58
+ def _auth_headers(self) -> dict[str, str]:
59
+ if self._token is None or time.time() > self._token_expiry:
60
+ spec = [{"allPorts": {}}] if self.all_ports else [{"port": p} for p in self.ports]
61
+ resp = self.api.create_microvm_auth_token(
62
+ microvmIdentifier=self.microvm_id,
63
+ expirationInMinutes=self.ttl_minutes,
64
+ allowedPorts=spec,
65
+ )
66
+ # authToken maps header names to values (e.g. "X-aws-proxy-auth").
67
+ self._token = dict(resp["authToken"])
68
+ self._token_expiry = time.time() + self.ttl_minutes * 60 * 0.8
69
+ return self._token
70
+
71
+ # -- requests ----------------------------------------------------------------
72
+ def request(
73
+ self,
74
+ method: str,
75
+ path: str,
76
+ *,
77
+ port: int | None = None,
78
+ timeout: float = 60,
79
+ max_attempts: int = 6,
80
+ resume_patience: float = 30,
81
+ **kwargs,
82
+ ) -> requests.Response:
83
+ """HTTP request to the microVM. 502s are retried for `resume_patience`
84
+ seconds to ride out an auto-resume; 429s back off with jitter."""
85
+ url = f"https://{self.endpoint}{path if path.startswith('/') else '/' + path}"
86
+ base_headers = dict(kwargs.pop("headers", None) or {})
87
+ if port and port != 8080:
88
+ base_headers[PORT_HEADER] = str(port)
89
+ started, delay = time.time(), 0.5
90
+ last: requests.Response | None = None
91
+ for attempt in range(1, max_attempts + 1):
92
+ # Auth headers are merged per attempt so a re-minted token after a
93
+ # 403 is picked up without losing the caller's own headers.
94
+ headers = {**base_headers, **self._auth_headers()}
95
+ last = self.http.request(method, url, headers=headers, timeout=timeout, **kwargs)
96
+ if last.status_code == 429:
97
+ time.sleep(delay + random.uniform(0, delay))
98
+ delay = min(delay * 2, 8)
99
+ continue
100
+ if last.status_code == 502 and time.time() - started < resume_patience:
101
+ time.sleep(2)
102
+ continue
103
+ if last.status_code == 403 and attempt < max_attempts:
104
+ # token may have been revoked/expired server-side; re-mint and retry
105
+ self._token = None
106
+ continue
107
+ return last
108
+ return last # type: ignore[return-value]
109
+
110
+ def get(self, path: str, **kw) -> requests.Response:
111
+ return self.request("GET", path, **kw)
112
+
113
+ def post(self, path: str, **kw) -> requests.Response:
114
+ return self.request("POST", path, **kw)
115
+
116
+ # -- conveniences ------------------------------------------------------------
117
+ def wait_ready(self, path: str = "/healthz", timeout: float = 90) -> float:
118
+ """Poll until the app answers 200; returns time-to-first-byte seconds."""
119
+ started = time.time()
120
+ deadline = started + timeout
121
+ while time.time() < deadline:
122
+ try:
123
+ if self.get(path, timeout=10, max_attempts=1).status_code == 200:
124
+ return round(time.time() - started, 2)
125
+ except requests.RequestException:
126
+ pass
127
+ time.sleep(1)
128
+ raise EndpointError(f"{self.microvm_id} not serving {path} after {timeout}s")
129
+
130
+ def shell_token(self, minutes: int = 15) -> dict[str, str]:
131
+ """Token for interactive shell access (VM must run with SHELL_INGRESS)."""
132
+ return dict(
133
+ self.api.create_microvm_shell_auth_token(
134
+ microvmIdentifier=self.microvm_id, expirationInMinutes=minutes
135
+ )["authToken"]
136
+ )
microvm/fleet.py ADDED
@@ -0,0 +1,291 @@
1
+ """Fleet manager: run, suspend, resume, terminate, and scale microVM fleets.
2
+
3
+ A *fleet* is the set of microVMs launched from one image (optionally pinned
4
+ to a version). One endpoint == one microVM — there is no load balancer in
5
+ the service — so horizontal scale is literally more RunMicrovm calls, and
6
+ routing across the fleet is the control plane's job (see endpoint.py).
7
+
8
+ Every mutating call is throttled to the service's published TPS quotas and
9
+ retried with jittered backoff, so `scale_to(50)` is safe to call in one shot.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import concurrent.futures as futures
15
+ import time
16
+ from dataclasses import dataclass, field
17
+ from typing import Callable
18
+
19
+ from microvm.client import image_arn, microvm_client
20
+ from microvm.config import TPS, PlaneConfig
21
+ from microvm.throttle import Throttled
22
+
23
+ ACTIVE_STATES = {"PENDING", "RUNNING", "SUSPENDING", "SUSPENDED"}
24
+
25
+
26
+ @dataclass
27
+ class Microvm:
28
+ microvm_id: str
29
+ state: str
30
+ image_arn: str
31
+ image_version: str
32
+ started_at: object = None # datetime from the API, or None
33
+ endpoint: str | None = None
34
+
35
+ @property
36
+ def started_epoch(self) -> float:
37
+ """startedAt as a POSIX timestamp (0.0 when unknown) - safe to sort on."""
38
+ ts = getattr(self.started_at, "timestamp", None)
39
+ return float(ts()) if callable(ts) else 0.0
40
+
41
+ @classmethod
42
+ def from_api(cls, d: dict) -> Microvm:
43
+ return cls(
44
+ microvm_id=d["microvmId"],
45
+ state=d["state"],
46
+ image_arn=d["imageArn"],
47
+ image_version=d["imageVersion"],
48
+ started_at=d.get("startedAt"),
49
+ endpoint=d.get("endpoint"),
50
+ )
51
+
52
+
53
+ @dataclass
54
+ class IdlePolicy:
55
+ """Suspend after `max_idle` seconds without endpoint traffic; auto-resume on
56
+ the next request; auto-TERMINATE after `suspended_for` seconds suspended."""
57
+
58
+ max_idle: int = 300
59
+ suspended_for: int = 3600
60
+ auto_resume: bool = True
61
+
62
+ def to_api(self) -> dict:
63
+ return {
64
+ "maxIdleDurationSeconds": self.max_idle,
65
+ "suspendedDurationSeconds": self.suspended_for,
66
+ "autoResumeEnabled": self.auto_resume,
67
+ }
68
+
69
+
70
+ # Service Quotas codes for the applied (per-account) API rates — new accounts
71
+ # run reduced profiles (e.g. RunMicrovm 1/s instead of 5/s), so the published
72
+ # defaults are the wrong thing to throttle against.
73
+ QUOTA_CODES = {
74
+ "RunMicrovm": "L-535CA9B6",
75
+ "SuspendMicrovm": "L-90045317",
76
+ "ResumeMicrovm": "L-118C44B3",
77
+ "TerminateMicrovm": "L-74787B8A",
78
+ "MaxMemoryGb": "L-CD1C0CC4",
79
+ }
80
+
81
+
82
+ def applied_quotas(config: PlaneConfig) -> dict[str, float]:
83
+ """Best-effort lookup of this account's *applied* microVM quotas."""
84
+ from microvm.client import lambda_client
85
+ try:
86
+ sq = lambda_client("service-quotas", config.region, config.profile)
87
+ out = {}
88
+ for name, code in QUOTA_CODES.items():
89
+ try:
90
+ out[name] = sq.get_service_quota(ServiceCode="lambda", QuotaCode=code)[
91
+ "Quota"]["Value"]
92
+ except Exception:
93
+ pass
94
+ return out
95
+ except Exception:
96
+ return {}
97
+
98
+
99
+ class FleetManager:
100
+ """Low-level lifecycle operations, one instance per (account, region)."""
101
+
102
+ #: fraction of the applied TPS quota the token buckets are allowed to use
103
+ QUOTA_HEADROOM = 0.8
104
+
105
+ def __init__(self, config: PlaneConfig, quota_aware: bool = True):
106
+ self.cfg = config
107
+ self.api = microvm_client(config.region, config.profile)
108
+ self.quotas = applied_quotas(config) if quota_aware else {}
109
+ self._run = Throttled(self.api.run_microvm, self.tps("RunMicrovm"))
110
+ self._suspend = Throttled(self.api.suspend_microvm, self.tps("SuspendMicrovm"))
111
+ self._resume = Throttled(self.api.resume_microvm, self.tps("ResumeMicrovm"))
112
+ self._terminate = Throttled(self.api.terminate_microvm, self.tps("TerminateMicrovm"))
113
+
114
+ def tps(self, op: str) -> float:
115
+ """Effective rate for one mutating API: applied quota (or the published
116
+ default when Service Quotas is unavailable) times QUOTA_HEADROOM."""
117
+ return (self.quotas.get(op) or TPS[op]) * self.QUOTA_HEADROOM
118
+
119
+ @property
120
+ def memory_quota_gb(self) -> float | None:
121
+ """Applied 'max allocated microVM memory' quota, if Service Quotas answered."""
122
+ return self.quotas.get("MaxMemoryGb")
123
+
124
+ # -- single VM ---------------------------------------------------------------
125
+ def run(
126
+ self,
127
+ image: str,
128
+ *,
129
+ version: str | None = None,
130
+ idle_policy: IdlePolicy | None = None,
131
+ run_payload: str | None = None,
132
+ max_duration: int | None = None,
133
+ ingress: list[str] | None = None,
134
+ egress: list[str] | None = None,
135
+ execution_role: str | None = None,
136
+ ) -> Microvm:
137
+ params: dict = {"imageIdentifier": image_arn(image, self.cfg.region, self.cfg.profile)}
138
+ if version:
139
+ params["imageVersion"] = version
140
+ params["idlePolicy"] = (idle_policy or IdlePolicy()).to_api()
141
+ if run_payload is not None:
142
+ params["runHookPayload"] = run_payload
143
+ if max_duration:
144
+ params["maximumDurationInSeconds"] = max_duration
145
+ if ingress:
146
+ params["ingressNetworkConnectors"] = ingress
147
+ if egress:
148
+ params["egressNetworkConnectors"] = egress
149
+ role = execution_role or self.cfg.execution_role_arn
150
+ if role:
151
+ params["executionRoleArn"] = role
152
+ return Microvm.from_api(self._run(**params))
153
+
154
+ def get(self, microvm_id: str) -> Microvm:
155
+ return Microvm.from_api(self.api.get_microvm(microvmIdentifier=microvm_id))
156
+
157
+ def suspend(self, microvm_id: str) -> None:
158
+ self._suspend(microvmIdentifier=microvm_id)
159
+
160
+ def resume(self, microvm_id: str) -> None:
161
+ self._resume(microvmIdentifier=microvm_id)
162
+
163
+ def terminate(self, microvm_id: str) -> None:
164
+ self._terminate(microvmIdentifier=microvm_id)
165
+
166
+ def wait_until(self, microvm_id: str, state: str, timeout: int = 120) -> Microvm:
167
+ deadline = time.time() + timeout
168
+ while time.time() < deadline:
169
+ vm = self.get(microvm_id)
170
+ if vm.state == state:
171
+ return vm
172
+ if vm.state == "TERMINATED" and state != "TERMINATED":
173
+ raise RuntimeError(f"{microvm_id} terminated while waiting for {state}")
174
+ time.sleep(2)
175
+ raise TimeoutError(f"{microvm_id} did not reach {state} in {timeout}s")
176
+
177
+ def list(self, image: str | None = None, version: str | None = None) -> list[Microvm]:
178
+ kwargs: dict = {}
179
+ if image:
180
+ kwargs["imageIdentifier"] = image_arn(image, self.cfg.region, self.cfg.profile)
181
+ if version:
182
+ kwargs["imageVersion"] = version
183
+ out = self.api.get_paginator("list_microvms").paginate(**kwargs).build_full_result()
184
+ return [Microvm.from_api(d) for d in out.get("items", [])]
185
+
186
+
187
+ @dataclass
188
+ class Fleet:
189
+ """Declarative fleet of microVMs from one image: scale up, down, drain."""
190
+
191
+ manager: FleetManager
192
+ image: str
193
+ version: str | None = None
194
+ idle_policy: IdlePolicy = field(default_factory=IdlePolicy)
195
+ max_duration: int | None = None
196
+ #: called with the launch index (0..n-1) to produce that VM's runHookPayload
197
+ run_payload_factory: Callable[[int], str] | None = None
198
+ ingress: list[str] | None = None
199
+ egress: list[str] | None = None
200
+ execution_role: str | None = None
201
+ _pool: futures.ThreadPoolExecutor = field(
202
+ default_factory=lambda: futures.ThreadPoolExecutor(max_workers=8), repr=False
203
+ )
204
+
205
+ # -- observation -------------------------------------------------------------
206
+ def members(self) -> list[Microvm]:
207
+ return [
208
+ vm
209
+ for vm in self.manager.list(self.image, self.version)
210
+ if vm.state in ACTIVE_STATES
211
+ ]
212
+
213
+ def size(self) -> int:
214
+ return len(self.members())
215
+
216
+ # -- scaling -----------------------------------------------------------------
217
+ def scale_to(self, desired: int, wait_running: bool = False) -> list[Microvm]:
218
+ """Converge the fleet to `desired` active microVMs.
219
+
220
+ Scale-up launches new VMs (throttled to the RunMicrovm quota).
221
+ Scale-down terminates SUSPENDED VMs first (they cost only snapshot
222
+ storage but count against the regional memory quota), then the
223
+ youngest RUNNING VMs, sparing the oldest — they hold the warmest state.
224
+ """
225
+ current = self.members()
226
+ delta = desired - len(current)
227
+ if delta > 0:
228
+ launched = list(
229
+ self._pool.map(lambda i: self._launch_one(i), range(delta))
230
+ )
231
+ if wait_running:
232
+ launched = [
233
+ self.manager.wait_until(vm.microvm_id, "RUNNING") for vm in launched
234
+ ]
235
+ return launched
236
+ if delta < 0:
237
+ for vm in self.scale_down_victims(current, -delta):
238
+ self.manager.terminate(vm.microvm_id)
239
+ return []
240
+
241
+ @staticmethod
242
+ def scale_down_victims(members: list[Microvm], count: int) -> list[Microvm]:
243
+ """Pick `count` members to terminate: every SUSPENDED VM first (storage
244
+ cost only, but they hold memory quota), then RUNNING VMs youngest first
245
+ so the oldest, warmest members survive."""
246
+ suspended = [v for v in members if v.state == "SUSPENDED"]
247
+ running = [v for v in members if v.state != "SUSPENDED"]
248
+ running.sort(key=lambda vm: vm.started_epoch, reverse=True) # youngest first
249
+ return (suspended + running)[:count]
250
+
251
+ def _launch_one(self, index: int) -> Microvm:
252
+ payload = self.run_payload_factory(index) if self.run_payload_factory else None
253
+ return self.manager.run(
254
+ self.image,
255
+ version=self.version,
256
+ idle_policy=self.idle_policy,
257
+ run_payload=payload,
258
+ max_duration=self.max_duration,
259
+ ingress=self.ingress,
260
+ egress=self.egress,
261
+ execution_role=self.execution_role,
262
+ )
263
+
264
+ def suspend_all(self) -> int:
265
+ vms = [v for v in self.members() if v.state == "RUNNING"]
266
+ list(self._pool.map(lambda v: self.manager.suspend(v.microvm_id), vms))
267
+ return len(vms)
268
+
269
+ def resume_all(self) -> int:
270
+ vms = [v for v in self.members() if v.state == "SUSPENDED"]
271
+ list(self._pool.map(lambda v: self.manager.resume(v.microvm_id), vms))
272
+ return len(vms)
273
+
274
+ def drain(self) -> int:
275
+ """Terminate every member of the fleet."""
276
+ vms = self.members()
277
+ list(self._pool.map(lambda v: self.manager.terminate(v.microvm_id), vms))
278
+ return len(vms)
279
+
280
+ # -- reaper ------------------------------------------------------------------
281
+ def reap(self, max_age_seconds: int) -> list[str]:
282
+ """Terminate members older than `max_age_seconds` (belt-and-braces on top
283
+ of maximumDurationInSeconds and the idle policy's auto-terminate)."""
284
+ now = time.time()
285
+ reaped = []
286
+ for vm in self.members():
287
+ started = vm.started_epoch
288
+ if started and now - started > max_age_seconds:
289
+ self.manager.terminate(vm.microvm_id)
290
+ reaped.append(vm.microvm_id)
291
+ return reaped
@@ -0,0 +1,3 @@
1
+ from microvm.hooks.server import HookApp
2
+
3
+ __all__ = ["HookApp"]
@@ -0,0 +1,168 @@
1
+ """Zero-dependency in-VM hook server implementing the Lambda MicroVMs contract.
2
+
3
+ Lambda drives the microVM lifecycle by calling HTTP hooks that *your app*
4
+ serves (stdlib only — nothing to install inside the image):
5
+
6
+ build-time GET/POST /aws/lambda-microvms/runtime/v1/ready 503 until warm, 200 => snapshot now
7
+ GET/POST /aws/lambda-microvms/runtime/v1/validate runs on a fresh VM from the image
8
+ runtime POST /aws/lambda-microvms/runtime/v1/run after clone; traffic starts on 200
9
+ POST /aws/lambda-microvms/runtime/v1/resume VM stays SUSPENDED until 200
10
+ POST /aws/lambda-microvms/runtime/v1/suspend flush before snapshot
11
+ POST /aws/lambda-microvms/runtime/v1/terminate cleanup before teardown
12
+
13
+ Because every VM is cloned from one snapshot, the /run hook is where you
14
+ restore uniqueness (reseed RNG, regenerate IDs, fetch per-tenant secrets from
15
+ the runHookPayload) — never at build time. Return fast; Lambda retries /ready
16
+ on 503, and a held-open hook request at timeout fails the build.
17
+
18
+ Usage:
19
+ app = HookApp()
20
+
21
+ @app.on_run
22
+ def on_run(ctx): # ctx = {"microvmId": ..., "runHookPayload": ...}
23
+ seed_rng(); load_tenant(ctx.get("runHookPayload"))
24
+
25
+ @app.route("POST", "/execute")
26
+ def execute(body, headers):
27
+ return 200, {"result": run(body["code"])}
28
+
29
+ app.serve(port=8080)
30
+ """
31
+
32
+ from __future__ import annotations
33
+
34
+ import json
35
+ import os
36
+ import random
37
+ import threading
38
+ import traceback
39
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
40
+
41
+ HOOK_BASE = "/aws/lambda-microvms/runtime/v1"
42
+
43
+
44
+ class HookApp:
45
+ def __init__(self):
46
+ self._hooks: dict[str, object] = {}
47
+ self._routes: dict[tuple[str, str], object] = {}
48
+ self.ready = False
49
+ self.microvm_id: str | None = None
50
+ self.run_payload: str | None = None
51
+
52
+ @self.route("GET", "/healthz")
53
+ def _healthz(body, headers):
54
+ return 200, {"ok": True, "microvmId": self.microvm_id}
55
+
56
+ # -- decorators --------------------------------------------------------------
57
+ def on_ready(self, fn):
58
+ """Return truthy when warm enough to snapshot (else 503 -> retried)."""
59
+ self._hooks["ready"] = fn
60
+ return fn
61
+
62
+ def on_validate(self, fn):
63
+ """Exercise real code paths here: Lambda records the snapshot regions the
64
+ validate run touches and prefetches them, cutting launch latency.
65
+ Return False (or raise) to fail the build."""
66
+ self._hooks["validate"] = fn
67
+ return fn
68
+
69
+ def on_run(self, fn):
70
+ self._hooks["run"] = fn
71
+ return fn
72
+
73
+ def on_resume(self, fn):
74
+ self._hooks["resume"] = fn
75
+ return fn
76
+
77
+ def on_suspend(self, fn):
78
+ self._hooks["suspend"] = fn
79
+ return fn
80
+
81
+ def on_terminate(self, fn):
82
+ self._hooks["terminate"] = fn
83
+ return fn
84
+
85
+ def route(self, method: str, path: str):
86
+ def deco(fn):
87
+ self._routes[(method.upper(), path)] = fn
88
+ return fn
89
+ return deco
90
+
91
+ # -- hook dispatch -----------------------------------------------------------
92
+ def _dispatch_hook(self, name: str, ctx: dict) -> int:
93
+ if name == "ready":
94
+ fn = self._hooks.get("ready")
95
+ ok = fn(ctx) if fn else True
96
+ self.ready = bool(ok) if fn else True
97
+ return 200 if self.ready else 503
98
+ if name == "validate":
99
+ fn = self._hooks.get("validate")
100
+ if fn is None:
101
+ return 200
102
+ ok = fn(ctx)
103
+ # None (no return statement) means "ran fine"; an explicit False
104
+ # rejects the image version so a bad build never becomes ACTIVE.
105
+ return 200 if ok is None or ok else 500
106
+ if name == "run":
107
+ self.microvm_id = ctx.get("microvmId") or os.environ.get("AWS_MICROVM_ID")
108
+ self.run_payload = ctx.get("runHookPayload")
109
+ # Restore entropy uniqueness for every clone before user code runs.
110
+ random.seed()
111
+ fn = self._hooks.get(name)
112
+ if fn:
113
+ fn(ctx)
114
+ return 200
115
+
116
+ # -- server ------------------------------------------------------------------
117
+ def serve(self, port: int = 8080, background: bool = False):
118
+ app = self
119
+
120
+ class Handler(BaseHTTPRequestHandler):
121
+ protocol_version = "HTTP/1.1"
122
+
123
+ def _read_json(self):
124
+ n = int(self.headers.get("Content-Length") or 0)
125
+ raw = self.rfile.read(n) if n else b""
126
+ try:
127
+ return json.loads(raw) if raw else {}
128
+ except json.JSONDecodeError:
129
+ return {"_raw": raw.decode("utf-8", "replace")}
130
+
131
+ def _send(self, status: int, payload):
132
+ body = (payload if isinstance(payload, (bytes, bytearray))
133
+ else json.dumps(payload).encode())
134
+ self.send_response(status)
135
+ self.send_header("Content-Type", "application/json")
136
+ self.send_header("Content-Length", str(len(body)))
137
+ self.end_headers()
138
+ self.wfile.write(body)
139
+
140
+ def _handle(self, method: str):
141
+ path = self.path.split("?", 1)[0]
142
+ try:
143
+ if path.startswith(HOOK_BASE + "/"):
144
+ hook = path[len(HOOK_BASE) + 1:]
145
+ status = app._dispatch_hook(hook, self._read_json())
146
+ return self._send(status, {"hook": hook, "status": status})
147
+ fn = app._routes.get((method, path))
148
+ if fn is None:
149
+ return self._send(404, {"error": f"no route {method} {path}"})
150
+ status, payload = fn(self._read_json(), dict(self.headers))
151
+ return self._send(status, payload)
152
+ except Exception:
153
+ return self._send(500, {"error": traceback.format_exc(limit=5)})
154
+
155
+ def do_GET(self):
156
+ self._handle("GET")
157
+
158
+ def do_POST(self):
159
+ self._handle("POST")
160
+
161
+ def log_message(self, *a): # keep container logs for the app, not access noise
162
+ pass
163
+
164
+ server = ThreadingHTTPServer(("0.0.0.0", port), Handler)
165
+ if background:
166
+ threading.Thread(target=server.serve_forever, daemon=True).start()
167
+ return server
168
+ server.serve_forever()