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
microvm/__init__.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""microvm-ctl: control and execution plane for AWS Lambda MicroVMs.
|
|
2
|
+
|
|
3
|
+
Control plane : build images, run/suspend/resume/terminate, scale fleets.
|
|
4
|
+
Execution plane: authenticated calls into the microVM endpoint, in-VM hook server.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from microvm.client import lambda_client, microvm_client
|
|
8
|
+
from microvm.config import PlaneConfig
|
|
9
|
+
from microvm.endpoint import EndpointClient, EndpointError
|
|
10
|
+
from microvm.fleet import Fleet, FleetManager
|
|
11
|
+
from microvm.images import ImageBuilder, ImageBuildError
|
|
12
|
+
from microvm.monitor import CostModel, FleetMonitor
|
|
13
|
+
|
|
14
|
+
__version__ = "0.1.0"
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"microvm_client",
|
|
18
|
+
"lambda_client",
|
|
19
|
+
"PlaneConfig",
|
|
20
|
+
"ImageBuilder",
|
|
21
|
+
"ImageBuildError",
|
|
22
|
+
"Fleet",
|
|
23
|
+
"FleetManager",
|
|
24
|
+
"EndpointClient",
|
|
25
|
+
"EndpointError",
|
|
26
|
+
"FleetMonitor",
|
|
27
|
+
"CostModel",
|
|
28
|
+
]
|
microvm/bootstrap.py
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""One-time account bootstrap: artifact bucket + build/execution IAM roles.
|
|
2
|
+
|
|
3
|
+
Build role — assumed by the image build infrastructure: reads the code
|
|
4
|
+
artifact zip from S3 and writes build logs.
|
|
5
|
+
Execution — assumed by the running microVM (credentials are vended inside
|
|
6
|
+
role the VM): runtime logs plus whatever your workload needs.
|
|
7
|
+
Build vs execution role separation is deliberate — never let
|
|
8
|
+
the sandbox role read your artifact bucket.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
import time
|
|
15
|
+
|
|
16
|
+
from microvm.client import lambda_client
|
|
17
|
+
from microvm.config import PlaneConfig
|
|
18
|
+
|
|
19
|
+
TRUST = {
|
|
20
|
+
"Version": "2012-10-17",
|
|
21
|
+
"Statement": [
|
|
22
|
+
{
|
|
23
|
+
"Effect": "Allow",
|
|
24
|
+
"Principal": {"Service": "lambda.amazonaws.com"},
|
|
25
|
+
"Action": ["sts:AssumeRole", "sts:TagSession"],
|
|
26
|
+
}
|
|
27
|
+
],
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _ensure_role(iam, name: str, policy_doc: dict) -> str:
|
|
32
|
+
try:
|
|
33
|
+
arn = iam.get_role(RoleName=name)["Role"]["Arn"]
|
|
34
|
+
except iam.exceptions.NoSuchEntityException:
|
|
35
|
+
arn = iam.create_role(
|
|
36
|
+
RoleName=name,
|
|
37
|
+
AssumeRolePolicyDocument=json.dumps(TRUST),
|
|
38
|
+
Description="microvm-ctl control plane",
|
|
39
|
+
)["Role"]["Arn"]
|
|
40
|
+
time.sleep(8) # IAM eventual consistency before first use
|
|
41
|
+
iam.put_role_policy(
|
|
42
|
+
RoleName=name, PolicyName=f"{name}-inline", PolicyDocument=json.dumps(policy_doc)
|
|
43
|
+
)
|
|
44
|
+
return arn
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def bootstrap(cfg: PlaneConfig, prefix: str = "microvm-ctl") -> dict:
|
|
48
|
+
"""Create (idempotently) the artifact bucket and the two IAM roles.
|
|
49
|
+
|
|
50
|
+
Returns the three values the rest of the plane needs, ready to export as
|
|
51
|
+
MVM_ARTIFACT_BUCKET / MVM_BUILD_ROLE_ARN / MVM_EXECUTION_ROLE_ARN.
|
|
52
|
+
"""
|
|
53
|
+
sts = lambda_client("sts", cfg.region, cfg.profile)
|
|
54
|
+
account = sts.get_caller_identity()["Account"]
|
|
55
|
+
bucket = cfg.artifact_bucket or f"{prefix}-artifacts-{account}-{cfg.region}"
|
|
56
|
+
|
|
57
|
+
s3 = lambda_client("s3", cfg.region, cfg.profile)
|
|
58
|
+
try:
|
|
59
|
+
s3.head_bucket(Bucket=bucket)
|
|
60
|
+
except Exception:
|
|
61
|
+
params = {"Bucket": bucket}
|
|
62
|
+
if cfg.region != "us-east-1":
|
|
63
|
+
params["CreateBucketConfiguration"] = {"LocationConstraint": cfg.region}
|
|
64
|
+
s3.create_bucket(**params)
|
|
65
|
+
s3.put_public_access_block(
|
|
66
|
+
Bucket=bucket,
|
|
67
|
+
PublicAccessBlockConfiguration={
|
|
68
|
+
"BlockPublicAcls": True, "IgnorePublicAcls": True,
|
|
69
|
+
"BlockPublicPolicy": True, "RestrictPublicBuckets": True,
|
|
70
|
+
},
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
iam = lambda_client("iam", cfg.region, cfg.profile)
|
|
74
|
+
logs_stmt = {
|
|
75
|
+
"Effect": "Allow",
|
|
76
|
+
"Action": ["logs:CreateLogGroup", "logs:CreateLogStream", "logs:PutLogEvents"],
|
|
77
|
+
"Resource": f"arn:aws:logs:{cfg.region}:{account}:log-group:/aws/lambda/microvms/*",
|
|
78
|
+
}
|
|
79
|
+
build_role = _ensure_role(
|
|
80
|
+
iam,
|
|
81
|
+
f"{prefix}-build-role",
|
|
82
|
+
{
|
|
83
|
+
"Version": "2012-10-17",
|
|
84
|
+
"Statement": [
|
|
85
|
+
{"Effect": "Allow", "Action": "s3:GetObject", "Resource": f"arn:aws:s3:::{bucket}/*"},
|
|
86
|
+
logs_stmt,
|
|
87
|
+
],
|
|
88
|
+
},
|
|
89
|
+
)
|
|
90
|
+
execution_role = _ensure_role(
|
|
91
|
+
iam,
|
|
92
|
+
f"{prefix}-execution-role",
|
|
93
|
+
{"Version": "2012-10-17", "Statement": [logs_stmt]},
|
|
94
|
+
)
|
|
95
|
+
return {
|
|
96
|
+
"artifact_bucket": bucket,
|
|
97
|
+
"build_role_arn": build_role,
|
|
98
|
+
"execution_role_arn": execution_role,
|
|
99
|
+
}
|
microvm/cli.py
ADDED
|
@@ -0,0 +1,382 @@
|
|
|
1
|
+
"""`mvm`: the microvm-ctl command line.
|
|
2
|
+
|
|
3
|
+
mvm bootstrap one-time: S3 artifact bucket + build/execution roles
|
|
4
|
+
mvm quotas applied vs published quotas for this account
|
|
5
|
+
mvm image build NAME DIR zip -> S3 -> build -> ACTIVE version
|
|
6
|
+
mvm image ls | versions NAME
|
|
7
|
+
mvm run IMAGE [-n 5] spin up microVM(s)
|
|
8
|
+
mvm ls [--image NAME] fleet listing
|
|
9
|
+
mvm scale IMAGE N converge fleet to N
|
|
10
|
+
mvm suspend|resume|terminate ID... lifecycle control
|
|
11
|
+
mvm drain IMAGE terminate the whole fleet
|
|
12
|
+
mvm call ID /path [-X POST -d '{}'] authenticated request into the VM
|
|
13
|
+
mvm top [--image NAME] [--watch] live fleet dashboard
|
|
14
|
+
mvm logs IMAGE CloudWatch tail
|
|
15
|
+
mvm cost [--memory-gb 2 ...] session economics
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import argparse
|
|
21
|
+
import json
|
|
22
|
+
import sys
|
|
23
|
+
import time
|
|
24
|
+
|
|
25
|
+
from rich.console import Console
|
|
26
|
+
from rich.table import Table
|
|
27
|
+
|
|
28
|
+
from microvm import __version__
|
|
29
|
+
from microvm.config import TPS, PlaneConfig
|
|
30
|
+
from microvm.endpoint import EndpointClient
|
|
31
|
+
from microvm.fleet import Fleet, FleetManager, IdlePolicy, applied_quotas
|
|
32
|
+
from microvm.images import ImageBuilder
|
|
33
|
+
from microvm.monitor import CostModel, FleetMonitor
|
|
34
|
+
|
|
35
|
+
console = Console()
|
|
36
|
+
|
|
37
|
+
STATE_STYLE = {
|
|
38
|
+
"PENDING": "yellow",
|
|
39
|
+
"RUNNING": "bold green",
|
|
40
|
+
"SUSPENDING": "yellow",
|
|
41
|
+
"SUSPENDED": "cyan",
|
|
42
|
+
"TERMINATING": "red",
|
|
43
|
+
"TERMINATED": "dim",
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _cfg(args) -> PlaneConfig:
|
|
48
|
+
kw = {}
|
|
49
|
+
if args.region:
|
|
50
|
+
kw["region"] = args.region
|
|
51
|
+
if args.profile:
|
|
52
|
+
kw["profile"] = args.profile
|
|
53
|
+
return PlaneConfig(**kw)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _state(s: str) -> str:
|
|
57
|
+
return f"[{STATE_STYLE.get(s, 'white')}]{s}[/]"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
# ---------------------------------------------------------------- bootstrap
|
|
61
|
+
def cmd_bootstrap(args):
|
|
62
|
+
from microvm.bootstrap import bootstrap
|
|
63
|
+
out = bootstrap(_cfg(args), prefix=args.prefix)
|
|
64
|
+
console.print("[bold green]✓ control plane bootstrapped[/]")
|
|
65
|
+
for k, v in out.items():
|
|
66
|
+
console.print(f" [cyan]{k}[/] = {v}")
|
|
67
|
+
console.print(
|
|
68
|
+
"\nExport these (or put them in your shell profile):\n"
|
|
69
|
+
f" export MVM_ARTIFACT_BUCKET={out['artifact_bucket']}\n"
|
|
70
|
+
f" export MVM_BUILD_ROLE_ARN={out['build_role_arn']}\n"
|
|
71
|
+
f" export MVM_EXECUTION_ROLE_ARN={out['execution_role_arn']}"
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def cmd_quotas(args):
|
|
76
|
+
cfg = _cfg(args)
|
|
77
|
+
applied = applied_quotas(cfg)
|
|
78
|
+
t = Table(title=f"Lambda MicroVMs quotas in {cfg.region}", header_style="bold magenta")
|
|
79
|
+
for c in ("quota", "published default", "applied to this account", "plane throttles at"):
|
|
80
|
+
t.add_column(c)
|
|
81
|
+
for op, default in TPS.items():
|
|
82
|
+
if op in ("GetMicrovm", "CreateMicrovmAuthToken"):
|
|
83
|
+
continue
|
|
84
|
+
got = applied.get(op)
|
|
85
|
+
eff = (got or default) * FleetManager.QUOTA_HEADROOM
|
|
86
|
+
t.add_row(f"{op} (TPS)", str(default), str(got) if got is not None else "[dim]unknown[/]",
|
|
87
|
+
f"{eff:g}/s")
|
|
88
|
+
mem = applied.get("MaxMemoryGb")
|
|
89
|
+
t.add_row("Max allocated microVM memory (GB)", "1024",
|
|
90
|
+
str(mem) if mem is not None else "[dim]unknown[/]", "-")
|
|
91
|
+
console.print(t)
|
|
92
|
+
if not applied:
|
|
93
|
+
console.print("[dim]Service Quotas did not answer (missing servicequotas:GetServiceQuota?); "
|
|
94
|
+
"showing published defaults.[/]")
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
# ---------------------------------------------------------------- images
|
|
98
|
+
def cmd_image_build(args):
|
|
99
|
+
cfg = _cfg(args)
|
|
100
|
+
builder = ImageBuilder(cfg)
|
|
101
|
+
env = dict(kv.split("=", 1) for kv in args.env) if args.env else None
|
|
102
|
+
with console.status(f"[bold]building image [cyan]{args.name}[/] from {args.dir}…"):
|
|
103
|
+
built = builder.build(
|
|
104
|
+
args.name,
|
|
105
|
+
args.dir,
|
|
106
|
+
memory_mib=args.memory,
|
|
107
|
+
environment=env,
|
|
108
|
+
os_capabilities_all=args.caps_all,
|
|
109
|
+
description=args.description,
|
|
110
|
+
)
|
|
111
|
+
console.print(f"[bold green]✓ {built.name}:{built.version}[/] ({built.build_seconds}s)")
|
|
112
|
+
if built.memory_snapshot_bytes:
|
|
113
|
+
console.print(
|
|
114
|
+
f" memory snapshot: {built.memory_snapshot_bytes/1e6:.0f} MB "
|
|
115
|
+
f"disk snapshot: {(built.disk_snapshot_bytes or 0)/1e6:.0f} MB"
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def cmd_image_ls(args):
|
|
120
|
+
t = Table(title="MicroVM images", header_style="bold magenta")
|
|
121
|
+
for c in ("name", "state", "active version", "created"):
|
|
122
|
+
t.add_column(c)
|
|
123
|
+
for img in ImageBuilder(_cfg(args)).list_images():
|
|
124
|
+
t.add_row(
|
|
125
|
+
img["name"], img["state"], img.get("latestActiveImageVersion", "-"),
|
|
126
|
+
str(img["createdAt"])[:19],
|
|
127
|
+
)
|
|
128
|
+
console.print(t)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def cmd_image_versions(args):
|
|
132
|
+
t = Table(title=f"versions of {args.name}", header_style="bold magenta")
|
|
133
|
+
for c in ("version", "build state", "status", "created"):
|
|
134
|
+
t.add_column(c)
|
|
135
|
+
for v in ImageBuilder(_cfg(args)).list_versions(args.name):
|
|
136
|
+
t.add_row(v["imageVersion"], v["state"], v["status"], str(v["createdAt"])[:19])
|
|
137
|
+
console.print(t)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
# ---------------------------------------------------------------- lifecycle
|
|
141
|
+
def cmd_run(args):
|
|
142
|
+
cfg = _cfg(args)
|
|
143
|
+
fm = FleetManager(cfg)
|
|
144
|
+
policy = IdlePolicy(max_idle=args.idle, suspended_for=args.suspended_ttl)
|
|
145
|
+
for _ in range(args.count):
|
|
146
|
+
vm = fm.run(
|
|
147
|
+
args.image,
|
|
148
|
+
version=args.version,
|
|
149
|
+
idle_policy=policy,
|
|
150
|
+
run_payload=args.payload,
|
|
151
|
+
max_duration=args.max_duration,
|
|
152
|
+
)
|
|
153
|
+
console.print(
|
|
154
|
+
f"[bold green]✓[/] {vm.microvm_id} {_state(vm.state)} "
|
|
155
|
+
f"[link=https://{vm.endpoint}]{vm.endpoint}[/link]"
|
|
156
|
+
)
|
|
157
|
+
if args.wait:
|
|
158
|
+
vm = fm.wait_until(vm.microvm_id, "RUNNING")
|
|
159
|
+
console.print(f" now {_state(vm.state)}")
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def cmd_ls(args):
|
|
163
|
+
fm = FleetManager(_cfg(args))
|
|
164
|
+
vms = fm.list(args.image)
|
|
165
|
+
if not args.all:
|
|
166
|
+
vms = [v for v in vms if v.state != "TERMINATED"]
|
|
167
|
+
t = Table(title="microVMs", header_style="bold magenta")
|
|
168
|
+
for c in ("microvm id", "state", "image", "version", "started"):
|
|
169
|
+
t.add_column(c)
|
|
170
|
+
for vm in vms:
|
|
171
|
+
t.add_row(
|
|
172
|
+
vm.microvm_id, _state(vm.state),
|
|
173
|
+
vm.image_arn.split(":")[-1], vm.image_version, str(vm.started_at)[:19],
|
|
174
|
+
)
|
|
175
|
+
console.print(t)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def cmd_get(args):
|
|
179
|
+
vm = FleetManager(_cfg(args)).api.get_microvm(microvmIdentifier=args.id)
|
|
180
|
+
vm.pop("ResponseMetadata", None)
|
|
181
|
+
console.print_json(json.dumps(vm, default=str))
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _lifecycle(args, verb: str):
|
|
185
|
+
fm = FleetManager(_cfg(args))
|
|
186
|
+
for vid in args.ids:
|
|
187
|
+
getattr(fm, verb)(vid)
|
|
188
|
+
console.print(f"[bold green]✓[/] {verb} {vid}")
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def cmd_scale(args):
|
|
192
|
+
cfg = _cfg(args)
|
|
193
|
+
fleet = Fleet(FleetManager(cfg), args.image, idle_policy=IdlePolicy(max_idle=args.idle))
|
|
194
|
+
before = fleet.size()
|
|
195
|
+
with console.status(f"[bold]scaling [cyan]{args.image}[/] {before} → {args.n}…"):
|
|
196
|
+
fleet.scale_to(args.n, wait_running=args.wait)
|
|
197
|
+
console.print(f"[bold green]✓[/] fleet [cyan]{args.image}[/]: {before} → {fleet.size()} microVMs")
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def cmd_drain(args):
|
|
201
|
+
n = Fleet(FleetManager(_cfg(args)), args.image).drain()
|
|
202
|
+
console.print(f"[bold green]✓[/] terminated {n} microVMs of [cyan]{args.image}[/]")
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
# ---------------------------------------------------------------- execution plane
|
|
206
|
+
def cmd_call(args):
|
|
207
|
+
cfg = _cfg(args)
|
|
208
|
+
client = EndpointClient(cfg, args.id, ports=[args.port] if args.port else None)
|
|
209
|
+
resp = client.request(
|
|
210
|
+
args.method, args.path,
|
|
211
|
+
port=args.port,
|
|
212
|
+
data=args.data.encode() if args.data else None,
|
|
213
|
+
headers={"Content-Type": "application/json"} if args.data else {},
|
|
214
|
+
)
|
|
215
|
+
console.print(f"[bold]{resp.status_code}[/] in {resp.elapsed.total_seconds()*1000:.0f} ms")
|
|
216
|
+
try:
|
|
217
|
+
console.print_json(json.dumps(resp.json()))
|
|
218
|
+
except ValueError:
|
|
219
|
+
console.print(resp.text[:2000])
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
# ---------------------------------------------------------------- monitor
|
|
223
|
+
def _top_table(mon: FleetMonitor, image: str | None) -> Table:
|
|
224
|
+
snap = mon.snapshot(image)
|
|
225
|
+
counts = " ".join(f"{_state(s)}×{n}" for s, n in sorted(snap["by_state"].items()))
|
|
226
|
+
t = Table(
|
|
227
|
+
title=f"fleet: {image or 'all images'} — {snap['total']} VMs {counts}",
|
|
228
|
+
header_style="bold magenta",
|
|
229
|
+
)
|
|
230
|
+
for c in ("microvm id", "state", "image", "started"):
|
|
231
|
+
t.add_column(c)
|
|
232
|
+
for vm in snap["members"]:
|
|
233
|
+
t.add_row(
|
|
234
|
+
vm["microvmId"], _state(vm["state"]),
|
|
235
|
+
vm["imageArn"].split(":")[-1], str(vm["startedAt"])[:19],
|
|
236
|
+
)
|
|
237
|
+
return t
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def cmd_top(args):
|
|
241
|
+
mon = FleetMonitor(_cfg(args))
|
|
242
|
+
if not args.watch:
|
|
243
|
+
console.print(_top_table(mon, args.image))
|
|
244
|
+
return
|
|
245
|
+
from rich.live import Live
|
|
246
|
+
with Live(_top_table(mon, args.image), refresh_per_second=0.5, console=console) as live:
|
|
247
|
+
while True:
|
|
248
|
+
time.sleep(args.interval)
|
|
249
|
+
live.update(_top_table(mon, args.image))
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def cmd_logs(args):
|
|
253
|
+
mon = FleetMonitor(_cfg(args))
|
|
254
|
+
events = mon.tail_logs(args.image, minutes=args.minutes)
|
|
255
|
+
if not events:
|
|
256
|
+
console.print(f"[dim]no events in /aws/lambda/microvms/{args.image}[/]")
|
|
257
|
+
for e in events:
|
|
258
|
+
console.print(f"[dim]{e['stream'][:20]}[/] {e['message']}")
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def cmd_cost(args):
|
|
262
|
+
model = CostModel(memory_gb=args.memory_gb, snapshot_gb=args.snapshot_gb)
|
|
263
|
+
s = model.session(args.active * 60, args.suspended * 60, args.cycles)
|
|
264
|
+
t = Table(title=f"session economics: {args.memory_gb} GB / {model.vcpu:g} vCPU",
|
|
265
|
+
header_style="bold magenta")
|
|
266
|
+
t.add_column("dimension")
|
|
267
|
+
t.add_column("USD", justify="right")
|
|
268
|
+
t.add_row("running compute", f"${s['running_usd']:.6f}")
|
|
269
|
+
t.add_row(f"suspend cycles x{args.cycles}", f"${s['suspend_cycles_usd']:.6f}")
|
|
270
|
+
t.add_row("suspended storage", f"${s['suspended_storage_usd']:.6f}")
|
|
271
|
+
t.add_row("[bold]total[/]", f"[bold]${s['total_usd']:.6f}[/]")
|
|
272
|
+
t.add_row("[dim]always-on equivalent[/]", f"[dim]${s['vs_always_on_usd']:.6f}[/]")
|
|
273
|
+
t.add_row("[bold green]savings[/]", f"[bold green]{s['savings_pct']}%[/]")
|
|
274
|
+
console.print(t)
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
# ---------------------------------------------------------------- parser
|
|
278
|
+
def main(argv: list[str] | None = None):
|
|
279
|
+
p = argparse.ArgumentParser(prog="mvm",
|
|
280
|
+
description="Control and execution plane for AWS Lambda MicroVMs")
|
|
281
|
+
p.add_argument("--version", action="version", version=f"microvm-ctl {__version__}")
|
|
282
|
+
p.add_argument("--region", default=None, help="service region (default: MVM_REGION or us-east-1)")
|
|
283
|
+
p.add_argument("--profile", default=None, help="AWS profile (default: MVM_PROFILE or AWS_PROFILE)")
|
|
284
|
+
sub = p.add_subparsers(dest="cmd", required=True)
|
|
285
|
+
|
|
286
|
+
b = sub.add_parser("bootstrap", help="provision S3 bucket + IAM roles")
|
|
287
|
+
b.add_argument("--prefix", default="microvm-ctl", help="name prefix for the bucket and roles")
|
|
288
|
+
b.set_defaults(fn=cmd_bootstrap)
|
|
289
|
+
|
|
290
|
+
q = sub.add_parser("quotas", help="applied vs published quotas for this account")
|
|
291
|
+
q.set_defaults(fn=cmd_quotas)
|
|
292
|
+
|
|
293
|
+
img = sub.add_parser("image", help="image factory").add_subparsers(dest="sub", required=True)
|
|
294
|
+
ib = img.add_parser("build", help="zip -> S3 -> build -> ACTIVE version")
|
|
295
|
+
ib.add_argument("name")
|
|
296
|
+
ib.add_argument("dir")
|
|
297
|
+
ib.add_argument("--memory", type=int, default=2048, help="minimum memory in MiB")
|
|
298
|
+
ib.add_argument("--env", action="append", metavar="K=V",
|
|
299
|
+
help="image-level env var (shared by every clone)")
|
|
300
|
+
ib.add_argument("--caps-all", action="store_true",
|
|
301
|
+
help="additionalOsCapabilities=ALL (EFS/FUSE/containerd/eBPF)")
|
|
302
|
+
ib.add_argument("--description")
|
|
303
|
+
ib.set_defaults(fn=cmd_image_build)
|
|
304
|
+
il = img.add_parser("ls", help="list images")
|
|
305
|
+
il.set_defaults(fn=cmd_image_ls)
|
|
306
|
+
iv = img.add_parser("versions", help="list versions of one image")
|
|
307
|
+
iv.add_argument("name")
|
|
308
|
+
iv.set_defaults(fn=cmd_image_versions)
|
|
309
|
+
|
|
310
|
+
r = sub.add_parser("run", help="spin up microVM(s)")
|
|
311
|
+
r.add_argument("image")
|
|
312
|
+
r.add_argument("--version", help="image version (default: latest ACTIVE)")
|
|
313
|
+
r.add_argument("-n", "--count", type=int, default=1)
|
|
314
|
+
r.add_argument("--idle", type=int, default=300, help="suspend after N idle seconds")
|
|
315
|
+
r.add_argument("--suspended-ttl", type=int, default=3600, help="auto-terminate after N suspended seconds")
|
|
316
|
+
r.add_argument("--payload", help="runHookPayload (per-VM context)")
|
|
317
|
+
r.add_argument("--max-duration", type=int, default=None, help="hard lifetime cap in seconds")
|
|
318
|
+
r.add_argument("--wait", action="store_true", help="poll until RUNNING")
|
|
319
|
+
r.set_defaults(fn=cmd_run)
|
|
320
|
+
|
|
321
|
+
ls = sub.add_parser("ls", help="list microVMs")
|
|
322
|
+
ls.add_argument("--image")
|
|
323
|
+
ls.add_argument("--all", action="store_true", help="include TERMINATED")
|
|
324
|
+
ls.set_defaults(fn=cmd_ls)
|
|
325
|
+
|
|
326
|
+
g = sub.add_parser("get", help="raw GetMicrovm JSON")
|
|
327
|
+
g.add_argument("id")
|
|
328
|
+
g.set_defaults(fn=cmd_get)
|
|
329
|
+
|
|
330
|
+
for verb in ("suspend", "resume", "terminate"):
|
|
331
|
+
v = sub.add_parser(verb, help=f"{verb} one or more microVMs")
|
|
332
|
+
v.add_argument("ids", nargs="+")
|
|
333
|
+
v.set_defaults(fn=lambda a, _v=verb: _lifecycle(a, _v))
|
|
334
|
+
|
|
335
|
+
sc = sub.add_parser("scale", help="converge fleet to N")
|
|
336
|
+
sc.add_argument("image")
|
|
337
|
+
sc.add_argument("n", type=int)
|
|
338
|
+
sc.add_argument("--idle", type=int, default=300)
|
|
339
|
+
sc.add_argument("--wait", action="store_true", help="wait for new members to reach RUNNING")
|
|
340
|
+
sc.set_defaults(fn=cmd_scale)
|
|
341
|
+
|
|
342
|
+
d = sub.add_parser("drain", help="terminate every microVM of an image")
|
|
343
|
+
d.add_argument("image")
|
|
344
|
+
d.set_defaults(fn=cmd_drain)
|
|
345
|
+
|
|
346
|
+
c = sub.add_parser("call", help="authenticated request into a microVM")
|
|
347
|
+
c.add_argument("id")
|
|
348
|
+
c.add_argument("path")
|
|
349
|
+
c.add_argument("-X", "--method", default="GET")
|
|
350
|
+
c.add_argument("-d", "--data", help="request body (sent as application/json)")
|
|
351
|
+
c.add_argument("--port", type=int, default=None, help="non-default app port")
|
|
352
|
+
c.set_defaults(fn=cmd_call)
|
|
353
|
+
|
|
354
|
+
tp = sub.add_parser("top", help="live fleet dashboard")
|
|
355
|
+
tp.add_argument("--image")
|
|
356
|
+
tp.add_argument("--watch", action="store_true")
|
|
357
|
+
tp.add_argument("--interval", type=float, default=3)
|
|
358
|
+
tp.set_defaults(fn=cmd_top)
|
|
359
|
+
|
|
360
|
+
lg = sub.add_parser("logs", help="tail CloudWatch logs for an image")
|
|
361
|
+
lg.add_argument("image")
|
|
362
|
+
lg.add_argument("--minutes", type=int, default=15)
|
|
363
|
+
lg.set_defaults(fn=cmd_logs)
|
|
364
|
+
|
|
365
|
+
co = sub.add_parser("cost", help="session economics")
|
|
366
|
+
co.add_argument("--memory-gb", type=float, default=2)
|
|
367
|
+
co.add_argument("--snapshot-gb", type=float, default=None,
|
|
368
|
+
help="suspended snapshot size (default: memory)")
|
|
369
|
+
co.add_argument("--active", type=float, default=30, help="active minutes")
|
|
370
|
+
co.add_argument("--suspended", type=float, default=480, help="suspended minutes")
|
|
371
|
+
co.add_argument("--cycles", type=int, default=1, help="suspend/resume cycles")
|
|
372
|
+
co.set_defaults(fn=cmd_cost)
|
|
373
|
+
|
|
374
|
+
args = p.parse_args(argv)
|
|
375
|
+
try:
|
|
376
|
+
args.fn(args)
|
|
377
|
+
except KeyboardInterrupt:
|
|
378
|
+
sys.exit(130)
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
if __name__ == "__main__":
|
|
382
|
+
main()
|
microvm/client.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""boto3 client factory with a vendored `lambda-microvms` service model.
|
|
2
|
+
|
|
3
|
+
The service model ships inside this package (microvm/data), so the control
|
|
4
|
+
plane works even on boto3/botocore versions that predate the service —
|
|
5
|
+
botocore's loader picks up our data directory first.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import threading
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
import boto3
|
|
14
|
+
import botocore.session
|
|
15
|
+
|
|
16
|
+
_DATA_DIR = str(Path(__file__).parent / "data")
|
|
17
|
+
_lock = threading.Lock()
|
|
18
|
+
_sessions: dict[tuple[str | None, str], boto3.Session] = {}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _session(profile: str | None, region: str) -> boto3.Session:
|
|
22
|
+
"""One boto3 Session per (profile, region), with our model dir on the loader path."""
|
|
23
|
+
key = (profile, region)
|
|
24
|
+
with _lock:
|
|
25
|
+
if key not in _sessions:
|
|
26
|
+
bc = botocore.session.Session()
|
|
27
|
+
loader = bc.get_component("data_loader")
|
|
28
|
+
if _DATA_DIR not in loader.search_paths:
|
|
29
|
+
loader.search_paths.insert(0, _DATA_DIR)
|
|
30
|
+
_sessions[key] = boto3.Session(
|
|
31
|
+
profile_name=profile, region_name=region, botocore_session=bc
|
|
32
|
+
)
|
|
33
|
+
return _sessions[key]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def microvm_client(region: str = "us-east-1", profile: str | None = None):
|
|
37
|
+
"""A `lambda-microvms` client (RunMicrovm, SuspendMicrovm, images, tokens…)."""
|
|
38
|
+
return _session(profile, region).client("lambda-microvms")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def lambda_client(service: str, region: str = "us-east-1", profile: str | None = None):
|
|
42
|
+
"""Any other client (s3, iam, logs, cloudwatch…) on the same session/credentials."""
|
|
43
|
+
return _session(profile, region).client(service)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
_accounts: dict[tuple[str | None, str], str] = {}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def account_id(region: str = "us-east-1", profile: str | None = None) -> str:
|
|
50
|
+
key = (profile, region)
|
|
51
|
+
if key not in _accounts:
|
|
52
|
+
_accounts[key] = _session(profile, region).client("sts").get_caller_identity()["Account"]
|
|
53
|
+
return _accounts[key]
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def image_arn(name_or_arn: str, region: str, profile: str | None = None) -> str:
|
|
57
|
+
"""The API's imageIdentifier params want full ARNs; accept a bare name anywhere."""
|
|
58
|
+
if name_or_arn.startswith("arn:"):
|
|
59
|
+
return name_or_arn
|
|
60
|
+
return f"arn:aws:lambda:{region}:{account_id(region, profile)}:microvm-image:{name_or_arn}"
|
microvm/config.py
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""Shared configuration for the control plane.
|
|
2
|
+
|
|
3
|
+
Lambda MicroVMs is available in: us-east-1, us-east-2, us-west-2,
|
|
4
|
+
eu-west-1, ap-northeast-1. All microVMs are ARM64 (Graviton).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
|
|
12
|
+
SUPPORTED_REGIONS = ("us-east-1", "us-east-2", "us-west-2", "eu-west-1", "ap-northeast-1")
|
|
13
|
+
|
|
14
|
+
# AWS-managed network connector ARNs (account-agnostic, per region).
|
|
15
|
+
INGRESS_ALL = "arn:aws:lambda:{region}:aws:network-connector:aws-network-connector:ALL_INGRESS"
|
|
16
|
+
INGRESS_NONE = "arn:aws:lambda:{region}:aws:network-connector:aws-network-connector:NO_INGRESS"
|
|
17
|
+
INGRESS_SHELL = "arn:aws:lambda:{region}:aws:network-connector:aws-network-connector:SHELL_INGRESS"
|
|
18
|
+
EGRESS_INTERNET = "arn:aws:lambda:{region}:aws:network-connector:aws-network-connector:INTERNET_EGRESS"
|
|
19
|
+
|
|
20
|
+
# Lambda-managed base image every microVM image builds on.
|
|
21
|
+
MANAGED_BASE_IMAGE = "arn:aws:lambda:{region}:aws:microvm-image:al2023-1"
|
|
22
|
+
|
|
23
|
+
# Reserved proxy headers on the microVM endpoint.
|
|
24
|
+
AUTH_HEADER = "X-aws-proxy-auth"
|
|
25
|
+
PORT_HEADER = "X-aws-proxy-port"
|
|
26
|
+
FORCE_H2_HEADER = "X-aws-proxy-force-h2"
|
|
27
|
+
|
|
28
|
+
# In-VM runtime hook base path (served BY your app, called by Lambda).
|
|
29
|
+
HOOK_BASE_PATH = "/aws/lambda-microvms/runtime/v1"
|
|
30
|
+
|
|
31
|
+
# Published TPS quotas the control plane throttles around (soft, raisable).
|
|
32
|
+
TPS = {
|
|
33
|
+
"RunMicrovm": 5,
|
|
34
|
+
"SuspendMicrovm": 2,
|
|
35
|
+
"ResumeMicrovm": 5,
|
|
36
|
+
"TerminateMicrovm": 10,
|
|
37
|
+
"GetMicrovm": 100,
|
|
38
|
+
"CreateMicrovmAuthToken": 50,
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
MAX_DURATION_SECONDS = 28_800 # 8 h hard ceiling across RUNNING + SUSPENDED
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass
|
|
45
|
+
class PlaneConfig:
|
|
46
|
+
"""Everything the control plane needs to know about one deployment."""
|
|
47
|
+
|
|
48
|
+
region: str = field(default_factory=lambda: os.environ.get("MVM_REGION", "us-east-1"))
|
|
49
|
+
profile: str | None = field(
|
|
50
|
+
default_factory=lambda: os.environ.get("MVM_PROFILE") or os.environ.get("AWS_PROFILE")
|
|
51
|
+
)
|
|
52
|
+
artifact_bucket: str | None = field(default_factory=lambda: os.environ.get("MVM_ARTIFACT_BUCKET"))
|
|
53
|
+
build_role_arn: str | None = field(default_factory=lambda: os.environ.get("MVM_BUILD_ROLE_ARN"))
|
|
54
|
+
execution_role_arn: str | None = field(default_factory=lambda: os.environ.get("MVM_EXECUTION_ROLE_ARN"))
|
|
55
|
+
|
|
56
|
+
def __post_init__(self) -> None:
|
|
57
|
+
if self.region not in SUPPORTED_REGIONS:
|
|
58
|
+
raise ValueError(
|
|
59
|
+
f"Lambda MicroVMs is not available in {self.region}; pick one of {SUPPORTED_REGIONS}"
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
# Convenience per-region ARNs -------------------------------------------------
|
|
63
|
+
@property
|
|
64
|
+
def base_image_arn(self) -> str:
|
|
65
|
+
return MANAGED_BASE_IMAGE.format(region=self.region)
|
|
66
|
+
|
|
67
|
+
@property
|
|
68
|
+
def ingress_all(self) -> str:
|
|
69
|
+
return INGRESS_ALL.format(region=self.region)
|
|
70
|
+
|
|
71
|
+
@property
|
|
72
|
+
def ingress_none(self) -> str:
|
|
73
|
+
return INGRESS_NONE.format(region=self.region)
|
|
74
|
+
|
|
75
|
+
@property
|
|
76
|
+
def ingress_shell(self) -> str:
|
|
77
|
+
return INGRESS_SHELL.format(region=self.region)
|
|
78
|
+
|
|
79
|
+
@property
|
|
80
|
+
def egress_internet(self) -> str:
|
|
81
|
+
return EGRESS_INTERNET.format(region=self.region)
|
|
Binary file
|