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.
- microvm/__init__.py +28 -0
- microvm/bootstrap.py +99 -0
- microvm/cli.py +382 -0
- microvm/client.py +60 -0
- microvm/config.py +81 -0
- microvm/data/lambda-microvms/2025-09-09/endpoint-rule-set-1.json.gz +0 -0
- microvm/data/lambda-microvms/2025-09-09/paginators-1.json +40 -0
- microvm/data/lambda-microvms/2025-09-09/service-2.json.gz +0 -0
- microvm/endpoint.py +136 -0
- microvm/fleet.py +291 -0
- microvm/hooks/__init__.py +3 -0
- microvm/hooks/server.py +168 -0
- microvm/images.py +204 -0
- microvm/monitor.py +107 -0
- microvm/throttle.py +65 -0
- microvm_ctl-0.1.0.dist-info/METADATA +173 -0
- microvm_ctl-0.1.0.dist-info/RECORD +21 -0
- microvm_ctl-0.1.0.dist-info/WHEEL +5 -0
- microvm_ctl-0.1.0.dist-info/entry_points.txt +2 -0
- microvm_ctl-0.1.0.dist-info/licenses/LICENSE +201 -0
- microvm_ctl-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -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
|
+
}
|
|
Binary file
|
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
|
microvm/hooks/server.py
ADDED
|
@@ -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()
|