microvm-ctl 0.4.0__tar.gz → 0.5.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 (51) hide show
  1. {microvm_ctl-0.4.0 → microvm_ctl-0.5.0}/PKG-INFO +1 -1
  2. {microvm_ctl-0.4.0 → microvm_ctl-0.5.0}/microvm/__init__.py +1 -1
  3. {microvm_ctl-0.4.0 → microvm_ctl-0.5.0}/microvm/cli.py +4 -2
  4. {microvm_ctl-0.4.0 → microvm_ctl-0.5.0}/microvm/fleet.py +2 -1
  5. {microvm_ctl-0.4.0 → microvm_ctl-0.5.0}/microvm/hooks/server.py +277 -13
  6. {microvm_ctl-0.4.0 → microvm_ctl-0.5.0}/microvm_ctl.egg-info/PKG-INFO +1 -1
  7. {microvm_ctl-0.4.0 → microvm_ctl-0.5.0}/microvm_ctl.egg-info/SOURCES.txt +1 -0
  8. {microvm_ctl-0.4.0 → microvm_ctl-0.5.0}/pyproject.toml +1 -1
  9. microvm_ctl-0.5.0/tests/test_hooks_aws_contract.py +234 -0
  10. {microvm_ctl-0.4.0 → microvm_ctl-0.5.0}/LICENSE +0 -0
  11. {microvm_ctl-0.4.0 → microvm_ctl-0.5.0}/README.md +0 -0
  12. {microvm_ctl-0.4.0 → microvm_ctl-0.5.0}/microvm/bootstrap.py +0 -0
  13. {microvm_ctl-0.4.0 → microvm_ctl-0.5.0}/microvm/client.py +0 -0
  14. {microvm_ctl-0.4.0 → microvm_ctl-0.5.0}/microvm/config.py +0 -0
  15. {microvm_ctl-0.4.0 → microvm_ctl-0.5.0}/microvm/data/lambda-microvms/2025-09-09/endpoint-rule-set-1.json.gz +0 -0
  16. {microvm_ctl-0.4.0 → microvm_ctl-0.5.0}/microvm/data/lambda-microvms/2025-09-09/paginators-1.json +0 -0
  17. {microvm_ctl-0.4.0 → microvm_ctl-0.5.0}/microvm/data/lambda-microvms/2025-09-09/service-2.json.gz +0 -0
  18. {microvm_ctl-0.4.0 → microvm_ctl-0.5.0}/microvm/endpoint.py +0 -0
  19. {microvm_ctl-0.4.0 → microvm_ctl-0.5.0}/microvm/hooks/__init__.py +0 -0
  20. {microvm_ctl-0.4.0 → microvm_ctl-0.5.0}/microvm/images.py +0 -0
  21. {microvm_ctl-0.4.0 → microvm_ctl-0.5.0}/microvm/integrations/__init__.py +0 -0
  22. {microvm_ctl-0.4.0 → microvm_ctl-0.5.0}/microvm/integrations/durable.py +0 -0
  23. {microvm_ctl-0.4.0 → microvm_ctl-0.5.0}/microvm/integrations/stepfunctions.py +0 -0
  24. {microvm_ctl-0.4.0 → microvm_ctl-0.5.0}/microvm/lease.py +0 -0
  25. {microvm_ctl-0.4.0 → microvm_ctl-0.5.0}/microvm/monitor.py +0 -0
  26. {microvm_ctl-0.4.0 → microvm_ctl-0.5.0}/microvm/playground/__init__.py +0 -0
  27. {microvm_ctl-0.4.0 → microvm_ctl-0.5.0}/microvm/playground/server.py +0 -0
  28. {microvm_ctl-0.4.0 → microvm_ctl-0.5.0}/microvm/playground/static/index.html +0 -0
  29. {microvm_ctl-0.4.0 → microvm_ctl-0.5.0}/microvm/throttle.py +0 -0
  30. {microvm_ctl-0.4.0 → microvm_ctl-0.5.0}/microvm_ctl.egg-info/dependency_links.txt +0 -0
  31. {microvm_ctl-0.4.0 → microvm_ctl-0.5.0}/microvm_ctl.egg-info/entry_points.txt +0 -0
  32. {microvm_ctl-0.4.0 → microvm_ctl-0.5.0}/microvm_ctl.egg-info/requires.txt +0 -0
  33. {microvm_ctl-0.4.0 → microvm_ctl-0.5.0}/microvm_ctl.egg-info/top_level.txt +0 -0
  34. {microvm_ctl-0.4.0 → microvm_ctl-0.5.0}/setup.cfg +0 -0
  35. {microvm_ctl-0.4.0 → microvm_ctl-0.5.0}/tests/test_cli_scale.py +0 -0
  36. {microvm_ctl-0.4.0 → microvm_ctl-0.5.0}/tests/test_config.py +0 -0
  37. {microvm_ctl-0.4.0 → microvm_ctl-0.5.0}/tests/test_cost_model.py +0 -0
  38. {microvm_ctl-0.4.0 → microvm_ctl-0.5.0}/tests/test_endpoint.py +0 -0
  39. {microvm_ctl-0.4.0 → microvm_ctl-0.5.0}/tests/test_fleet.py +0 -0
  40. {microvm_ctl-0.4.0 → microvm_ctl-0.5.0}/tests/test_hooks.py +0 -0
  41. {microvm_ctl-0.4.0 → microvm_ctl-0.5.0}/tests/test_hooks_lease.py +0 -0
  42. {microvm_ctl-0.4.0 → microvm_ctl-0.5.0}/tests/test_hooks_validate.py +0 -0
  43. {microvm_ctl-0.4.0 → microvm_ctl-0.5.0}/tests/test_integrations.py +0 -0
  44. {microvm_ctl-0.4.0 → microvm_ctl-0.5.0}/tests/test_integrations_durable.py +0 -0
  45. {microvm_ctl-0.4.0 → microvm_ctl-0.5.0}/tests/test_integrations_durable_scale.py +0 -0
  46. {microvm_ctl-0.4.0 → microvm_ctl-0.5.0}/tests/test_integrations_scale.py +0 -0
  47. {microvm_ctl-0.4.0 → microvm_ctl-0.5.0}/tests/test_lease.py +0 -0
  48. {microvm_ctl-0.4.0 → microvm_ctl-0.5.0}/tests/test_monitor_jobs.py +0 -0
  49. {microvm_ctl-0.4.0 → microvm_ctl-0.5.0}/tests/test_playground.py +0 -0
  50. {microvm_ctl-0.4.0 → microvm_ctl-0.5.0}/tests/test_scale.py +0 -0
  51. {microvm_ctl-0.4.0 → microvm_ctl-0.5.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.4.0
3
+ Version: 0.5.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
@@ -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.4.0"
15
+ __version__ = "0.5.0"
16
16
 
17
17
  __all__ = [
18
18
  "microvm_client",
@@ -246,13 +246,15 @@ def cmd_dispatch(args):
246
246
  by_vm: dict = {}
247
247
  for r in results:
248
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")
249
+ rate = len(results) / wall
250
+ t = Table(title=f"{len(results)} requests in {wall:.2f} s ({rate:.1f}/s), {args.per_vm} in flight per VM")
250
251
  for col in ("microVM", "requests", "ok", "failed", "p50 ms", "max ms"):
251
252
  t.add_column(col)
252
253
  for vid, rs in sorted(by_vm.items()):
253
254
  lat = sorted(r["ms"] for r in rs)
254
255
  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
+ p50 = lat[len(lat) // 2]
257
+ t.add_row(vid, str(len(rs)), str(ok), str(len(rs) - ok), f"{p50:.0f}", f"{lat[-1]:.0f}")
256
258
  console.print(t)
257
259
  if any(not r["status"] or r["status"] >= 400 for r in results):
258
260
  sys.exit(1)
@@ -468,7 +468,8 @@ class Fleet:
468
468
  "ms": round((time.time() - started) * 1000, 1), "body": payload}
469
469
  except Exception as e: # one failed request must not stop the batch
470
470
  out = {"index": i, "microvm_id": microvm_id, "status": None,
471
- "ms": round((time.time() - started) * 1000, 1), "error": f"{type(e).__name__}: {e}"}
471
+ "ms": round((time.time() - started) * 1000, 1),
472
+ "error": f"{type(e).__name__}: {e}"}
472
473
  with lock:
473
474
  results[i] = out
474
475
  if on_result:
@@ -52,6 +52,12 @@ import traceback
52
52
  from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
53
53
 
54
54
  HOOK_BASE = "/aws/lambda-microvms/runtime/v1"
55
+ #: The job path of the AWS durable execution SDK's microvm operation (@aws/durable-execution-sdk-js-extras).
56
+ AWS_JOB_PATH = "/durable-execution/v1/job"
57
+ #: Error names from the durable callback APIs that mean no later attempt can succeed.
58
+ AWS_TERMINAL_CALLBACK_ERRORS = ("CallbackTimeoutException", "InvalidParameterValueException",
59
+ "ResourceNotFoundException")
60
+ AWS_MAX_RESULT_BYTES = 256 * 1024
55
61
  LEASE_KINDS = ("sfn", "durable", "http", "sqs", "eventbridge", "none")
56
62
  RESULT_LIMIT = 240 * 1024
57
63
  SUMMARY_LIMIT = 4096
@@ -276,7 +282,8 @@ def _closed_if(call, names: tuple, **kw):
276
282
  try:
277
283
  return call(**kw)
278
284
  except Exception as e:
279
- if type(e).__name__ in names:
285
+ # botocore raises a modeled class named after the code, or a plain ClientError carrying it
286
+ if type(e).__name__ in names or _error_code(e) in names:
280
287
  raise _TokenClosed(str(e)) from e
281
288
  raise
282
289
 
@@ -301,7 +308,10 @@ class _SfnCompleter(_Completer):
301
308
 
302
309
 
303
310
  class _DurableCompleter(_Completer):
304
- CLOSED = ("CallbackTimeoutException", "InvalidToken")
311
+ # measured by the durable SDK team: a closed callback answers CallbackTimeoutException; an unknown or
312
+ # malformed id answers InvalidParameterValueException or ResourceNotFoundException
313
+ CLOSED = ("CallbackTimeoutException", "InvalidParameterValueException", "ResourceNotFoundException",
314
+ "InvalidToken")
305
315
 
306
316
  def __init__(self, lease: dict, factory):
307
317
  super().__init__(lease, factory)
@@ -374,9 +384,29 @@ COMPLETERS = {"none": _NoneCompleter, "sfn": _SfnCompleter, "durable": _DurableC
374
384
  "http": _HttpCompleter, "sqs": _SqsCompleter, "eventbridge": _EventBridgeCompleter}
375
385
 
376
386
 
387
+ def _error_code(e) -> str | None:
388
+ resp = getattr(e, "response", None)
389
+ if isinstance(resp, dict):
390
+ return (resp.get("Error") or {}).get("Code")
391
+ return getattr(e, "code", None) or type(e).__name__
392
+
393
+
394
+ class AwsJobContext:
395
+ """One job of the AWS durable execution SDK contract, as a durable_job handler sees it."""
396
+
397
+ def __init__(self, callback_id: str, input, microvm_id: str | None, job: Job, path: str):
398
+ self.callback_id, self.input, self.path = callback_id, input, path
399
+ self.microvm_id, self.job = microvm_id, job
400
+ self.cancelled = threading.Event() # the durable function stopped waiting, or the microVM terminates
401
+ self.closed = False
402
+ self.heartbeats = 0
403
+
404
+
377
405
  class HookApp:
378
406
  #: (service, region) -> boto3-like client; tests replace this with a fake factory.
379
407
  lease_client_factory = staticmethod(_default_client_factory)
408
+ #: multiplier on the completion retry backoff (1, 2, 4, 8, 16 s); tests set it to 0
409
+ completion_backoff = 1.0
380
410
 
381
411
  def __init__(self):
382
412
  self._hooks: dict[str, object] = {}
@@ -386,6 +416,16 @@ class HookApp:
386
416
  self.run_payload: str | None = None
387
417
  self.job = Job(self.microvm_id)
388
418
  self.lease: LeaseContext | None = None
419
+ # AWS durable execution SDK job contract (see durable_job)
420
+ self._aws_handlers: dict[str, object] = {}
421
+ self._aws_seen: collections.OrderedDict = collections.OrderedDict() # callback id -> "running"/"done"
422
+ self._aws_running: dict[str, AwsJobContext] = {}
423
+ self._aws_lock = threading.Lock()
424
+ self._aws_region: str | None = None
425
+ self._aws_refuse_until = 0.0
426
+ self._aws_terminated = False
427
+ self._aws_idle_s: float | None = None
428
+ self._aws_idle_timer: threading.Timer | None = None
389
429
 
390
430
  @self.route("GET", "/healthz")
391
431
  def _healthz(body, headers):
@@ -432,6 +472,24 @@ class HookApp:
432
472
  self._hooks["lease"] = fn
433
473
  return fn
434
474
 
475
+ def durable_job(self, fn=None, *, path: str | None = None):
476
+ """Serve jobs from the AWS durable execution SDK's ``microvm`` and ``microvmSession`` operations.
477
+
478
+ ``@app.durable_job`` handles a job that names no route: one in the run hook payload, or one
479
+ POSTed to /durable-execution/v1/job. ``@app.durable_job(path="/build")`` handles jobs sent with
480
+ ``request: {path: "/build"}``. ``fn(input, ctx)`` returns the JSON-serializable result, which
481
+ completes the durable callback; an exception fails it with the exception's ``error_type`` (a
482
+ LeaseError) or class name. ``ctx`` is an AwsJobContext: ``.cancelled`` is set when the durable
483
+ function stops waiting or the microVM terminates, and ``.job`` is the job telemetry that
484
+ GET /status and /events serve.
485
+ """
486
+ def deco(f):
487
+ key = path or AWS_JOB_PATH
488
+ self._aws_handlers[key] = f
489
+ self._routes[("POST", key)] = lambda body, headers: self._aws_http_job(key, body)
490
+ return f
491
+ return deco(fn) if fn is not None else deco
492
+
435
493
  def route(self, method: str, path: str):
436
494
  def deco(fn):
437
495
  self._routes[(method.upper(), path)] = fn
@@ -439,7 +497,27 @@ class HookApp:
439
497
  return deco
440
498
 
441
499
  # -- hook dispatch -----------------------------------------------------------
442
- def _dispatch_hook(self, name: str, ctx: dict) -> int:
500
+ @staticmethod
501
+ def _from_lambda(headers: dict | None) -> bool:
502
+ """Lambda's own lifecycle calls carry a localhost Host and no x-amzn-requestid. The endpoint
503
+ forwards a hook path from anyone holding an auth token for the microVM, with the endpoint's
504
+ host name and an x-amzn-requestid header, so suspend, resume and terminate act only on the first."""
505
+ if headers is None:
506
+ return True
507
+ h = {k.lower(): v for k, v in headers.items()}
508
+ host = (h.get("host") or "").rsplit(":", 1)[0] if not (h.get("host") or "").startswith("[") \
509
+ else (h.get("host") or "").split("]")[0] + "]"
510
+ return host in ("localhost", "127.0.0.1", "[::1]") and "x-amzn-requestid" not in h
511
+
512
+ def _dispatch_hook(self, name: str, ctx: dict, headers: dict | None = None) -> int:
513
+ if name in ("suspend", "resume", "terminate") and not self._from_lambda(headers):
514
+ self.job.log(f"ignored a forwarded {name} hook call", level="warning")
515
+ return 200
516
+ if name == "suspend":
517
+ self._aws_refuse(30)
518
+ if name == "resume":
519
+ self._aws_refuse_until = 0.0
520
+ self._aws_arm_idle()
443
521
  if name == "ready":
444
522
  fn = self._hooks.get("ready")
445
523
  ok = fn(ctx) if fn else True
@@ -462,11 +540,13 @@ class HookApp:
462
540
  random.seed()
463
541
  if name == "terminate":
464
542
  self._terminate_lease()
543
+ self._aws_terminate()
465
544
  fn = self._hooks.get(name)
466
545
  if fn:
467
546
  fn(ctx)
468
547
  if name == "run":
469
- self._maybe_start_lease(self.run_payload)
548
+ if not self._aws_run_hook(self.run_payload):
549
+ self._maybe_start_lease(self.run_payload)
470
550
  return 200
471
551
 
472
552
  # -- lease worker ------------------------------------------------------------
@@ -541,14 +621,19 @@ class HookApp:
541
621
  if ctx.lost:
542
622
  self.job.log(f"lease {outcome} not delivered: orchestrator stopped waiting", level="warning")
543
623
  return
544
- try:
545
- completer.send(outcome, payload)
546
- self.job.log(f"lease {outcome} delivered", kind=ctx.lease.get("kind"))
547
- except _TokenClosed as e:
548
- ctx.lost = True
549
- self.job.log(f"lease {outcome} not delivered: {e}", level="warning")
550
- except Exception as e:
551
- self.job.log(f"lease {outcome} delivery failed: {type(e).__name__}: {e}", level="error")
624
+ for attempt in range(5): # a lost completion leaves the orchestrator waiting until its timeout
625
+ try:
626
+ completer.send(outcome, payload)
627
+ self.job.log(f"lease {outcome} delivered", kind=ctx.lease.get("kind"), attempt=attempt + 1)
628
+ return
629
+ except _TokenClosed as e:
630
+ ctx.lost = True
631
+ self.job.log(f"lease {outcome} not delivered: {e}", level="warning")
632
+ return
633
+ except Exception as e:
634
+ self.job.log(f"lease {outcome} delivery failed (attempt {attempt + 1}): "
635
+ f"{type(e).__name__}: {e}", level="error")
636
+ time.sleep(min(2 ** attempt, 16) * type(self).completion_backoff)
552
637
 
553
638
  def _terminate_lease(self) -> None:
554
639
  ctx = self.lease
@@ -560,6 +645,185 @@ class HookApp:
560
645
  except Exception:
561
646
  pass
562
647
 
648
+ # -- AWS durable execution SDK job contract --------------------------------------
649
+ def _aws_run_hook(self, raw) -> bool:
650
+ """True when the run hook payload is the SDK's (version 1); starts its job when it carries one."""
651
+ try:
652
+ obj = json.loads(raw) if isinstance(raw, str) else raw
653
+ except (TypeError, ValueError):
654
+ return False
655
+ if not isinstance(obj, dict) or obj.get("version") != 1 or "lease" in obj:
656
+ return False
657
+ self._aws_region = obj.get("region") or self._aws_region
658
+ idle = obj.get("autoSuspendIdleSeconds")
659
+ self._aws_idle_s = float(idle) if isinstance(idle, (int, float)) and idle > 0 else None
660
+ job = obj.get("job")
661
+ if isinstance(job, dict) and self._aws_handlers:
662
+ self._aws_start(AWS_JOB_PATH, job)
663
+ else:
664
+ self._aws_arm_idle()
665
+ return True
666
+
667
+ def _aws_http_job(self, path: str, body: dict):
668
+ cb = body.get("callbackId") if isinstance(body, dict) else None
669
+ valid = isinstance(cb, str) and body.get("version") == 1
670
+ if not valid:
671
+ if isinstance(body, dict) and isinstance(body.get("callbackId"), str):
672
+ self._aws_report_async(body.get("region"), body["callbackId"], "failure",
673
+ {"ErrorType": "InvalidJobRequest",
674
+ "ErrorMessage": "malformed job request"})
675
+ return 400, {"error": "not a durable execution job request"}
676
+ if self._aws_terminated or time.time() < self._aws_refuse_until:
677
+ return 503, {"error": "suspending or terminating; send the job again after the resume"}
678
+ if body.get("microvmId") and not self.microvm_id:
679
+ self.microvm_id = self.job.microvm_id = body["microvmId"]
680
+ self._aws_region = self._aws_region or body.get("region")
681
+ self._aws_start(path, body)
682
+ return 202, {"accepted": body["callbackId"]}
683
+
684
+ def _aws_start(self, path: str, job: dict) -> None:
685
+ cb = job.get("callbackId")
686
+ if not isinstance(cb, str):
687
+ return
688
+ with self._aws_lock:
689
+ if cb in self._aws_seen: # a second delivery of the same job
690
+ return
691
+ self._aws_seen[cb] = "running"
692
+ while len(self._aws_seen) > 1000:
693
+ self._aws_seen.popitem(last=False)
694
+ ctx = AwsJobContext(cb, job.get("input"), self.microvm_id, self.job, path)
695
+ self._aws_running[cb] = ctx
696
+ if self._aws_idle_timer:
697
+ self._aws_idle_timer.cancel()
698
+ handler = self._aws_handlers.get(path) or self._aws_handlers.get(AWS_JOB_PATH)
699
+ region = job.get("region") or self._aws_region or os.environ.get("AWS_REGION", "us-east-1")
700
+ hb = job.get("heartbeatTimeoutSeconds")
701
+ threading.Thread(target=self._aws_run, args=(handler, ctx, region, hb), daemon=True).start()
702
+
703
+ def _aws_run(self, handler, ctx: AwsJobContext, region: str, hb_timeout) -> None:
704
+ client = type(self).lease_client_factory("lambda", region) # per job: none frozen in the snapshot
705
+ if isinstance(hb_timeout, (int, float)) and hb_timeout > 0:
706
+ threading.Thread(target=self._aws_heartbeats, args=(client, ctx, float(hb_timeout)),
707
+ daemon=True).start()
708
+ self.job.phase(f"job {ctx.path}")
709
+ self.job.log("durable job started", callback=ctx.callback_id[-12:], path=ctx.path)
710
+ try:
711
+ if handler is None:
712
+ raise LeaseError("NoHandler", f"no durable_job handler for {ctx.path}")
713
+ result = handler(ctx.input, ctx)
714
+ body = json.dumps(result, default=str)
715
+ if len(body.encode()) > AWS_MAX_RESULT_BYTES:
716
+ raise LeaseError("ResultTooLargeError",
717
+ f"the result is {len(body.encode())} bytes; the limit is 256 KB")
718
+ outcome, kw = "success", {"Result": body.encode()}
719
+ except Exception as e:
720
+ data = getattr(e, "data", None)
721
+ outcome, kw = "failure", {"Error": {
722
+ "ErrorType": str(getattr(e, "error_type", type(e).__name__))[:256],
723
+ "ErrorMessage": str(getattr(e, "message", None) or e)[:8192],
724
+ **({"ErrorData": json.dumps(data, default=str)[:8192]} if data else {})}}
725
+ ctx.cancelled.set()
726
+ if not ctx.closed:
727
+ self._aws_report(client, ctx.callback_id, outcome, kw)
728
+ self._aws_finish(ctx, outcome)
729
+
730
+ def _aws_finish(self, ctx: AwsJobContext, outcome: str) -> None:
731
+ with self._aws_lock:
732
+ self._aws_running.pop(ctx.callback_id, None)
733
+ self._aws_seen[ctx.callback_id] = "done"
734
+ idle = not self._aws_running
735
+ self.job.log(f"durable job {outcome}", callback=ctx.callback_id[-12:])
736
+ self.job.counter("durable_jobs_" + outcome, self.job.counters.get("durable_jobs_" + outcome, 0) + 1)
737
+ if idle:
738
+ self.job.phase("idle")
739
+ self._aws_arm_idle()
740
+
741
+ def _aws_heartbeats(self, client, ctx: AwsJobContext, timeout_s: float) -> None:
742
+ # a third of the timeout, shortened by 1 to 2 s from a hash of the callback id: clones of one
743
+ # snapshot share Math.random-style state, so jitter must come from the job itself
744
+ jitter = 1 + (int.from_bytes(ctx.callback_id.encode()[-4:], "big") % 1000) / 1000
745
+ interval = max(1.0, timeout_s / 3 - jitter)
746
+ first = True
747
+ while first or not ctx.cancelled.wait(interval):
748
+ first = False
749
+ try:
750
+ client.send_durable_execution_callback_heartbeat(CallbackId=ctx.callback_id)
751
+ ctx.heartbeats += 1
752
+ except Exception as e:
753
+ if _error_code(e) in AWS_TERMINAL_CALLBACK_ERRORS:
754
+ ctx.closed = True
755
+ ctx.cancelled.set()
756
+ self.job.log("durable function stopped waiting; cancelling the job", level="warning")
757
+ return
758
+ self.job.log(f"heartbeat failed: {e}", level="warning")
759
+
760
+ def _aws_report(self, client, callback_id: str, outcome: str, kw: dict) -> None:
761
+ call = (client.send_durable_execution_callback_success if outcome == "success"
762
+ else client.send_durable_execution_callback_failure)
763
+ for attempt in range(5):
764
+ try:
765
+ call(CallbackId=callback_id, **kw)
766
+ return
767
+ except Exception as e:
768
+ if _error_code(e) in AWS_TERMINAL_CALLBACK_ERRORS:
769
+ self.job.log(f"callback already closed: {_error_code(e)}", level="warning")
770
+ return
771
+ self.job.log(f"{outcome} report failed (attempt {attempt + 1}): {e}", level="warning")
772
+ time.sleep(min(2 ** attempt, 16))
773
+
774
+ def _aws_report_async(self, region, callback_id: str, outcome: str, error: dict) -> None:
775
+ region = region or self._aws_region or os.environ.get("AWS_REGION", "us-east-1")
776
+ client = type(self).lease_client_factory("lambda", region)
777
+ threading.Thread(target=self._aws_report, args=(client, callback_id, outcome, {"Error": error}),
778
+ daemon=True).start()
779
+
780
+ def _aws_terminate(self) -> None:
781
+ self._aws_terminated = True
782
+ with self._aws_lock:
783
+ running = list(self._aws_running.values())
784
+ for ctx in running:
785
+ ctx.closed = True
786
+ ctx.cancelled.set()
787
+ region = self._aws_region or os.environ.get("AWS_REGION", "us-east-1")
788
+ try:
789
+ client = type(self).lease_client_factory("lambda", region)
790
+ self._aws_report(client, ctx.callback_id, "failure",
791
+ {"Error": {"ErrorType": "MicrovmTerminatedError",
792
+ "ErrorMessage": "the microVM terminated while the job ran"}})
793
+ except Exception:
794
+ pass
795
+
796
+ def _aws_refuse(self, seconds: float) -> None:
797
+ self._aws_refuse_until = time.time() + seconds
798
+ if self._aws_idle_timer:
799
+ self._aws_idle_timer.cancel()
800
+
801
+ def _aws_arm_idle(self) -> None:
802
+ """With autoSuspendIdleSeconds, suspend this microVM after that long without a running job."""
803
+ if not self._aws_idle_s or self._aws_terminated:
804
+ return
805
+ if self._aws_idle_timer:
806
+ self._aws_idle_timer.cancel()
807
+ self._aws_idle_timer = threading.Timer(self._aws_idle_s, self._aws_self_suspend)
808
+ self._aws_idle_timer.daemon = True
809
+ self._aws_idle_timer.start()
810
+
811
+ def _aws_self_suspend(self) -> None:
812
+ with self._aws_lock:
813
+ if self._aws_running or not self.microvm_id:
814
+ return
815
+ self._aws_refuse(30)
816
+ try:
817
+ region = self._aws_region or os.environ.get("AWS_REGION", "us-east-1")
818
+ type(self).lease_client_factory("lambda-microvms", region).suspend_microvm(
819
+ microvmIdentifier=self.microvm_id)
820
+ self.job.log("suspended itself after the idle time", idle_s=self._aws_idle_s)
821
+ except Exception as e:
822
+ code = _error_code(e)
823
+ if code in ("AccessDeniedException", "ValidationException", "ResourceNotFoundException"):
824
+ self._aws_refuse_until = 0.0 # rejected outright: keep running and accept jobs
825
+ self.job.log(f"self-suspend failed: {code or e}", level="warning")
826
+
563
827
  # -- server ------------------------------------------------------------------
564
828
  def serve(self, port: int = 8080, background: bool = False):
565
829
  app = self
@@ -648,7 +912,7 @@ class HookApp:
648
912
  try:
649
913
  if path.startswith(HOOK_BASE + "/"):
650
914
  hook = path[len(HOOK_BASE) + 1:]
651
- status = app._dispatch_hook(hook, self._read_json())
915
+ status = app._dispatch_hook(hook, self._read_json(), dict(self.headers))
652
916
  return self._send(status, {"hook": hook, "status": status})
653
917
  fn = app._routes.get((method, path))
654
918
  if fn is None:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: microvm-ctl
3
- Version: 0.4.0
3
+ Version: 0.5.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
@@ -35,6 +35,7 @@ tests/test_cost_model.py
35
35
  tests/test_endpoint.py
36
36
  tests/test_fleet.py
37
37
  tests/test_hooks.py
38
+ tests/test_hooks_aws_contract.py
38
39
  tests/test_hooks_lease.py
39
40
  tests/test_hooks_validate.py
40
41
  tests/test_integrations.py
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "microvm-ctl"
7
- version = "0.4.0"
7
+ version = "0.5.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"
@@ -0,0 +1,234 @@
1
+ """The AWS durable execution SDK job contract in the hook server (loopback only, no AWS).
2
+
3
+ The contract is the one @aws/durable-execution-sdk-js-extras's `microvm` and `microvmSession` operations
4
+ speak: a version 1 run hook payload with an optional job, jobs POSTed over HTTP, durable callback
5
+ heartbeats and completion, and the suspend, resume and terminate lifecycle hooks.
6
+ """
7
+
8
+ import json
9
+ import threading
10
+ import time
11
+ import urllib.error
12
+ import urllib.request
13
+
14
+ import pytest
15
+
16
+ from microvm.hooks.server import AWS_JOB_PATH, HookApp, LeaseError
17
+
18
+ HOOK = "/aws/lambda-microvms/runtime/v1/"
19
+
20
+
21
+ class ClientError(Exception):
22
+ def __init__(self, code):
23
+ super().__init__(code)
24
+ self.response = {"Error": {"Code": code}}
25
+
26
+
27
+ class FakeLambda:
28
+ def __init__(self):
29
+ self.calls = []
30
+ self.fail = {} # method name -> error code to raise
31
+ self.cv = threading.Condition()
32
+
33
+ def _call(self, name, **kw):
34
+ with self.cv:
35
+ self.calls.append((name, kw))
36
+ self.cv.notify_all()
37
+ if name in self.fail:
38
+ raise ClientError(self.fail[name])
39
+ return {}
40
+
41
+ def __getattr__(self, name):
42
+ return lambda **kw: self._call(name, **kw)
43
+
44
+ def wait(self, name, n=1, timeout=5.0):
45
+ deadline = time.time() + timeout
46
+ with self.cv:
47
+ while len([c for c in self.calls if c[0] == name]) < n:
48
+ if time.time() > deadline:
49
+ raise AssertionError(f"no {n}x {name}: {self.calls}")
50
+ self.cv.wait(0.05)
51
+ return [kw for c, kw in self.calls if c == name]
52
+
53
+
54
+ @pytest.fixture
55
+ def env():
56
+ fake = FakeLambda()
57
+
58
+ class App(HookApp):
59
+ lease_client_factory = staticmethod(lambda service, region: fake)
60
+
61
+ app = App()
62
+ server = app.serve(port=0, background=True)
63
+ port = server.server_address[1]
64
+
65
+ def post(path, body, host=f"127.0.0.1:{port}", extra=None):
66
+ req = urllib.request.Request(f"http://127.0.0.1:{port}{path}", data=json.dumps(body).encode(),
67
+ method="POST", headers={"Content-Type": "application/json", "Host": host,
68
+ **(extra or {})})
69
+ try:
70
+ with urllib.request.urlopen(req, timeout=5) as r:
71
+ return r.status, json.loads(r.read() or b"{}")
72
+ except urllib.error.HTTPError as e:
73
+ return e.code, json.loads(e.read() or b"{}")
74
+
75
+ yield app, fake, post
76
+ server.shutdown()
77
+
78
+
79
+ def run_hook(post, payload):
80
+ return post(HOOK + "run", {"microvmId": "mvm-1", "runHookPayload": json.dumps(payload)})
81
+
82
+
83
+ def test_run_hook_job_succeeds_with_the_raw_result_and_heartbeats(env):
84
+ app, fake, post = env
85
+
86
+ @app.durable_job
87
+ def handler(inp, ctx):
88
+ time.sleep(0.2)
89
+ return {"echo": inp["text"], "vm": ctx.microvm_id}
90
+
91
+ job = {"callbackId": "cb-1", "heartbeatTimeoutSeconds": 6, "input": {"text": "hi"}}
92
+ assert run_hook(post, {"version": 1, "region": "us-east-1", "job": job})[0] == 200
93
+ ok = fake.wait("send_durable_execution_callback_success")[0]
94
+ assert ok["CallbackId"] == "cb-1"
95
+ assert json.loads(ok["Result"]) == {"echo": "hi", "vm": "mvm-1"} # the handler's value, no envelope
96
+ assert fake.wait("send_durable_execution_callback_heartbeat")[0]["CallbackId"] == "cb-1"
97
+
98
+
99
+ def test_failure_carries_the_error_type_and_data(env):
100
+ app, fake, post = env
101
+
102
+ @app.durable_job
103
+ def handler(inp, ctx):
104
+ raise LeaseError("BuildFailed", "exit 2", data={"step": 3})
105
+
106
+ run_hook(post, {"version": 1, "job": {"callbackId": "cb-2", "input": {}}})
107
+ err = fake.wait("send_durable_execution_callback_failure")[0]["Error"]
108
+ assert err["ErrorType"] == "BuildFailed" and err["ErrorMessage"] == "exit 2"
109
+ assert json.loads(err["ErrorData"]) == {"step": 3}
110
+
111
+
112
+ def test_http_job_on_a_named_route_runs_once_per_callback(env):
113
+ app, fake, post = env
114
+ seen = []
115
+
116
+ @app.durable_job(path="/build")
117
+ def build(inp, ctx):
118
+ seen.append(inp)
119
+ return {"n": len(seen)}
120
+
121
+ run_hook(post, {"version": 1, "region": "us-east-1"})
122
+ job = {"version": 1, "region": "us-east-1", "microvmId": "mvm-1", "callbackId": "cb-3", "input": {"x": 1}}
123
+ assert post("/build", job)[0] == 202
124
+ assert post("/build", job)[0] == 202 # a second delivery is accepted and ignored
125
+ fake.wait("send_durable_execution_callback_success")
126
+ time.sleep(0.2)
127
+ assert seen == [{"x": 1}]
128
+
129
+
130
+ def test_default_job_path_and_malformed_job_fails_its_callback(env):
131
+ app, fake, post = env
132
+ app.durable_job(lambda inp, ctx: "ok")
133
+ assert post(AWS_JOB_PATH, {"version": 1, "callbackId": "cb-4", "input": 1})[0] == 202
134
+ fake.wait("send_durable_execution_callback_success")
135
+ assert post(AWS_JOB_PATH, {"version": 9, "callbackId": "cb-5"})[0] == 400
136
+ assert fake.wait("send_durable_execution_callback_failure")[0]["CallbackId"] == "cb-5"
137
+
138
+
139
+ def test_closed_callback_cancels_the_job_and_reports_nothing(env):
140
+ app, fake, post = env
141
+ fake.fail["send_durable_execution_callback_heartbeat"] = "CallbackTimeoutException"
142
+ cancelled = threading.Event()
143
+
144
+ @app.durable_job
145
+ def handler(inp, ctx):
146
+ ctx.cancelled.wait(5)
147
+ cancelled.set()
148
+ return "late"
149
+
150
+ run_hook(post, {"version": 1, "job": {"callbackId": "cb-6", "heartbeatTimeoutSeconds": 30, "input": {}}})
151
+ assert cancelled.wait(5)
152
+ time.sleep(0.2)
153
+ assert not [c for c in fake.calls if c[0] == "send_durable_execution_callback_success"]
154
+
155
+
156
+ def test_terminate_from_lambda_fails_running_jobs_but_a_forwarded_one_is_ignored(env):
157
+ app, fake, post = env
158
+ release = threading.Event()
159
+ app.durable_job(lambda inp, ctx: release.wait(5) or "done")
160
+ run_hook(post, {"version": 1, "job": {"callbackId": "cb-7", "input": {}}})
161
+ time.sleep(0.2)
162
+ # forwarded through the endpoint: endpoint host name and a request id
163
+ assert post(HOOK + "terminate", {}, host="abc.lambda-microvm.us-east-1.on.aws",
164
+ extra={"x-amzn-requestid": "r-1"})[0] == 200
165
+ time.sleep(0.2)
166
+ assert not [c for c in fake.calls if c[0] == "send_durable_execution_callback_failure"]
167
+ assert post(HOOK + "terminate", {}, host="localhost:8080")[0] == 200
168
+ err = fake.wait("send_durable_execution_callback_failure")[0]["Error"]
169
+ assert err["ErrorType"] == "MicrovmTerminatedError"
170
+ assert post(AWS_JOB_PATH, {"version": 1, "callbackId": "cb-8", "input": {}})[0] == 503
171
+ release.set()
172
+
173
+
174
+ def test_suspend_hook_refuses_jobs_until_resume(env):
175
+ app, fake, post = env
176
+ app.durable_job(lambda inp, ctx: "ok")
177
+ run_hook(post, {"version": 1})
178
+ post(HOOK + "suspend", {}, host="localhost:8080")
179
+ assert post(AWS_JOB_PATH, {"version": 1, "callbackId": "cb-9", "input": {}})[0] == 503
180
+ post(HOOK + "resume", {}, host="localhost:8080")
181
+ assert post(AWS_JOB_PATH, {"version": 1, "callbackId": "cb-9", "input": {}})[0] == 202
182
+
183
+
184
+ def test_idle_microvm_suspends_itself(env):
185
+ app, fake, post = env
186
+ app.durable_job(lambda inp, ctx: "ok")
187
+ run_hook(post, {"version": 1, "autoSuspendIdleSeconds": 0.3})
188
+ call = fake.wait("suspend_microvm", timeout=3)[0]
189
+ assert call == {"microvmIdentifier": "mvm-1"}
190
+ assert post(AWS_JOB_PATH, {"version": 1, "callbackId": "cb-10", "input": {}})[0] == 503
191
+
192
+
193
+ def test_a_lease_payload_still_goes_to_on_lease(env):
194
+ app, fake, post = env
195
+ got = threading.Event()
196
+ app.on_lease(lambda task, lease: got.set() or {"ok": True})
197
+ run_hook(post, {"lease": {"kind": "none", "id": "l-1"}, "task": {"steps": []}})
198
+ assert got.wait(5)
199
+
200
+
201
+ def test_durable_lease_retries_a_transient_completion_and_reads_codes_from_client_errors(env):
202
+ app, fake, post = env
203
+ type(app).completion_backoff = 0
204
+ attempts = {"n": 0}
205
+ real = fake._call
206
+
207
+ def flaky(name, **kw):
208
+ if name == "send_durable_execution_callback_success":
209
+ attempts["n"] += 1
210
+ if attempts["n"] == 1:
211
+ fake.calls.append((name, kw))
212
+ raise ClientError("ServiceUnavailableException")
213
+ return real(name, **kw)
214
+
215
+ fake._call = flaky
216
+ app.on_lease(lambda task, lease: {"ok": True})
217
+ run_hook(post, {"lease": {"kind": "durable", "token": "cb-11", "heartbeat_s": 30}, "task": {}})
218
+ fake.wait("send_durable_execution_callback_success", n=2)
219
+ assert attempts["n"] == 2
220
+
221
+
222
+ def test_durable_lease_treats_an_unknown_callback_as_closed(env):
223
+ app, fake, post = env
224
+ type(app).completion_backoff = 0
225
+ fake.fail["send_durable_execution_callback_success"] = "ResourceNotFoundException"
226
+ fake.fail["send_durable_execution_callback_heartbeat"] = "ResourceNotFoundException"
227
+ app.on_lease(lambda task, lease: {"ok": True})
228
+ run_hook(post, {"lease": {"kind": "durable", "token": "cb-12", "heartbeat_s": 30}, "task": {}})
229
+ deadline = time.time() + 5
230
+ while time.time() < deadline and not (app.lease and app.lease.lost):
231
+ time.sleep(0.05)
232
+ assert app.lease.lost # the first heartbeat found the callback gone
233
+ time.sleep(0.2)
234
+ assert not [c for c in fake.calls if c[0] == "send_durable_execution_callback_success"]
File without changes
File without changes
File without changes