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.
- jambu_gpu/__init__.py +5 -0
- jambu_gpu/__main__.py +4 -0
- jambu_gpu/cli/__init__.py +0 -0
- jambu_gpu/cli/_common.py +117 -0
- jambu_gpu/cli/inspect.py +207 -0
- jambu_gpu/cli/lifecycle.py +127 -0
- jambu_gpu/cli/main.py +103 -0
- jambu_gpu/cli/providers.py +96 -0
- jambu_gpu/cli/run.py +37 -0
- jambu_gpu/cli/setup.py +165 -0
- jambu_gpu/cli/status.py +214 -0
- jambu_gpu/cli/validate.py +57 -0
- jambu_gpu/core/__init__.py +0 -0
- jambu_gpu/core/beacon.py +124 -0
- jambu_gpu/core/config.py +596 -0
- jambu_gpu/core/credentials.py +75 -0
- jambu_gpu/core/durations.py +47 -0
- jambu_gpu/core/engine.py +951 -0
- jambu_gpu/core/errors.py +68 -0
- jambu_gpu/core/events.py +115 -0
- jambu_gpu/core/execution.py +169 -0
- jambu_gpu/core/health.py +62 -0
- jambu_gpu/core/model_inspect.py +368 -0
- jambu_gpu/core/models.py +283 -0
- jambu_gpu/core/policy.py +233 -0
- jambu_gpu/core/state.py +361 -0
- jambu_gpu/core/tool_parsers.py +63 -0
- jambu_gpu/providers/__init__.py +4 -0
- jambu_gpu/providers/base.py +125 -0
- jambu_gpu/providers/registry.py +63 -0
- jambu_gpu/providers/vast/__init__.py +3 -0
- jambu_gpu/providers/vast/client.py +230 -0
- jambu_gpu/providers/vast/config.py +51 -0
- jambu_gpu/providers/vast/mapper.py +221 -0
- jambu_gpu/providers/vast/provider.py +422 -0
- jambu_gpu/runtime/__init__.py +6 -0
- jambu_gpu/runtime/base.py +99 -0
- jambu_gpu/runtime/vllm.py +224 -0
- jambu_gpu/watchdog/__init__.py +3 -0
- jambu_gpu/watchdog/agent_body.py +547 -0
- jambu_gpu/watchdog/builder.py +178 -0
- jambu_gpu-0.1.0.dist-info/METADATA +82 -0
- jambu_gpu-0.1.0.dist-info/RECORD +46 -0
- jambu_gpu-0.1.0.dist-info/WHEEL +4 -0
- jambu_gpu-0.1.0.dist-info/entry_points.txt +2 -0
- 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())
|