microvm-ctl 0.3.0__tar.gz → 0.3.1__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.0 → microvm_ctl-0.3.1}/PKG-INFO +1 -1
  2. {microvm_ctl-0.3.0 → microvm_ctl-0.3.1}/microvm/__init__.py +1 -1
  3. {microvm_ctl-0.3.0 → microvm_ctl-0.3.1}/microvm/cli.py +5 -5
  4. {microvm_ctl-0.3.0 → microvm_ctl-0.3.1}/microvm/hooks/server.py +5 -1
  5. {microvm_ctl-0.3.0 → microvm_ctl-0.3.1}/microvm/integrations/durable.py +1 -1
  6. {microvm_ctl-0.3.0 → microvm_ctl-0.3.1}/microvm/integrations/stepfunctions.py +26 -4
  7. {microvm_ctl-0.3.0 → microvm_ctl-0.3.1}/microvm/lease.py +9 -0
  8. {microvm_ctl-0.3.0 → microvm_ctl-0.3.1}/microvm_ctl.egg-info/PKG-INFO +1 -1
  9. {microvm_ctl-0.3.0 → microvm_ctl-0.3.1}/pyproject.toml +1 -1
  10. {microvm_ctl-0.3.0 → microvm_ctl-0.3.1}/tests/test_integrations.py +12 -2
  11. {microvm_ctl-0.3.0 → microvm_ctl-0.3.1}/tests/test_integrations_durable.py +1 -1
  12. {microvm_ctl-0.3.0 → microvm_ctl-0.3.1}/tests/test_integrations_scale.py +2 -1
  13. {microvm_ctl-0.3.0 → microvm_ctl-0.3.1}/tests/test_scale.py +21 -0
  14. {microvm_ctl-0.3.0 → microvm_ctl-0.3.1}/LICENSE +0 -0
  15. {microvm_ctl-0.3.0 → microvm_ctl-0.3.1}/README.md +0 -0
  16. {microvm_ctl-0.3.0 → microvm_ctl-0.3.1}/microvm/bootstrap.py +0 -0
  17. {microvm_ctl-0.3.0 → microvm_ctl-0.3.1}/microvm/client.py +0 -0
  18. {microvm_ctl-0.3.0 → microvm_ctl-0.3.1}/microvm/config.py +0 -0
  19. {microvm_ctl-0.3.0 → microvm_ctl-0.3.1}/microvm/data/lambda-microvms/2025-09-09/endpoint-rule-set-1.json.gz +0 -0
  20. {microvm_ctl-0.3.0 → microvm_ctl-0.3.1}/microvm/data/lambda-microvms/2025-09-09/paginators-1.json +0 -0
  21. {microvm_ctl-0.3.0 → microvm_ctl-0.3.1}/microvm/data/lambda-microvms/2025-09-09/service-2.json.gz +0 -0
  22. {microvm_ctl-0.3.0 → microvm_ctl-0.3.1}/microvm/endpoint.py +0 -0
  23. {microvm_ctl-0.3.0 → microvm_ctl-0.3.1}/microvm/fleet.py +0 -0
  24. {microvm_ctl-0.3.0 → microvm_ctl-0.3.1}/microvm/hooks/__init__.py +0 -0
  25. {microvm_ctl-0.3.0 → microvm_ctl-0.3.1}/microvm/images.py +0 -0
  26. {microvm_ctl-0.3.0 → microvm_ctl-0.3.1}/microvm/integrations/__init__.py +0 -0
  27. {microvm_ctl-0.3.0 → microvm_ctl-0.3.1}/microvm/monitor.py +0 -0
  28. {microvm_ctl-0.3.0 → microvm_ctl-0.3.1}/microvm/playground/__init__.py +0 -0
  29. {microvm_ctl-0.3.0 → microvm_ctl-0.3.1}/microvm/playground/server.py +0 -0
  30. {microvm_ctl-0.3.0 → microvm_ctl-0.3.1}/microvm/playground/static/index.html +0 -0
  31. {microvm_ctl-0.3.0 → microvm_ctl-0.3.1}/microvm/throttle.py +0 -0
  32. {microvm_ctl-0.3.0 → microvm_ctl-0.3.1}/microvm_ctl.egg-info/SOURCES.txt +0 -0
  33. {microvm_ctl-0.3.0 → microvm_ctl-0.3.1}/microvm_ctl.egg-info/dependency_links.txt +0 -0
  34. {microvm_ctl-0.3.0 → microvm_ctl-0.3.1}/microvm_ctl.egg-info/entry_points.txt +0 -0
  35. {microvm_ctl-0.3.0 → microvm_ctl-0.3.1}/microvm_ctl.egg-info/requires.txt +0 -0
  36. {microvm_ctl-0.3.0 → microvm_ctl-0.3.1}/microvm_ctl.egg-info/top_level.txt +0 -0
  37. {microvm_ctl-0.3.0 → microvm_ctl-0.3.1}/setup.cfg +0 -0
  38. {microvm_ctl-0.3.0 → microvm_ctl-0.3.1}/tests/test_cli_scale.py +0 -0
  39. {microvm_ctl-0.3.0 → microvm_ctl-0.3.1}/tests/test_config.py +0 -0
  40. {microvm_ctl-0.3.0 → microvm_ctl-0.3.1}/tests/test_cost_model.py +0 -0
  41. {microvm_ctl-0.3.0 → microvm_ctl-0.3.1}/tests/test_endpoint.py +0 -0
  42. {microvm_ctl-0.3.0 → microvm_ctl-0.3.1}/tests/test_fleet.py +0 -0
  43. {microvm_ctl-0.3.0 → microvm_ctl-0.3.1}/tests/test_hooks.py +0 -0
  44. {microvm_ctl-0.3.0 → microvm_ctl-0.3.1}/tests/test_hooks_lease.py +0 -0
  45. {microvm_ctl-0.3.0 → microvm_ctl-0.3.1}/tests/test_hooks_validate.py +0 -0
  46. {microvm_ctl-0.3.0 → microvm_ctl-0.3.1}/tests/test_integrations_durable_scale.py +0 -0
  47. {microvm_ctl-0.3.0 → microvm_ctl-0.3.1}/tests/test_lease.py +0 -0
  48. {microvm_ctl-0.3.0 → microvm_ctl-0.3.1}/tests/test_monitor_jobs.py +0 -0
  49. {microvm_ctl-0.3.0 → microvm_ctl-0.3.1}/tests/test_playground.py +0 -0
  50. {microvm_ctl-0.3.0 → microvm_ctl-0.3.1}/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.0
