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,1335 @@
|
|
|
1
|
+
"""Orchestration engine.
|
|
2
|
+
|
|
3
|
+
Owns the flows in spec sections 15-18 and 23: provisioning, run, status,
|
|
4
|
+
reconciliation, idempotency and cleanup. It knows about the provider
|
|
5
|
+
*contract* and about capabilities - never about a specific provider.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
import secrets
|
|
12
|
+
import time
|
|
13
|
+
from typing import Any, Callable, Optional, Sequence
|
|
14
|
+
|
|
15
|
+
from .. import watchdog as watchdog_pkg
|
|
16
|
+
from ..providers.base import GPUProvider
|
|
17
|
+
from ..providers.registry import load_builtin_providers
|
|
18
|
+
from ..runtime import runtime_registry
|
|
19
|
+
from ..runtime.base import ENDPOINT_NAME, ModelRuntime, image_tag, max_cuda
|
|
20
|
+
from ..watchdog.builder import build_agent_config, build_agent_source, build_onstart_script
|
|
21
|
+
from . import events as ev
|
|
22
|
+
from . import policy
|
|
23
|
+
from .beacon import WatchdogClient, WatchdogUnavailable
|
|
24
|
+
from .config import RuntimeConfig
|
|
25
|
+
from .credentials import CredentialResolver
|
|
26
|
+
from .durations import format_duration
|
|
27
|
+
from .errors import (
|
|
28
|
+
BudgetExceededError,
|
|
29
|
+
ConfigError,
|
|
30
|
+
HostFailure,
|
|
31
|
+
JambuError,
|
|
32
|
+
LockBusy,
|
|
33
|
+
ProviderError,
|
|
34
|
+
RuntimeStartupError,
|
|
35
|
+
StateError,
|
|
36
|
+
UnsupportedCapabilityError,
|
|
37
|
+
WorkloadFailed,
|
|
38
|
+
)
|
|
39
|
+
from .events import EventLog
|
|
40
|
+
from .execution import ExecutionManager, Heartbeat
|
|
41
|
+
from .health import wait_for_healthy
|
|
42
|
+
from .models import (
|
|
43
|
+
ComputeSpec,
|
|
44
|
+
GpuSpec,
|
|
45
|
+
Instance,
|
|
46
|
+
InstanceState,
|
|
47
|
+
JAMBU_LABEL_PREFIX,
|
|
48
|
+
ValidationResult,
|
|
49
|
+
Workload,
|
|
50
|
+
)
|
|
51
|
+
from .hosts import BadHosts
|
|
52
|
+
from .state import RuntimeState, StateStore, WatchdogInfo, utc_now
|
|
53
|
+
|
|
54
|
+
Echo = Callable[[str], None]
|
|
55
|
+
|
|
56
|
+
# Signatures, in a dead model server's log, of a network that failed it - not
|
|
57
|
+
# of a model or config that would fail on any machine.
|
|
58
|
+
_HOST_NETWORK_FAILURE = re.compile(
|
|
59
|
+
r"RemoteProtocolError|ConnectError|ConnectTimeout|ReadTimeout|"
|
|
60
|
+
r"Connection (?:reset|refused|aborted)|TLS handshake timeout|"
|
|
61
|
+
r"Temporary failure in name resolution|Network is unreachable"
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
# A pull stuck retrying a layer: its countdown changes, but it is no progress.
|
|
65
|
+
_PULL_RETRY = re.compile(r"\bretrying\b", re.IGNORECASE)
|
|
66
|
+
|
|
67
|
+
# Where `gpu logs --source` reads from: the watchdog serves the instance files.
|
|
68
|
+
LOG_SOURCES = ("runtime", "onstart", "watchdog", "provider")
|
|
69
|
+
# Runtime log lines quoted in the error when the model server dies at boot.
|
|
70
|
+
STARTUP_FAILURE_LOG_LINES = 30
|
|
71
|
+
# How long read-only commands wait for a busy lock before reporting the last
|
|
72
|
+
# saved state instead: a setup holds it for the whole boot, often 10+ minutes.
|
|
73
|
+
READ_LOCK_WAIT_S = 3.0
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _noop(_message: str) -> None:
|
|
77
|
+
return None
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _pull_progress(message: Optional[str]) -> Optional[str]:
|
|
81
|
+
"""The provider's boot message when it shows the pull moving, else None.
|
|
82
|
+
|
|
83
|
+
Vast reports the image pull's latest line (``<layer>: Download complete``,
|
|
84
|
+
``<layer>: Pull complete``). A new line means a layer moved; the same line,
|
|
85
|
+
no line, or a retry countdown means it did not.
|
|
86
|
+
"""
|
|
87
|
+
text = (message or "").strip()
|
|
88
|
+
if not text or _PULL_RETRY.search(text):
|
|
89
|
+
return None
|
|
90
|
+
return text
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class Engine:
|
|
94
|
+
def __init__(
|
|
95
|
+
self,
|
|
96
|
+
config: RuntimeConfig,
|
|
97
|
+
provider: Optional[GPUProvider] = None,
|
|
98
|
+
echo: Optional[Echo] = None,
|
|
99
|
+
credentials: Optional[CredentialResolver] = None,
|
|
100
|
+
) -> None:
|
|
101
|
+
self.config = config
|
|
102
|
+
self.echo = echo or _noop
|
|
103
|
+
self.credentials = credentials or CredentialResolver(
|
|
104
|
+
config.project_dir, profile=config.provider.profile
|
|
105
|
+
)
|
|
106
|
+
registry = load_builtin_providers()
|
|
107
|
+
self.provider = provider or registry.get(config, self.credentials)
|
|
108
|
+
self.runtime: ModelRuntime = runtime_registry.get(config)
|
|
109
|
+
self.runtime.provider_image = self.provider.preferred_images.get(self.runtime.engine)
|
|
110
|
+
self.store = StateStore(config.state_dir)
|
|
111
|
+
self.events = EventLog(self.store.logs_dir).bind(provider=self.provider.name)
|
|
112
|
+
# Whether the last reconcile read the watchdog's state. Traffic activity
|
|
113
|
+
# lives only on the instance, so without it the local idle clock lies.
|
|
114
|
+
self.watchdog_read = False
|
|
115
|
+
|
|
116
|
+
# ------------------------------------------------------------------
|
|
117
|
+
# policy plumbing
|
|
118
|
+
# ------------------------------------------------------------------
|
|
119
|
+
|
|
120
|
+
def policy_config(self, hourly_cost_usd: Optional[float] = None) -> policy.PolicyConfig:
|
|
121
|
+
caps = self.provider.capabilities
|
|
122
|
+
cfg = self.config
|
|
123
|
+
return policy.PolicyConfig(
|
|
124
|
+
idle_timeout_s=cfg.idle_timeout_s,
|
|
125
|
+
max_lifetime_s=cfg.max_lifetime_s,
|
|
126
|
+
heartbeat_grace_s=cfg.heartbeat_grace_s,
|
|
127
|
+
auto_stop=cfg.lifecycle.auto_stop,
|
|
128
|
+
auto_destroy=cfg.lifecycle.auto_destroy,
|
|
129
|
+
allow_indefinite_lock=cfg.lifecycle.allow_indefinite_lock,
|
|
130
|
+
stop_supported=caps.stop,
|
|
131
|
+
destroy_supported=caps.destroy,
|
|
132
|
+
stop_eliminates_billing=caps.stop_eliminates_billing,
|
|
133
|
+
hourly_cost_usd=hourly_cost_usd,
|
|
134
|
+
max_session_cost_usd=cfg.budget.max_session_cost_usd,
|
|
135
|
+
stopped_ttl_s=cfg.stopped_ttl_s,
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
def snapshot(self, state: RuntimeState) -> policy.Snapshot:
|
|
139
|
+
now = utc_now()
|
|
140
|
+
return policy.Snapshot(
|
|
141
|
+
now=now,
|
|
142
|
+
created_at=state.created_at or now,
|
|
143
|
+
last_activity=state.last_activity or state.created_at or now,
|
|
144
|
+
workloads=[w.to_policy() for w in state.workloads.values()],
|
|
145
|
+
starting=state.starting,
|
|
146
|
+
locked=state.locked,
|
|
147
|
+
locked_until=state.locked_until,
|
|
148
|
+
provider_state=state.state,
|
|
149
|
+
session_started_at=state.session_started_at,
|
|
150
|
+
stopped_since=state.stopped_since,
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
def _idle_blind(self, state: RuntimeState) -> bool:
|
|
154
|
+
"""True when only the unreachable watchdog knows about recent activity.
|
|
155
|
+
|
|
156
|
+
Inference traffic is counted on the instance; the local last_activity
|
|
157
|
+
never sees it. Judging idleness from it would stop a serving endpoint,
|
|
158
|
+
so the CLI leaves the idle rule to the watchdog. max_lifetime, the
|
|
159
|
+
budget and stopped_ttl still apply.
|
|
160
|
+
"""
|
|
161
|
+
return (
|
|
162
|
+
self.config.lifecycle.watchdog.enabled
|
|
163
|
+
and state.watchdog.installed
|
|
164
|
+
and not self.watchdog_read
|
|
165
|
+
# A stopped machine has no watchdog to reach and no idle to judge.
|
|
166
|
+
and InstanceState(state.state).is_billable_compute
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
def beacon(self, state: RuntimeState) -> Optional[WatchdogClient]:
|
|
170
|
+
info = state.watchdog
|
|
171
|
+
if not info.installed or not info.url:
|
|
172
|
+
return None
|
|
173
|
+
return WatchdogClient(info.url, info.token)
|
|
174
|
+
|
|
175
|
+
# ------------------------------------------------------------------
|
|
176
|
+
# validate (spec section 15, steps 1-5)
|
|
177
|
+
# ------------------------------------------------------------------
|
|
178
|
+
|
|
179
|
+
def validate(self, check_provider: bool = True) -> ValidationResult:
|
|
180
|
+
result = ValidationResult()
|
|
181
|
+
cfg = self.config
|
|
182
|
+
|
|
183
|
+
if cfg.lifecycle.idle_timeout is None and cfg.lifecycle.max_lifetime is None:
|
|
184
|
+
result.error(
|
|
185
|
+
"lifecycle needs at least one of idle_timeout or max_lifetime; "
|
|
186
|
+
"without a bound an abandoned GPU bills forever",
|
|
187
|
+
"lifecycle",
|
|
188
|
+
)
|
|
189
|
+
if not cfg.lifecycle.auto_stop and not cfg.lifecycle.auto_destroy:
|
|
190
|
+
result.warn(
|
|
191
|
+
"auto_stop and auto_destroy are both false: the guard will report "
|
|
192
|
+
"but never shut anything down",
|
|
193
|
+
"lifecycle",
|
|
194
|
+
)
|
|
195
|
+
if not cfg.lifecycle.watchdog.enabled:
|
|
196
|
+
result.warn(
|
|
197
|
+
"lifecycle.watchdog.enabled is false: shutdown depends on this CLI "
|
|
198
|
+
"staying alive (spec section 14)",
|
|
199
|
+
"lifecycle.watchdog",
|
|
200
|
+
)
|
|
201
|
+
if cfg.lifecycle.allow_indefinite_lock:
|
|
202
|
+
result.warn(
|
|
203
|
+
"allow_indefinite_lock is true: a forgotten lock defeats cost protection",
|
|
204
|
+
"lifecycle.allow_indefinite_lock",
|
|
205
|
+
)
|
|
206
|
+
if cfg.budget.max_hourly_cost_usd is None:
|
|
207
|
+
result.warn("no budget.max_hourly_cost_usd set", "budget")
|
|
208
|
+
if (
|
|
209
|
+
not self.provider.capabilities.stop_eliminates_billing
|
|
210
|
+
and cfg.stopped_ttl_s is None
|
|
211
|
+
and not cfg.lifecycle.auto_destroy
|
|
212
|
+
):
|
|
213
|
+
result.warn(
|
|
214
|
+
f"a stopped {self.provider.display_name} instance still bills storage, and "
|
|
215
|
+
"with lifecycle.stopped_ttl disabled and auto_destroy false nothing ever "
|
|
216
|
+
"removes it; set lifecycle.stopped_ttl (e.g. 24h)",
|
|
217
|
+
"lifecycle.stopped_ttl",
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
result.merge(self.runtime.validate())
|
|
221
|
+
if cfg.runtime.engine != "none" and image_tag(self.runtime.image()) == "latest":
|
|
222
|
+
result.warn(
|
|
223
|
+
f"unpinned image '{self.runtime.image()}': the CUDA it needs can change "
|
|
224
|
+
"under you (pin a tag such as "
|
|
225
|
+
f"'{type(self.runtime).default_image}')",
|
|
226
|
+
"compute.image",
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
if cfg.model.hf_token_env and not self.credentials.get(cfg.model.hf_token_env):
|
|
230
|
+
result.warn(
|
|
231
|
+
f"{cfg.model.hf_token_env} is not set; gated models will fail to download",
|
|
232
|
+
"model.hf_token_env",
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
if check_provider:
|
|
236
|
+
try:
|
|
237
|
+
# The provider previews offers from config alone; hand it the
|
|
238
|
+
# CUDA floor the runtime image adds, so validate sees what setup will.
|
|
239
|
+
checked = cfg
|
|
240
|
+
if self.cuda_min() != cfg.compute.gpu.cuda_min:
|
|
241
|
+
checked = cfg.model_copy(deep=True)
|
|
242
|
+
checked.compute.gpu.cuda_min = self.cuda_min()
|
|
243
|
+
result.merge(self.provider.validate(checked))
|
|
244
|
+
except JambuError as exc:
|
|
245
|
+
result.error(str(exc), "provider")
|
|
246
|
+
return result
|
|
247
|
+
|
|
248
|
+
# ------------------------------------------------------------------
|
|
249
|
+
# reconciliation (spec section 18)
|
|
250
|
+
# ------------------------------------------------------------------
|
|
251
|
+
|
|
252
|
+
def reconcile(self, state: Optional[RuntimeState] = None) -> RuntimeState:
|
|
253
|
+
"""Merge local state with provider truth. Repairs local state in place."""
|
|
254
|
+
owns_lock = state is None
|
|
255
|
+
if owns_lock:
|
|
256
|
+
with self.store.process_lock():
|
|
257
|
+
state = self.store.load()
|
|
258
|
+
reconciled = self._reconcile(state)
|
|
259
|
+
self.store.save(reconciled)
|
|
260
|
+
return reconciled
|
|
261
|
+
return self._reconcile(state)
|
|
262
|
+
|
|
263
|
+
def _reconcile(self, state: RuntimeState) -> RuntimeState:
|
|
264
|
+
self.watchdog_read = False
|
|
265
|
+
if not state.instance_id:
|
|
266
|
+
state.state = InstanceState.DESTROYED.value
|
|
267
|
+
return state
|
|
268
|
+
|
|
269
|
+
instance = state.instance or Instance(id=state.instance_id, provider=self.provider.name)
|
|
270
|
+
try:
|
|
271
|
+
status = self.provider.status(instance)
|
|
272
|
+
except ProviderError as exc:
|
|
273
|
+
self.events.emit(ev.PROVIDER_ERROR, operation="status", error=str(exc))
|
|
274
|
+
return state # keep local state; the API blip is not authoritative
|
|
275
|
+
|
|
276
|
+
previous = state.state
|
|
277
|
+
if not status.exists:
|
|
278
|
+
state.clear_instance(InstanceState.DESTROYED.value)
|
|
279
|
+
self.events.emit(
|
|
280
|
+
ev.STATE_RECONCILED,
|
|
281
|
+
previous_state=previous,
|
|
282
|
+
state=InstanceState.DESTROYED.value,
|
|
283
|
+
reason="instance missing at provider",
|
|
284
|
+
)
|
|
285
|
+
return state
|
|
286
|
+
|
|
287
|
+
current = status.instance or instance
|
|
288
|
+
state.instance = current
|
|
289
|
+
state.instance_id = current.id
|
|
290
|
+
state.hourly_cost_usd = status.hourly_cost_usd or state.hourly_cost_usd
|
|
291
|
+
if current.created_at:
|
|
292
|
+
state.created_at = current.created_at
|
|
293
|
+
endpoint = current.endpoint_url(ENDPOINT_NAME)
|
|
294
|
+
if endpoint:
|
|
295
|
+
state.endpoint = endpoint
|
|
296
|
+
|
|
297
|
+
watchdog_url = current.endpoint_url("watchdog")
|
|
298
|
+
if watchdog_url and state.watchdog.token:
|
|
299
|
+
state.watchdog.url = watchdog_url
|
|
300
|
+
state.watchdog.installed = True
|
|
301
|
+
|
|
302
|
+
# The watchdog, when reachable, is the authority on activity.
|
|
303
|
+
remote = None
|
|
304
|
+
remote_terminated = False
|
|
305
|
+
client = self.beacon(state)
|
|
306
|
+
if client is not None:
|
|
307
|
+
remote = client.try_state()
|
|
308
|
+
if remote:
|
|
309
|
+
self.watchdog_read = True
|
|
310
|
+
state.watchdog.last_contact = utc_now()
|
|
311
|
+
state.watchdog.last_error = None
|
|
312
|
+
state.locked = bool(remote.get("locked"))
|
|
313
|
+
state.locked_until = remote.get("locked_until")
|
|
314
|
+
state.starting = bool(remote.get("starting"))
|
|
315
|
+
if remote.get("last_activity"):
|
|
316
|
+
state.last_activity = max(
|
|
317
|
+
state.last_activity or 0.0, float(remote["last_activity"])
|
|
318
|
+
)
|
|
319
|
+
# `terminated` is the watchdog's memory of having shut the machine
|
|
320
|
+
# down. It survives a restart, so it is only a hint for when the
|
|
321
|
+
# provider itself cannot say whether the instance runs.
|
|
322
|
+
remote_terminated = bool(remote.get("terminated"))
|
|
323
|
+
pulled = client.events(limit=200)
|
|
324
|
+
if pulled:
|
|
325
|
+
self.events.extend(pulled)
|
|
326
|
+
elif state.watchdog.installed:
|
|
327
|
+
state.watchdog.last_error = "unreachable"
|
|
328
|
+
|
|
329
|
+
if status.state in (InstanceState.READY, InstanceState.BUSY, InstanceState.IDLE):
|
|
330
|
+
# Provider truth first: a previous local STOPPED must not leak
|
|
331
|
+
# into the derivation after the instance was restarted.
|
|
332
|
+
state.state = status.state.value
|
|
333
|
+
derived = policy.derive_state(self.snapshot(state), self.policy_config())
|
|
334
|
+
state.state = derived if derived != InstanceState.UNKNOWN.value else status.state.value
|
|
335
|
+
else:
|
|
336
|
+
if status.state is InstanceState.UNKNOWN and remote_terminated:
|
|
337
|
+
state.state = InstanceState.STOPPED.value
|
|
338
|
+
else:
|
|
339
|
+
state.state = status.state.value
|
|
340
|
+
if status.state in (InstanceState.STOPPED, InstanceState.FAILED):
|
|
341
|
+
for record in state.workloads.values():
|
|
342
|
+
if record.state in ("running", "queued"):
|
|
343
|
+
record.state = "failed"
|
|
344
|
+
record.finished_at = utc_now()
|
|
345
|
+
|
|
346
|
+
# Track actual billable time, not wall-clock time since creation - a
|
|
347
|
+
# `status` checked long after auto-stop must not keep inflating cost.
|
|
348
|
+
billable = InstanceState(state.state).is_billable_compute
|
|
349
|
+
state.mark_billable(billable)
|
|
350
|
+
if state.state == InstanceState.STOPPED.value:
|
|
351
|
+
state.mark_stopped(ran_at=[current.created_at or None])
|
|
352
|
+
elif billable and state.stopped_since is not None:
|
|
353
|
+
# Restarted outside this CLI (web console, another machine): a new
|
|
354
|
+
# session begins now.
|
|
355
|
+
state.mark_session_start()
|
|
356
|
+
elif billable and state.session_started_at is None:
|
|
357
|
+
# A state file from before sessions existed: never grant it a
|
|
358
|
+
# fresh max_lifetime, count from creation.
|
|
359
|
+
state.session_started_at = state.created_at
|
|
360
|
+
if state.hourly_cost_usd:
|
|
361
|
+
state.session_cost_usd = round(
|
|
362
|
+
state.hourly_cost_usd * state.accrued_billable_seconds() / 3600.0, 4
|
|
363
|
+
)
|
|
364
|
+
|
|
365
|
+
if previous != state.state:
|
|
366
|
+
self.events.emit(
|
|
367
|
+
ev.STATE_RECONCILED, previous_state=previous, state=state.state
|
|
368
|
+
)
|
|
369
|
+
return state
|
|
370
|
+
|
|
371
|
+
# ------------------------------------------------------------------
|
|
372
|
+
# setup (spec section 15)
|
|
373
|
+
# ------------------------------------------------------------------
|
|
374
|
+
|
|
375
|
+
def build_spec(self, onstart: str = "") -> ComputeSpec:
|
|
376
|
+
cfg = self.config
|
|
377
|
+
env = dict(self.runtime.env())
|
|
378
|
+
hf_token = (
|
|
379
|
+
self.credentials.get(cfg.model.hf_token_env) if cfg.model.hf_token_env else None
|
|
380
|
+
)
|
|
381
|
+
if hf_token:
|
|
382
|
+
env["HF_TOKEN"] = hf_token
|
|
383
|
+
env["HUGGING_FACE_HUB_TOKEN"] = hf_token
|
|
384
|
+
return ComputeSpec(
|
|
385
|
+
gpu=GpuSpec(
|
|
386
|
+
min_vram_gb=cfg.compute.gpu.min_vram_gb,
|
|
387
|
+
count=cfg.compute.gpu.count,
|
|
388
|
+
name_filter=cfg.compute.gpu.name_filter,
|
|
389
|
+
cuda_min=self.cuda_min(),
|
|
390
|
+
compute_cap_min=self.compute_cap_min(),
|
|
391
|
+
),
|
|
392
|
+
disk_gb=cfg.compute.disk_gb,
|
|
393
|
+
image=self.runtime.image(),
|
|
394
|
+
ports=self._ports(),
|
|
395
|
+
env=env,
|
|
396
|
+
onstart=onstart,
|
|
397
|
+
label=f"{JAMBU_LABEL_PREFIX}{cfg.model.id.split('/')[-1][:24]}-{cfg.fingerprint()[:6]}",
|
|
398
|
+
spot=cfg.compute.spot,
|
|
399
|
+
region=cfg.compute.region,
|
|
400
|
+
max_hourly_cost_usd=cfg.budget.max_hourly_cost_usd,
|
|
401
|
+
provider_options=dict(cfg.provider.options),
|
|
402
|
+
)
|
|
403
|
+
|
|
404
|
+
def cuda_min(self) -> Optional[str]:
|
|
405
|
+
"""The stricter of compute.gpu.cuda_min and what the runtime image needs."""
|
|
406
|
+
configured = self.config.compute.gpu.cuda_min
|
|
407
|
+
needed = max_cuda(configured, self.runtime.required_cuda())
|
|
408
|
+
if needed is None:
|
|
409
|
+
return configured
|
|
410
|
+
if configured is not None and max_cuda(configured) == needed:
|
|
411
|
+
return configured
|
|
412
|
+
return f"{needed:.1f}"
|
|
413
|
+
|
|
414
|
+
def compute_cap_min(self) -> Optional[float]:
|
|
415
|
+
"""The stricter of compute.gpu.compute_cap_min and the runtime's floor."""
|
|
416
|
+
candidates = [
|
|
417
|
+
c
|
|
418
|
+
for c in (
|
|
419
|
+
self.config.compute.gpu.compute_cap_min,
|
|
420
|
+
self.runtime.required_compute_cap(),
|
|
421
|
+
)
|
|
422
|
+
if c is not None
|
|
423
|
+
]
|
|
424
|
+
return max(candidates) if candidates else None
|
|
425
|
+
|
|
426
|
+
def _ports(self) -> list[int]:
|
|
427
|
+
ports = list(self.runtime.ports())
|
|
428
|
+
if self.config.lifecycle.watchdog.enabled:
|
|
429
|
+
ports.append(self.config.lifecycle.watchdog.port)
|
|
430
|
+
return ports
|
|
431
|
+
|
|
432
|
+
def _api_key(self) -> Optional[str]:
|
|
433
|
+
"""The key the model server will demand: the configured one, or a fresh one.
|
|
434
|
+
|
|
435
|
+
The endpoint is public, and traffic counts as activity: an open one
|
|
436
|
+
would let anyone use the GPU and keep it billing.
|
|
437
|
+
"""
|
|
438
|
+
if self.config.runtime.api_key:
|
|
439
|
+
return self.config.runtime.api_key
|
|
440
|
+
if self.runtime.api_key_env is None:
|
|
441
|
+
return None
|
|
442
|
+
return secrets.token_urlsafe(32)
|
|
443
|
+
|
|
444
|
+
def _build_onstart(self, token: str, api_key: Optional[str] = None) -> str:
|
|
445
|
+
cfg = self.config
|
|
446
|
+
enabled = cfg.lifecycle.watchdog.enabled
|
|
447
|
+
spec = self.provider.watchdog_spec(None) if enabled else None
|
|
448
|
+
if enabled and spec is None:
|
|
449
|
+
raise UnsupportedCapabilityError(
|
|
450
|
+
f"provider '{self.provider.name}' supplies no watchdog actions; "
|
|
451
|
+
"set lifecycle.watchdog.enabled: false to proceed without remote "
|
|
452
|
+
"lifecycle enforcement"
|
|
453
|
+
)
|
|
454
|
+
agent_source = build_agent_source(spec.actions_source if spec else None)
|
|
455
|
+
probe = (
|
|
456
|
+
self.runtime.activity_probe() if cfg.lifecycle.traffic_counts_as_activity else None
|
|
457
|
+
)
|
|
458
|
+
agent_config = build_agent_config(
|
|
459
|
+
provider=self.provider.name,
|
|
460
|
+
instance_id="", # resolved by /identify or the provider's own env
|
|
461
|
+
token=token,
|
|
462
|
+
port=cfg.lifecycle.watchdog.port,
|
|
463
|
+
interval_s=cfg.watchdog_interval_s,
|
|
464
|
+
# The watchdog starts with the container, but the CLI's waits span
|
|
465
|
+
# the whole boot: never let the remote guard cut a legitimate one.
|
|
466
|
+
startup_grace_s=cfg.boot_timeout_s + cfg.startup_timeout_s + 300.0,
|
|
467
|
+
policy=self.policy_config(),
|
|
468
|
+
created_at=utc_now(),
|
|
469
|
+
watchdog_spec=spec,
|
|
470
|
+
activity_probe=probe,
|
|
471
|
+
runtime_pid_path=self.runtime.runtime_pid_path(),
|
|
472
|
+
runtime_log_path=self.runtime.runtime_log_path(),
|
|
473
|
+
)
|
|
474
|
+
runtime_env = dict(self.runtime.env())
|
|
475
|
+
# A configured key is already on the command line; only a generated
|
|
476
|
+
# one travels through the environment.
|
|
477
|
+
if api_key and api_key != cfg.runtime.api_key and self.runtime.api_key_env:
|
|
478
|
+
runtime_env[self.runtime.api_key_env] = api_key
|
|
479
|
+
return build_onstart_script(
|
|
480
|
+
agent_source=agent_source,
|
|
481
|
+
agent_config=agent_config,
|
|
482
|
+
runtime_start_command=self.runtime.start_command(),
|
|
483
|
+
runtime_env=runtime_env,
|
|
484
|
+
watchdog_enabled=enabled,
|
|
485
|
+
)
|
|
486
|
+
|
|
487
|
+
def _check_budget(self, spec: ComputeSpec, allow_override: bool) -> Optional[float]:
|
|
488
|
+
ceiling = self.config.budget.max_hourly_cost_usd
|
|
489
|
+
if not self.provider.capabilities.pricing_query:
|
|
490
|
+
if ceiling and not allow_override:
|
|
491
|
+
self.echo(
|
|
492
|
+
"! provider cannot query pricing; budget.max_hourly_cost_usd "
|
|
493
|
+
"cannot be enforced before provisioning"
|
|
494
|
+
)
|
|
495
|
+
return None
|
|
496
|
+
offers = self.provider.find_offers(spec, limit=5)
|
|
497
|
+
if not offers:
|
|
498
|
+
return None
|
|
499
|
+
cheapest = offers[0]
|
|
500
|
+
if ceiling and cheapest.hourly_cost_usd > float(ceiling):
|
|
501
|
+
if not allow_override:
|
|
502
|
+
self.events.emit(
|
|
503
|
+
ev.BUDGET_REJECTED,
|
|
504
|
+
cheapest_hourly_cost_usd=cheapest.hourly_cost_usd,
|
|
505
|
+
max_hourly_cost_usd=float(ceiling),
|
|
506
|
+
)
|
|
507
|
+
raise BudgetExceededError(
|
|
508
|
+
f"cheapest matching offer is ${cheapest.hourly_cost_usd:.3f}/h, above "
|
|
509
|
+
f"budget.max_hourly_cost_usd (${float(ceiling):.3f}/h). "
|
|
510
|
+
"Re-run with --allow-cost-override to proceed anyway."
|
|
511
|
+
)
|
|
512
|
+
self.echo(
|
|
513
|
+
f"! cost override: ${cheapest.hourly_cost_usd:.3f}/h exceeds the "
|
|
514
|
+
f"${float(ceiling):.3f}/h budget"
|
|
515
|
+
)
|
|
516
|
+
return cheapest.hourly_cost_usd
|
|
517
|
+
|
|
518
|
+
def setup(
|
|
519
|
+
self,
|
|
520
|
+
*,
|
|
521
|
+
recreate: bool = False,
|
|
522
|
+
allow_cost_override: bool = False,
|
|
523
|
+
wait: bool = True,
|
|
524
|
+
) -> RuntimeState:
|
|
525
|
+
cfg = self.config
|
|
526
|
+
fingerprint = cfg.fingerprint()
|
|
527
|
+
|
|
528
|
+
with self.store.process_lock():
|
|
529
|
+
state = self._reconcile(self.store.load())
|
|
530
|
+
|
|
531
|
+
# -- idempotency (spec section 23) -----------------------------
|
|
532
|
+
if state.has_instance():
|
|
533
|
+
if state.fingerprint != fingerprint and not recreate:
|
|
534
|
+
raise ConfigError(
|
|
535
|
+
f"instance {state.instance_id} was provisioned for a different "
|
|
536
|
+
"runtime (jambu.yaml changed since setup). Re-run with "
|
|
537
|
+
"`--recreate` to replace it, or `gpu destroy` first."
|
|
538
|
+
)
|
|
539
|
+
if not recreate:
|
|
540
|
+
self.echo(f"= reusing instance {state.instance_id} ({state.state})")
|
|
541
|
+
self.store.save(state)
|
|
542
|
+
return self.ensure_ready(state=state) if wait else state
|
|
543
|
+
self.echo(f"~ replacing instance {state.instance_id}")
|
|
544
|
+
self._terminate(state, action=policy.ACTION_DESTROY, reason="recreate")
|
|
545
|
+
|
|
546
|
+
# -- provision ------------------------------------------------
|
|
547
|
+
token = watchdog_pkg.generate_token()
|
|
548
|
+
api_key = self._api_key()
|
|
549
|
+
onstart = self._build_onstart(token, api_key)
|
|
550
|
+
|
|
551
|
+
self.events.emit(
|
|
552
|
+
ev.PROVISION_REQUESTED,
|
|
553
|
+
model=cfg.model.id,
|
|
554
|
+
engine=cfg.runtime.engine,
|
|
555
|
+
gpu_count=cfg.compute.gpu.count,
|
|
556
|
+
min_vram_gb=cfg.compute.gpu.min_vram_gb,
|
|
557
|
+
fingerprint=fingerprint,
|
|
558
|
+
)
|
|
559
|
+
self.echo(
|
|
560
|
+
f"> searching {self.provider.display_name} for {cfg.compute.gpu.count}x "
|
|
561
|
+
f">={cfg.compute.gpu.min_vram_gb}GB GPU, {cfg.compute.disk_gb}GB disk"
|
|
562
|
+
)
|
|
563
|
+
|
|
564
|
+
# On a GPU marketplace a bad host is normal: one that cannot pull
|
|
565
|
+
# the image, or reach the registry at all, costs one attempt and
|
|
566
|
+
# its machine is not rented again by this setup. Retrying means
|
|
567
|
+
# destroying the bad one first, so it needs cleanup_on_setup_failure.
|
|
568
|
+
attempts = cfg.compute.host_attempts if cfg.lifecycle.cleanup_on_setup_failure else 1
|
|
569
|
+
bad_hosts = BadHosts()
|
|
570
|
+
excluded: list[str] = bad_hosts.recent(self.provider.name)
|
|
571
|
+
for attempt in range(1, attempts + 1):
|
|
572
|
+
spec = self.build_spec(onstart)
|
|
573
|
+
spec.exclude_hosts = list(excluded)
|
|
574
|
+
# Every new offer is held to the budget, not just the first.
|
|
575
|
+
estimated = self._check_budget(spec, allow_cost_override)
|
|
576
|
+
if estimated:
|
|
577
|
+
self.echo(f" cheapest match: ${estimated:.3f}/h")
|
|
578
|
+
try:
|
|
579
|
+
return self._provision(state, spec, token, fingerprint, wait, api_key)
|
|
580
|
+
except HostFailure as exc:
|
|
581
|
+
retry = attempt < attempts
|
|
582
|
+
self._host_failed(state, exc, attempt, attempts, retry)
|
|
583
|
+
if retry and self._discard_host(state, exc, excluded, bad_hosts):
|
|
584
|
+
continue
|
|
585
|
+
if exc.instance is not None and exc.instance.host_id:
|
|
586
|
+
# The last host failed as surely as the others: the
|
|
587
|
+
# next setup must not walk straight back onto it.
|
|
588
|
+
bad_hosts.mark(self.provider.name, exc.instance.host_id, exc.reason)
|
|
589
|
+
if retry or attempts == 1:
|
|
590
|
+
self._fail_setup(state, exc)
|
|
591
|
+
raise
|
|
592
|
+
final = HostFailure(
|
|
593
|
+
f"{attempts} machines in a row failed to boot, giving up "
|
|
594
|
+
f"(compute.host_attempts). Last: {exc}",
|
|
595
|
+
reason=exc.reason,
|
|
596
|
+
instance=exc.instance,
|
|
597
|
+
)
|
|
598
|
+
self._fail_setup(state, final)
|
|
599
|
+
raise final from exc
|
|
600
|
+
except (JambuError, KeyboardInterrupt) as exc:
|
|
601
|
+
# Ctrl-C included: nobody is left to use the machine this
|
|
602
|
+
# setup was renting, so it must not stay booting on the bill.
|
|
603
|
+
self._fail_setup(state, exc)
|
|
604
|
+
raise
|
|
605
|
+
raise AssertionError("unreachable: the last attempt returns or raises")
|
|
606
|
+
|
|
607
|
+
def _provision(
|
|
608
|
+
self,
|
|
609
|
+
state: RuntimeState,
|
|
610
|
+
spec: ComputeSpec,
|
|
611
|
+
token: str,
|
|
612
|
+
fingerprint: str,
|
|
613
|
+
wait: bool,
|
|
614
|
+
api_key: Optional[str] = None,
|
|
615
|
+
) -> RuntimeState:
|
|
616
|
+
cfg = self.config
|
|
617
|
+
instance = self.provider.setup(spec)
|
|
618
|
+
self.echo(
|
|
619
|
+
f"+ instance {instance.id} created "
|
|
620
|
+
f"({instance.gpu_count or cfg.compute.gpu.count}x "
|
|
621
|
+
f"{instance.gpu_name or 'GPU'}"
|
|
622
|
+
+ (
|
|
623
|
+
f", ${instance.hourly_cost_usd:.3f}/h)"
|
|
624
|
+
if instance.hourly_cost_usd
|
|
625
|
+
else ")"
|
|
626
|
+
)
|
|
627
|
+
)
|
|
628
|
+
|
|
629
|
+
state.provider = self.provider.name
|
|
630
|
+
state.instance_id = instance.id
|
|
631
|
+
state.instance = instance
|
|
632
|
+
state.fingerprint = fingerprint
|
|
633
|
+
state.model_id = cfg.model.id
|
|
634
|
+
state.api_key = api_key
|
|
635
|
+
state.created_at = instance.created_at or utc_now()
|
|
636
|
+
state.last_activity = utc_now()
|
|
637
|
+
state.starting = True
|
|
638
|
+
state.state = InstanceState.PROVISIONING.value
|
|
639
|
+
state.hourly_cost_usd = instance.hourly_cost_usd
|
|
640
|
+
state.locked = False
|
|
641
|
+
state.locked_until = None
|
|
642
|
+
# Billing starts the moment the provider creates the instance,
|
|
643
|
+
# not at the next reconcile - start the clock now so a long
|
|
644
|
+
# provisioning/startup wait is never undercounted.
|
|
645
|
+
state.billed_seconds = 0.0
|
|
646
|
+
state.running_since = state.created_at
|
|
647
|
+
state.mark_session_start(state.created_at)
|
|
648
|
+
state.watchdog = WatchdogInfo(
|
|
649
|
+
installed=cfg.lifecycle.watchdog.enabled,
|
|
650
|
+
token=token,
|
|
651
|
+
port=cfg.lifecycle.watchdog.port,
|
|
652
|
+
url=instance.endpoint_url("watchdog"),
|
|
653
|
+
)
|
|
654
|
+
# Persist before the long wait: a crash here must still leave a
|
|
655
|
+
# destroyable record (spec criterion 4).
|
|
656
|
+
self.store.save(state)
|
|
657
|
+
self.events.emit(
|
|
658
|
+
ev.INSTANCE_CREATED,
|
|
659
|
+
instance_id=instance.id,
|
|
660
|
+
host_id=instance.host_id,
|
|
661
|
+
gpu_name=instance.gpu_name,
|
|
662
|
+
gpu_count=instance.gpu_count,
|
|
663
|
+
hourly_cost_usd=instance.hourly_cost_usd,
|
|
664
|
+
)
|
|
665
|
+
|
|
666
|
+
if not wait:
|
|
667
|
+
return state
|
|
668
|
+
return self.ensure_ready(state=state)
|
|
669
|
+
|
|
670
|
+
def _host_failed(
|
|
671
|
+
self,
|
|
672
|
+
state: RuntimeState,
|
|
673
|
+
exc: HostFailure,
|
|
674
|
+
attempt: int,
|
|
675
|
+
attempts: int,
|
|
676
|
+
retry: bool,
|
|
677
|
+
) -> None:
|
|
678
|
+
instance = exc.instance
|
|
679
|
+
host_id = instance.host_id if instance else None
|
|
680
|
+
instance_id = instance.id if instance else state.instance_id
|
|
681
|
+
self.events.emit(
|
|
682
|
+
ev.HOST_FAILED,
|
|
683
|
+
instance_id=instance_id,
|
|
684
|
+
host_id=host_id,
|
|
685
|
+
reason=exc.reason,
|
|
686
|
+
error=str(exc),
|
|
687
|
+
attempt=attempt,
|
|
688
|
+
host_attempts=attempts,
|
|
689
|
+
)
|
|
690
|
+
line = f"! host {host_id or instance_id} failed ({exc.reason})"
|
|
691
|
+
if retry:
|
|
692
|
+
line += f"; trying another machine ({attempt + 1}/{attempts})"
|
|
693
|
+
self.echo(line)
|
|
694
|
+
|
|
695
|
+
def _discard_host(
|
|
696
|
+
self,
|
|
697
|
+
state: RuntimeState,
|
|
698
|
+
exc: HostFailure,
|
|
699
|
+
excluded: list[str],
|
|
700
|
+
bad_hosts: Optional["BadHosts"] = None,
|
|
701
|
+
) -> bool:
|
|
702
|
+
"""Destroy a machine that failed to boot so another can be rented.
|
|
703
|
+
|
|
704
|
+
False when it could not be destroyed: renting another would leave two
|
|
705
|
+
instances billing, so setup stops there.
|
|
706
|
+
"""
|
|
707
|
+
instance = exc.instance
|
|
708
|
+
try:
|
|
709
|
+
if state.instance_id:
|
|
710
|
+
self._terminate(state, action=policy.ACTION_DESTROY, reason="host_failure")
|
|
711
|
+
elif instance is not None:
|
|
712
|
+
# Vanished: already out of the state. Destroying is idempotent
|
|
713
|
+
# and makes sure it is really gone before renting another.
|
|
714
|
+
self.provider.destroy(instance)
|
|
715
|
+
except JambuError as cleanup_exc:
|
|
716
|
+
self.echo(f"! could not destroy the failed instance: {cleanup_exc}")
|
|
717
|
+
return False
|
|
718
|
+
self.store.save(state)
|
|
719
|
+
host_id = instance.host_id if instance else None
|
|
720
|
+
if host_id:
|
|
721
|
+
excluded.append(host_id)
|
|
722
|
+
if bad_hosts is not None:
|
|
723
|
+
bad_hosts.mark(self.provider.name, host_id, exc.reason)
|
|
724
|
+
return True
|
|
725
|
+
|
|
726
|
+
def _fail_setup(self, state: RuntimeState, exc: BaseException) -> None:
|
|
727
|
+
cfg = self.config
|
|
728
|
+
self.events.emit(ev.SETUP_FAILED, error=str(exc), error_type=type(exc).__name__)
|
|
729
|
+
if cfg.lifecycle.cleanup_on_setup_failure and state.instance_id:
|
|
730
|
+
self.echo(f"! setup failed, cleaning up instance {state.instance_id}")
|
|
731
|
+
try:
|
|
732
|
+
self._terminate(state, action=policy.ACTION_DESTROY, reason="setup_failure")
|
|
733
|
+
self.events.emit(ev.CLEANUP_PERFORMED, instance_id=state.instance_id)
|
|
734
|
+
except JambuError as cleanup_exc:
|
|
735
|
+
self.events.emit(
|
|
736
|
+
ev.PROVIDER_ERROR, operation="cleanup", error=str(cleanup_exc)
|
|
737
|
+
)
|
|
738
|
+
self.echo(f"! cleanup failed: {cleanup_exc}")
|
|
739
|
+
self.store.save(state)
|
|
740
|
+
|
|
741
|
+
# ------------------------------------------------------------------
|
|
742
|
+
# readiness
|
|
743
|
+
# ------------------------------------------------------------------
|
|
744
|
+
|
|
745
|
+
def ensure_ready(self, state: Optional[RuntimeState] = None) -> RuntimeState:
|
|
746
|
+
"""Guarantee the configured runtime is available (spec section 16)."""
|
|
747
|
+
with self.store.process_lock():
|
|
748
|
+
state = state if state is not None else self._reconcile(self.store.load())
|
|
749
|
+
|
|
750
|
+
if not state.has_instance():
|
|
751
|
+
self.store.save(state)
|
|
752
|
+
return self.setup()
|
|
753
|
+
|
|
754
|
+
instance = state.instance
|
|
755
|
+
if instance is None:
|
|
756
|
+
raise StateError("state has an instance id but no instance record")
|
|
757
|
+
|
|
758
|
+
if state.state == InstanceState.STOPPED.value:
|
|
759
|
+
if not self.provider.capabilities.stop:
|
|
760
|
+
raise UnsupportedCapabilityError(
|
|
761
|
+
f"provider '{self.provider.name}' cannot restart instances"
|
|
762
|
+
)
|
|
763
|
+
self.echo(f"> starting instance {instance.id}")
|
|
764
|
+
instance = self.provider.start(instance)
|
|
765
|
+
state.instance = instance
|
|
766
|
+
state.starting = True
|
|
767
|
+
state.mark_billable(True) # billing resumes now, not at the next reconcile
|
|
768
|
+
state.mark_session_start() # max_lifetime and budget restart with it
|
|
769
|
+
self.events.emit(ev.INSTANCE_STARTED, instance_id=instance.id)
|
|
770
|
+
|
|
771
|
+
instance = self._wait_running(state)
|
|
772
|
+
state.instance = instance
|
|
773
|
+
state.endpoint = instance.endpoint_url(ENDPOINT_NAME)
|
|
774
|
+
watchdog_url = instance.endpoint_url("watchdog")
|
|
775
|
+
if watchdog_url:
|
|
776
|
+
state.watchdog.url = watchdog_url
|
|
777
|
+
self.store.save(state)
|
|
778
|
+
|
|
779
|
+
self._connect_watchdog(state)
|
|
780
|
+
|
|
781
|
+
if self.config.runtime.engine != "none":
|
|
782
|
+
self._wait_runtime(state)
|
|
783
|
+
|
|
784
|
+
state.starting = False
|
|
785
|
+
state.state = InstanceState.READY.value
|
|
786
|
+
state.touch()
|
|
787
|
+
self.store.save(state)
|
|
788
|
+
return state
|
|
789
|
+
|
|
790
|
+
def _wait_running(self, state: RuntimeState) -> Instance:
|
|
791
|
+
"""Wait for the machine itself (mostly the image pull), up to
|
|
792
|
+
health.boot_timeout. The container has not run yet, so every failure
|
|
793
|
+
here is the host's.
|
|
794
|
+
"""
|
|
795
|
+
instance = state.instance
|
|
796
|
+
assert instance is not None
|
|
797
|
+
boot_timeout = self.config.boot_timeout_s
|
|
798
|
+
stall_timeout = self.config.pull_stall_timeout_s
|
|
799
|
+
deadline = time.time() + boot_timeout
|
|
800
|
+
announced = False
|
|
801
|
+
# Live, healthy pulls printed a new line every 1-8 minutes; stalled ones
|
|
802
|
+
# froze (or retried one layer) until boot_timeout, 30 minutes per host.
|
|
803
|
+
progress: Optional[str] = None
|
|
804
|
+
progress_at = time.time()
|
|
805
|
+
while time.time() < deadline:
|
|
806
|
+
status = self.provider.status(instance)
|
|
807
|
+
if not status.exists:
|
|
808
|
+
state.clear_instance()
|
|
809
|
+
self.store.save(state)
|
|
810
|
+
raise HostFailure(
|
|
811
|
+
f"instance {instance.id} vanished at the provider while starting",
|
|
812
|
+
reason="vanished while booting",
|
|
813
|
+
instance=instance,
|
|
814
|
+
)
|
|
815
|
+
current = status.instance or instance
|
|
816
|
+
if status.state is InstanceState.FAILED:
|
|
817
|
+
detail = (status.message or "").strip() or "no detail"
|
|
818
|
+
raise HostFailure(
|
|
819
|
+
f"instance {instance.id} failed: {detail}",
|
|
820
|
+
reason=detail.splitlines()[0][:160],
|
|
821
|
+
instance=current,
|
|
822
|
+
)
|
|
823
|
+
ready_ports = self._ports()
|
|
824
|
+
have_ports = all(
|
|
825
|
+
current.endpoints.get(name) is not None
|
|
826
|
+
for name in self._endpoint_names()
|
|
827
|
+
)
|
|
828
|
+
if status.state in (
|
|
829
|
+
InstanceState.READY,
|
|
830
|
+
InstanceState.BUSY,
|
|
831
|
+
InstanceState.IDLE,
|
|
832
|
+
) and (have_ports or not ready_ports):
|
|
833
|
+
return current
|
|
834
|
+
moved = _pull_progress(status.message)
|
|
835
|
+
if moved is not None and moved != progress:
|
|
836
|
+
progress, progress_at = moved, time.time()
|
|
837
|
+
elif stall_timeout and time.time() - progress_at >= stall_timeout:
|
|
838
|
+
last = (status.message or "").strip().splitlines()
|
|
839
|
+
raise HostFailure(
|
|
840
|
+
f"instance {instance.id}'s image pull made no progress for "
|
|
841
|
+
f"{format_duration(stall_timeout)} (health.pull_stall_timeout)"
|
|
842
|
+
+ (f"; last: {last[-1][:160]}" if last else ""),
|
|
843
|
+
reason=f"image pull stalled for {format_duration(stall_timeout)}",
|
|
844
|
+
instance=current,
|
|
845
|
+
)
|
|
846
|
+
if not announced:
|
|
847
|
+
announced = True
|
|
848
|
+
self.echo(" waiting for the machine to come up ...")
|
|
849
|
+
time.sleep(6)
|
|
850
|
+
raise HostFailure(
|
|
851
|
+
f"instance {instance.id} was not running after "
|
|
852
|
+
f"{format_duration(boot_timeout)} (health.boot_timeout)",
|
|
853
|
+
reason=f"image pull timed out after {format_duration(boot_timeout)}",
|
|
854
|
+
instance=instance,
|
|
855
|
+
)
|
|
856
|
+
|
|
857
|
+
def _endpoint_names(self) -> list[str]:
|
|
858
|
+
names = []
|
|
859
|
+
if self.config.runtime.engine != "none":
|
|
860
|
+
names.append(ENDPOINT_NAME)
|
|
861
|
+
if self.config.lifecycle.watchdog.enabled:
|
|
862
|
+
names.append("watchdog")
|
|
863
|
+
return names
|
|
864
|
+
|
|
865
|
+
def _connect_watchdog(self, state: RuntimeState) -> None:
|
|
866
|
+
if not self.config.lifecycle.watchdog.enabled:
|
|
867
|
+
return
|
|
868
|
+
client = self.beacon(state)
|
|
869
|
+
if client is None:
|
|
870
|
+
state.watchdog.last_error = "no watchdog endpoint published"
|
|
871
|
+
self.echo("! watchdog port was not published; lifecycle guard is local-only")
|
|
872
|
+
return
|
|
873
|
+
|
|
874
|
+
deadline = time.time() + 90
|
|
875
|
+
while time.time() < deadline:
|
|
876
|
+
if client.ping():
|
|
877
|
+
client.identify(state.instance_id or "", state.hourly_cost_usd)
|
|
878
|
+
client.mark_starting()
|
|
879
|
+
state.watchdog.installed = True
|
|
880
|
+
state.watchdog.last_contact = utc_now()
|
|
881
|
+
state.watchdog.last_error = None
|
|
882
|
+
self.echo(f"+ lifecycle watchdog live at {state.watchdog.url}")
|
|
883
|
+
self.store.save(state)
|
|
884
|
+
return
|
|
885
|
+
time.sleep(5)
|
|
886
|
+
|
|
887
|
+
state.watchdog.last_error = "did not answer within 90s"
|
|
888
|
+
self.echo(
|
|
889
|
+
"! the remote watchdog never answered. The instance is NOT protected "
|
|
890
|
+
"by an independent guard - check `gpu logs`, and run "
|
|
891
|
+
"`gpu destroy` if this is unexpected."
|
|
892
|
+
)
|
|
893
|
+
|
|
894
|
+
def _wait_runtime(self, state: RuntimeState) -> None:
|
|
895
|
+
endpoint = state.endpoint
|
|
896
|
+
if not endpoint:
|
|
897
|
+
raise RuntimeStartupError(
|
|
898
|
+
"the model port was never published by the provider; cannot reach the runtime"
|
|
899
|
+
)
|
|
900
|
+
url = self.runtime.health_url(endpoint)
|
|
901
|
+
self.events.emit(
|
|
902
|
+
ev.RUNTIME_STARTING, endpoint=endpoint, engine=self.config.runtime.engine
|
|
903
|
+
)
|
|
904
|
+
self.echo(f"> waiting for {self.runtime.describe()} at {endpoint}")
|
|
905
|
+
started = time.time()
|
|
906
|
+
|
|
907
|
+
def _progress(attempt: int, remaining: float, detail: str) -> None:
|
|
908
|
+
if attempt % 4 == 0:
|
|
909
|
+
self.echo(
|
|
910
|
+
f" still starting ({int(time.time() - started)}s elapsed, "
|
|
911
|
+
f"{int(remaining)}s left, last probe: {detail})"
|
|
912
|
+
)
|
|
913
|
+
|
|
914
|
+
def _abort() -> Optional[str]:
|
|
915
|
+
instance = state.instance
|
|
916
|
+
if instance is None:
|
|
917
|
+
return None
|
|
918
|
+
try:
|
|
919
|
+
status = self.provider.status(instance)
|
|
920
|
+
except ProviderError:
|
|
921
|
+
return None
|
|
922
|
+
if not status.exists:
|
|
923
|
+
return "the instance disappeared at the provider"
|
|
924
|
+
if status.state is InstanceState.FAILED:
|
|
925
|
+
return status.message or "the instance entered a failed state"
|
|
926
|
+
if status.state is InstanceState.STOPPED:
|
|
927
|
+
return "the instance was stopped"
|
|
928
|
+
reason = self._runtime_exited(state)
|
|
929
|
+
if reason and _HOST_NETWORK_FAILURE.search(reason):
|
|
930
|
+
# The model server died fetching weights over a broken network:
|
|
931
|
+
# the machine's fault, so another host gets a chance.
|
|
932
|
+
raise HostFailure(
|
|
933
|
+
f"model server did not start: {reason}",
|
|
934
|
+
reason="model download failed on this host's network",
|
|
935
|
+
instance=state.instance,
|
|
936
|
+
)
|
|
937
|
+
return reason
|
|
938
|
+
|
|
939
|
+
wait_for_healthy(
|
|
940
|
+
url,
|
|
941
|
+
timeout=self.config.startup_timeout_s,
|
|
942
|
+
interval=self.config.health_interval_s,
|
|
943
|
+
on_attempt=_progress,
|
|
944
|
+
should_abort=_abort,
|
|
945
|
+
)
|
|
946
|
+
client = self.beacon(state)
|
|
947
|
+
if client is not None:
|
|
948
|
+
client.mark_ready()
|
|
949
|
+
self.events.emit(
|
|
950
|
+
ev.RUNTIME_READY,
|
|
951
|
+
endpoint=endpoint,
|
|
952
|
+
startup_seconds=round(time.time() - started, 1),
|
|
953
|
+
)
|
|
954
|
+
self.echo(f"+ runtime ready: {endpoint}")
|
|
955
|
+
|
|
956
|
+
def _runtime_exited(self, state: RuntimeState) -> Optional[str]:
|
|
957
|
+
"""The watchdog saw the model server process die: say why, from its log."""
|
|
958
|
+
client = self.beacon(state)
|
|
959
|
+
if client is None:
|
|
960
|
+
return None
|
|
961
|
+
snapshot = client.try_state()
|
|
962
|
+
if not snapshot or snapshot.get("runtime_alive") is not False:
|
|
963
|
+
return None
|
|
964
|
+
reason = "the model server process exited on the instance"
|
|
965
|
+
try:
|
|
966
|
+
tail = client.logs("runtime", STARTUP_FAILURE_LOG_LINES)
|
|
967
|
+
except WatchdogUnavailable:
|
|
968
|
+
tail = []
|
|
969
|
+
if not tail:
|
|
970
|
+
return reason + " (its log is empty or unreadable)"
|
|
971
|
+
return reason + ". Last lines of its log:\n" + "\n".join(
|
|
972
|
+
" " + line for line in tail
|
|
973
|
+
)
|
|
974
|
+
|
|
975
|
+
# ------------------------------------------------------------------
|
|
976
|
+
# run (spec section 16)
|
|
977
|
+
# ------------------------------------------------------------------
|
|
978
|
+
|
|
979
|
+
def api_key_for(self, state: RuntimeState) -> str:
|
|
980
|
+
# An instance from before generated keys runs without one; vLLM then
|
|
981
|
+
# accepts any value, and OpenAI clients refuse an empty one.
|
|
982
|
+
return state.api_key or self.config.runtime.api_key or "jambu-local"
|
|
983
|
+
|
|
984
|
+
def served_model(self) -> str:
|
|
985
|
+
"""The name clients pass as `model` in OpenAI requests."""
|
|
986
|
+
return self.config.model.served_name or self.config.model.id
|
|
987
|
+
|
|
988
|
+
def connection(self, state: RuntimeState) -> dict[str, Any]:
|
|
989
|
+
"""How an application reaches the model: the stable integration contract."""
|
|
990
|
+
endpoint = state.endpoint
|
|
991
|
+
return {
|
|
992
|
+
"instance_id": state.instance_id,
|
|
993
|
+
"state": state.state if state.instance_id else "none",
|
|
994
|
+
"endpoint": endpoint,
|
|
995
|
+
"base_url": f"{endpoint.rstrip('/')}/v1" if endpoint else None,
|
|
996
|
+
"api_key": self.api_key_for(state) if endpoint else None,
|
|
997
|
+
"model": self.served_model(),
|
|
998
|
+
"hourly_cost_usd": state.hourly_cost_usd,
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
def workload_env(self, state: RuntimeState, workload_id: str) -> dict[str, str]:
|
|
1002
|
+
endpoint = state.endpoint or ""
|
|
1003
|
+
env: dict[str, str] = {
|
|
1004
|
+
"JAMBU_PROVIDER": self.provider.name,
|
|
1005
|
+
"JAMBU_INSTANCE_ID": state.instance_id or "",
|
|
1006
|
+
"JAMBU_WORKLOAD_ID": workload_id,
|
|
1007
|
+
"JAMBU_MODEL_ID": self.served_model(),
|
|
1008
|
+
}
|
|
1009
|
+
if endpoint:
|
|
1010
|
+
env["JAMBU_GPU_ENDPOINT"] = endpoint
|
|
1011
|
+
env["OPENAI_BASE_URL"] = f"{endpoint.rstrip('/')}/v1"
|
|
1012
|
+
env["OPENAI_API_KEY"] = self.api_key_for(state)
|
|
1013
|
+
if state.instance and state.instance.ssh_host:
|
|
1014
|
+
env["JAMBU_SSH_HOST"] = state.instance.ssh_host
|
|
1015
|
+
env["JAMBU_SSH_PORT"] = str(state.instance.ssh_port or 22)
|
|
1016
|
+
env.update({k: str(v) for k, v in self.config.workload.env.items()})
|
|
1017
|
+
return env
|
|
1018
|
+
|
|
1019
|
+
def run(self, command: Sequence[str], label: str = "") -> int:
|
|
1020
|
+
if not command:
|
|
1021
|
+
raise ConfigError("nothing to run: pass a command, e.g. `gpu run python x.py`")
|
|
1022
|
+
|
|
1023
|
+
state = self.ensure_ready()
|
|
1024
|
+
manager = ExecutionManager(
|
|
1025
|
+
self.store,
|
|
1026
|
+
self.events,
|
|
1027
|
+
beacon=self.beacon(state),
|
|
1028
|
+
heartbeat_interval=self.config.heartbeat_interval_s,
|
|
1029
|
+
)
|
|
1030
|
+
record = manager.register(command, label=label)
|
|
1031
|
+
workload = Workload(
|
|
1032
|
+
id=record.id,
|
|
1033
|
+
command=list(command),
|
|
1034
|
+
label=record.label,
|
|
1035
|
+
cwd=self.config.workload.workdir or str(self.config.project_dir),
|
|
1036
|
+
env=self.workload_env(state, record.id),
|
|
1037
|
+
mode=self.config.workload.mode,
|
|
1038
|
+
)
|
|
1039
|
+
|
|
1040
|
+
returncode: Optional[int] = None
|
|
1041
|
+
try:
|
|
1042
|
+
with Heartbeat(
|
|
1043
|
+
record.id,
|
|
1044
|
+
self.config.heartbeat_interval_s,
|
|
1045
|
+
self.store,
|
|
1046
|
+
self.beacon(state),
|
|
1047
|
+
):
|
|
1048
|
+
if workload.mode == "remote":
|
|
1049
|
+
self.provider.require_capability("remote_exec")
|
|
1050
|
+
execution = self.provider.run(state.instance, workload)
|
|
1051
|
+
else:
|
|
1052
|
+
execution = manager.run_local(workload, record)
|
|
1053
|
+
returncode = execution.returncode
|
|
1054
|
+
finally:
|
|
1055
|
+
manager.finish(record, returncode)
|
|
1056
|
+
|
|
1057
|
+
if returncode:
|
|
1058
|
+
raise WorkloadFailed(
|
|
1059
|
+
f"workload exited with code {returncode}", returncode=returncode
|
|
1060
|
+
)
|
|
1061
|
+
return 0
|
|
1062
|
+
|
|
1063
|
+
# ------------------------------------------------------------------
|
|
1064
|
+
# status (spec section 18)
|
|
1065
|
+
# ------------------------------------------------------------------
|
|
1066
|
+
|
|
1067
|
+
def _observe(self) -> tuple[RuntimeState, bool]:
|
|
1068
|
+
"""Reconciled state, or the last saved one when another command holds the lock."""
|
|
1069
|
+
try:
|
|
1070
|
+
with self.store.process_lock(timeout=READ_LOCK_WAIT_S):
|
|
1071
|
+
state = self._reconcile(self.store.load())
|
|
1072
|
+
self.store.save(state)
|
|
1073
|
+
return state, False
|
|
1074
|
+
except LockBusy:
|
|
1075
|
+
return self.store.load(), True
|
|
1076
|
+
|
|
1077
|
+
def status(self) -> dict[str, Any]:
|
|
1078
|
+
state, busy = self._observe()
|
|
1079
|
+
|
|
1080
|
+
cfg = self.policy_config(state.hourly_cost_usd)
|
|
1081
|
+
idle_skipped = self._idle_blind(state)
|
|
1082
|
+
if idle_skipped:
|
|
1083
|
+
cfg.idle_timeout_s = None
|
|
1084
|
+
snap = self.snapshot(state)
|
|
1085
|
+
decision = policy.evaluate(snap, cfg)
|
|
1086
|
+
if idle_skipped:
|
|
1087
|
+
decision.detail["idle_skipped"] = "watchdog_unreachable"
|
|
1088
|
+
remote = None
|
|
1089
|
+
client = self.beacon(state)
|
|
1090
|
+
if client is not None:
|
|
1091
|
+
remote = client.try_state()
|
|
1092
|
+
|
|
1093
|
+
return {
|
|
1094
|
+
"provider": self.provider.name,
|
|
1095
|
+
"instance_id": state.instance_id,
|
|
1096
|
+
"state": state.state if state.instance_id else "none",
|
|
1097
|
+
"model": state.model_id or self.config.model.id,
|
|
1098
|
+
"endpoint": state.endpoint,
|
|
1099
|
+
"base_url": f"{state.endpoint.rstrip('/')}/v1" if state.endpoint else None,
|
|
1100
|
+
"created_at": state.created_at,
|
|
1101
|
+
"age_seconds": snap.age_seconds() if state.created_at else None,
|
|
1102
|
+
"idle_seconds": snap.idle_seconds(cfg),
|
|
1103
|
+
"locked": snap.lock_active(cfg),
|
|
1104
|
+
"locked_until": state.locked_until,
|
|
1105
|
+
"hourly_cost_usd": state.hourly_cost_usd,
|
|
1106
|
+
"session_cost_usd": state.session_cost_usd,
|
|
1107
|
+
"session_started_at": state.session_started_at,
|
|
1108
|
+
"stopped_since": state.stopped_since,
|
|
1109
|
+
"workloads": [
|
|
1110
|
+
{
|
|
1111
|
+
"id": w.id,
|
|
1112
|
+
"label": w.label,
|
|
1113
|
+
"state": w.state,
|
|
1114
|
+
"started_at": w.started_at,
|
|
1115
|
+
"last_heartbeat": w.last_heartbeat,
|
|
1116
|
+
"returncode": w.returncode,
|
|
1117
|
+
}
|
|
1118
|
+
for w in state.workloads.values()
|
|
1119
|
+
],
|
|
1120
|
+
"decision": decision.to_dict(),
|
|
1121
|
+
"policy": cfg.to_dict(),
|
|
1122
|
+
"watchdog": {
|
|
1123
|
+
"enabled": self.config.lifecycle.watchdog.enabled,
|
|
1124
|
+
"installed": state.watchdog.installed,
|
|
1125
|
+
"url": state.watchdog.url,
|
|
1126
|
+
"reachable": remote is not None,
|
|
1127
|
+
"last_error": state.watchdog.last_error,
|
|
1128
|
+
"remote_state": remote,
|
|
1129
|
+
},
|
|
1130
|
+
"capabilities": self.provider.capabilities.__dict__,
|
|
1131
|
+
# True while another command (a setup) holds the lock: the state is
|
|
1132
|
+
# the one it last saved, not freshly asked of the provider.
|
|
1133
|
+
"busy": busy,
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
# ------------------------------------------------------------------
|
|
1137
|
+
# lock / unlock (spec section 11)
|
|
1138
|
+
# ------------------------------------------------------------------
|
|
1139
|
+
|
|
1140
|
+
def lock(self, ttl_seconds: Optional[float], reason: str = "") -> dict[str, Any]:
|
|
1141
|
+
if ttl_seconds is None and not self.config.lifecycle.allow_indefinite_lock:
|
|
1142
|
+
raise ConfigError(
|
|
1143
|
+
"indefinite locks are disabled. Pass `--for 2h`, or set "
|
|
1144
|
+
"lifecycle.allow_indefinite_lock: true in jambu.yaml."
|
|
1145
|
+
)
|
|
1146
|
+
with self.store.process_lock():
|
|
1147
|
+
state = self._reconcile(self.store.load())
|
|
1148
|
+
if not state.has_instance():
|
|
1149
|
+
raise StateError("no instance to lock")
|
|
1150
|
+
until = utc_now() + ttl_seconds if ttl_seconds else None
|
|
1151
|
+
client = self.beacon(state)
|
|
1152
|
+
if client is not None and client.try_request(
|
|
1153
|
+
"POST", "/lock", {"ttl_seconds": ttl_seconds, "reason": reason}
|
|
1154
|
+
) is None:
|
|
1155
|
+
state.watchdog.last_error = "unreachable"
|
|
1156
|
+
self.echo(
|
|
1157
|
+
"! the remote watchdog did not accept the lock; it may still stop "
|
|
1158
|
+
"the instance. Check `gpu status`."
|
|
1159
|
+
)
|
|
1160
|
+
state.locked = True
|
|
1161
|
+
state.locked_until = until
|
|
1162
|
+
state.lock_reason = reason
|
|
1163
|
+
state.touch()
|
|
1164
|
+
self.store.save(state)
|
|
1165
|
+
self.events.emit(
|
|
1166
|
+
ev.INSTANCE_LOCKED,
|
|
1167
|
+
instance_id=state.instance_id,
|
|
1168
|
+
locked_until=until,
|
|
1169
|
+
reason=reason,
|
|
1170
|
+
)
|
|
1171
|
+
return {"locked": True, "locked_until": until}
|
|
1172
|
+
|
|
1173
|
+
def unlock(self, reason: str = "manual") -> dict[str, Any]:
|
|
1174
|
+
with self.store.process_lock():
|
|
1175
|
+
state = self._reconcile(self.store.load())
|
|
1176
|
+
client = self.beacon(state)
|
|
1177
|
+
if client is not None:
|
|
1178
|
+
client.try_request("POST", "/unlock", {"reason": reason})
|
|
1179
|
+
state.locked = False
|
|
1180
|
+
state.locked_until = None
|
|
1181
|
+
state.lock_reason = ""
|
|
1182
|
+
state.touch()
|
|
1183
|
+
self.store.save(state)
|
|
1184
|
+
self.events.emit(ev.INSTANCE_UNLOCKED, instance_id=state.instance_id, reason=reason)
|
|
1185
|
+
return {"locked": False}
|
|
1186
|
+
|
|
1187
|
+
# ------------------------------------------------------------------
|
|
1188
|
+
# stop / destroy
|
|
1189
|
+
# ------------------------------------------------------------------
|
|
1190
|
+
|
|
1191
|
+
def stop(self, reason: str = "manual") -> dict[str, Any]:
|
|
1192
|
+
with self.store.process_lock():
|
|
1193
|
+
state = self._reconcile(self.store.load())
|
|
1194
|
+
if not state.has_instance():
|
|
1195
|
+
self.echo("= nothing to stop")
|
|
1196
|
+
return {"changed": False, "state": state.state}
|
|
1197
|
+
if state.state == InstanceState.STOPPED.value:
|
|
1198
|
+
self.echo(f"= instance {state.instance_id} is already stopped")
|
|
1199
|
+
return {"changed": False, "state": state.state}
|
|
1200
|
+
action = (
|
|
1201
|
+
policy.ACTION_STOP if self.provider.capabilities.stop else policy.ACTION_DESTROY
|
|
1202
|
+
)
|
|
1203
|
+
self._terminate(state, action=action, reason=reason)
|
|
1204
|
+
self.store.save(state)
|
|
1205
|
+
return {"changed": True, "state": state.state, "action": action}
|
|
1206
|
+
|
|
1207
|
+
def destroy(self, reason: str = "manual") -> dict[str, Any]:
|
|
1208
|
+
with self.store.process_lock():
|
|
1209
|
+
state = self._reconcile(self.store.load())
|
|
1210
|
+
if not state.instance_id:
|
|
1211
|
+
self.echo("= nothing to destroy")
|
|
1212
|
+
return {"changed": False, "state": state.state}
|
|
1213
|
+
self._terminate(state, action=policy.ACTION_DESTROY, reason=reason)
|
|
1214
|
+
self.store.save(state)
|
|
1215
|
+
return {"changed": True, "state": state.state}
|
|
1216
|
+
|
|
1217
|
+
def _terminate(self, state: RuntimeState, action: str, reason: str) -> None:
|
|
1218
|
+
instance = state.instance or (
|
|
1219
|
+
Instance(id=state.instance_id, provider=self.provider.name)
|
|
1220
|
+
if state.instance_id
|
|
1221
|
+
else None
|
|
1222
|
+
)
|
|
1223
|
+
if instance is None:
|
|
1224
|
+
return
|
|
1225
|
+
instance_id = instance.id
|
|
1226
|
+
try:
|
|
1227
|
+
if action == policy.ACTION_DESTROY:
|
|
1228
|
+
self.provider.destroy(instance)
|
|
1229
|
+
else:
|
|
1230
|
+
self.provider.stop(instance)
|
|
1231
|
+
except ProviderError as exc:
|
|
1232
|
+
self.events.emit(
|
|
1233
|
+
ev.PROVIDER_ERROR, operation=action, instance_id=instance_id, error=str(exc)
|
|
1234
|
+
)
|
|
1235
|
+
raise
|
|
1236
|
+
|
|
1237
|
+
if action == policy.ACTION_DESTROY:
|
|
1238
|
+
state.clear_instance(InstanceState.DESTROYED.value)
|
|
1239
|
+
self.events.emit(ev.INSTANCE_DESTROYED, instance_id=instance_id, reason=reason)
|
|
1240
|
+
self.echo(f"- instance {instance_id} destroyed ({reason})")
|
|
1241
|
+
else:
|
|
1242
|
+
state.state = InstanceState.STOPPED.value
|
|
1243
|
+
state.starting = False
|
|
1244
|
+
state.mark_billable(False)
|
|
1245
|
+
state.mark_stopped()
|
|
1246
|
+
if state.hourly_cost_usd:
|
|
1247
|
+
state.session_cost_usd = round(
|
|
1248
|
+
state.hourly_cost_usd * state.accrued_billable_seconds() / 3600.0, 4
|
|
1249
|
+
)
|
|
1250
|
+
for record in state.workloads.values():
|
|
1251
|
+
if record.state in ("running", "queued"):
|
|
1252
|
+
record.state = "failed"
|
|
1253
|
+
record.finished_at = utc_now()
|
|
1254
|
+
self.events.emit(ev.INSTANCE_STOPPED, instance_id=instance_id, reason=reason)
|
|
1255
|
+
self.echo(f"- instance {instance_id} stopped ({reason})")
|
|
1256
|
+
|
|
1257
|
+
# ------------------------------------------------------------------
|
|
1258
|
+
# guard (external controller path, spec section 14 alternative)
|
|
1259
|
+
# ------------------------------------------------------------------
|
|
1260
|
+
|
|
1261
|
+
def guard_tick(self, apply: bool = True) -> dict[str, Any]:
|
|
1262
|
+
"""Evaluate the lifecycle rule locally and optionally enforce it.
|
|
1263
|
+
|
|
1264
|
+
The remote watchdog does this on the instance. This entry point exists
|
|
1265
|
+
for a scheduled external controller (CI job, cron) and for `status`.
|
|
1266
|
+
"""
|
|
1267
|
+
with self.store.process_lock():
|
|
1268
|
+
state = self._reconcile(self.store.load())
|
|
1269
|
+
if not state.has_instance():
|
|
1270
|
+
self.store.save(state)
|
|
1271
|
+
return {"action": policy.ACTION_NONE, "reason": "no_instance"}
|
|
1272
|
+
|
|
1273
|
+
cfg = self.policy_config(state.hourly_cost_usd)
|
|
1274
|
+
idle_skipped = self._idle_blind(state)
|
|
1275
|
+
if idle_skipped:
|
|
1276
|
+
cfg.idle_timeout_s = None
|
|
1277
|
+
snap = self.snapshot(state)
|
|
1278
|
+
|
|
1279
|
+
if state.locked and snap.lock_expired(cfg):
|
|
1280
|
+
state.locked = False
|
|
1281
|
+
state.locked_until = None
|
|
1282
|
+
self.events.emit(ev.INSTANCE_UNLOCKED, reason="lock_expired")
|
|
1283
|
+
snap = self.snapshot(state)
|
|
1284
|
+
|
|
1285
|
+
decision = policy.evaluate(snap, cfg)
|
|
1286
|
+
if idle_skipped:
|
|
1287
|
+
decision.detail["idle_skipped"] = "watchdog_unreachable"
|
|
1288
|
+
if decision.terminates and apply:
|
|
1289
|
+
self.events.emit(
|
|
1290
|
+
ev.LIFECYCLE_GUARD_TRIGGERED,
|
|
1291
|
+
instance_id=state.instance_id,
|
|
1292
|
+
action=decision.action,
|
|
1293
|
+
reason=decision.reason,
|
|
1294
|
+
**decision.detail,
|
|
1295
|
+
)
|
|
1296
|
+
self._terminate(state, action=decision.action, reason=decision.reason or "guard")
|
|
1297
|
+
self.store.save(state)
|
|
1298
|
+
return decision.to_dict()
|
|
1299
|
+
|
|
1300
|
+
# ------------------------------------------------------------------
|
|
1301
|
+
# logs
|
|
1302
|
+
# ------------------------------------------------------------------
|
|
1303
|
+
|
|
1304
|
+
def logs(self, limit: int = 200, source: Optional[str] = None) -> list[str]:
|
|
1305
|
+
return self.read_logs(limit, source)[1]
|
|
1306
|
+
|
|
1307
|
+
def read_logs(
|
|
1308
|
+
self, limit: int = 200, source: Optional[str] = None
|
|
1309
|
+
) -> tuple[str, list[str]]:
|
|
1310
|
+
"""(source used, lines). ``source`` None means the runtime log when the
|
|
1311
|
+
watchdog can serve it, else the provider's container log."""
|
|
1312
|
+
if source is not None and source not in LOG_SOURCES:
|
|
1313
|
+
raise ConfigError(
|
|
1314
|
+
f"unknown log source '{source}' (choose from {', '.join(LOG_SOURCES)})"
|
|
1315
|
+
)
|
|
1316
|
+
state, _ = self._observe()
|
|
1317
|
+
if not state.instance:
|
|
1318
|
+
raise StateError("no instance to read logs from")
|
|
1319
|
+
if source != "provider":
|
|
1320
|
+
name = source or "runtime"
|
|
1321
|
+
client = self.beacon(state)
|
|
1322
|
+
try:
|
|
1323
|
+
if client is None:
|
|
1324
|
+
raise WatchdogUnavailable("no watchdog installed on this instance")
|
|
1325
|
+
return name, client.logs(name, limit)
|
|
1326
|
+
except WatchdogUnavailable as exc:
|
|
1327
|
+
if source is not None:
|
|
1328
|
+
raise StateError(
|
|
1329
|
+
f"cannot read the {source} log: {exc}. "
|
|
1330
|
+
"`gpu logs --source provider` shows the container log instead."
|
|
1331
|
+
) from exc
|
|
1332
|
+
self.provider.require_capability("logs")
|
|
1333
|
+
stream = self.provider.logs(state.instance)
|
|
1334
|
+
lines = list(stream)
|
|
1335
|
+
return "provider", lines[-limit:]
|