gpu4agents 0.5.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.
- gpu4agents/__init__.py +5 -0
- gpu4agents/__main__.py +4 -0
- gpu4agents/agent/__init__.py +1 -0
- gpu4agents/agent/install.py +173 -0
- gpu4agents/agent/jobs.py +193 -0
- gpu4agents/agent/mcp.py +646 -0
- gpu4agents/agent/skill/SKILL.md +68 -0
- gpu4agents/cli/__init__.py +0 -0
- gpu4agents/cli/_common.py +131 -0
- gpu4agents/cli/account.py +330 -0
- gpu4agents/cli/agent.py +74 -0
- gpu4agents/cli/inspect.py +207 -0
- gpu4agents/cli/lifecycle.py +127 -0
- gpu4agents/cli/main.py +163 -0
- gpu4agents/cli/providers.py +96 -0
- gpu4agents/cli/run.py +38 -0
- gpu4agents/cli/setup.py +164 -0
- gpu4agents/cli/status.py +224 -0
- gpu4agents/cli/timer.py +143 -0
- gpu4agents/cli/validate.py +57 -0
- gpu4agents/core/__init__.py +0 -0
- gpu4agents/core/beacon.py +133 -0
- gpu4agents/core/config.py +647 -0
- gpu4agents/core/credentials.py +95 -0
- gpu4agents/core/durations.py +47 -0
- gpu4agents/core/engine.py +1335 -0
- gpu4agents/core/errors.py +94 -0
- gpu4agents/core/events.py +117 -0
- gpu4agents/core/execution.py +169 -0
- gpu4agents/core/health.py +62 -0
- gpu4agents/core/hosts.py +58 -0
- gpu4agents/core/model_inspect.py +368 -0
- gpu4agents/core/models.py +313 -0
- gpu4agents/core/paths.py +42 -0
- gpu4agents/core/policy.py +264 -0
- gpu4agents/core/state.py +406 -0
- gpu4agents/core/sweep.py +478 -0
- gpu4agents/core/tool_parsers.py +63 -0
- gpu4agents/providers/__init__.py +4 -0
- gpu4agents/providers/base.py +137 -0
- gpu4agents/providers/registry.py +63 -0
- gpu4agents/providers/vast/__init__.py +3 -0
- gpu4agents/providers/vast/client.py +255 -0
- gpu4agents/providers/vast/config.py +56 -0
- gpu4agents/providers/vast/mapper.py +292 -0
- gpu4agents/providers/vast/provider.py +438 -0
- gpu4agents/runtime/__init__.py +6 -0
- gpu4agents/runtime/base.py +184 -0
- gpu4agents/runtime/vllm.py +295 -0
- gpu4agents/watchdog/__init__.py +3 -0
- gpu4agents/watchdog/agent_body.py +959 -0
- gpu4agents/watchdog/builder.py +248 -0
- gpu4agents-0.5.0.dist-info/METADATA +157 -0
- gpu4agents-0.5.0.dist-info/RECORD +59 -0
- gpu4agents-0.5.0.dist-info/WHEEL +4 -0
- gpu4agents-0.5.0.dist-info/entry_points.txt +2 -0
- gpu4agents-0.5.0.dist-info/licenses/LICENSE +21 -0
- jambu_gpu/__init__.py +9 -0
- jambu_gpu/__main__.py +4 -0
|
@@ -0,0 +1,959 @@
|
|
|
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
|
+
GET /logs?name=X&lines=N -> tail of a configured log (runtime|onstart|watchdog)
|
|
14
|
+
POST /activity -> generic activity mark
|
|
15
|
+
POST /ready -> model server finished starting
|
|
16
|
+
POST /workload/start -> {id, label}
|
|
17
|
+
POST /workload/heartbeat -> {id}
|
|
18
|
+
POST /workload/end -> {id, returncode, state}
|
|
19
|
+
POST /lock -> {ttl_seconds|until|null}
|
|
20
|
+
POST /unlock
|
|
21
|
+
POST /shutdown -> {action: stop|destroy, reason}
|
|
22
|
+
|
|
23
|
+
Sessions: the boot script exports ``JAMBU_SESSION_STARTED_AT`` once per
|
|
24
|
+
container start. A value different from the stored one means the instance was
|
|
25
|
+
restarted, so the persisted "terminated" flag and stale workloads are reset and
|
|
26
|
+
the guard starts over. The same value means a supervisor respawn: keep state.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
# The generated script inlines core/policy.py above this body. When this file
|
|
30
|
+
# is imported directly (tests, linting) we pull the same names from the package.
|
|
31
|
+
if "evaluate" not in globals(): # pragma: no cover - only in standalone import
|
|
32
|
+
from gpu4agents.core.policy import ( # noqa: F401
|
|
33
|
+
ACTION_DESTROY,
|
|
34
|
+
ACTION_NONE,
|
|
35
|
+
ACTION_STOP,
|
|
36
|
+
Decision,
|
|
37
|
+
PolicyConfig,
|
|
38
|
+
Snapshot,
|
|
39
|
+
Workload,
|
|
40
|
+
derive_state,
|
|
41
|
+
evaluate,
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
import hmac
|
|
45
|
+
import json
|
|
46
|
+
import math
|
|
47
|
+
import os
|
|
48
|
+
import sys
|
|
49
|
+
import threading
|
|
50
|
+
import time
|
|
51
|
+
import traceback
|
|
52
|
+
import urllib.request
|
|
53
|
+
from urllib.parse import parse_qs
|
|
54
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
55
|
+
|
|
56
|
+
CONFIG_PATH = os.environ.get("JAMBU_WATCHDOG_CONFIG", "/etc/jambu/watchdog.json")
|
|
57
|
+
STATE_PATH = os.environ.get("JAMBU_WATCHDOG_STATE", "/var/lib/jambu/activity.json")
|
|
58
|
+
EVENTS_PATH = os.environ.get("JAMBU_WATCHDOG_EVENTS", "/var/log/jambu/events.jsonl")
|
|
59
|
+
|
|
60
|
+
_LOCK = threading.RLock()
|
|
61
|
+
_STATE = {}
|
|
62
|
+
_CONFIG = {}
|
|
63
|
+
_STOPPED = threading.Event()
|
|
64
|
+
# Serializes provider calls; the guard loop and /shutdown may race.
|
|
65
|
+
_ACTION_LOCK = threading.Lock()
|
|
66
|
+
_PROBE = {}
|
|
67
|
+
|
|
68
|
+
# Seconds of continuously failing stops before giving up on the disk and
|
|
69
|
+
# destroying (config key escalate_after_s). Long enough to ride out a provider
|
|
70
|
+
# API outage: losing the disk to a 3-minute blip is worse than 30 more minutes.
|
|
71
|
+
ESCALATE_AFTER_S = 1800.0
|
|
72
|
+
PROBE_ERROR_LOG_EVERY_S = 600.0
|
|
73
|
+
LOG_LINES_MAX = 2000
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
# --------------------------------------------------------------------------
|
|
77
|
+
# persistence
|
|
78
|
+
# --------------------------------------------------------------------------
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _atomic_write(path, payload):
|
|
82
|
+
directory = os.path.dirname(path) or "."
|
|
83
|
+
try:
|
|
84
|
+
os.makedirs(directory, exist_ok=True)
|
|
85
|
+
except OSError:
|
|
86
|
+
pass
|
|
87
|
+
tmp = path + ".tmp"
|
|
88
|
+
with open(tmp, "w") as handle:
|
|
89
|
+
handle.write(payload)
|
|
90
|
+
handle.flush()
|
|
91
|
+
os.fsync(handle.fileno())
|
|
92
|
+
os.replace(tmp, path)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def log_event(event, **fields):
|
|
96
|
+
record = {
|
|
97
|
+
"ts": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
|
98
|
+
"event": event,
|
|
99
|
+
"source": "watchdog",
|
|
100
|
+
"provider": _CONFIG.get("provider"),
|
|
101
|
+
"instance_id": (_STATE or {}).get("instance_id") or _CONFIG.get("instance_id"),
|
|
102
|
+
}
|
|
103
|
+
record.update(fields)
|
|
104
|
+
line = json.dumps(record, default=str)
|
|
105
|
+
try:
|
|
106
|
+
os.makedirs(os.path.dirname(EVENTS_PATH) or ".", exist_ok=True)
|
|
107
|
+
with open(EVENTS_PATH, "a") as handle:
|
|
108
|
+
handle.write(line + "\n")
|
|
109
|
+
except OSError:
|
|
110
|
+
pass
|
|
111
|
+
print("[jambu-watchdog] " + line, flush=True)
|
|
112
|
+
return record
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _session_from_env():
|
|
116
|
+
raw = os.environ.get("JAMBU_SESSION_STARTED_AT")
|
|
117
|
+
if not raw:
|
|
118
|
+
return None
|
|
119
|
+
try:
|
|
120
|
+
return float(raw)
|
|
121
|
+
except ValueError:
|
|
122
|
+
return None
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def load_state():
|
|
126
|
+
now = time.time()
|
|
127
|
+
default = {
|
|
128
|
+
"created_at": float(_CONFIG.get("created_at") or now),
|
|
129
|
+
"last_activity": now,
|
|
130
|
+
"starting": True,
|
|
131
|
+
"locked": False,
|
|
132
|
+
"locked_until": None,
|
|
133
|
+
"workloads": {},
|
|
134
|
+
"terminated": False,
|
|
135
|
+
"terminated_at": None,
|
|
136
|
+
"stop_failures": 0,
|
|
137
|
+
"action_failures": 0,
|
|
138
|
+
"pending_shutdown": None,
|
|
139
|
+
"session_started_at": None,
|
|
140
|
+
}
|
|
141
|
+
stored = None
|
|
142
|
+
try:
|
|
143
|
+
with open(STATE_PATH) as handle:
|
|
144
|
+
stored = json.load(handle)
|
|
145
|
+
default.update(stored)
|
|
146
|
+
except (OSError, ValueError):
|
|
147
|
+
stored = None
|
|
148
|
+
|
|
149
|
+
session = _session_from_env()
|
|
150
|
+
if stored is None:
|
|
151
|
+
default["session_started_at"] = session if session is not None else now
|
|
152
|
+
log_event("session_started", session_started_at=default["session_started_at"], first_boot=True)
|
|
153
|
+
return default
|
|
154
|
+
if session is None:
|
|
155
|
+
# Old boot scripts and tests: no session marker, behave as before.
|
|
156
|
+
if default.get("session_started_at") is None:
|
|
157
|
+
default["session_started_at"] = default["created_at"]
|
|
158
|
+
return default
|
|
159
|
+
if default.get("session_started_at") == session:
|
|
160
|
+
return default # supervisor respawn inside the same container start
|
|
161
|
+
return _new_session(default, session, now)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _new_session(state, session, now):
|
|
165
|
+
"""The container was started again (e.g. `gpu` restarted a stopped instance).
|
|
166
|
+
|
|
167
|
+
Container disk survives a stop, so the previous session's "terminated" flag
|
|
168
|
+
would otherwise disarm the guard on the restarted, billing machine.
|
|
169
|
+
"""
|
|
170
|
+
abandoned = []
|
|
171
|
+
for key, record in (state.get("workloads") or {}).items():
|
|
172
|
+
if record.get("state") in ("running", "queued"):
|
|
173
|
+
record["state"] = "failed"
|
|
174
|
+
record["finished_at"] = now
|
|
175
|
+
record["failure"] = "session_restarted"
|
|
176
|
+
abandoned.append(key)
|
|
177
|
+
previous = state.get("session_started_at")
|
|
178
|
+
state.update(
|
|
179
|
+
{
|
|
180
|
+
"session_started_at": session,
|
|
181
|
+
"terminated": False,
|
|
182
|
+
"terminated_at": None,
|
|
183
|
+
"starting": True,
|
|
184
|
+
"last_activity": now,
|
|
185
|
+
"stop_failures": 0,
|
|
186
|
+
"stop_failing_since": None,
|
|
187
|
+
"action_failures": 0,
|
|
188
|
+
"pending_shutdown": None,
|
|
189
|
+
}
|
|
190
|
+
)
|
|
191
|
+
log_event(
|
|
192
|
+
"session_started",
|
|
193
|
+
session_started_at=session,
|
|
194
|
+
previous_session_started_at=previous,
|
|
195
|
+
abandoned_workloads=abandoned,
|
|
196
|
+
)
|
|
197
|
+
return state
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def save_state():
|
|
201
|
+
try:
|
|
202
|
+
_atomic_write(STATE_PATH, json.dumps(_STATE, indent=2, default=str))
|
|
203
|
+
except OSError:
|
|
204
|
+
pass
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def touch(reason="activity"):
|
|
208
|
+
with _LOCK:
|
|
209
|
+
_STATE["last_activity"] = time.time()
|
|
210
|
+
save_state()
|
|
211
|
+
return reason
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
# --------------------------------------------------------------------------
|
|
215
|
+
# policy wiring
|
|
216
|
+
# --------------------------------------------------------------------------
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def policy_config():
|
|
220
|
+
merged = dict(_CONFIG.get("policy") or {})
|
|
221
|
+
merged.update(_STATE.get("policy_overrides") or {})
|
|
222
|
+
return PolicyConfig.from_dict(merged)
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def snapshot(now=None):
|
|
226
|
+
now = now if now is not None else time.time()
|
|
227
|
+
with _LOCK:
|
|
228
|
+
workloads = [
|
|
229
|
+
Workload(
|
|
230
|
+
id=key,
|
|
231
|
+
started_at=float(value.get("started_at") or now),
|
|
232
|
+
last_heartbeat=float(value.get("last_heartbeat") or now),
|
|
233
|
+
label=value.get("label", ""),
|
|
234
|
+
state=value.get("state", "running"),
|
|
235
|
+
)
|
|
236
|
+
for key, value in (_STATE.get("workloads") or {}).items()
|
|
237
|
+
]
|
|
238
|
+
starting = bool(_STATE.get("starting"))
|
|
239
|
+
if starting and runtime_alive() is False:
|
|
240
|
+
# A dead model server must not hold the startup grace open.
|
|
241
|
+
starting = False
|
|
242
|
+
_STATE["starting"] = False
|
|
243
|
+
log_event("runtime_exited", pid_path=_CONFIG.get("runtime_pid_path"))
|
|
244
|
+
save_state()
|
|
245
|
+
if starting:
|
|
246
|
+
grace = float(_CONFIG.get("startup_grace_s") or 900.0)
|
|
247
|
+
began = _STATE.get("session_started_at") or _STATE.get("created_at") or now
|
|
248
|
+
if now - float(began) > grace:
|
|
249
|
+
# The CLI never confirmed readiness; stop pretending we booted.
|
|
250
|
+
starting = False
|
|
251
|
+
_STATE["starting"] = False
|
|
252
|
+
log_event("runtime_startup_grace_expired", grace_seconds=grace)
|
|
253
|
+
save_state()
|
|
254
|
+
return Snapshot(
|
|
255
|
+
now=now,
|
|
256
|
+
created_at=float(_STATE.get("created_at") or now),
|
|
257
|
+
last_activity=float(_STATE.get("last_activity") or now),
|
|
258
|
+
workloads=workloads,
|
|
259
|
+
starting=starting,
|
|
260
|
+
locked=bool(_STATE.get("locked")),
|
|
261
|
+
locked_until=_STATE.get("locked_until"),
|
|
262
|
+
provider_state="ready",
|
|
263
|
+
session_started_at=_STATE.get("session_started_at"),
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def runtime_alive():
|
|
268
|
+
"""True/False for the model server process; None until it wrote a pid file.
|
|
269
|
+
|
|
270
|
+
A pid file older than this container start is a previous session's.
|
|
271
|
+
"""
|
|
272
|
+
path = _CONFIG.get("runtime_pid_path")
|
|
273
|
+
if not path:
|
|
274
|
+
return None
|
|
275
|
+
try:
|
|
276
|
+
if os.stat(path).st_mtime < float(_STATE.get("session_started_at") or 0) - 5:
|
|
277
|
+
return None
|
|
278
|
+
with open(path) as handle:
|
|
279
|
+
pid = int(handle.read().strip())
|
|
280
|
+
except (OSError, ValueError):
|
|
281
|
+
return None
|
|
282
|
+
try:
|
|
283
|
+
os.kill(pid, 0)
|
|
284
|
+
except ProcessLookupError:
|
|
285
|
+
return False
|
|
286
|
+
except OSError:
|
|
287
|
+
return True
|
|
288
|
+
try:
|
|
289
|
+
with open("/proc/%d/stat" % pid) as handle:
|
|
290
|
+
return handle.read().rsplit(")", 1)[1].split()[0] not in ("Z", "X")
|
|
291
|
+
except (OSError, IndexError):
|
|
292
|
+
return True
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def tail_lines(path, count):
|
|
296
|
+
"""The last ``count`` lines of ``path``, reading backwards from its end."""
|
|
297
|
+
try:
|
|
298
|
+
with open(path, "rb") as handle:
|
|
299
|
+
pos = handle.seek(0, 2)
|
|
300
|
+
data = b""
|
|
301
|
+
while pos > 0 and data.count(b"\n") <= count:
|
|
302
|
+
step = min(65536, pos)
|
|
303
|
+
pos -= step
|
|
304
|
+
handle.seek(pos)
|
|
305
|
+
data = handle.read(step) + data
|
|
306
|
+
except OSError:
|
|
307
|
+
return []
|
|
308
|
+
return data.decode("utf-8", "replace").splitlines()[-count:] if count > 0 else []
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def build_context():
|
|
312
|
+
ctx = dict(_CONFIG.get("provider_context") or {})
|
|
313
|
+
ctx["instance_id"] = _STATE.get("instance_id") or _CONFIG.get("instance_id")
|
|
314
|
+
return ctx
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def terminate(action, reason, detail=None, force=False):
|
|
318
|
+
"""Ask the provider to stop or destroy this machine.
|
|
319
|
+
|
|
320
|
+
Returns True on success, False on a provider failure and None when skipped
|
|
321
|
+
(another call in flight, or already terminated and not ``force``). A
|
|
322
|
+
failure never disarms the guard: the loop retries on its next tick.
|
|
323
|
+
"""
|
|
324
|
+
detail = detail or {}
|
|
325
|
+
if not _ACTION_LOCK.acquire(blocking=False):
|
|
326
|
+
return None
|
|
327
|
+
try:
|
|
328
|
+
if _STATE.get("terminated") and not force:
|
|
329
|
+
return None
|
|
330
|
+
log_event(
|
|
331
|
+
"lifecycle_guard_triggered",
|
|
332
|
+
action=action,
|
|
333
|
+
reason=reason,
|
|
334
|
+
**detail,
|
|
335
|
+
)
|
|
336
|
+
ctx = build_context()
|
|
337
|
+
try:
|
|
338
|
+
if action == ACTION_DESTROY:
|
|
339
|
+
provider_destroy(ctx) # noqa: F821 - injected by the builder
|
|
340
|
+
log_event("instance_destroyed", reason=reason, **detail)
|
|
341
|
+
else:
|
|
342
|
+
provider_stop(ctx) # noqa: F821 - injected by the builder
|
|
343
|
+
log_event("instance_stopped", reason=reason, **detail)
|
|
344
|
+
except Exception as exc: # noqa: BLE001 - last line of defence
|
|
345
|
+
with _LOCK:
|
|
346
|
+
_STATE["action_failures"] = int(_STATE.get("action_failures") or 0) + 1
|
|
347
|
+
if action == ACTION_STOP:
|
|
348
|
+
_STATE["stop_failures"] = int(_STATE.get("stop_failures") or 0) + 1
|
|
349
|
+
_STATE["stop_failing_since"] = _STATE.get("stop_failing_since") or time.time()
|
|
350
|
+
save_state()
|
|
351
|
+
log_event(
|
|
352
|
+
"provider_error",
|
|
353
|
+
action=action,
|
|
354
|
+
reason=reason,
|
|
355
|
+
error=str(exc),
|
|
356
|
+
traceback=traceback.format_exc(limit=3),
|
|
357
|
+
)
|
|
358
|
+
return False
|
|
359
|
+
with _LOCK:
|
|
360
|
+
_STATE["terminated"] = True
|
|
361
|
+
_STATE["terminated_at"] = time.time()
|
|
362
|
+
_STATE["terminated_action"] = action
|
|
363
|
+
_STATE["terminated_reason"] = reason
|
|
364
|
+
_reset_failures()
|
|
365
|
+
# Done: a pending request must not be replayed after a restart.
|
|
366
|
+
_STATE["pending_shutdown"] = None
|
|
367
|
+
save_state()
|
|
368
|
+
return True
|
|
369
|
+
finally:
|
|
370
|
+
_ACTION_LOCK.release()
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def reissue_after_s(interval):
|
|
374
|
+
configured = _CONFIG.get("reissue_after_s")
|
|
375
|
+
if configured is not None:
|
|
376
|
+
return float(configured)
|
|
377
|
+
return max(300.0, 5.0 * interval)
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def _reset_failures():
|
|
381
|
+
_STATE["stop_failures"] = 0
|
|
382
|
+
_STATE["stop_failing_since"] = None
|
|
383
|
+
_STATE["action_failures"] = 0
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def _choose(decision, cfg, now=None):
|
|
387
|
+
"""(action, reason, pre-escalation reason) to attempt this tick, or None."""
|
|
388
|
+
action, reason = None, None
|
|
389
|
+
pending = _STATE.get("pending_shutdown")
|
|
390
|
+
if pending:
|
|
391
|
+
# An explicit request outranks whatever the policy would pick.
|
|
392
|
+
action = pending.get("action") or ACTION_STOP
|
|
393
|
+
reason = pending.get("reason") or "manual"
|
|
394
|
+
elif decision.terminates:
|
|
395
|
+
action, reason = decision.action, decision.reason
|
|
396
|
+
if action is None:
|
|
397
|
+
# Back in use: failures from an earlier idle spell must not count
|
|
398
|
+
# towards destroying the disk later.
|
|
399
|
+
if _STATE.get("stop_failures") or _STATE.get("action_failures"):
|
|
400
|
+
_reset_failures()
|
|
401
|
+
save_state()
|
|
402
|
+
return None
|
|
403
|
+
since = _STATE.get("stop_failing_since")
|
|
404
|
+
limit = float(_CONFIG.get("escalate_after_s") or ESCALATE_AFTER_S)
|
|
405
|
+
if (
|
|
406
|
+
action == ACTION_STOP
|
|
407
|
+
and since
|
|
408
|
+
and (now or time.time()) - float(since) >= limit
|
|
409
|
+
and cfg.destroy_supported
|
|
410
|
+
):
|
|
411
|
+
# Billing forever is worse than losing the disk.
|
|
412
|
+
return ACTION_DESTROY, "stop_failed_escalation", reason
|
|
413
|
+
return action, reason, None
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
def guard_tick(interval, idle_announced):
|
|
417
|
+
cfg = policy_config()
|
|
418
|
+
|
|
419
|
+
with _LOCK:
|
|
420
|
+
if _STATE.get("terminated"):
|
|
421
|
+
since = float(_STATE.get("terminated_at") or 0)
|
|
422
|
+
if time.time() - since <= reissue_after_s(interval):
|
|
423
|
+
return idle_announced
|
|
424
|
+
# The provider accepted the call, yet this machine is still running.
|
|
425
|
+
_STATE["terminated"] = False
|
|
426
|
+
save_state()
|
|
427
|
+
log_event(
|
|
428
|
+
"guard_reissue",
|
|
429
|
+
previous_action=_STATE.get("terminated_action"),
|
|
430
|
+
seconds_since_terminate=round(time.time() - since, 1),
|
|
431
|
+
)
|
|
432
|
+
|
|
433
|
+
snap = snapshot()
|
|
434
|
+
with _LOCK:
|
|
435
|
+
if _STATE.get("locked") and snap.lock_expired(cfg):
|
|
436
|
+
_STATE["locked"] = False
|
|
437
|
+
_STATE["locked_until"] = None
|
|
438
|
+
save_state()
|
|
439
|
+
log_event("instance_unlocked", reason="lock_expired")
|
|
440
|
+
|
|
441
|
+
decision = evaluate(snap, cfg)
|
|
442
|
+
state = decision.detail.get("state")
|
|
443
|
+
if state == "idle" and not idle_announced:
|
|
444
|
+
idle_announced = True
|
|
445
|
+
log_event("instance_idle", **decision.detail)
|
|
446
|
+
elif state != "idle":
|
|
447
|
+
idle_announced = False
|
|
448
|
+
|
|
449
|
+
with _LOCK:
|
|
450
|
+
choice = _choose(decision, cfg)
|
|
451
|
+
if choice is None:
|
|
452
|
+
return idle_announced
|
|
453
|
+
action, reason, original = choice
|
|
454
|
+
detail = dict(decision.detail)
|
|
455
|
+
if original:
|
|
456
|
+
detail["original_reason"] = original
|
|
457
|
+
if terminate(action, reason, detail) is False:
|
|
458
|
+
log_event(
|
|
459
|
+
"guard_retry",
|
|
460
|
+
action=action,
|
|
461
|
+
reason=reason,
|
|
462
|
+
attempt=int(_STATE.get("action_failures") or 0),
|
|
463
|
+
stop_failures=int(_STATE.get("stop_failures") or 0),
|
|
464
|
+
)
|
|
465
|
+
return idle_announced
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
def guard_loop():
|
|
469
|
+
interval = float(_CONFIG.get("interval_s") or 60.0)
|
|
470
|
+
cfg = policy_config()
|
|
471
|
+
log_event(
|
|
472
|
+
"watchdog_started",
|
|
473
|
+
interval_seconds=interval,
|
|
474
|
+
idle_timeout_seconds=cfg.idle_timeout_s,
|
|
475
|
+
max_lifetime_seconds=cfg.max_lifetime_s,
|
|
476
|
+
auto_stop=cfg.auto_stop,
|
|
477
|
+
auto_destroy=cfg.auto_destroy,
|
|
478
|
+
session_started_at=_STATE.get("session_started_at"),
|
|
479
|
+
)
|
|
480
|
+
idle_announced = False
|
|
481
|
+
# Only process exit ends this loop: no decision or provider failure may.
|
|
482
|
+
while not _STOPPED.wait(interval):
|
|
483
|
+
try:
|
|
484
|
+
idle_announced = guard_tick(interval, idle_announced)
|
|
485
|
+
except Exception as exc: # noqa: BLE001 - never let the guard die
|
|
486
|
+
log_event("watchdog_error", error=str(exc), traceback=traceback.format_exc(limit=3))
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
# --------------------------------------------------------------------------
|
|
490
|
+
# inference traffic probe
|
|
491
|
+
# --------------------------------------------------------------------------
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
def _split_sample(line):
|
|
495
|
+
"""``name{labels} value [timestamp]`` -> (name, value_text) or None."""
|
|
496
|
+
brace = line.find("{")
|
|
497
|
+
space = line.find(" ")
|
|
498
|
+
if brace != -1 and (space == -1 or brace < space):
|
|
499
|
+
name = line[:brace]
|
|
500
|
+
i, quoted = brace + 1, False
|
|
501
|
+
while i < len(line):
|
|
502
|
+
ch = line[i]
|
|
503
|
+
if quoted and ch == "\\":
|
|
504
|
+
i += 2
|
|
505
|
+
continue
|
|
506
|
+
if ch == '"':
|
|
507
|
+
quoted = not quoted
|
|
508
|
+
elif ch == "}" and not quoted:
|
|
509
|
+
break
|
|
510
|
+
i += 1
|
|
511
|
+
rest = line[i + 1 :].split()
|
|
512
|
+
else:
|
|
513
|
+
parts = line.split()
|
|
514
|
+
name, rest = (parts[0], parts[1:]) if parts else ("", [])
|
|
515
|
+
if not name or not rest:
|
|
516
|
+
return None
|
|
517
|
+
return name, rest[0]
|
|
518
|
+
|
|
519
|
+
|
|
520
|
+
def parse_prometheus(text):
|
|
521
|
+
"""Prometheus text format -> {metric name: sum of samples across label sets}."""
|
|
522
|
+
totals = {}
|
|
523
|
+
for raw in text.splitlines():
|
|
524
|
+
line = raw.strip()
|
|
525
|
+
if not line or line.startswith("#"):
|
|
526
|
+
continue
|
|
527
|
+
sample = _split_sample(line)
|
|
528
|
+
if sample is None:
|
|
529
|
+
continue
|
|
530
|
+
name, value_text = sample
|
|
531
|
+
try:
|
|
532
|
+
value = float(value_text)
|
|
533
|
+
except ValueError:
|
|
534
|
+
continue
|
|
535
|
+
if math.isnan(value) or math.isinf(value):
|
|
536
|
+
continue
|
|
537
|
+
totals[name] = totals.get(name, 0.0) + value
|
|
538
|
+
return totals
|
|
539
|
+
|
|
540
|
+
|
|
541
|
+
def metric_value(totals, name):
|
|
542
|
+
"""Look a metric up, tolerating exporters that add or drop ``_total``."""
|
|
543
|
+
if name in totals:
|
|
544
|
+
return totals[name]
|
|
545
|
+
alt = name[: -len("_total")] if name.endswith("_total") else name + "_total"
|
|
546
|
+
return totals.get(alt)
|
|
547
|
+
|
|
548
|
+
|
|
549
|
+
def traffic_seen(totals, previous, gauges, counters):
|
|
550
|
+
"""True when a gauge is non-zero or a counter grew since ``previous``."""
|
|
551
|
+
for name in gauges:
|
|
552
|
+
value = metric_value(totals, name)
|
|
553
|
+
if value is not None and value > 0:
|
|
554
|
+
return True
|
|
555
|
+
if previous is None:
|
|
556
|
+
return False # the first read only establishes the baseline
|
|
557
|
+
for name in counters:
|
|
558
|
+
value = metric_value(totals, name)
|
|
559
|
+
before = metric_value(previous, name)
|
|
560
|
+
# A drop means the runtime restarted its counters: rebaseline, not traffic.
|
|
561
|
+
if value is not None and before is not None and value > before:
|
|
562
|
+
return True
|
|
563
|
+
return False
|
|
564
|
+
|
|
565
|
+
|
|
566
|
+
def probe_once(spec, previous):
|
|
567
|
+
"""One scrape. Returns the new baseline (the old one on error)."""
|
|
568
|
+
now = time.time()
|
|
569
|
+
try:
|
|
570
|
+
with urllib.request.urlopen(spec["url"], timeout=5) as response:
|
|
571
|
+
text = response.read().decode("utf-8", "replace")
|
|
572
|
+
totals = parse_prometheus(text)
|
|
573
|
+
except Exception as exc: # noqa: BLE001 - a probe error is never activity
|
|
574
|
+
with _LOCK:
|
|
575
|
+
_PROBE.update({"ok": False, "at": now, "error": str(exc)})
|
|
576
|
+
last_logged = _PROBE.get("error_logged_at") or 0
|
|
577
|
+
if now - last_logged >= PROBE_ERROR_LOG_EVERY_S:
|
|
578
|
+
_PROBE["error_logged_at"] = now
|
|
579
|
+
log_event("activity_probe_error", url=spec.get("url"), error=str(exc))
|
|
580
|
+
return previous
|
|
581
|
+
|
|
582
|
+
gauges = list(spec.get("gauges") or [])
|
|
583
|
+
counters = list(spec.get("counters") or [])
|
|
584
|
+
traffic = traffic_seen(totals, previous, gauges, counters)
|
|
585
|
+
summary = {}
|
|
586
|
+
for name in gauges + counters:
|
|
587
|
+
value = metric_value(totals, name)
|
|
588
|
+
if value is not None:
|
|
589
|
+
summary[name] = value
|
|
590
|
+
with _LOCK:
|
|
591
|
+
_PROBE.update({"ok": True, "at": now, "error": None, "values": summary})
|
|
592
|
+
if traffic:
|
|
593
|
+
_PROBE["last_traffic_at"] = now
|
|
594
|
+
if traffic:
|
|
595
|
+
touch("inference_traffic")
|
|
596
|
+
return totals
|
|
597
|
+
|
|
598
|
+
|
|
599
|
+
def probe_loop(spec):
|
|
600
|
+
interval = float(_CONFIG.get("interval_s") or 60.0)
|
|
601
|
+
period = min(interval, 15.0)
|
|
602
|
+
previous = None
|
|
603
|
+
while not _STOPPED.is_set():
|
|
604
|
+
try:
|
|
605
|
+
previous = probe_once(spec, previous)
|
|
606
|
+
except Exception as exc: # noqa: BLE001
|
|
607
|
+
log_event("watchdog_error", where="activity_probe", error=str(exc))
|
|
608
|
+
_STOPPED.wait(period)
|
|
609
|
+
|
|
610
|
+
|
|
611
|
+
def probe_spec():
|
|
612
|
+
spec = _CONFIG.get("activity_probe")
|
|
613
|
+
if not isinstance(spec, dict) or not spec.get("url"):
|
|
614
|
+
return None
|
|
615
|
+
if (spec.get("kind") or "prometheus") != "prometheus":
|
|
616
|
+
return None
|
|
617
|
+
return spec
|
|
618
|
+
|
|
619
|
+
|
|
620
|
+
def probe_payload():
|
|
621
|
+
spec = probe_spec()
|
|
622
|
+
if spec is None:
|
|
623
|
+
return None
|
|
624
|
+
with _LOCK:
|
|
625
|
+
return {
|
|
626
|
+
"url": spec.get("url"),
|
|
627
|
+
"ok": _PROBE.get("ok"),
|
|
628
|
+
"at": _PROBE.get("at"),
|
|
629
|
+
"error": _PROBE.get("error"),
|
|
630
|
+
"values": dict(_PROBE.get("values") or {}),
|
|
631
|
+
"last_traffic_at": _PROBE.get("last_traffic_at"),
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
|
|
635
|
+
# --------------------------------------------------------------------------
|
|
636
|
+
# HTTP control surface
|
|
637
|
+
# --------------------------------------------------------------------------
|
|
638
|
+
|
|
639
|
+
|
|
640
|
+
class Handler(BaseHTTPRequestHandler):
|
|
641
|
+
server_version = "jambu-watchdog/1.0"
|
|
642
|
+
|
|
643
|
+
def log_message(self, fmt, *args): # noqa: A003 - silence access logs
|
|
644
|
+
return
|
|
645
|
+
|
|
646
|
+
# -- plumbing -------------------------------------------------------
|
|
647
|
+
|
|
648
|
+
def _authorized(self):
|
|
649
|
+
expected = _CONFIG.get("token")
|
|
650
|
+
if not expected:
|
|
651
|
+
return True
|
|
652
|
+
supplied = self.headers.get("X-Jambu-Token") or ""
|
|
653
|
+
if not hmac.compare_digest(supplied.encode(), str(expected).encode()):
|
|
654
|
+
self._reply(401, {"error": "unauthorized"})
|
|
655
|
+
return False
|
|
656
|
+
return True
|
|
657
|
+
|
|
658
|
+
def _reply(self, code, payload):
|
|
659
|
+
body = json.dumps(payload, default=str).encode()
|
|
660
|
+
self.send_response(code)
|
|
661
|
+
self.send_header("Content-Type", "application/json")
|
|
662
|
+
self.send_header("Content-Length", str(len(body)))
|
|
663
|
+
self.end_headers()
|
|
664
|
+
try:
|
|
665
|
+
self.wfile.write(body)
|
|
666
|
+
except OSError:
|
|
667
|
+
pass
|
|
668
|
+
|
|
669
|
+
def _payload(self):
|
|
670
|
+
try:
|
|
671
|
+
length = int(self.headers.get("Content-Length") or 0)
|
|
672
|
+
except ValueError:
|
|
673
|
+
length = 0
|
|
674
|
+
if not length:
|
|
675
|
+
return {}
|
|
676
|
+
try:
|
|
677
|
+
return json.loads(self.rfile.read(length) or b"{}")
|
|
678
|
+
except ValueError:
|
|
679
|
+
return {}
|
|
680
|
+
|
|
681
|
+
# -- routes ---------------------------------------------------------
|
|
682
|
+
|
|
683
|
+
def do_GET(self): # noqa: N802
|
|
684
|
+
path = self.path.split("?")[0].rstrip("/") or "/"
|
|
685
|
+
if path == "/health":
|
|
686
|
+
self._reply(200, {"ok": True, "service": "jambu-watchdog"})
|
|
687
|
+
return
|
|
688
|
+
if not self._authorized():
|
|
689
|
+
return
|
|
690
|
+
if path == "/state":
|
|
691
|
+
self._reply(200, state_payload())
|
|
692
|
+
return
|
|
693
|
+
if path == "/events":
|
|
694
|
+
limit = 100
|
|
695
|
+
if "limit=" in self.path:
|
|
696
|
+
try:
|
|
697
|
+
limit = int(self.path.split("limit=")[1].split("&")[0])
|
|
698
|
+
except ValueError:
|
|
699
|
+
limit = 100
|
|
700
|
+
self._reply(200, {"events": read_events(limit)})
|
|
701
|
+
return
|
|
702
|
+
if path == "/logs":
|
|
703
|
+
query = parse_qs(self.path.partition("?")[2])
|
|
704
|
+
name = (query.get("name") or ["runtime"])[0]
|
|
705
|
+
target = (_CONFIG.get("logs") or {}).get(name)
|
|
706
|
+
if not target:
|
|
707
|
+
self._reply(404, {"error": "unknown log %r" % name})
|
|
708
|
+
return
|
|
709
|
+
try:
|
|
710
|
+
count = int((query.get("lines") or [200])[0])
|
|
711
|
+
except ValueError:
|
|
712
|
+
count = 200
|
|
713
|
+
count = max(0, min(count, LOG_LINES_MAX))
|
|
714
|
+
self._reply(200, {"name": name, "lines": tail_lines(target, count)})
|
|
715
|
+
return
|
|
716
|
+
self._reply(404, {"error": "not found"})
|
|
717
|
+
|
|
718
|
+
def do_POST(self): # noqa: N802
|
|
719
|
+
path = self.path.split("?")[0].rstrip("/") or "/"
|
|
720
|
+
if not self._authorized():
|
|
721
|
+
return
|
|
722
|
+
body = self._payload()
|
|
723
|
+
try:
|
|
724
|
+
self._reply(*dispatch(path, body))
|
|
725
|
+
except Exception as exc: # noqa: BLE001
|
|
726
|
+
log_event("watchdog_error", route=path, error=str(exc))
|
|
727
|
+
self._reply(500, {"error": str(exc)})
|
|
728
|
+
|
|
729
|
+
|
|
730
|
+
def state_payload():
|
|
731
|
+
cfg = policy_config()
|
|
732
|
+
snap = snapshot()
|
|
733
|
+
decision = evaluate(snap, cfg)
|
|
734
|
+
with _LOCK:
|
|
735
|
+
workloads = json.loads(json.dumps(_STATE.get("workloads") or {}, default=str))
|
|
736
|
+
return {
|
|
737
|
+
"instance_id": _STATE.get("instance_id") or _CONFIG.get("instance_id"),
|
|
738
|
+
"provider": _CONFIG.get("provider"),
|
|
739
|
+
"created_at": _STATE.get("created_at"),
|
|
740
|
+
"last_activity": _STATE.get("last_activity"),
|
|
741
|
+
"starting": snap.starting,
|
|
742
|
+
"runtime_alive": runtime_alive(),
|
|
743
|
+
"locked": snap.lock_active(cfg),
|
|
744
|
+
"locked_until": _STATE.get("locked_until"),
|
|
745
|
+
"terminated": bool(_STATE.get("terminated")),
|
|
746
|
+
"session_started_at": _STATE.get("session_started_at"),
|
|
747
|
+
"session_age_seconds": round(snap.session_age_seconds(), 1),
|
|
748
|
+
"stop_failures": int(_STATE.get("stop_failures") or 0),
|
|
749
|
+
"pending_shutdown": _STATE.get("pending_shutdown"),
|
|
750
|
+
"last_probe": probe_payload(),
|
|
751
|
+
"state": derive_state(snap, cfg),
|
|
752
|
+
"idle_seconds": round(snap.idle_seconds(cfg), 1),
|
|
753
|
+
"age_seconds": round(snap.age_seconds(), 1),
|
|
754
|
+
"workloads": workloads,
|
|
755
|
+
"decision": decision.to_dict(),
|
|
756
|
+
"policy": cfg.to_dict(),
|
|
757
|
+
"now": time.time(),
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
|
|
761
|
+
def read_events(limit=100):
|
|
762
|
+
try:
|
|
763
|
+
with open(EVENTS_PATH) as handle:
|
|
764
|
+
lines = handle.read().splitlines()
|
|
765
|
+
except OSError:
|
|
766
|
+
return []
|
|
767
|
+
out = []
|
|
768
|
+
for line in lines[-limit:]:
|
|
769
|
+
try:
|
|
770
|
+
out.append(json.loads(line))
|
|
771
|
+
except ValueError:
|
|
772
|
+
continue
|
|
773
|
+
return out
|
|
774
|
+
|
|
775
|
+
|
|
776
|
+
def dispatch(path, body):
|
|
777
|
+
now = time.time()
|
|
778
|
+
|
|
779
|
+
if path == "/identify":
|
|
780
|
+
# The instance id only exists after creation, and the onstart script is
|
|
781
|
+
# baked before that; the CLI tells the watchdog who it is once it can.
|
|
782
|
+
with _LOCK:
|
|
783
|
+
if body.get("instance_id"):
|
|
784
|
+
_STATE["instance_id"] = str(body["instance_id"])
|
|
785
|
+
overrides = _STATE.setdefault("policy_overrides", {})
|
|
786
|
+
if body.get("hourly_cost_usd") is not None:
|
|
787
|
+
overrides["hourly_cost_usd"] = float(body["hourly_cost_usd"])
|
|
788
|
+
if body.get("policy"):
|
|
789
|
+
overrides.update(dict(body["policy"]))
|
|
790
|
+
_STATE["last_activity"] = now
|
|
791
|
+
save_state()
|
|
792
|
+
log_event("watchdog_identified", instance_id=_STATE.get("instance_id"))
|
|
793
|
+
return 200, {"ok": True, "instance_id": _STATE.get("instance_id")}
|
|
794
|
+
|
|
795
|
+
if path == "/activity":
|
|
796
|
+
touch()
|
|
797
|
+
return 200, {"ok": True, "last_activity": _STATE["last_activity"]}
|
|
798
|
+
|
|
799
|
+
if path == "/ready":
|
|
800
|
+
with _LOCK:
|
|
801
|
+
_STATE["starting"] = False
|
|
802
|
+
_STATE["last_activity"] = now
|
|
803
|
+
save_state()
|
|
804
|
+
log_event("runtime_ready")
|
|
805
|
+
return 200, {"ok": True}
|
|
806
|
+
|
|
807
|
+
if path == "/starting":
|
|
808
|
+
with _LOCK:
|
|
809
|
+
_STATE["starting"] = True
|
|
810
|
+
_STATE["created_at"] = _STATE.get("created_at") or now
|
|
811
|
+
_STATE["last_activity"] = now
|
|
812
|
+
save_state()
|
|
813
|
+
log_event("runtime_starting")
|
|
814
|
+
return 200, {"ok": True}
|
|
815
|
+
|
|
816
|
+
if path == "/workload/start":
|
|
817
|
+
workload_id = str(body.get("id") or "w-%d" % int(now * 1000))
|
|
818
|
+
with _LOCK:
|
|
819
|
+
_STATE.setdefault("workloads", {})[workload_id] = {
|
|
820
|
+
"started_at": now,
|
|
821
|
+
"last_heartbeat": now,
|
|
822
|
+
"label": body.get("label", ""),
|
|
823
|
+
"state": body.get("state", "running"),
|
|
824
|
+
}
|
|
825
|
+
_STATE["last_activity"] = now
|
|
826
|
+
_STATE["starting"] = False
|
|
827
|
+
save_state()
|
|
828
|
+
log_event("workload_started", workload_id=workload_id, label=body.get("label", ""))
|
|
829
|
+
return 200, {"ok": True, "id": workload_id}
|
|
830
|
+
|
|
831
|
+
if path == "/workload/heartbeat":
|
|
832
|
+
workload_id = body.get("id")
|
|
833
|
+
with _LOCK:
|
|
834
|
+
record = (_STATE.get("workloads") or {}).get(workload_id)
|
|
835
|
+
if record is None and workload_id:
|
|
836
|
+
record = {
|
|
837
|
+
"started_at": now,
|
|
838
|
+
"label": body.get("label", ""),
|
|
839
|
+
"state": "running",
|
|
840
|
+
}
|
|
841
|
+
_STATE.setdefault("workloads", {})[workload_id] = record
|
|
842
|
+
if record is not None:
|
|
843
|
+
record["last_heartbeat"] = now
|
|
844
|
+
_STATE["last_activity"] = now
|
|
845
|
+
save_state()
|
|
846
|
+
return 200, {"ok": True, "id": workload_id}
|
|
847
|
+
|
|
848
|
+
if path == "/workload/end":
|
|
849
|
+
workload_id = body.get("id")
|
|
850
|
+
with _LOCK:
|
|
851
|
+
record = (_STATE.get("workloads") or {}).get(workload_id)
|
|
852
|
+
if record is not None:
|
|
853
|
+
record["state"] = body.get("state") or (
|
|
854
|
+
"finished" if not body.get("returncode") else "failed"
|
|
855
|
+
)
|
|
856
|
+
record["finished_at"] = now
|
|
857
|
+
record["returncode"] = body.get("returncode")
|
|
858
|
+
_STATE["last_activity"] = now
|
|
859
|
+
save_state()
|
|
860
|
+
log_event(
|
|
861
|
+
"workload_finished",
|
|
862
|
+
workload_id=workload_id,
|
|
863
|
+
returncode=body.get("returncode"),
|
|
864
|
+
)
|
|
865
|
+
return 200, {"ok": True}
|
|
866
|
+
|
|
867
|
+
if path == "/lock":
|
|
868
|
+
ttl = body.get("ttl_seconds")
|
|
869
|
+
until = body.get("until")
|
|
870
|
+
cfg = policy_config()
|
|
871
|
+
if until is None and ttl:
|
|
872
|
+
until = now + float(ttl)
|
|
873
|
+
if until is None and not cfg.allow_indefinite_lock:
|
|
874
|
+
return 400, {
|
|
875
|
+
"error": "indefinite locks are disabled "
|
|
876
|
+
"(set lifecycle.allow_indefinite_lock: true to permit them)"
|
|
877
|
+
}
|
|
878
|
+
with _LOCK:
|
|
879
|
+
_STATE["locked"] = True
|
|
880
|
+
_STATE["locked_until"] = float(until) if until is not None else None
|
|
881
|
+
_STATE["last_activity"] = now
|
|
882
|
+
save_state()
|
|
883
|
+
log_event("instance_locked", locked_until=until, reason=body.get("reason", ""))
|
|
884
|
+
return 200, {"ok": True, "locked_until": until}
|
|
885
|
+
|
|
886
|
+
if path == "/unlock":
|
|
887
|
+
with _LOCK:
|
|
888
|
+
_STATE["locked"] = False
|
|
889
|
+
_STATE["locked_until"] = None
|
|
890
|
+
_STATE["last_activity"] = now
|
|
891
|
+
save_state()
|
|
892
|
+
log_event("instance_unlocked", reason=body.get("reason", "manual"))
|
|
893
|
+
return 200, {"ok": True}
|
|
894
|
+
|
|
895
|
+
if path == "/shutdown":
|
|
896
|
+
action = body.get("action") or ACTION_STOP
|
|
897
|
+
reason = body.get("reason") or "manual"
|
|
898
|
+
if action not in (ACTION_STOP, ACTION_DESTROY):
|
|
899
|
+
return 400, {"error": "action must be stop or destroy"}
|
|
900
|
+
with _LOCK:
|
|
901
|
+
# Remembered so the guard loop retries it if the provider call fails.
|
|
902
|
+
_STATE["pending_shutdown"] = {"action": action, "reason": reason}
|
|
903
|
+
save_state()
|
|
904
|
+
threading.Thread(
|
|
905
|
+
target=terminate,
|
|
906
|
+
args=(action, reason, {"requested_by": "cli"}),
|
|
907
|
+
kwargs={"force": True},
|
|
908
|
+
daemon=True,
|
|
909
|
+
).start()
|
|
910
|
+
return 202, {"ok": True, "action": action}
|
|
911
|
+
|
|
912
|
+
return 404, {"error": "not found"}
|
|
913
|
+
|
|
914
|
+
|
|
915
|
+
# --------------------------------------------------------------------------
|
|
916
|
+
# entrypoint
|
|
917
|
+
# --------------------------------------------------------------------------
|
|
918
|
+
|
|
919
|
+
|
|
920
|
+
def main():
|
|
921
|
+
global _CONFIG, _STATE
|
|
922
|
+
|
|
923
|
+
try:
|
|
924
|
+
with open(CONFIG_PATH) as handle:
|
|
925
|
+
_CONFIG = json.load(handle)
|
|
926
|
+
except (OSError, ValueError) as exc:
|
|
927
|
+
print("[jambu-watchdog] cannot read %s: %s" % (CONFIG_PATH, exc), file=sys.stderr)
|
|
928
|
+
return 1
|
|
929
|
+
|
|
930
|
+
for key, value in (_CONFIG.get("env") or {}).items():
|
|
931
|
+
os.environ.setdefault(str(key), str(value))
|
|
932
|
+
|
|
933
|
+
_STATE = load_state()
|
|
934
|
+
save_state()
|
|
935
|
+
|
|
936
|
+
port = int(_CONFIG.get("port") or 8777)
|
|
937
|
+
server = ThreadingHTTPServer(("0.0.0.0", port), Handler)
|
|
938
|
+
server.daemon_threads = True
|
|
939
|
+
|
|
940
|
+
guard = threading.Thread(target=guard_loop, daemon=True)
|
|
941
|
+
guard.start()
|
|
942
|
+
|
|
943
|
+
spec = probe_spec()
|
|
944
|
+
if spec is not None:
|
|
945
|
+
threading.Thread(target=probe_loop, args=(spec,), daemon=True).start()
|
|
946
|
+
|
|
947
|
+
log_event("watchdog_listening", port=port)
|
|
948
|
+
try:
|
|
949
|
+
server.serve_forever(poll_interval=1.0)
|
|
950
|
+
except KeyboardInterrupt: # pragma: no cover
|
|
951
|
+
pass
|
|
952
|
+
finally:
|
|
953
|
+
_STOPPED.set()
|
|
954
|
+
server.server_close()
|
|
955
|
+
return 0
|
|
956
|
+
|
|
957
|
+
|
|
958
|
+
if __name__ == "__main__":
|
|
959
|
+
sys.exit(main())
|