3
+ Version: 0.3.1
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.3.0"
15
+ __version__ = "0.3.1"
16
16
 
17
17
  __all__ = [
18
18
  "microvm_client",
@@ -531,11 +531,11 @@ def cmd_lease_run(args):
531
531
  task = json.loads(args.task) if args.task else {}
532
532
  except ValueError as e:
533
533
  sys.exit(f"mvm lease run: --task is not valid JSON: {e}")
534
+ policy = _lease_policy(args)
534
535
  lease = Lease(kind=args.kind, token=args.token or "", region=cfg.region, target=args.target,
535
- heartbeat_s=args.heartbeat_every, id=args.id)
536
+ heartbeat_s=policy.heartbeat_every(args.heartbeat_every), id=args.id)
536
537
  fm = FleetManager(cfg)
537
- vm = fm.lease(args.image, lease, task, _lease_policy(args), version=args.version,
538
- execution_role=args.execution_role)
538
+ vm = fm.lease(args.image, lease, task, policy, version=args.version, execution_role=args.execution_role)
539
539
  console.print(
540
540
  f"[bold green]✓[/] {vm.microvm_id} {_state(vm.state)} "
541
541
  f"[link=https://{vm.endpoint}]{vm.endpoint}[/link] lease {lease.kind}"
@@ -562,14 +562,14 @@ def cmd_lease_run_many(args, cfg):
562
562
  sys.exit(f"mvm lease run: --task-template is not valid JSON: {e}")
563
563
  if not isinstance(template, dict):
564
564
  sys.exit("mvm lease run: --task-template must be a JSON object")
565
+ policy = _lease_policy(args)
565
566
  leases, tasks = [], []
566
567
  for i in range(args.shards):
567
568
  token = args.token_template.replace("{i}", str(i)) if args.token_template else ""
568
569
  lease_id = args.id.replace("{i}", str(i)) if args.id else None
569
570
  leases.append(Lease(kind=kind, token=token, region=cfg.region, target=args.target,
570
- heartbeat_s=args.heartbeat_every, id=lease_id))
571
+ heartbeat_s=policy.heartbeat_every(args.heartbeat_every), id=lease_id))
571
572
  tasks.append(_fill_template(template, i))
