microvm-ctl 0.3.1__tar.gz → 0.4.0__tar.gz

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.
Files changed (50) hide show
  1. {microvm_ctl-0.3.1 → microvm_ctl-0.4.0}/PKG-INFO +4 -2
  2. {microvm_ctl-0.3.1 → microvm_ctl-0.4.0}/README.md +3 -1
  3. {microvm_ctl-0.3.1 → microvm_ctl-0.4.0}/microvm/__init__.py +1 -1
  4. {microvm_ctl-0.3.1 → microvm_ctl-0.4.0}/microvm/cli.py +45 -0
  5. {microvm_ctl-0.3.1 → microvm_ctl-0.4.0}/microvm/fleet.py +90 -5
  6. {microvm_ctl-0.3.1 → microvm_ctl-0.4.0}/microvm_ctl.egg-info/PKG-INFO +4 -2
  7. {microvm_ctl-0.3.1 → microvm_ctl-0.4.0}/pyproject.toml +1 -1
  8. {microvm_ctl-0.3.1 → microvm_ctl-0.4.0}/tests/test_fleet.py +64 -0
  9. {microvm_ctl-0.3.1 → microvm_ctl-0.4.0}/tests/test_integrations_durable_scale.py +2 -1
  10. {microvm_ctl-0.3.1 → microvm_ctl-0.4.0}/LICENSE +0 -0
  11. {microvm_ctl-0.3.1 → microvm_ctl-0.4.0}/microvm/bootstrap.py +0 -0
  12. {microvm_ctl-0.3.1 → microvm_ctl-0.4.0}/microvm/client.py +0 -0
  13. {microvm_ctl-0.3.1 → microvm_ctl-0.4.0}/microvm/config.py +0 -0
  14. {microvm_ctl-0.3.1 → microvm_ctl-0.4.0}/microvm/data/lambda-microvms/2025-09-09/endpoint-rule-set-1.json.gz +0 -0
  15. {microvm_ctl-0.3.1 → microvm_ctl-0.4.0}/microvm/data/lambda-microvms/2025-09-09/paginators-1.json +0 -0
  16. {microvm_ctl-0.3.1 → microvm_ctl-0.4.0}/microvm/data/lambda-microvms/2025-09-09/service-2.json.gz +0 -0
  17. {microvm_ctl-0.3.1 → microvm_ctl-0.4.0}/microvm/endpoint.py +0 -0
  18. {microvm_ctl-0.3.1 → microvm_ctl-0.4.0}/microvm/hooks/__init__.py +0 -0
  19. {microvm_ctl-0.3.1 → microvm_ctl-0.4.0}/microvm/hooks/server.py +0 -0
  20. {microvm_ctl-0.3.1 → microvm_ctl-0.4.0}/microvm/images.py +0 -0
  21. {microvm_ctl-0.3.1 → microvm_ctl-0.4.0}/microvm/integrations/__init__.py +0 -0
  22. {microvm_ctl-0.3.1 → microvm_ctl-0.4.0}/microvm/integrations/durable.py +0 -0
  23. {microvm_ctl-0.3.1 → microvm_ctl-0.4.0}/microvm/integrations/stepfunctions.py +0 -0
  24. {microvm_ctl-0.3.1 → microvm_ctl-0.4.0}/microvm/lease.py +0 -0
  25. {microvm_ctl-0.3.1 → microvm_ctl-0.4.0}/microvm/monitor.py +0 -0
  26. {microvm_ctl-0.3.1 → microvm_ctl-0.4.0}/microvm/playground/__init__.py +0 -0
  27. {microvm_ctl-0.3.1 → microvm_ctl-0.4.0}/microvm/playground/server.py +0 -0
  28. {microvm_ctl-0.3.1 → microvm_ctl-0.4.0}/microvm/playground/static/index.html +0 -0
  29. {microvm_ctl-0.3.1 → microvm_ctl-0.4.0}/microvm/throttle.py +0 -0
  30. {microvm_ctl-0.3.1 → microvm_ctl-0.4.0}/microvm_ctl.egg-info/SOURCES.txt +0 -0
  31. {microvm_ctl-0.3.1 → microvm_ctl-0.4.0}/microvm_ctl.egg-info/dependency_links.txt +0 -0
  32. {microvm_ctl-0.3.1 → microvm_ctl-0.4.0}/microvm_ctl.egg-info/entry_points.txt +0 -0
  33. {microvm_ctl-0.3.1 → microvm_ctl-0.4.0}/microvm_ctl.egg-info/requires.txt +0 -0
  34. {microvm_ctl-0.3.1 → microvm_ctl-0.4.0}/microvm_ctl.egg-info/top_level.txt +0 -0
  35. {microvm_ctl-0.3.1 → microvm_ctl-0.4.0}/setup.cfg +0 -0
  36. {microvm_ctl-0.3.1 → microvm_ctl-0.4.0}/tests/test_cli_scale.py +0 -0
  37. {microvm_ctl-0.3.1 → microvm_ctl-0.4.0}/tests/test_config.py +0 -0
  38. {microvm_ctl-0.3.1 → microvm_ctl-0.4.0}/tests/test_cost_model.py +0 -0
  39. {microvm_ctl-0.3.1 → microvm_ctl-0.4.0}/tests/test_endpoint.py +0 -0
  40. {microvm_ctl-0.3.1 → microvm_ctl-0.4.0}/tests/test_hooks.py +0 -0
  41. {microvm_ctl-0.3.1 → microvm_ctl-0.4.0}/tests/test_hooks_lease.py +0 -0
  42. {microvm_ctl-0.3.1 → microvm_ctl-0.4.0}/tests/test_hooks_validate.py +0 -0
  43. {microvm_ctl-0.3.1 → microvm_ctl-0.4.0}/tests/test_integrations.py +0 -0
  44. {microvm_ctl-0.3.1 → microvm_ctl-0.4.0}/tests/test_integrations_durable.py +0 -0
  45. {microvm_ctl-0.3.1 → microvm_ctl-0.4.0}/tests/test_integrations_scale.py +0 -0
  46. {microvm_ctl-0.3.1 → microvm_ctl-0.4.0}/tests/test_lease.py +0 -0
  47. {microvm_ctl-0.3.1 → microvm_ctl-0.4.0}/tests/test_monitor_jobs.py +0 -0
  48. {microvm_ctl-0.3.1 → microvm_ctl-0.4.0}/tests/test_playground.py +0 -0
  49. {microvm_ctl-0.3.1 → microvm_ctl-0.4.0}/tests/test_scale.py +0 -0
  50. {microvm_ctl-0.3.1 → microvm_ctl-0.4.0}/tests/test_throttle.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: microvm-ctl
