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.
- {microvm_ctl-0.4.0 → microvm_ctl-0.5.0}/PKG-INFO +1 -1
- {microvm_ctl-0.4.0 → microvm_ctl-0.5.0}/microvm/__init__.py +1 -1
- {microvm_ctl-0.4.0 → microvm_ctl-0.5.0}/microvm/cli.py +4 -2
- {microvm_ctl-0.4.0 → microvm_ctl-0.5.0}/microvm/fleet.py +2 -1
- {microvm_ctl-0.4.0 → microvm_ctl-0.5.0}/microvm/hooks/server.py +277 -13
- {microvm_ctl-0.4.0 → microvm_ctl-0.5.0}/microvm_ctl.egg-info/PKG-INFO +1 -1
- {microvm_ctl-0.4.0 → microvm_ctl-0.5.0}/microvm_ctl.egg-info/SOURCES.txt +1 -0
- {microvm_ctl-0.4.0 → microvm_ctl-0.5.0}/pyproject.toml +1 -1
- microvm_ctl-0.5.0/tests/test_hooks_aws_contract.py +234 -0
- {microvm_ctl-0.4.0 → microvm_ctl-0.5.0}/LICENSE +0 -0
- {microvm_ctl-0.4.0 → microvm_ctl-0.5.0}/README.md +0 -0
- {microvm_ctl-0.4.0 → microvm_ctl-0.5.0}/microvm/bootstrap.py +0 -0
- {microvm_ctl-0.4.0 → microvm_ctl-0.5.0}/microvm/client.py +0 -0
- {microvm_ctl-0.4.0 → microvm_ctl-0.5.0}/microvm/config.py +0 -0
- {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
- {microvm_ctl-0.4.0 → microvm_ctl-0.5.0}/microvm/data/lambda-microvms/2025-09-09/paginators-1.json +0 -0
- {microvm_ctl-0.4.0 → microvm_ctl-0.5.0}/microvm/data/lambda-microvms/2025-09-09/service-2.json.gz +0 -0
- {microvm_ctl-0.4.0 → microvm_ctl-0.5.0}/microvm/endpoint.py +0 -0
- {microvm_ctl-0.4.0 → microvm_ctl-0.5.0}/microvm/hooks/__init__.py +0 -0
- {microvm_ctl-0.4.0 → microvm_ctl-0.5.0}/microvm/images.py +0 -0
- {microvm_ctl-0.4.0 → microvm_ctl-0.5.0}/microvm/integrations/__init__.py +0 -0
- {microvm_ctl-0.4.0 → microvm_ctl-0.5.0}/microvm/integrations/durable.py +0 -0
- {microvm_ctl-0.4.0 → microvm_ctl-0.5.0}/microvm/integrations/stepfunctions.py +0 -0
- {microvm_ctl-0.4.0 → microvm_ctl-0.5.0}/microvm/lease.py +0 -0
- {microvm_ctl-0.4.0 → microvm_ctl-0.5.0}/microvm/monitor.py +0 -0
- {microvm_ctl-0.4.0 → microvm_ctl-0.5.0}/microvm/playground/__init__.py +0 -0
- {microvm_ctl-0.4.0 → microvm_ctl-0.5.0}/microvm/playground/server.py +0 -0
- {microvm_ctl-0.4.0 → microvm_ctl-0.5.0}/microvm/playground/static/index.html +0 -0
- {microvm_ctl-0.4.0 → microvm_ctl-0.5.0}/microvm/throttle.py +0 -0
- {microvm_ctl-0.4.0 → microvm_ctl-0.5.0}/microvm_ctl.egg-info/dependency_links.txt +0 -0
- {microvm_ctl-0.4.0 → microvm_ctl-0.5.0}/microvm_ctl.egg-info/entry_points.txt +0 -0
- {microvm_ctl-0.4.0 → microvm_ctl-0.5.0}/microvm_ctl.egg-info/requires.txt +0 -0
- {microvm_ctl-0.4.0 → microvm_ctl-0.5.0}/microvm_ctl.egg-info/top_level.txt +0 -0
- {microvm_ctl-0.4.0 → microvm_ctl-0.5.0}/setup.cfg +0 -0
- {microvm_ctl-0.4.0 → microvm_ctl-0.5.0}/tests/test_cli_scale.py +0 -0
- {microvm_ctl-0.4.0 → microvm_ctl-0.5.0}/tests/test_config.py +0 -0
- {microvm_ctl-0.4.0 → microvm_ctl-0.5.0}/tests/test_cost_model.py +0 -0
- {microvm_ctl-0.4.0 → microvm_ctl-0.5.0}/tests/test_endpoint.py +0 -0
- {microvm_ctl-0.4.0 → microvm_ctl-0.5.0}/tests/test_fleet.py +0 -0
- {microvm_ctl-0.4.0 → microvm_ctl-0.5.0}/tests/test_hooks.py +0 -0
- {microvm_ctl-0.4.0 → microvm_ctl-0.5.0}/tests/test_hooks_lease.py +0 -0
- {microvm_ctl-0.4.0 → microvm_ctl-0.5.0}/tests/test_hooks_validate.py +0 -0
- {microvm_ctl-0.4.0 → microvm_ctl-0.5.0}/tests/test_integrations.py +0 -0
- {microvm_ctl-0.4.0 → microvm_ctl-0.5.0}/tests/test_integrations_durable.py +0 -0
- {microvm_ctl-0.4.0 → microvm_ctl-0.5.0}/tests/test_integrations_durable_scale.py +0 -0
- {microvm_ctl-0.4.0 → microvm_ctl-0.5.0}/tests/test_integrations_scale.py +0 -0
- {microvm_ctl-0.4.0 → microvm_ctl-0.5.0}/tests/test_lease.py +0 -0
- {microvm_ctl-0.4.0 → microvm_ctl-0.5.0}/tests/test_monitor_jobs.py +0 -0
- {microvm_ctl-0.4.0 → microvm_ctl-0.5.0}/tests/test_playground.py +0 -0
- {microvm_ctl-0.4.0 → microvm_ctl-0.5.0}/tests/test_scale.py +0 -0
- {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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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),
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
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.
|
|
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
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "microvm-ctl"
|
|
7
|
-
version = "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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{microvm_ctl-0.4.0 → microvm_ctl-0.5.0}/microvm/data/lambda-microvms/2025-09-09/paginators-1.json
RENAMED
|
File without changes
|
{microvm_ctl-0.4.0 → microvm_ctl-0.5.0}/microvm/data/lambda-microvms/2025-09-09/service-2.json.gz
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|