572
- policy = _lease_policy(args)
573
573
  fm = FleetManager(cfg)
574
574
  baseline = _baseline_mib(args, cfg)
575
575
  plan = fm.plan(args.shards, baseline, policy)
@@ -514,7 +514,11 @@ class HookApp:
514
514
  self._deliver(ctx, completer, "failure", ctx.completion(error=err))
515
515
 
516
516
  def _heartbeat_loop(self, ctx: LeaseContext, completer, interval: float) -> None:
517
- while not ctx._stop.wait(interval):
517
+ # The first heartbeat goes out at once: the orchestrator's heartbeat clock started before
518
+ # RunMicrovm returned, so waiting a full interval first can lose a tight lease.
519
+ first = True
520
+ while first or not ctx._stop.wait(interval):
521
+ first = False
518
522
  try:
519
523
  with ctx._lock: # serialized with _deliver: no heartbeat lands after the completion
520
524
  if ctx.done:
@@ -187,7 +187,7 @@ def lease_microvm(
187
187
  ),
188
188
  )
189
189
  lease = Lease(kind="durable", token=callback.callback_id, region=region,
190
- heartbeat_s=heartbeat_s, id=execution_name(context))
190
+ heartbeat_s=policy.heartbeat_every(heartbeat_s), id=execution_name(context))
191
191
  policy_dict = {"budget_s": policy.budget_s, "heartbeat_timeout_s": policy.heartbeat_timeout_s,
192
192
  "slack_s": policy.slack_s}
