jambu-gpu 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. jambu_gpu/__init__.py +5 -0
  2. jambu_gpu/__main__.py +4 -0
  3. jambu_gpu/cli/__init__.py +0 -0
  4. jambu_gpu/cli/_common.py +117 -0
  5. jambu_gpu/cli/inspect.py +207 -0
  6. jambu_gpu/cli/lifecycle.py +127 -0
  7. jambu_gpu/cli/main.py +103 -0
  8. jambu_gpu/cli/providers.py +96 -0
  9. jambu_gpu/cli/run.py +37 -0
  10. jambu_gpu/cli/setup.py +165 -0
  11. jambu_gpu/cli/status.py +214 -0
  12. jambu_gpu/cli/validate.py +57 -0
  13. jambu_gpu/core/__init__.py +0 -0
  14. jambu_gpu/core/beacon.py +124 -0
  15. jambu_gpu/core/config.py +596 -0
  16. jambu_gpu/core/credentials.py +75 -0
  17. jambu_gpu/core/durations.py +47 -0
  18. jambu_gpu/core/engine.py +951 -0
  19. jambu_gpu/core/errors.py +68 -0
  20. jambu_gpu/core/events.py +115 -0
  21. jambu_gpu/core/execution.py +169 -0
  22. jambu_gpu/core/health.py +62 -0
  23. jambu_gpu/core/model_inspect.py +368 -0
  24. jambu_gpu/core/models.py +283 -0
  25. jambu_gpu/core/policy.py +233 -0
  26. jambu_gpu/core/state.py +361 -0
  27. jambu_gpu/core/tool_parsers.py +63 -0
  28. jambu_gpu/providers/__init__.py +4 -0
  29. jambu_gpu/providers/base.py +125 -0
  30. jambu_gpu/providers/registry.py +63 -0
  31. jambu_gpu/providers/vast/__init__.py +3 -0
  32. jambu_gpu/providers/vast/client.py +230 -0
  33. jambu_gpu/providers/vast/config.py +51 -0
  34. jambu_gpu/providers/vast/mapper.py +221 -0
  35. jambu_gpu/providers/vast/provider.py +422 -0
  36. jambu_gpu/runtime/__init__.py +6 -0
  37. jambu_gpu/runtime/base.py +99 -0
  38. jambu_gpu/runtime/vllm.py +224 -0
  39. jambu_gpu/watchdog/__init__.py +3 -0
  40. jambu_gpu/watchdog/agent_body.py +547 -0
  41. jambu_gpu/watchdog/builder.py +178 -0
  42. jambu_gpu-0.1.0.dist-info/METADATA +82 -0
  43. jambu_gpu-0.1.0.dist-info/RECORD +46 -0
  44. jambu_gpu-0.1.0.dist-info/WHEEL +4 -0
  45. jambu_gpu-0.1.0.dist-info/entry_points.txt +2 -0
  46. jambu_gpu-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,547 @@