3
- Version: 0.3.1
3
+ Version: 0.4.0
4
4
  Summary: Control and execution plane for AWS Lambda MicroVMs: build images, launch Firecracker microVMs, scale fleets inside your quotas, call into them securely, and watch it all live.
5
5
  Author: Vivek Raja P S
6
6
  License: Apache-2.0
@@ -196,10 +196,12 @@ The four-part article series **Building on AWS Lambda MicroVMs** on the AWS Buil
196
196
  1. [Control and scale AWS Lambda MicroVMs with microvm-ctl](https://builder.aws.com/content/3JIDTpz0ZgatSBv24drra3gEod9/control-and-scale-aws-lambda-microvms-with-microvm-ctl): this plane and its measurements.
197
197
  2. [Seven workloads Lambda could never run, until MicroVMs](https://builder.aws.com/content/3JJ2oNWY9EsZzivMMx044cSlrFQ/seven-workloads-lambda-could-never-run-until-microvms): the first seven examples through build, run, cost, and gotchas.
198
198
  3. [A kernel for every customer: scaling AI agents to 1,000 tenants on AWS Lambda MicroVMs with microvm-ctl](https://builder.aws.com/content/3JJ7tASPSSUUTrcpWnWtxOuu8g3/a-kernel-for-every-customer-scaling-ai-agents-to-1000-tenants-on-aws-lambda-microvms-with-microvm-ctl): the multi-tenant finale with the decision guide.
199
- 4. Hand a task to a MicroVM from anywhere (part 4, [source in awesome-microvm](https://github.com/Vivek0712/awesome-microvm/blob/main/blog/03-handoff.md), Builder Center link to follow): the lease contract, Step Functions, durable functions, your own controller, and leases at scale.
199
+ 4. [Hand a task to a MicroVM from anywhere: one lease, Step Functions, durable functions, or your own controller](https://builder.aws.com/content/3Jcx0VcEOq9QQBtqr59y4S8dVFC/hand-a-task-to-a-microvm-from-anywhere-one-lease-step-functions-durable-functions-or-your-own-controller): the lease contract, Step Functions, durable functions, your own controller, and leases at scale.
200
200
 
201
201
  The article sources and a long-form deep dive per example live under [awesome-microvm/blog](https://github.com/Vivek0712/awesome-microvm/tree/main/blog).
202
202
 
203
+ Every lease scenario also runs live, on Step Functions and on a Lambda durable function, in [microvm-handoff-demo](https://github.com/Vivek0712/microvm-handoff-demo): nine scenarios on both orchestrators plus one lease from the CLI, with the execution histories, VM and orchestrator logs, benchmarks, and console screenshots checked in. Its first pass ran on 0.3.0 and found the two things that became 0.3.1: a typed failure whose VM Step Functions never terminated, and a 30 s heartbeat interval against a 30 s heartbeat timeout that lost a durable callback before the first heartbeat landed.
204
+
203
205
  ## Requirements and regions
204
206
 
205
207
  Python 3.9 or newer on the machine running the plane. The VMs themselves are ARM64 (Graviton) only, so audit binary wheels before you build an image. The service is available in `us-east-1`, `us-east-2`, `us-west-2`, `eu-west-1`, and `ap-northeast-1`.
@@ -160,10 +160,12 @@ The four-part article series **Building on AWS Lambda MicroVMs** on the AWS Buil
160
160
  1. [Control and scale AWS Lambda MicroVMs with microvm-ctl](https://builder.aws.com/content/3JIDTpz0ZgatSBv24drra3gEod9/control-and-scale-aws-lambda-microvms-with-microvm-ctl): this plane and its measurements.
161
161
  2. [Seven workloads Lambda could never run, until MicroVMs](https://builder.aws.com/content/3JJ2oNWY9EsZzivMMx044cSlrFQ/seven-workloads-lambda-could-never-run-until-microvms): the first seven examples through build, run, cost, and gotchas.
162
162
  3. [A kernel for every customer: scaling AI agents to 1,000 tenants on AWS Lambda MicroVMs with microvm-ctl](https://builder.aws.com/content/3JJ7tASPSSUUTrcpWnWtxOuu8g3/a-kernel-for-every-customer-scaling-ai-agents-to-1000-tenants-on-aws-lambda-microvms-with-microvm-ctl): the multi-tenant finale with the decision guide.
163
- 4. Hand a task to a MicroVM from anywhere (part 4, [source in awesome-microvm](https://github.com/Vivek0712/awesome-microvm/blob/main/blog/03-handoff.md), Builder Center link to follow): the lease contract, Step Functions, durable functions, your own controller, and leases at scale.
163
+ 4. [Hand a task to a MicroVM from anywhere: one lease, Step Functions, durable functions, or your own controller](https://builder.aws.com/content/3Jcx0VcEOq9QQBtqr59y4S8dVFC/hand-a-task-to-a-microvm-from-anywhere-one-lease-step-functions-durable-functions-or-your-own-controller): the lease contract, Step Functions, durable functions, your own controller, and leases at scale.
164
164
 
165
165
  The article sources and a long-form deep dive per example live under [awesome-microvm/blog](https://github.com/Vivek0712/awesome-microvm/tree/main/blog).
166
166
 
167
+ Every lease scenario also runs live, on Step Functions and on a Lambda durable function, in [microvm-handoff-demo](https://github.com/Vivek0712/microvm-handoff-demo): nine scenarios on both orchestrators plus one lease from the CLI, with the execution histories, VM and orchestrator logs, benchmarks, and console screenshots checked in. Its first pass ran on 0.3.0 and found the two things that became 0.3.1: a typed failure whose VM Step Functions never terminated, and a 30 s heartbeat interval against a 30 s heartbeat timeout that lost a durable callback before the first heartbeat landed.
168
+
167
169
  ## Requirements and regions
168
170
 
169
171
  Python 3.9 or newer on the machine running the plane. The VMs themselves are ARM64 (Graviton) only, so audit binary wheels before you build an image. The service is available in `us-east-1`, `us-east-2`, `us-west-2`, `eu-west-1`, and `ap-northeast-1`.
@@ -12,7 +12,7 @@ from microvm.images import ImageBuilder, ImageBuildError
12
12
  from microvm.lease import FanoutLimit, Lease, LeasePlan, LeasePlanRejected, LeasePolicy, plan_fanout
13
13
  from microvm.monitor import CostModel, FleetMonitor
14
14
 
15
- __version__ = "0.3.1"
15
+ __version__ = "0.4.0"
16
16
 
17
17
  __all__ = [
18
18
  "microvm_client",
@@ -9,6 +9,7 @@
9
9
  mvm scale IMAGE N converge fleet to N
10
10
  mvm suspend|resume|terminate ID... lifecycle control
11
11
  mvm drain IMAGE terminate the whole fleet
12
+ mvm dispatch IMAGE /path -d '{}' -n 64 spread requests over the warm fleet, --per-vm in flight each
12
13
  mvm call ID /path [-X POST -d '{}'] authenticated request into the VM
13
14
  mvm status ID | watch ID job telemetry from the hook runtime (/status, /events)
14
15
  mvm watch --image NAME one live table for every leased VM of an image
@@ -226,6 +227,37 @@ def cmd_drain(args):
226
227
  console.print(f"[bold green]✓[/] terminated {n} microVMs of [cyan]{args.image}[/]")
227
228
 
228
229
 
230
+ def cmd_dispatch(args):
231
+ if args.bodies:
232
+ with open(args.bodies) as f:
233
+ bodies = [json.loads(line) for line in f if line.strip()]
234
+ else:
235
+ bodies = [json.loads(args.data or "{}")] * args.n
236
+ fleet = Fleet(FleetManager(_cfg(args)), args.image)
237
+ started = time.time()
238
+ with console.status(f"[bold]dispatching {len(bodies)} requests over [cyan]{args.image}[/]…"):
239
+ results = fleet.dispatch(args.path, bodies, per_vm=args.per_vm, method=args.method, port=args.port,
240
+ timeout=args.timeout)
241
+ wall = time.time() - started
242
+ if args.out:
243
+ with open(args.out, "w") as f:
244
+ for r in results:
245
+ f.write(json.dumps(r, default=str) + "\n")
246
+ by_vm: dict = {}
247
+ for r in results:
248
+ by_vm.setdefault(r["microvm_id"], []).append(r)
249
+ t = Table(title=f"{len(results)} requests in {wall:.2f} s ({len(results) / wall:.1f}/s), {args.per_vm} in flight per VM")
250
+ for col in ("microVM", "requests", "ok", "failed", "p50 ms", "max ms"):
251
+ t.add_column(col)
252
+ for vid, rs in sorted(by_vm.items()):
253
+ lat = sorted(r["ms"] for r in rs)
254
+ ok = sum(1 for r in rs if r["status"] and r["status"] < 400)
255
+ t.add_row(vid, str(len(rs)), str(ok), str(len(rs) - ok), f"{lat[len(lat) // 2]:.0f}", f"{lat[-1]:.0f}")
256
+ console.print(t)
257
+ if any(not r["status"] or r["status"] >= 400 for r in results):
258
+ sys.exit(1)
259
+
260
+
229
261
  # ---------------------------------------------------------------- execution plane
230
262
  def cmd_call(args):
231
263
  cfg = _cfg(args)
@@ -724,6 +756,19 @@ def main(argv: list[str] | None = None):
724
756
  d.add_argument("image")
725
757
  d.set_defaults(fn=cmd_drain)
726
758
 
759
+ dp = sub.add_parser("dispatch", help="spread requests over the RUNNING members of an image")
760
+ dp.add_argument("image")
761
+ dp.add_argument("path")
762
+ dp.add_argument("-X", "--method", default="POST")
763
+ dp.add_argument("-d", "--data", help="one JSON body, sent -n times")
764
+ dp.add_argument("-n", type=int, default=1, help="how many times to send --data")
765
+ dp.add_argument("--bodies", help="a JSON Lines file, one request body per line")
766
+ dp.add_argument("--per-vm", type=int, default=4, help="requests in flight per VM")
767
+ dp.add_argument("--port", type=int, default=8080)
768
+ dp.add_argument("--timeout", type=float, default=300)
769
+ dp.add_argument("--out", help="write every result as JSON Lines")
770
+ dp.set_defaults(fn=cmd_dispatch)
771
+
727
772
  c = sub.add_parser("call", help="authenticated request into a microVM")
728
773
  c.add_argument("id")
729
774
  c.add_argument("path")
@@ -12,9 +12,11 @@ retried with jittered backoff, so `scale_to(50)` is safe to call in one shot.
12
12
  from __future__ import annotations
13
13
 
14
14
  import concurrent.futures as futures
15
+ import queue
16
+ import threading
15
17
  import time
16
18
  from dataclasses import dataclass, field
17
- from typing import Callable
19
+ from typing import Any, Callable
18
20
 
19
21
  from microvm.client import image_arn, microvm_client
20
22
  from microvm.config import TPS, PlaneConfig
@@ -311,15 +313,23 @@ class Fleet:
311
313
  _pool: futures.ThreadPoolExecutor = field(
312
314
  default_factory=lambda: futures.ThreadPoolExecutor(max_workers=Fleet.MAX_WORKERS), repr=False
313
315
  )
316
+ #: VMs this fleet has asked to terminate. ListMicrovms keeps reporting a terminated VM in its old
317
+ #: state for about a second, so without this a size() right after a scale-down counts the victim
318
+ #: and a drain terminates it twice.
319
+ _terminated: set = field(default_factory=set, repr=False)
314
320
 
315
321
  # -- observation -------------------------------------------------------------
316
322
  def members(self) -> list[Microvm]:
317
323
  return [
318
324
  vm
319
325
  for vm in self.manager.list(self.image, self.version)
320
- if vm.state in ACTIVE_STATES
326
+ if vm.state in ACTIVE_STATES and vm.microvm_id not in self._terminated
321
327
  ]
322
328
 
329
+ def _terminate(self, microvm_id: str) -> None:
330
+ self.manager.terminate(microvm_id)
331
+ self._terminated.add(microvm_id)
332
+
323
333
  def size(self) -> int:
324
334
  return len(self.members())
325
335
 
@@ -345,7 +355,7 @@ class Fleet:
345
355
  return launched
346
356
  if delta < 0:
347
357
  for vm in self.scale_down_victims(current, -delta):
348
- self.manager.terminate(vm.microvm_id)
358
+ self._terminate(vm.microvm_id)
349
359
  return []
350
360
 
351
361
  @staticmethod
@@ -384,7 +394,7 @@ class Fleet:
384
394
  def drain(self) -> int:
385
395
  """Terminate every member of the fleet."""
386
396
  vms = self.members()
387
- list(self._pool.map(lambda v: self.manager.terminate(v.microvm_id), vms))
397
+ list(self._pool.map(lambda v: self._terminate(v.microvm_id), vms))
388
398
  return len(vms)
389
399
 
390
400
  # -- reaper ------------------------------------------------------------------
@@ -396,6 +406,81 @@ class Fleet:
396
406
  for vm in self.members():
397
407
  started = vm.started_epoch
398
408
  if started and now - started > max_age_seconds:
399
- self.manager.terminate(vm.microvm_id)
409
+ self._terminate(vm.microvm_id)
400
410
  reaped.append(vm.microvm_id)
401
411
  return reaped
412
+
413
+ # -- work distribution -------------------------------------------------------
414
+ def dispatch(
415
+ self,
416
+ path: str,
417
+ bodies: list,
418
+ *,
419
+ per_vm: int = 4,
420
+ method: str = "POST",
421
+ port: int = 8080,
422
+ timeout: float = 300,
423
+ client_factory: Callable[[str], Any] | None = None,
424
+ on_result: Callable[[dict], None] | None = None,
425
+ ) -> list[dict]:
426
+ """Send one request per body to the fleet's RUNNING members and return the results in order.
427
+
428
+ Each RUNNING member gets `per_vm` workers that pull from one shared queue, so a slow VM takes
429
+ fewer items and no VM ever holds more than `per_vm` requests in flight. This is the warm-fleet
430
+ path: hundreds of short tasks over VMs that are already up, where a lease per task would pay a
431
+ launch per task. A request that raises is recorded with its error and does not stop the others.
432
+
433
+ Each result is {"index", "microvm_id", "status", "ms", "body"} or, on an exception,
434
+ {"index", "microvm_id", "status": None, "ms", "error"}. `on_result` is called with each result
435
+ as it lands, from a worker thread.
436
+ """
437
+ if per_vm < 1:
438
+ raise ValueError("per_vm must be at least 1")
439
+ running = [vm for vm in self.members() if vm.state == "RUNNING"]
440
+ if not running:
441
+ raise RuntimeError(f"fleet {self.image} has no RUNNING members to dispatch to")
442
+ if client_factory is None:
443
+ from microvm.endpoint import EndpointClient
444
+
445
+ def client_factory(microvm_id: str):
446
+ return EndpointClient(self.manager.cfg, microvm_id, ports=[port])
447
+
448
+ work: queue.Queue = queue.Queue()
449
+ for i, body in enumerate(bodies):
450
+ work.put((i, body))
451
+ results: list = [None] * len(bodies)
452
+ lock = threading.Lock()
453
+
454
+ def worker(microvm_id: str, client) -> None:
455
+ while True:
456
+ try:
457
+ i, body = work.get_nowait()
458
+ except queue.Empty:
459
+ return
460
+ started = time.time()
461
+ try:
462
+ resp = client.request(method, path, json=body, timeout=timeout, port=port)
463
+ try:
464
+ payload = resp.json()
465
+ except ValueError:
466
+ payload = resp.text
467
+ out = {"index": i, "microvm_id": microvm_id, "status": resp.status_code,
468
+ "ms": round((time.time() - started) * 1000, 1), "body": payload}
469
+ except Exception as e: # one failed request must not stop the batch
470
+ out = {"index": i, "microvm_id": microvm_id, "status": None,
471
+ "ms": round((time.time() - started) * 1000, 1), "error": f"{type(e).__name__}: {e}"}
472
+ with lock:
473
+ results[i] = out
474
+ if on_result:
475
+ on_result(out)
476
+
477
+ threads = []
478
+ for vm in running:
479
+ client = client_factory(vm.microvm_id)
480
+ for _ in range(per_vm):
481
+ t = threading.Thread(target=worker, args=(vm.microvm_id, client), daemon=True)
482
+ t.start()
483
+ threads.append(t)
484
+ for t in threads:
485
+ t.join()
486
+ return results
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: microvm-ctl
3
- Version: 0.3.1
3
+ Version: 0.4.0
4
4
  Summary: Control and execution plane for AWS Lambda MicroVMs: build images, launch Firecracker microVMs, scale fleets inside your quotas, call into them securely, and watch it all live.
5
5
  Author: Vivek Raja P S
6
6
  License: Apache-2.0
@@ -196,10 +196,12 @@ The four-part article series **Building on AWS Lambda MicroVMs** on the AWS Buil
196
196
  1. [Control and scale AWS Lambda MicroVMs with microvm-ctl](https://builder.aws.com/content/3JIDTpz0ZgatSBv24drra3gEod9/control-and-scale-aws-lambda-microvms-with-microvm-ctl): this plane and its measurements.
197
197
  2. [Seven workloads Lambda could never run, until MicroVMs](https://builder.aws.com/content/3JJ2oNWY9EsZzivMMx044cSlrFQ/seven-workloads-lambda-could-never-run-until-microvms): the first seven examples through build, run, cost, and gotchas.
198
198
  3. [A kernel for every customer: scaling AI agents to 1,000 tenants on AWS Lambda MicroVMs with microvm-ctl](https://builder.aws.com/content/3JJ7tASPSSUUTrcpWnWtxOuu8g3/a-kernel-for-every-customer-scaling-ai-agents-to-1000-tenants-on-aws-lambda-microvms-with-microvm-ctl): the multi-tenant finale with the decision guide.
199
- 4. Hand a task to a MicroVM from anywhere (part 4, [source in awesome-microvm](https://github.com/Vivek0712/awesome-microvm/blob/main/blog/03-handoff.md), Builder Center link to follow): the lease contract, Step Functions, durable functions, your own controller, and leases at scale.
199
+ 4. [Hand a task to a MicroVM from anywhere: one lease, Step Functions, durable functions, or your own controller](https://builder.aws.com/content/3Jcx0VcEOq9QQBtqr59y4S8dVFC/hand-a-task-to-a-microvm-from-anywhere-one-lease-step-functions-durable-functions-or-your-own-controller): the lease contract, Step Functions, durable functions, your own controller, and leases at scale.
200
200
 
201
201
  The article sources and a long-form deep dive per example live under [awesome-microvm/blog](https://github.com/Vivek0712/awesome-microvm/tree/main/blog).
202
202
 
203
+ Every lease scenario also runs live, on Step Functions and on a Lambda durable function, in [microvm-handoff-demo](https://github.com/Vivek0712/microvm-handoff-demo): nine scenarios on both orchestrators plus one lease from the CLI, with the execution histories, VM and orchestrator logs, benchmarks, and console screenshots checked in. Its first pass ran on 0.3.0 and found the two things that became 0.3.1: a typed failure whose VM Step Functions never terminated, and a 30 s heartbeat interval against a 30 s heartbeat timeout that lost a durable callback before the first heartbeat landed.
204
+
203
205
  ## Requirements and regions
204
206
 
205
207
  Python 3.9 or newer on the machine running the plane. The VMs themselves are ARM64 (Graviton) only, so audit binary wheels before you build an image. The service is available in `us-east-1`, `us-east-2`, `us-west-2`, `eu-west-1`, and `ap-northeast-1`.
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "microvm-ctl"
7
- version = "0.3.1"
7
+ version = "0.4.0"
8
8
  description = "Control and execution plane for AWS Lambda MicroVMs: build images, launch Firecracker microVMs, scale fleets inside your quotas, call into them securely, and watch it all live."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
@@ -84,3 +84,67 @@ def test_run_params_carries_client_token(monkeypatch):
84
84
  assert params["runHookPayload"] == '{"callback_id": "abc"}'
85
85
  assert params["maximumDurationInSeconds"] == 900
86
86
  assert "clientToken" not in fm.run_params("img")
87
+
88
+
89
+ def test_scale_down_victim_is_not_counted_while_list_still_shows_it():
90
+ # ListMicrovms reports a terminated VM in its old state for a moment; the fleet must not count it.
91
+ members = [_vm(1, "RUNNING", 600), _vm(2, "SUSPENDED", 300), _vm(3, "RUNNING", 10)]
92
+ fm = FakeManager(members)
93
+ fleet = Fleet(fm, "img")
94
+ fleet.scale_to(2)
95
+ assert fm.terminated == ["vm-2"]
96
+ assert fleet.size() == 2
97
+ assert fleet.drain() == 2
98
+ assert sorted(fm.terminated) == ["vm-1", "vm-2", "vm-3"]
99
+
100
+
101
+ class FakeResponse:
102
+ def __init__(self, status, payload):
103
+ self.status_code, self._payload = status, payload
104
+
105
+ def json(self):
106
+ return self._payload
107
+
108
+
109
+ class FakeClient:
110
+ def __init__(self, microvm_id, log, fail_on=None, delay=0.0):
111
+ self.microvm_id, self.log, self.fail_on, self.delay = microvm_id, log, fail_on, delay
112
+
113
+ def request(self, method, path, json=None, timeout=None, port=None):
114
+ import time as _t
115
+ _t.sleep(self.delay)
116
+ self.log.append((self.microvm_id, method, path, json))
117
+ if self.fail_on is not None and json == self.fail_on:
118
+ raise ConnectionError("boom")
119
+ return FakeResponse(200, {"echo": json, "vm": self.microvm_id})
120
+
121
+
122
+ def test_dispatch_spreads_bodies_over_running_members_and_keeps_order():
123
+ fm = FakeManager([_vm(1, "RUNNING", 60), _vm(2, "RUNNING", 60), _vm(3, "SUSPENDED", 60)])
124
+ log = []
125
+ fleet = Fleet(fm, "img")
126
+ seen = []
127
+ results = fleet.dispatch("/task", [{"n": i} for i in range(10)], per_vm=2,
128
+ client_factory=lambda vm_id: FakeClient(vm_id, log, delay=0.01),
129
+ on_result=seen.append)
130
+ assert [r["index"] for r in results] == list(range(10))
131
+ assert [r["body"]["echo"] for r in results] == [{"n": i} for i in range(10)]
132
+ assert {r["microvm_id"] for r in results} == {"vm-1", "vm-2"} # the SUSPENDED member gets nothing
133
+ assert all(m == "POST" and p == "/task" for _, m, p, _ in log)
134
+ assert len(seen) == 10
135
+
136
+
137
+ def test_dispatch_records_a_failed_request_and_finishes_the_rest():
138
+ fm = FakeManager([_vm(1, "RUNNING", 60)])
139
+ fleet = Fleet(fm, "img")
140
+ results = fleet.dispatch("/task", [{"n": 0}, {"n": 1}, {"n": 2}], per_vm=1,
141
+ client_factory=lambda vm_id: FakeClient(vm_id, [], fail_on={"n": 1}))
142
+ assert results[0]["status"] == 200 and results[2]["status"] == 200
143
+ assert results[1]["status"] is None and "ConnectionError" in results[1]["error"]
144
+
145
+
146
+ def test_dispatch_needs_a_running_member():
147
+ import pytest
148
+ fleet = Fleet(FakeManager([_vm(1, "SUSPENDED", 60)]), "img")
149
+ with pytest.raises(RuntimeError, match="no RUNNING members"):
150
+ fleet.dispatch("/task", [{}], client_factory=lambda vm_id: None)
@@ -118,7 +118,8 @@ def test_two_shards_happy_path(fm):
118
118
  cb1 = runner.wait_for_callback(arn, name="job-1-0-callback", timeout=30)
119
119
  assert cb0 != cb1
120
120
  assert fm.plans == [(2, 2048, POLICY)] # the plan step ran once, with the reconstructed policy
121
- assert [launch["task"] for launch in fm.leases] == SHARDS
121
+ # map items launch concurrently, so compare as a set of shards, not in launch order
122
+ assert sorted((launch["task"] for launch in fm.leases), key=lambda t: t["i"]) == SHARDS
122
123
  assert all(launch["version"] == "3" and launch["policy"] == POLICY for launch in fm.leases)
123
124
  _succeed(runner, cb0, 1)
124
125
  _succeed(runner, cb1, 2)
File without changes
File without changes