193
193
  launch_once = StepConfig(step_semantics=StepSemantics.AT_MOST_ONCE_PER_RETRY,
@@ -103,8 +103,10 @@ def _lease_states(
103
103
  task_expr: str, heartbeat_s: int, lease_id_expr: str, client_token_expr: str,
104
104
  done_name: str = "Done",
105
105
  ) -> dict:
106
- """The states of one lease: `name` -> Terminate -> Done, failures -> Reap ->
107
- TerminateStale -> Failed. Used as the whole machine and as a Map item processor."""
106
+ """The states of one lease: `name` -> Terminate -> Done; a typed failure ->
107
+ OnLeaseError -> TerminateFailed (the VM the cause names) -> Reap -> TerminateStale ->
108
+ Failed; a timeout -> OnLeaseError -> Reap -> TerminateStale -> Failed. Used as the whole
109
+ machine and as a Map item processor."""
108
110
  max_duration = policy.max_duration()
109
111
  stale_ms = max_duration * 1000
110
112
  items_expr = (
@@ -118,7 +120,8 @@ def _lease_states(
118
120
  "Arguments": {
119
121
  "ImageIdentifier": image_arn,
120
122
  "ExecutionRoleArn": execution_role_arn,
121
- "RunHookPayload": run_hook_payload_expr(region, heartbeat_s, task_expr, lease_id_expr),
123
+ "RunHookPayload": run_hook_payload_expr(
124
+ region, policy.heartbeat_every(heartbeat_s), task_expr, lease_id_expr),
122
125
  "IdlePolicy": _idle_policy_args(policy),
123
126
  "MaximumDurationInSeconds": max_duration,
124
127
  "ClientToken": _jsonata(f"$substring({client_token_expr}, 0, 128)"),
@@ -129,11 +132,30 @@ def _lease_states(
129
132
  "Catch": [{
130
133
  "ErrorEquals": ["States.Timeout", "States.HeartbeatTimeout", "States.TaskFailed"],
131
134
  "Assign": {"lease_error": _jsonata("$states.errorOutput")},
132
- "Next": "Reap",
135
+ "Next": "OnLeaseError",
133
136
  }],
134
137
  "Assign": {"vm": _jsonata("$states.result.microvm_id")},
135
138
  "Next": "Terminate",
136
139
  },
140
+ # A typed failure's cause is the VM's own payload and names it: terminate that VM at
141
+ # once. A timeout carries no id; that VM is bounded by MaximumDurationInSeconds
142
+ # (budget + slack) and Reap terminates anything older than that.
143
+ "OnLeaseError": {
144
+ "Type": "Choice",
145
+ "Choices": [{
146
+ "Condition": _jsonata("$contains($string($lease_error.Cause), '\"microvm_id\"')"),
147
+ "Next": "TerminateFailed",
148
+ }],
149
+ "Default": "Reap",
150
+ },
151
+ "TerminateFailed": {
152
+ "Type": "Task",
153
+ "Resource": TERMINATE,
154
+ "Arguments": {"MicrovmIdentifier": _jsonata("$parse($lease_error.Cause).microvm_id")},
155
+ "Retry": _retry_throttling(),
156
+ "Catch": [{"ErrorEquals": ["States.ALL"], "Next": "Reap"}],
157
+ "Next": "Reap",
158
+ },
137
159
  "Terminate": {
138
160
  "Type": "Task",
139
161
  "Resource": TERMINATE,
@@ -119,6 +119,15 @@ class LeasePolicy:
119
119
  def to_dict(self) -> dict:
120
120
  return asdict(self)
121
121
 
122
+ def heartbeat_every(self, requested: int | float | None = None) -> int:
123
+ """The interval the VM should heartbeat at: the requested seconds (default 30), never
124
+ more than a third of `heartbeat_timeout_s` and never under 5 s. A heartbeat interval at
125
+ or above the timeout loses the lease before the first heartbeat lands, because the
126
+ orchestrator's clock starts before RunMicrovm returns."""
127
+ want = 30 if requested is None else float(requested)
128
+ ceiling = max(5.0, self.heartbeat_timeout_s / 3.0)
129
+ return int(max(5.0, min(want, ceiling)))
130
+
122
131
  def idle_policy(self):
123
132
  """No auto-resume: a leased VM that goes idle is finished, not dormant."""
124
133
  from microvm.fleet import IdlePolicy
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: microvm-ctl
3
- Version: 0.3.0
3
+ Version: 0.3.1
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.3.0"
7
+ version = "0.3.1"
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"
@@ -23,7 +23,8 @@ def test_asl_is_jsonata_and_json_serialisable():
23
23
  json.dumps(asl) # no sets, no dataclasses
24
24
  assert asl["QueryLanguage"] == "JSONata"
25
25
  assert asl["StartAt"] == "Lease"
26
- assert set(asl["States"]) == {"Lease", "Terminate", "Reap", "TerminateStale", "Failed", "Done"}
26
+ assert set(asl["States"]) == {"Lease", "Terminate", "OnLeaseError", "TerminateFailed", "Reap",
27
+ "TerminateStale", "Failed", "Done"}
27
28
  assert asl["States"]["Done"] == {"Type": "Succeed"}
28
29
  assert asl["States"]["Failed"]["Type"] == "Fail"
29
30
  assert asl["States"]["Failed"]["Error"] == "LeaseFailed"
@@ -51,7 +52,16 @@ def test_lease_state_resources_and_arguments():
51
52
  assert lease["Retry"][0]["BackoffRate"] == 2
52
53
  catch = lease["Catch"][0]
53
54
  assert catch["ErrorEquals"] == ["States.Timeout", "States.HeartbeatTimeout", "States.TaskFailed"]
54
- assert catch["Next"] == "Reap"
55
+ assert catch["Next"] == "OnLeaseError"
56
+ asl = _asl()
57
+ choice = asl["States"]["OnLeaseError"]
58
+ assert choice["Type"] == "Choice" and choice["Default"] == "Reap"
59
+ assert choice["Choices"][0]["Next"] == "TerminateFailed"
60
+ assert "microvm_id" in choice["Choices"][0]["Condition"]
61
+ tf = asl["States"]["TerminateFailed"]
62
+ assert tf["Resource"] == asl["States"]["Terminate"]["Resource"]
63
+ assert "$parse($lease_error.Cause).microvm_id" in tf["Arguments"]["MicrovmIdentifier"]
64
+ assert tf["Next"] == "Reap" and tf["Catch"][0]["Next"] == "Reap"
55
65
  assert catch["Assign"] == {"lease_error": "{% $states.errorOutput %}"}
56
66
 
57
67
 
@@ -91,7 +91,7 @@ def test_success_carries_the_lease_and_terminates_once(fm):
91
91
  launch = fm.leases[0]
92
92
  lease = launch["lease"]
93
93
  assert lease.kind == "durable" and lease.token == cb and lease.region == "us-east-1"
94
- assert lease.heartbeat_s == 30 and lease.id
94
+ assert lease.heartbeat_s == 5 and lease.id # clamped: timeout 2 s -> floor of 5 s
95
95
  assert launch["image"] == "handoff-agent" and launch["task"] == TASK
96
96
  assert launch["policy"].budget_s == 4 and launch["policy"].max_duration() == 124
97
97
  assert launch["version"] == "3" and launch["execution_role"] == "arn:aws:iam::1:role/agent"
@@ -11,7 +11,8 @@ from microvm.lease import LeasePolicy
11
11
  IMAGE = "arn:aws:lambda:us-east-1:123456789012:microvm-image:handoff-agent"
12
12
  ROLE = "arn:aws:iam::123456789012:role/agent"
13
13
  TOPIC = "arn:aws:sns:us-east-1:1:t"
14
- LEASE_STATES = {"Lease", "Terminate", "Reap", "TerminateStale", "Failed", "Done"}
14
+ LEASE_STATES = {"Lease", "Terminate", "OnLeaseError", "TerminateFailed", "Reap", "TerminateStale", "Failed",
15
+ "Done"}
15
16
 
16
17
  def _indexed(expr: str) -> str:
17
18
  return "{% $map(" + expr + ", function($v, $i) { {'index': $i, 'task': $v} }) %}"
@@ -329,3 +329,24 @@ def test_package_exports():
329
329
 
330
330
  assert microvm.LeasePlan is LeasePlan and microvm.LeasePlanRejected is LeasePlanRejected
331
331
  assert microvm.FanoutLimit is FanoutLimit and microvm.plan_fanout is plan_fanout
332
+
333
+
334
+ def test_heartbeat_every_clamps_to_a_third_of_the_timeout():
335
+ from microvm.lease import LeasePolicy
336
+
337
+ assert LeasePolicy().heartbeat_every() == 30 # 120 s timeout: the default 30 s stands
338
+ assert LeasePolicy(heartbeat_timeout_s=30).heartbeat_every() == 10
339
+ assert LeasePolicy(heartbeat_timeout_s=30).heartbeat_every(30) == 10
340
+ assert LeasePolicy(heartbeat_timeout_s=30).heartbeat_every(4) == 5
341
+ assert LeasePolicy(heartbeat_timeout_s=9).heartbeat_every() == 5
342
+ assert LeasePolicy(heartbeat_timeout_s=600).heartbeat_every(45) == 45
343
+
344
+
345
+ def test_state_machine_and_durable_lease_use_the_clamped_interval():
346
+ from microvm.integrations.stepfunctions import lease_state_machine
347
+ from microvm.lease import LeasePolicy
348
+
349
+ asl = lease_state_machine(image_arn="arn:aws:lambda:us-east-1:1:microvm-image:x",
350
+ execution_role_arn="arn:aws:iam::1:role/r",
351
+ policy=LeasePolicy(heartbeat_timeout_s=30), region="us-east-1", heartbeat_s=30)
352
+ assert "'heartbeat_s': 10" in asl["States"]["Lease"]["Arguments"]["RunHookPayload"]
File without changes
File without changes
File without changes