1
+ """Remote lifecycle watchdog (spec section 14).
2
+
3
+ This file is NOT imported at runtime by the CLI. Its source is concatenated
4
+ with ``core/policy.py`` and the provider-supplied action source into a single
5
+ stdlib-only script that runs *on the rented instance*, so lifecycle
6
+ enforcement survives the local CLI crashing, the laptop sleeping or the
7
+ network dying.
8
+
9
+ Contract with the CLI (all routes require ``X-Jambu-Token``):
10
+ GET /health -> liveness
11
+ GET /state -> snapshot + current policy decision
12
+ GET /events?limit=N -> structured events recorded on the instance
13
+ POST /activity -> generic activity mark
14
+ POST /ready -> model server finished starting
15
+ POST /workload/start -> {id, label}
16
+ POST /workload/heartbeat -> {id}
17
+ POST /workload/end -> {id, returncode, state}
18
+ POST /lock -> {ttl_seconds|until|null}
19
+ POST /unlock
20
+ POST /shutdown -> {action: stop|destroy, reason}
21
+ """
22
+
23
+ # The generated script inlines core/policy.py above this body. When this file
24
+ # is imported directly (tests, linting) we pull the same names from the package.
25
+ if "evaluate" not in globals(): # pragma: no cover - only in standalone import
26
+ from jambu_gpu.core.policy import ( # noqa: F401
27
+ ACTION_DESTROY,
28
+ ACTION_NONE,
29
+ ACTION_STOP,
30
+ Decision,
31
+ PolicyConfig,
32
+ Snapshot,
33
+ Workload,
34
+ derive_state,
35
+ evaluate,
36
+ )
37
+
38
+ import json
39
+ import os
40
+ import sys
41
+ import threading
42
+ import time
43
+ import traceback
44
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
45
+
46
+ CONFIG_PATH = os.environ.get("JAMBU_WATCHDOG_CONFIG", "/etc/jambu/watchdog.json")
47
+ STATE_PATH = os.environ.get("JAMBU_WATCHDOG_STATE", "/var/lib/jambu/activity.json")
48
+ EVENTS_PATH = os.environ.get("JAMBU_WATCHDOG_EVENTS", "/var/log/jambu/events.jsonl")
49
+
50
+ _LOCK = threading.RLock()
51
+ _STATE = {}
52
+ _CONFIG = {}
53
+ _STOPPED = threading.Event()
54
+
55
+
56
+ # --------------------------------------------------------------------------
57
+ # persistence
58
+ # --------------------------------------------------------------------------
59
+
60
+
61
+ def _atomic_write(path, payload):
62
+ directory = os.path.dirname(path) or "."
63
+ try:
64
+ os.makedirs(directory, exist_ok=True)
65
+ except OSError:
66
+ pass
67
+ tmp = path + ".tmp"
68
+ with open(tmp, "w") as handle:
69
+ handle.write(payload)
70
+ handle.flush()
71
+ os.fsync(handle.fileno())
72
+ os.replace(tmp, path)
73
+
74
+
75
+ def log_event(event, **fields):
76
+ record = {
77
+ "ts": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
78
+ "event": event,
79
+ "source": "watchdog",
80
+ "provider": _CONFIG.get("provider"),
81
+ "instance_id": (_STATE or {}).get("instance_id") or _CONFIG.get("instance_id"),
82
+ }
83
+ record.update(fields)
84
+ line = json.dumps(record, default=str)
85
+ try:
86
+ os.makedirs(os.path.dirname(EVENTS_PATH) or ".", exist_ok=True)
87
+ with open(EVENTS_PATH, "a") as handle:
88
+ handle.write(line + "\n")
89
+ except OSError:
90
+ pass
91
+ print("[jambu-watchdog] " + line, flush=True)
92
+ return record
93
+
94
+
95
+ def load_state():
96
+ now = time.time()
97
+ default = {
98
+ "created_at": float(_CONFIG.get("created_at") or now),
99
+ "last_activity": now,
100
+ "starting": True,
101
+ "locked": False,
102
+ "locked_until": None,
103
+ "workloads": {},
104
+ "terminated": False,
105
+ }
106
+ try:
107
+ with open(STATE_PATH) as handle:
108
+ stored = json.load(handle)
109
+ default.update(stored)
110
+ except (OSError, ValueError):
111
+ pass
112
+ return default
113
+
114
+
115
+ def save_state():
116
+ try:
117
+ _atomic_write(STATE_PATH, json.dumps(_STATE, indent=2, default=str))
118
+ except OSError:
119
+ pass
120
+
121
+
122
+ def touch(reason="activity"):
123
+ with _LOCK:
124
+ _STATE["last_activity"] = time.time()
125
+ save_state()
126
+ return reason
127
+
128
+
129
+ # --------------------------------------------------------------------------
130
+ # policy wiring
131
+ # --------------------------------------------------------------------------
132
+
133
+
134
+ def policy_config():
135
+ merged = dict(_CONFIG.get("policy") or {})
136
+ merged.update(_STATE.get("policy_overrides") or {})
137
+ return PolicyConfig.from_dict(merged)
138
+
139
+
140
+ def snapshot(now=None):
141
+ now = now if now is not None else time.time()
142
+ with _LOCK:
143
+ workloads = [
144
+ Workload(
145
+ id=key,
146
+ started_at=float(value.get("started_at") or now),
147
+ last_heartbeat=float(value.get("last_heartbeat") or now),
148
+ label=value.get("label", ""),
149
+ state=value.get("state", "running"),
150
+ )
151
+ for key, value in (_STATE.get("workloads") or {}).items()
152
+ ]
153
+ starting = bool(_STATE.get("starting"))
154
+ if starting:
155
+ grace = float(_CONFIG.get("startup_grace_s") or 900.0)
156
+ if now - float(_STATE.get("created_at") or now) > grace:
157
+ # The CLI never confirmed readiness; stop pretending we booted.
158
+ starting = False
159
+ _STATE["starting"] = False
160
+ log_event("runtime_startup_grace_expired", grace_seconds=grace)
161
+ save_state()
162
+ return Snapshot(
163
+ now=now,
164
+ created_at=float(_STATE.get("created_at") or now),
165
+ last_activity=float(_STATE.get("last_activity") or now),
166
+ workloads=workloads,
167
+ starting=starting,
168
+ locked=bool(_STATE.get("locked")),
169
+ locked_until=_STATE.get("locked_until"),
170
+ provider_state="ready",
171
+ )
172
+
173
+
174
+ def build_context():
175
+ ctx = dict(_CONFIG.get("provider_context") or {})
176
+ ctx["instance_id"] = _STATE.get("instance_id") or _CONFIG.get("instance_id")
177
+ return ctx
178
+
179
+
180
+ def terminate(action, reason, detail=None):
181
+ detail = detail or {}
182
+ with _LOCK:
183
+ if _STATE.get("terminated"):
184
+ return False
185
+ _STATE["terminated"] = True
186
+ save_state()
187
+
188
+ log_event(
189
+ "lifecycle_guard_triggered",
190
+ action=action,
191
+ reason=reason,
192
+ **detail,
193
+ )
194
+ ctx = build_context()
195
+ try:
196
+ if action == ACTION_DESTROY:
197
+ provider_destroy(ctx) # noqa: F821 - injected by the builder
198
+ log_event("instance_destroyed", reason=reason, **detail)
199
+ else:
200
+ provider_stop(ctx) # noqa: F821 - injected by the builder
201
+ log_event("instance_stopped", reason=reason, **detail)
202
+ except Exception as exc: # noqa: BLE001 - last line of defence
203
+ log_event(
204
+ "provider_error",
205
+ action=action,
206
+ reason=reason,
207
+ error=str(exc),
208
+ traceback=traceback.format_exc(limit=3),
209
+ )
210
+ with _LOCK:
211
+ _STATE["terminated"] = False
212
+ save_state()
213
+ return False
214
+ return True
215
+
216
+
217
+ def guard_loop():
218
+ interval = float(_CONFIG.get("interval_s") or 60.0)
219
+ cfg = policy_config()
220
+ log_event(
221
+ "watchdog_started",
222
+ interval_seconds=interval,
223
+ idle_timeout_seconds=cfg.idle_timeout_s,
224
+ max_lifetime_seconds=cfg.max_lifetime_s,
225
+ auto_stop=cfg.auto_stop,
226
+ auto_destroy=cfg.auto_destroy,
227
+ )
228
+ idle_announced = False
229
+ while not _STOPPED.wait(interval):
230
+ try:
231
+ cfg = policy_config()
232
+ snap = snapshot()
233
+
234
+ with _LOCK:
235
+ if _STATE.get("locked") and snap.lock_expired(cfg):
236
+ _STATE["locked"] = False
237
+ _STATE["locked_until"] = None
238
+ save_state()
239
+ log_event("instance_unlocked", reason="lock_expired")
240
+
241
+ decision = evaluate(snap, cfg)
242
+ state = decision.detail.get("state")
243
+ if state == "idle" and not idle_announced:
244
+ idle_announced = True
245
+ log_event("instance_idle", **decision.detail)
246
+ elif state != "idle":
247
+ idle_announced = False
248
+
249
+ if decision.terminates:
250
+ terminate(decision.action, decision.reason, decision.detail)
251
+ _STOPPED.set()
252
+ except Exception as exc: # noqa: BLE001 - never let the guard die
253
+ log_event("watchdog_error", error=str(exc), traceback=traceback.format_exc(limit=3))
254
+
255
+
256
+ # --------------------------------------------------------------------------
257
+ # HTTP control surface
258
+ # --------------------------------------------------------------------------
259
+
260
+
261
+ class Handler(BaseHTTPRequestHandler):
262
+ server_version = "jambu-watchdog/1.0"
263
+
264
+ def log_message(self, fmt, *args): # noqa: A003 - silence access logs
265
+ return
266
+
267
+ # -- plumbing -------------------------------------------------------
268
+
269
+ def _authorized(self):
270
+ expected = _CONFIG.get("token")
271
+ if not expected:
272
+ return True
273
+ supplied = self.headers.get("X-Jambu-Token") or ""
274
+ if supplied != expected:
275
+ self._reply(401, {"error": "unauthorized"})
276
+ return False
277
+ return True
278
+
279
+ def _reply(self, code, payload):
280
+ body = json.dumps(payload, default=str).encode()
281
+ self.send_response(code)
282
+ self.send_header("Content-Type", "application/json")
283
+ self.send_header("Content-Length", str(len(body)))
284
+ self.end_headers()
285
+ try:
286
+ self.wfile.write(body)
287
+ except OSError:
288
+ pass
289
+
290
+ def _payload(self):
291
+ try:
292
+ length = int(self.headers.get("Content-Length") or 0)
293
+ except ValueError:
294
+ length = 0
295
+ if not length:
296
+ return {}
297
+ try:
298
+ return json.loads(self.rfile.read(length) or b"{}")
299
+ except ValueError:
300
+ return {}
301
+
302
+ # -- routes ---------------------------------------------------------
303
+
304
+ def do_GET(self): # noqa: N802
305
+ path = self.path.split("?")[0].rstrip("/") or "/"
306
+ if path == "/health":
307
+ self._reply(200, {"ok": True, "service": "jambu-watchdog"})
308
+ return
309
+ if not self._authorized():
310
+ return
311
+ if path == "/state":
312
+ self._reply(200, state_payload())
313
+ return
314
+ if path == "/events":
315
+ limit = 100
316
+ if "limit=" in self.path:
317
+ try:
318
+ limit = int(self.path.split("limit=")[1].split("&")[0])
319
+ except ValueError:
320
+ limit = 100
321
+ self._reply(200, {"events": read_events(limit)})
322
+ return
323
+ self._reply(404, {"error": "not found"})
324
+
325
+ def do_POST(self): # noqa: N802
326
+ path = self.path.split("?")[0].rstrip("/") or "/"
327
+ if not self._authorized():
328
+ return
329
+ body = self._payload()
330
+ try:
331
+ self._reply(*dispatch(path, body))
332
+ except Exception as exc: # noqa: BLE001
333
+ log_event("watchdog_error", route=path, error=str(exc))
334
+ self._reply(500, {"error": str(exc)})
335
+
336
+
337
+ def state_payload():
338
+ cfg = policy_config()
339
+ snap = snapshot()
340
+ decision = evaluate(snap, cfg)
341
+ with _LOCK:
342
+ workloads = json.loads(json.dumps(_STATE.get("workloads") or {}, default=str))
343
+ return {
344
+ "instance_id": _STATE.get("instance_id") or _CONFIG.get("instance_id"),
345
+ "provider": _CONFIG.get("provider"),
346
+ "created_at": _STATE.get("created_at"),
347
+ "last_activity": _STATE.get("last_activity"),
348
+ "starting": snap.starting,
349
+ "locked": snap.lock_active(cfg),
350
+ "locked_until": _STATE.get("locked_until"),
351
+ "terminated": bool(_STATE.get("terminated")),
352
+ "state": derive_state(snap, cfg),
353
+ "idle_seconds": round(snap.idle_seconds(cfg), 1),
354
+ "age_seconds": round(snap.age_seconds(), 1),
355
+ "workloads": workloads,
356
+ "decision": decision.to_dict(),
357
+ "policy": cfg.to_dict(),
358
+ "now": time.time(),
359
+ }
360
+
361
+
362
+ def read_events(limit=100):
363
+ try:
364
+ with open(EVENTS_PATH) as handle:
365
+ lines = handle.read().splitlines()
366
+ except OSError:
367
+ return []
368
+ out = []
369
+ for line in lines[-limit:]:
370
+ try:
371
+ out.append(json.loads(line))
372
+ except ValueError:
373
+ continue
374
+ return out
375
+
376
+
377
+ def dispatch(path, body):
378
+ now = time.time()
379
+
380
+ if path == "/identify":
381
+ # The instance id only exists after creation, and the onstart script is
382
+ # baked before that; the CLI tells the watchdog who it is once it can.
383
+ with _LOCK:
384
+ if body.get("instance_id"):
385
+ _STATE["instance_id"] = str(body["instance_id"])
386
+ overrides = _STATE.setdefault("policy_overrides", {})
387
+ if body.get("hourly_cost_usd") is not None:
388
+ overrides["hourly_cost_usd"] = float(body["hourly_cost_usd"])
389
+ if body.get("policy"):
390
+ overrides.update(dict(body["policy"]))
391
+ _STATE["last_activity"] = now
392
+ save_state()
393
+ log_event("watchdog_identified", instance_id=_STATE.get("instance_id"))
394
+ return 200, {"ok": True, "instance_id": _STATE.get("instance_id")}
395
+
396
+ if path == "/activity":
397
+ touch()
398
+ return 200, {"ok": True, "last_activity": _STATE["last_activity"]}
399
+
400
+ if path == "/ready":
401
+ with _LOCK:
402
+ _STATE["starting"] = False
403
+ _STATE["last_activity"] = now
404
+ save_state()
405
+ log_event("runtime_ready")
406
+ return 200, {"ok": True}
407
+
408
+ if path == "/starting":
409
+ with _LOCK:
410
+ _STATE["starting"] = True
411
+ _STATE["created_at"] = _STATE.get("created_at") or now
412
+ _STATE["last_activity"] = now
413
+ save_state()
414
+ log_event("runtime_starting")
415
+ return 200, {"ok": True}
416
+
417
+ if path == "/workload/start":
418
+ workload_id = str(body.get("id") or "w-%d" % int(now * 1000))
419
+ with _LOCK:
420
+ _STATE.setdefault("workloads", {})[workload_id] = {
421
+ "started_at": now,
422
+ "last_heartbeat": now,
423
+ "label": body.get("label", ""),
424
+ "state": body.get("state", "running"),
425
+ }
426
+ _STATE["last_activity"] = now
427
+ _STATE["starting"] = False
428
+ save_state()
429
+ log_event("workload_started", workload_id=workload_id, label=body.get("label", ""))
430
+ return 200, {"ok": True, "id": workload_id}
431
+
432
+ if path == "/workload/heartbeat":
433
+ workload_id = body.get("id")
434
+ with _LOCK:
435
+ record = (_STATE.get("workloads") or {}).get(workload_id)
436
+ if record is None and workload_id:
437
+ record = {
438
+ "started_at": now,
439
+ "label": body.get("label", ""),
440
+ "state": "running",
441
+ }
442
+ _STATE.setdefault("workloads", {})[workload_id] = record
443
+ if record is not None:
444
+ record["last_heartbeat"] = now
445
+ _STATE["last_activity"] = now
446
+ save_state()
447
+ return 200, {"ok": True, "id": workload_id}
448
+
449
+ if path == "/workload/end":
450
+ workload_id = body.get("id")
451
+ with _LOCK:
452
+ record = (_STATE.get("workloads") or {}).get(workload_id)
453
+ if record is not None:
454
+ record["state"] = body.get("state") or (
455
+ "finished" if not body.get("returncode") else "failed"
456
+ )
457
+ record["finished_at"] = now
458
+ record["returncode"] = body.get("returncode")
459
+ _STATE["last_activity"] = now
460
+ save_state()
461
+ log_event(
462
+ "workload_finished",
463
+ workload_id=workload_id,
464
+ returncode=body.get("returncode"),
465
+ )
466
+ return 200, {"ok": True}
467
+
468
+ if path == "/lock":
469
+ ttl = body.get("ttl_seconds")
470
+ until = body.get("until")
471
+ cfg = policy_config()
472
+ if until is None and ttl:
473
+ until = now + float(ttl)
474
+ if until is None and not cfg.allow_indefinite_lock:
475
+ return 400, {
476
+ "error": "indefinite locks are disabled "
477
+ "(set lifecycle.allow_indefinite_lock: true to permit them)"
478
+ }
479
+ with _LOCK:
480
+ _STATE["locked"] = True
481
+ _STATE["locked_until"] = float(until) if until is not None else None
482
+ _STATE["last_activity"] = now
483
+ save_state()
484
+ log_event("instance_locked", locked_until=until, reason=body.get("reason", ""))
485
+ return 200, {"ok": True, "locked_until": until}
486
+
487
+ if path == "/unlock":
488
+ with _LOCK:
489
+ _STATE["locked"] = False
490
+ _STATE["locked_until"] = None
491
+ _STATE["last_activity"] = now
492
+ save_state()
493
+ log_event("instance_unlocked", reason=body.get("reason", "manual"))
494
+ return 200, {"ok": True}
495
+
496
+ if path == "/shutdown":
497
+ action = body.get("action") or ACTION_STOP
498
+ reason = body.get("reason") or "manual"
499
+ threading.Thread(
500
+ target=terminate, args=(action, reason, {"requested_by": "cli"}), daemon=True
501
+ ).start()
502
+ return 202, {"ok": True, "action": action}
503
+
504
+ return 404, {"error": "not found"}
505
+
506
+
507
+ # --------------------------------------------------------------------------
508
+ # entrypoint
509
+ # --------------------------------------------------------------------------
510
+
511
+
512
+ def main():
513
+ global _CONFIG, _STATE
514
+
515
+ try:
516
+ with open(CONFIG_PATH) as handle:
517
+ _CONFIG = json.load(handle)
518
+ except (OSError, ValueError) as exc:
519
+ print("[jambu-watchdog] cannot read %s: %s" % (CONFIG_PATH, exc), file=sys.stderr)
520
+ return 1
521
+
522
+ for key, value in (_CONFIG.get("env") or {}).items():
523
+ os.environ.setdefault(str(key), str(value))
524
+
525
+ _STATE = load_state()
526
+ save_state()
527
+
528
+ port = int(_CONFIG.get("port") or 8777)
529
+ server = ThreadingHTTPServer(("0.0.0.0", port), Handler)
530
+ server.daemon_threads = True
531
+
532
+ guard = threading.Thread(target=guard_loop, daemon=True)
533
+ guard.start()
534
+
535
+ log_event("watchdog_listening", port=port)
536
+ try:
537
+ server.serve_forever(poll_interval=1.0)
538
+ except KeyboardInterrupt: # pragma: no cover
539
+ pass
540
+ finally:
541
+ _STOPPED.set()
542
+ server.server_close()
543
+ return 0
544
+
545
+
546
+ if __name__ == "__main__":
547
+ sys.exit(main())