jambu-gpu 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- jambu_gpu/__init__.py +5 -0
- jambu_gpu/__main__.py +4 -0
- jambu_gpu/cli/__init__.py +0 -0
- jambu_gpu/cli/_common.py +117 -0
- jambu_gpu/cli/inspect.py +207 -0
- jambu_gpu/cli/lifecycle.py +127 -0
- jambu_gpu/cli/main.py +103 -0
- jambu_gpu/cli/providers.py +96 -0
- jambu_gpu/cli/run.py +37 -0
- jambu_gpu/cli/setup.py +165 -0
- jambu_gpu/cli/status.py +214 -0
- jambu_gpu/cli/validate.py +57 -0
- jambu_gpu/core/__init__.py +0 -0
- jambu_gpu/core/beacon.py +124 -0
- jambu_gpu/core/config.py +596 -0
- jambu_gpu/core/credentials.py +75 -0
- jambu_gpu/core/durations.py +47 -0
- jambu_gpu/core/engine.py +951 -0
- jambu_gpu/core/errors.py +68 -0
- jambu_gpu/core/events.py +115 -0
- jambu_gpu/core/execution.py +169 -0
- jambu_gpu/core/health.py +62 -0
- jambu_gpu/core/model_inspect.py +368 -0
- jambu_gpu/core/models.py +283 -0
- jambu_gpu/core/policy.py +233 -0
- jambu_gpu/core/state.py +361 -0
- jambu_gpu/core/tool_parsers.py +63 -0
- jambu_gpu/providers/__init__.py +4 -0
- jambu_gpu/providers/base.py +125 -0
- jambu_gpu/providers/registry.py +63 -0
- jambu_gpu/providers/vast/__init__.py +3 -0
- jambu_gpu/providers/vast/client.py +230 -0
- jambu_gpu/providers/vast/config.py +51 -0
- jambu_gpu/providers/vast/mapper.py +221 -0
- jambu_gpu/providers/vast/provider.py +422 -0
- jambu_gpu/runtime/__init__.py +6 -0
- jambu_gpu/runtime/base.py +99 -0
- jambu_gpu/runtime/vllm.py +224 -0
- jambu_gpu/watchdog/__init__.py +3 -0
- jambu_gpu/watchdog/agent_body.py +547 -0
- jambu_gpu/watchdog/builder.py +178 -0
- jambu_gpu-0.1.0.dist-info/METADATA +82 -0
- jambu_gpu-0.1.0.dist-info/RECORD +46 -0
- jambu_gpu-0.1.0.dist-info/WHEEL +4 -0
- jambu_gpu-0.1.0.dist-info/entry_points.txt +2 -0
- jambu_gpu-0.1.0.dist-info/licenses/LICENSE +21 -0
jambu_gpu/core/engine.py
ADDED
|
@@ -0,0 +1,951 @@
|
|
|
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 time
|
|
11
|
+
from typing import Any, Callable, Optional, Sequence
|
|
12
|
+
|
|
13
|
+
from .. import watchdog as watchdog_pkg
|
|
14
|
+
from ..providers.base import GPUProvider
|
|
15
|
+
from ..providers.registry import load_builtin_providers
|
|
16
|
+
from ..runtime import runtime_registry
|
|
17
|
+
from ..runtime.base import ENDPOINT_NAME, ModelRuntime
|
|
18
|
+
from ..watchdog.builder import build_agent_config, build_agent_source, build_onstart_script
|
|
19
|
+
from . import events as ev
|
|
20
|
+
from . import policy
|
|
21
|
+
from .beacon import WatchdogClient
|
|
22
|
+
from .config import RuntimeConfig
|
|
23
|
+
from .credentials import CredentialResolver
|
|
24
|
+
from .errors import (
|
|
25
|
+
BudgetExceededError,
|
|
26
|
+
ConfigError,
|
|
27
|
+
JambuError,
|
|
28
|
+
ProviderError,
|
|
29
|
+
RuntimeStartupError,
|
|
30
|
+
StateError,
|
|
31
|
+
UnsupportedCapabilityError,
|
|
32
|
+
WorkloadFailed,
|
|
33
|
+
)
|
|
34
|
+
from .events import EventLog
|
|
35
|
+
from .execution import ExecutionManager, Heartbeat
|
|
36
|
+
from .health import wait_for_healthy
|
|
37
|
+
from .models import (
|
|
38
|
+
ComputeSpec,
|
|
39
|
+
GpuSpec,
|
|
40
|
+
Instance,
|
|
41
|
+
InstanceState,
|
|
42
|
+
ValidationResult,
|
|
43
|
+
Workload,
|
|
44
|
+
)
|
|
45
|
+
from .state import RuntimeState, StateStore, WatchdogInfo, utc_now
|
|
46
|
+
|
|
47
|
+
Echo = Callable[[str], None]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _noop(_message: str) -> None:
|
|
51
|
+
return None
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class Engine:
|
|
55
|
+
def __init__(
|
|
56
|
+
self,
|
|
57
|
+
config: RuntimeConfig,
|
|
58
|
+
provider: Optional[GPUProvider] = None,
|
|
59
|
+
echo: Optional[Echo] = None,
|
|
60
|
+
credentials: Optional[CredentialResolver] = None,
|
|
61
|
+
) -> None:
|
|
62
|
+
self.config = config
|
|
63
|
+
self.echo = echo or _noop
|
|
64
|
+
self.credentials = credentials or CredentialResolver(
|
|
65
|
+
config.project_dir, profile=config.provider.profile
|
|
66
|
+
)
|
|
67
|
+
registry = load_builtin_providers()
|
|
68
|
+
self.provider = provider or registry.get(config, self.credentials)
|
|
69
|
+
self.runtime: ModelRuntime = runtime_registry.get(config)
|
|
70
|
+
self.store = StateStore(config.state_dir)
|
|
71
|
+
self.events = EventLog(self.store.logs_dir).bind(provider=self.provider.name)
|
|
72
|
+
|
|
73
|
+
# ------------------------------------------------------------------
|
|
74
|
+
# policy plumbing
|
|
75
|
+
# ------------------------------------------------------------------
|
|
76
|
+
|
|
77
|
+
def policy_config(self, hourly_cost_usd: Optional[float] = None) -> policy.PolicyConfig:
|
|
78
|
+
caps = self.provider.capabilities
|
|
79
|
+
cfg = self.config
|
|
80
|
+
return policy.PolicyConfig(
|
|
81
|
+
idle_timeout_s=cfg.idle_timeout_s,
|
|
82
|
+
max_lifetime_s=cfg.max_lifetime_s,
|
|
83
|
+
heartbeat_grace_s=cfg.heartbeat_grace_s,
|
|
84
|
+
auto_stop=cfg.lifecycle.auto_stop,
|
|
85
|
+
auto_destroy=cfg.lifecycle.auto_destroy,
|
|
86
|
+
allow_indefinite_lock=cfg.lifecycle.allow_indefinite_lock,
|
|
87
|
+
stop_supported=caps.stop,
|
|
88
|
+
destroy_supported=caps.destroy,
|
|
89
|
+
stop_eliminates_billing=caps.stop_eliminates_billing,
|
|
90
|
+
hourly_cost_usd=hourly_cost_usd,
|
|
91
|
+
max_session_cost_usd=cfg.budget.max_session_cost_usd,
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
def snapshot(self, state: RuntimeState) -> policy.Snapshot:
|
|
95
|
+
now = utc_now()
|
|
96
|
+
return policy.Snapshot(
|
|
97
|
+
now=now,
|
|
98
|
+
created_at=state.created_at or now,
|
|
99
|
+
last_activity=state.last_activity or state.created_at or now,
|
|
100
|
+
workloads=[w.to_policy() for w in state.workloads.values()],
|
|
101
|
+
starting=state.starting,
|
|
102
|
+
locked=state.locked,
|
|
103
|
+
locked_until=state.locked_until,
|
|
104
|
+
provider_state=state.state,
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
def beacon(self, state: RuntimeState) -> Optional[WatchdogClient]:
|
|
108
|
+
info = state.watchdog
|
|
109
|
+
if not info.installed or not info.url:
|
|
110
|
+
return None
|
|
111
|
+
return WatchdogClient(info.url, info.token)
|
|
112
|
+
|
|
113
|
+
# ------------------------------------------------------------------
|
|
114
|
+
# validate (spec section 15, steps 1-5)
|
|
115
|
+
# ------------------------------------------------------------------
|
|
116
|
+
|
|
117
|
+
def validate(self, check_provider: bool = True) -> ValidationResult:
|
|
118
|
+
result = ValidationResult()
|
|
119
|
+
cfg = self.config
|
|
120
|
+
|
|
121
|
+
if cfg.lifecycle.idle_timeout is None and cfg.lifecycle.max_lifetime is None:
|
|
122
|
+
result.error(
|
|
123
|
+
"lifecycle needs at least one of idle_timeout or max_lifetime; "
|
|
124
|
+
"without a bound an abandoned GPU bills forever",
|
|
125
|
+
"lifecycle",
|
|
126
|
+
)
|
|
127
|
+
if not cfg.lifecycle.auto_stop and not cfg.lifecycle.auto_destroy:
|
|
128
|
+
result.warn(
|
|
129
|
+
"auto_stop and auto_destroy are both false: the guard will report "
|
|
130
|
+
"but never shut anything down",
|
|
131
|
+
"lifecycle",
|
|
132
|
+
)
|
|
133
|
+
if not cfg.lifecycle.watchdog.enabled:
|
|
134
|
+
result.warn(
|
|
135
|
+
"lifecycle.watchdog.enabled is false: shutdown depends on this CLI "
|
|
136
|
+
"staying alive (spec section 14)",
|
|
137
|
+
"lifecycle.watchdog",
|
|
138
|
+
)
|
|
139
|
+
if cfg.lifecycle.allow_indefinite_lock:
|
|
140
|
+
result.warn(
|
|
141
|
+
"allow_indefinite_lock is true: a forgotten lock defeats cost protection",
|
|
142
|
+
"lifecycle.allow_indefinite_lock",
|
|
143
|
+
)
|
|
144
|
+
if cfg.budget.max_hourly_cost_usd is None:
|
|
145
|
+
result.warn("no budget.max_hourly_cost_usd set", "budget")
|
|
146
|
+
|
|
147
|
+
result.merge(self.runtime.validate())
|
|
148
|
+
|
|
149
|
+
if cfg.model.hf_token_env and not self.credentials.get(cfg.model.hf_token_env):
|
|
150
|
+
result.warn(
|
|
151
|
+
f"{cfg.model.hf_token_env} is not set; gated models will fail to download",
|
|
152
|
+
"model.hf_token_env",
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
if check_provider:
|
|
156
|
+
try:
|
|
157
|
+
result.merge(self.provider.validate(cfg))
|
|
158
|
+
except JambuError as exc:
|
|
159
|
+
result.error(str(exc), "provider")
|
|
160
|
+
return result
|
|
161
|
+
|
|
162
|
+
# ------------------------------------------------------------------
|
|
163
|
+
# reconciliation (spec section 18)
|
|
164
|
+
# ------------------------------------------------------------------
|
|
165
|
+
|
|
166
|
+
def reconcile(self, state: Optional[RuntimeState] = None) -> RuntimeState:
|
|
167
|
+
"""Merge local state with provider truth. Repairs local state in place."""
|
|
168
|
+
owns_lock = state is None
|
|
169
|
+
if owns_lock:
|
|
170
|
+
with self.store.process_lock():
|
|
171
|
+
state = self.store.load()
|
|
172
|
+
reconciled = self._reconcile(state)
|
|
173
|
+
self.store.save(reconciled)
|
|
174
|
+
return reconciled
|
|
175
|
+
return self._reconcile(state)
|
|
176
|
+
|
|
177
|
+
def _reconcile(self, state: RuntimeState) -> RuntimeState:
|
|
178
|
+
if not state.instance_id:
|
|
179
|
+
state.state = InstanceState.DESTROYED.value
|
|
180
|
+
return state
|
|
181
|
+
|
|
182
|
+
instance = state.instance or Instance(id=state.instance_id, provider=self.provider.name)
|
|
183
|
+
try:
|
|
184
|
+
status = self.provider.status(instance)
|
|
185
|
+
except ProviderError as exc:
|
|
186
|
+
self.events.emit(ev.PROVIDER_ERROR, operation="status", error=str(exc))
|
|
187
|
+
return state # keep local state; the API blip is not authoritative
|
|
188
|
+
|
|
189
|
+
previous = state.state
|
|
190
|
+
if not status.exists:
|
|
191
|
+
state.clear_instance(InstanceState.DESTROYED.value)
|
|
192
|
+
self.events.emit(
|
|
193
|
+
ev.STATE_RECONCILED,
|
|
194
|
+
previous_state=previous,
|
|
195
|
+
state=InstanceState.DESTROYED.value,
|
|
196
|
+
reason="instance missing at provider",
|
|
197
|
+
)
|
|
198
|
+
return state
|
|
199
|
+
|
|
200
|
+
current = status.instance or instance
|
|
201
|
+
state.instance = current
|
|
202
|
+
state.instance_id = current.id
|
|
203
|
+
state.hourly_cost_usd = status.hourly_cost_usd or state.hourly_cost_usd
|
|
204
|
+
if current.created_at:
|
|
205
|
+
state.created_at = current.created_at
|
|
206
|
+
endpoint = current.endpoint_url(ENDPOINT_NAME)
|
|
207
|
+
if endpoint:
|
|
208
|
+
state.endpoint = endpoint
|
|
209
|
+
|
|
210
|
+
watchdog_url = current.endpoint_url("watchdog")
|
|
211
|
+
if watchdog_url and state.watchdog.token:
|
|
212
|
+
state.watchdog.url = watchdog_url
|
|
213
|
+
state.watchdog.installed = True
|
|
214
|
+
|
|
215
|
+
# The watchdog, when reachable, is the authority on activity.
|
|
216
|
+
remote = None
|
|
217
|
+
client = self.beacon(state)
|
|
218
|
+
if client is not None:
|
|
219
|
+
remote = client.try_state()
|
|
220
|
+
if remote:
|
|
221
|
+
state.watchdog.last_contact = utc_now()
|
|
222
|
+
state.watchdog.last_error = None
|
|
223
|
+
state.locked = bool(remote.get("locked"))
|
|
224
|
+
state.locked_until = remote.get("locked_until")
|
|
225
|
+
state.starting = bool(remote.get("starting"))
|
|
226
|
+
if remote.get("last_activity"):
|
|
227
|
+
state.last_activity = max(
|
|
228
|
+
state.last_activity or 0.0, float(remote["last_activity"])
|
|
229
|
+
)
|
|
230
|
+
if remote.get("terminated"):
|
|
231
|
+
state.state = InstanceState.STOPPED.value
|
|
232
|
+
pulled = client.events(limit=200)
|
|
233
|
+
if pulled:
|
|
234
|
+
self.events.extend(pulled)
|
|
235
|
+
elif state.watchdog.installed:
|
|
236
|
+
state.watchdog.last_error = "unreachable"
|
|
237
|
+
|
|
238
|
+
if status.state in (InstanceState.READY, InstanceState.BUSY, InstanceState.IDLE):
|
|
239
|
+
derived = policy.derive_state(self.snapshot(state), self.policy_config())
|
|
240
|
+
state.state = derived if derived != InstanceState.UNKNOWN.value else status.state.value
|
|
241
|
+
else:
|
|
242
|
+
state.state = status.state.value
|
|
243
|
+
if status.state in (InstanceState.STOPPED, InstanceState.FAILED):
|
|
244
|
+
for record in state.workloads.values():
|
|
245
|
+
if record.state in ("running", "queued"):
|
|
246
|
+
record.state = "failed"
|
|
247
|
+
record.finished_at = utc_now()
|
|
248
|
+
|
|
249
|
+
# Track actual billable time, not wall-clock time since creation - a
|
|
250
|
+
# `status` checked long after auto-stop must not keep inflating cost.
|
|
251
|
+
state.mark_billable(InstanceState(state.state).is_billable_compute)
|
|
252
|
+
if state.hourly_cost_usd:
|
|
253
|
+
state.session_cost_usd = round(
|
|
254
|
+
state.hourly_cost_usd * state.accrued_billable_seconds() / 3600.0, 4
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
if previous != state.state:
|
|
258
|
+
self.events.emit(
|
|
259
|
+
ev.STATE_RECONCILED, previous_state=previous, state=state.state
|
|
260
|
+
)
|
|
261
|
+
return state
|
|
262
|
+
|
|
263
|
+
# ------------------------------------------------------------------
|
|
264
|
+
# setup (spec section 15)
|
|
265
|
+
# ------------------------------------------------------------------
|
|
266
|
+
|
|
267
|
+
def build_spec(self, onstart: str = "") -> ComputeSpec:
|
|
268
|
+
cfg = self.config
|
|
269
|
+
env = dict(self.runtime.env())
|
|
270
|
+
hf_token = (
|
|
271
|
+
self.credentials.get(cfg.model.hf_token_env) if cfg.model.hf_token_env else None
|
|
272
|
+
)
|
|
273
|
+
if hf_token:
|
|
274
|
+
env["HF_TOKEN"] = hf_token
|
|
275
|
+
env["HUGGING_FACE_HUB_TOKEN"] = hf_token
|
|
276
|
+
return ComputeSpec(
|
|
277
|
+
gpu=GpuSpec(
|
|
278
|
+
min_vram_gb=cfg.compute.gpu.min_vram_gb,
|
|
279
|
+
count=cfg.compute.gpu.count,
|
|
280
|
+
name_filter=cfg.compute.gpu.name_filter,
|
|
281
|
+
cuda_min=cfg.compute.gpu.cuda_min,
|
|
282
|
+
),
|
|
283
|
+
disk_gb=cfg.compute.disk_gb,
|
|
284
|
+
image=self.runtime.image(),
|
|
285
|
+
ports=self._ports(),
|
|
286
|
+
env=env,
|
|
287
|
+
onstart=onstart,
|
|
288
|
+
label=f"jambu-{cfg.model.id.split('/')[-1][:24]}-{cfg.fingerprint()[:6]}",
|
|
289
|
+
spot=cfg.compute.spot,
|
|
290
|
+
region=cfg.compute.region,
|
|
291
|
+
max_hourly_cost_usd=cfg.budget.max_hourly_cost_usd,
|
|
292
|
+
provider_options=dict(cfg.provider.options),
|
|
293
|
+
)
|
|
294
|
+
|
|
295
|
+
def _ports(self) -> list[int]:
|
|
296
|
+
ports = list(self.runtime.ports())
|
|
297
|
+
if self.config.lifecycle.watchdog.enabled:
|
|
298
|
+
ports.append(self.config.lifecycle.watchdog.port)
|
|
299
|
+
return ports
|
|
300
|
+
|
|
301
|
+
def _build_onstart(self, token: str) -> str:
|
|
302
|
+
cfg = self.config
|
|
303
|
+
enabled = cfg.lifecycle.watchdog.enabled
|
|
304
|
+
spec = self.provider.watchdog_spec(None) if enabled else None
|
|
305
|
+
if enabled and spec is None:
|
|
306
|
+
raise UnsupportedCapabilityError(
|
|
307
|
+
f"provider '{self.provider.name}' supplies no watchdog actions; "
|
|
308
|
+
"set lifecycle.watchdog.enabled: false to proceed without remote "
|
|
309
|
+
"lifecycle enforcement"
|
|
310
|
+
)
|
|
311
|
+
agent_source = build_agent_source(spec.actions_source if spec else None)
|
|
312
|
+
agent_config = build_agent_config(
|
|
313
|
+
provider=self.provider.name,
|
|
314
|
+
instance_id="", # resolved by /identify or the provider's own env
|
|
315
|
+
token=token,
|
|
316
|
+
port=cfg.lifecycle.watchdog.port,
|
|
317
|
+
interval_s=cfg.watchdog_interval_s,
|
|
318
|
+
startup_grace_s=cfg.startup_timeout_s + 300.0,
|
|
319
|
+
policy=self.policy_config(),
|
|
320
|
+
created_at=utc_now(),
|
|
321
|
+
watchdog_spec=spec,
|
|
322
|
+
)
|
|
323
|
+
return build_onstart_script(
|
|
324
|
+
agent_source=agent_source,
|
|
325
|
+
agent_config=agent_config,
|
|
326
|
+
runtime_start_command=self.runtime.start_command(),
|
|
327
|
+
runtime_env=self.runtime.env(),
|
|
328
|
+
watchdog_enabled=enabled,
|
|
329
|
+
)
|
|
330
|
+
|
|
331
|
+
def _check_budget(self, spec: ComputeSpec, allow_override: bool) -> Optional[float]:
|
|
332
|
+
ceiling = self.config.budget.max_hourly_cost_usd
|
|
333
|
+
if not self.provider.capabilities.pricing_query:
|
|
334
|
+
if ceiling and not allow_override:
|
|
335
|
+
self.echo(
|
|
336
|
+
"! provider cannot query pricing; budget.max_hourly_cost_usd "
|
|
337
|
+
"cannot be enforced before provisioning"
|
|
338
|
+
)
|
|
339
|
+
return None
|
|
340
|
+
offers = self.provider.find_offers(spec, limit=5)
|
|
341
|
+
if not offers:
|
|
342
|
+
return None
|
|
343
|
+
cheapest = offers[0]
|
|
344
|
+
if ceiling and cheapest.hourly_cost_usd > float(ceiling):
|
|
345
|
+
if not allow_override:
|
|
346
|
+
self.events.emit(
|
|
347
|
+
ev.BUDGET_REJECTED,
|
|
348
|
+
cheapest_hourly_cost_usd=cheapest.hourly_cost_usd,
|
|
349
|
+
max_hourly_cost_usd=float(ceiling),
|
|
350
|
+
)
|
|
351
|
+
raise BudgetExceededError(
|
|
352
|
+
f"cheapest matching offer is ${cheapest.hourly_cost_usd:.3f}/h, above "
|
|
353
|
+
f"budget.max_hourly_cost_usd (${float(ceiling):.3f}/h). "
|
|
354
|
+
"Re-run with --allow-cost-override to proceed anyway."
|
|
355
|
+
)
|
|
356
|
+
self.echo(
|
|
357
|
+
f"! cost override: ${cheapest.hourly_cost_usd:.3f}/h exceeds the "
|
|
358
|
+
f"${float(ceiling):.3f}/h budget"
|
|
359
|
+
)
|
|
360
|
+
return cheapest.hourly_cost_usd
|
|
361
|
+
|
|
362
|
+
def setup(
|
|
363
|
+
self,
|
|
364
|
+
*,
|
|
365
|
+
recreate: bool = False,
|
|
366
|
+
allow_cost_override: bool = False,
|
|
367
|
+
wait: bool = True,
|
|
368
|
+
) -> RuntimeState:
|
|
369
|
+
cfg = self.config
|
|
370
|
+
fingerprint = cfg.fingerprint()
|
|
371
|
+
|
|
372
|
+
with self.store.process_lock():
|
|
373
|
+
state = self._reconcile(self.store.load())
|
|
374
|
+
|
|
375
|
+
# -- idempotency (spec section 23) -----------------------------
|
|
376
|
+
if state.has_instance():
|
|
377
|
+
if state.fingerprint != fingerprint and not recreate:
|
|
378
|
+
raise ConfigError(
|
|
379
|
+
f"instance {state.instance_id} was provisioned for a different "
|
|
380
|
+
"runtime (jambu.yaml changed since setup). Re-run with "
|
|
381
|
+
"`--recreate` to replace it, or `gpu destroy` first."
|
|
382
|
+
)
|
|
383
|
+
if not recreate:
|
|
384
|
+
self.echo(f"= reusing instance {state.instance_id} ({state.state})")
|
|
385
|
+
self.store.save(state)
|
|
386
|
+
return self.ensure_ready(state=state) if wait else state
|
|
387
|
+
self.echo(f"~ replacing instance {state.instance_id}")
|
|
388
|
+
self._terminate(state, action=policy.ACTION_DESTROY, reason="recreate")
|
|
389
|
+
|
|
390
|
+
# -- provision ------------------------------------------------
|
|
391
|
+
token = watchdog_pkg.generate_token()
|
|
392
|
+
onstart = self._build_onstart(token)
|
|
393
|
+
spec = self.build_spec(onstart)
|
|
394
|
+
|
|
395
|
+
self.events.emit(
|
|
396
|
+
ev.PROVISION_REQUESTED,
|
|
397
|
+
model=cfg.model.id,
|
|
398
|
+
engine=cfg.runtime.engine,
|
|
399
|
+
gpu_count=cfg.compute.gpu.count,
|
|
400
|
+
min_vram_gb=cfg.compute.gpu.min_vram_gb,
|
|
401
|
+
fingerprint=fingerprint,
|
|
402
|
+
)
|
|
403
|
+
self.echo(
|
|
404
|
+
f"> searching {self.provider.display_name} for {cfg.compute.gpu.count}x "
|
|
405
|
+
f">={cfg.compute.gpu.min_vram_gb}GB GPU, {cfg.compute.disk_gb}GB disk"
|
|
406
|
+
)
|
|
407
|
+
estimated = self._check_budget(spec, allow_cost_override)
|
|
408
|
+
if estimated:
|
|
409
|
+
self.echo(f" cheapest match: ${estimated:.3f}/h")
|
|
410
|
+
|
|
411
|
+
instance: Optional[Instance] = None
|
|
412
|
+
try:
|
|
413
|
+
instance = self.provider.setup(spec)
|
|
414
|
+
self.echo(
|
|
415
|
+
f"+ instance {instance.id} created "
|
|
416
|
+
f"({instance.gpu_count or cfg.compute.gpu.count}x "
|
|
417
|
+
f"{instance.gpu_name or 'GPU'}"
|
|
418
|
+
+ (
|
|
419
|
+
f", ${instance.hourly_cost_usd:.3f}/h)"
|
|
420
|
+
if instance.hourly_cost_usd
|
|
421
|
+
else ")"
|
|
422
|
+
)
|
|
423
|
+
)
|
|
424
|
+
|
|
425
|
+
state.provider = self.provider.name
|
|
426
|
+
state.instance_id = instance.id
|
|
427
|
+
state.instance = instance
|
|
428
|
+
state.fingerprint = fingerprint
|
|
429
|
+
state.model_id = cfg.model.id
|
|
430
|
+
state.created_at = instance.created_at or utc_now()
|
|
431
|
+
state.last_activity = utc_now()
|
|
432
|
+
state.starting = True
|
|
433
|
+
state.state = InstanceState.PROVISIONING.value
|
|
434
|
+
state.hourly_cost_usd = instance.hourly_cost_usd
|
|
435
|
+
state.locked = False
|
|
436
|
+
state.locked_until = None
|
|
437
|
+
# Billing starts the moment the provider creates the instance,
|
|
438
|
+
# not at the next reconcile - start the clock now so a long
|
|
439
|
+
# provisioning/startup wait is never undercounted.
|
|
440
|
+
state.billed_seconds = 0.0
|
|
441
|
+
state.running_since = state.created_at
|
|
442
|
+
state.watchdog = WatchdogInfo(
|
|
443
|
+
installed=cfg.lifecycle.watchdog.enabled,
|
|
444
|
+
token=token,
|
|
445
|
+
port=cfg.lifecycle.watchdog.port,
|
|
446
|
+
url=instance.endpoint_url("watchdog"),
|
|
447
|
+
)
|
|
448
|
+
# Persist before the long wait: a crash here must still leave a
|
|
449
|
+
# destroyable record (spec criterion 4).
|
|
450
|
+
self.store.save(state)
|
|
451
|
+
self.events.emit(
|
|
452
|
+
ev.INSTANCE_CREATED,
|
|
453
|
+
instance_id=instance.id,
|
|
454
|
+
gpu_name=instance.gpu_name,
|
|
455
|
+
gpu_count=instance.gpu_count,
|
|
456
|
+
hourly_cost_usd=instance.hourly_cost_usd,
|
|
457
|
+
)
|
|
458
|
+
|
|
459
|
+
if not wait:
|
|
460
|
+
return state
|
|
461
|
+
return self.ensure_ready(state=state)
|
|
462
|
+
|
|
463
|
+
except JambuError as exc:
|
|
464
|
+
self.events.emit(
|
|
465
|
+
ev.SETUP_FAILED, error=str(exc), error_type=type(exc).__name__
|
|
466
|
+
)
|
|
467
|
+
if cfg.lifecycle.cleanup_on_setup_failure and state.instance_id:
|
|
468
|
+
self.echo(f"! setup failed, cleaning up instance {state.instance_id}")
|
|
469
|
+
try:
|
|
470
|
+
self._terminate(
|
|
471
|
+
state, action=policy.ACTION_DESTROY, reason="setup_failure"
|
|
472
|
+
)
|
|
473
|
+
self.events.emit(
|
|
474
|
+
ev.CLEANUP_PERFORMED, instance_id=state.instance_id
|
|
475
|
+
)
|
|
476
|
+
except JambuError as cleanup_exc:
|
|
477
|
+
self.events.emit(
|
|
478
|
+
ev.PROVIDER_ERROR, operation="cleanup", error=str(cleanup_exc)
|
|
479
|
+
)
|
|
480
|
+
self.echo(f"! cleanup failed: {cleanup_exc}")
|
|
481
|
+
self.store.save(state)
|
|
482
|
+
raise
|
|
483
|
+
|
|
484
|
+
# ------------------------------------------------------------------
|
|
485
|
+
# readiness
|
|
486
|
+
# ------------------------------------------------------------------
|
|
487
|
+
|
|
488
|
+
def ensure_ready(self, state: Optional[RuntimeState] = None) -> RuntimeState:
|
|
489
|
+
"""Guarantee the configured runtime is available (spec section 16)."""
|
|
490
|
+
with self.store.process_lock():
|
|
491
|
+
state = state if state is not None else self._reconcile(self.store.load())
|
|
492
|
+
|
|
493
|
+
if not state.has_instance():
|
|
494
|
+
self.store.save(state)
|
|
495
|
+
return self.setup()
|
|
496
|
+
|
|
497
|
+
instance = state.instance
|
|
498
|
+
if instance is None:
|
|
499
|
+
raise StateError("state has an instance id but no instance record")
|
|
500
|
+
|
|
501
|
+
if state.state == InstanceState.STOPPED.value:
|
|
502
|
+
if not self.provider.capabilities.stop:
|
|
503
|
+
raise UnsupportedCapabilityError(
|
|
504
|
+
f"provider '{self.provider.name}' cannot restart instances"
|
|
505
|
+
)
|
|
506
|
+
self.echo(f"> starting instance {instance.id}")
|
|
507
|
+
instance = self.provider.start(instance)
|
|
508
|
+
state.instance = instance
|
|
509
|
+
state.starting = True
|
|
510
|
+
state.mark_billable(True) # billing resumes now, not at the next reconcile
|
|
511
|
+
self.events.emit(ev.INSTANCE_STARTED, instance_id=instance.id)
|
|
512
|
+
|
|
513
|
+
instance = self._wait_running(state)
|
|
514
|
+
state.instance = instance
|
|
515
|
+
state.endpoint = instance.endpoint_url(ENDPOINT_NAME)
|
|
516
|
+
watchdog_url = instance.endpoint_url("watchdog")
|
|
517
|
+
if watchdog_url:
|
|
518
|
+
state.watchdog.url = watchdog_url
|
|
519
|
+
self.store.save(state)
|
|
520
|
+
|
|
521
|
+
self._connect_watchdog(state)
|
|
522
|
+
|
|
523
|
+
if self.config.runtime.engine != "none":
|
|
524
|
+
self._wait_runtime(state)
|
|
525
|
+
|
|
526
|
+
state.starting = False
|
|
527
|
+
state.state = InstanceState.READY.value
|
|
528
|
+
state.touch()
|
|
529
|
+
self.store.save(state)
|
|
530
|
+
return state
|
|
531
|
+
|
|
532
|
+
def _wait_running(self, state: RuntimeState) -> Instance:
|
|
533
|
+
instance = state.instance
|
|
534
|
+
assert instance is not None
|
|
535
|
+
deadline = time.time() + self.config.startup_timeout_s
|
|
536
|
+
announced = False
|
|
537
|
+
while time.time() < deadline:
|
|
538
|
+
status = self.provider.status(instance)
|
|
539
|
+
if not status.exists:
|
|
540
|
+
state.clear_instance()
|
|
541
|
+
self.store.save(state)
|
|
542
|
+
raise RuntimeStartupError(
|
|
543
|
+
f"instance {instance.id} vanished at the provider while starting"
|
|
544
|
+
)
|
|
545
|
+
current = status.instance or instance
|
|
546
|
+
if status.state is InstanceState.FAILED:
|
|
547
|
+
raise RuntimeStartupError(
|
|
548
|
+
f"instance {instance.id} failed: {status.message or 'no detail'}"
|
|
549
|
+
)
|
|
550
|
+
ready_ports = self._ports()
|
|
551
|
+
have_ports = all(
|
|
552
|
+
current.endpoints.get(name) is not None
|
|
553
|
+
for name in self._endpoint_names()
|
|
554
|
+
)
|
|
555
|
+
if status.state in (
|
|
556
|
+
InstanceState.READY,
|
|
557
|
+
InstanceState.BUSY,
|
|
558
|
+
InstanceState.IDLE,
|
|
559
|
+
) and (have_ports or not ready_ports):
|
|
560
|
+
return current
|
|
561
|
+
if not announced:
|
|
562
|
+
announced = True
|
|
563
|
+
self.echo(" waiting for the machine to come up ...")
|
|
564
|
+
time.sleep(6)
|
|
565
|
+
raise RuntimeStartupError(
|
|
566
|
+
f"instance {instance.id} was not running after "
|
|
567
|
+
f"{int(self.config.startup_timeout_s)}s"
|
|
568
|
+
)
|
|
569
|
+
|
|
570
|
+
def _endpoint_names(self) -> list[str]:
|
|
571
|
+
names = []
|
|
572
|
+
if self.config.runtime.engine != "none":
|
|
573
|
+
names.append(ENDPOINT_NAME)
|
|
574
|
+
if self.config.lifecycle.watchdog.enabled:
|
|
575
|
+
names.append("watchdog")
|
|
576
|
+
return names
|
|
577
|
+
|
|
578
|
+
def _connect_watchdog(self, state: RuntimeState) -> None:
|
|
579
|
+
if not self.config.lifecycle.watchdog.enabled:
|
|
580
|
+
return
|
|
581
|
+
client = self.beacon(state)
|
|
582
|
+
if client is None:
|
|
583
|
+
state.watchdog.last_error = "no watchdog endpoint published"
|
|
584
|
+
self.echo("! watchdog port was not published; lifecycle guard is local-only")
|
|
585
|
+
return
|
|
586
|
+
|
|
587
|
+
deadline = time.time() + 90
|
|
588
|
+
while time.time() < deadline:
|
|
589
|
+
if client.ping():
|
|
590
|
+
client.identify(state.instance_id or "", state.hourly_cost_usd)
|
|
591
|
+
client.mark_starting()
|
|
592
|
+
state.watchdog.installed = True
|
|
593
|
+
state.watchdog.last_contact = utc_now()
|
|
594
|
+
state.watchdog.last_error = None
|
|
595
|
+
self.echo(f"+ lifecycle watchdog live at {state.watchdog.url}")
|
|
596
|
+
self.store.save(state)
|
|
597
|
+
return
|
|
598
|
+
time.sleep(5)
|
|
599
|
+
|
|
600
|
+
state.watchdog.last_error = "did not answer within 90s"
|
|
601
|
+
self.echo(
|
|
602
|
+
"! the remote watchdog never answered. The instance is NOT protected "
|
|
603
|
+
"by an independent guard - check `gpu logs`, and run "
|
|
604
|
+
"`gpu destroy` if this is unexpected."
|
|
605
|
+
)
|
|
606
|
+
|
|
607
|
+
def _wait_runtime(self, state: RuntimeState) -> None:
|
|
608
|
+
endpoint = state.endpoint
|
|
609
|
+
if not endpoint:
|
|
610
|
+
raise RuntimeStartupError(
|
|
611
|
+
"the model port was never published by the provider; cannot reach the runtime"
|
|
612
|
+
)
|
|
613
|
+
url = self.runtime.health_url(endpoint)
|
|
614
|
+
self.events.emit(
|
|
615
|
+
ev.RUNTIME_STARTING, endpoint=endpoint, engine=self.config.runtime.engine
|
|
616
|
+
)
|
|
617
|
+
self.echo(f"> waiting for {self.runtime.describe()} at {endpoint}")
|
|
618
|
+
started = time.time()
|
|
619
|
+
|
|
620
|
+
def _progress(attempt: int, remaining: float, detail: str) -> None:
|
|
621
|
+
if attempt % 4 == 0:
|
|
622
|
+
self.echo(
|
|
623
|
+
f" still starting ({int(time.time() - started)}s elapsed, "
|
|
624
|
+
f"{int(remaining)}s left, last probe: {detail})"
|
|
625
|
+
)
|
|
626
|
+
|
|
627
|
+
def _abort() -> Optional[str]:
|
|
628
|
+
instance = state.instance
|
|
629
|
+
if instance is None:
|
|
630
|
+
return None
|
|
631
|
+
try:
|
|
632
|
+
status = self.provider.status(instance)
|
|
633
|
+
except ProviderError:
|
|
634
|
+
return None
|
|
635
|
+
if not status.exists:
|
|
636
|
+
return "the instance disappeared at the provider"
|
|
637
|
+
if status.state is InstanceState.FAILED:
|
|
638
|
+
return status.message or "the instance entered a failed state"
|
|
639
|
+
if status.state is InstanceState.STOPPED:
|
|
640
|
+
return "the instance was stopped"
|
|
641
|
+
return None
|
|
642
|
+
|
|
643
|
+
wait_for_healthy(
|
|
644
|
+
url,
|
|
645
|
+
timeout=self.config.startup_timeout_s,
|
|
646
|
+
interval=self.config.health_interval_s,
|
|
647
|
+
on_attempt=_progress,
|
|
648
|
+
should_abort=_abort,
|
|
649
|
+
)
|
|
650
|
+
client = self.beacon(state)
|
|
651
|
+
if client is not None:
|
|
652
|
+
client.mark_ready()
|
|
653
|
+
self.events.emit(
|
|
654
|
+
ev.RUNTIME_READY,
|
|
655
|
+
endpoint=endpoint,
|
|
656
|
+
startup_seconds=round(time.time() - started, 1),
|
|
657
|
+
)
|
|
658
|
+
self.echo(f"+ runtime ready: {endpoint}")
|
|
659
|
+
|
|
660
|
+
# ------------------------------------------------------------------
|
|
661
|
+
# run (spec section 16)
|
|
662
|
+
# ------------------------------------------------------------------
|
|
663
|
+
|
|
664
|
+
def workload_env(self, state: RuntimeState, workload_id: str) -> dict[str, str]:
|
|
665
|
+
endpoint = state.endpoint or ""
|
|
666
|
+
env: dict[str, str] = {
|
|
667
|
+
"JAMBU_PROVIDER": self.provider.name,
|
|
668
|
+
"JAMBU_INSTANCE_ID": state.instance_id or "",
|
|
669
|
+
"JAMBU_WORKLOAD_ID": workload_id,
|
|
670
|
+
"JAMBU_MODEL_ID": self.config.model.id,
|
|
671
|
+
}
|
|
672
|
+
if endpoint:
|
|
673
|
+
env["JAMBU_GPU_ENDPOINT"] = endpoint
|
|
674
|
+
env["OPENAI_BASE_URL"] = f"{endpoint.rstrip('/')}/v1"
|
|
675
|
+
env["OPENAI_API_KEY"] = self.config.runtime.api_key or "jambu-local"
|
|
676
|
+
if state.instance and state.instance.ssh_host:
|
|
677
|
+
env["JAMBU_SSH_HOST"] = state.instance.ssh_host
|
|
678
|
+
env["JAMBU_SSH_PORT"] = str(state.instance.ssh_port or 22)
|
|
679
|
+
env.update({k: str(v) for k, v in self.config.workload.env.items()})
|
|
680
|
+
return env
|
|
681
|
+
|
|
682
|
+
def run(self, command: Sequence[str], label: str = "") -> int:
|
|
683
|
+
if not command:
|
|
684
|
+
raise ConfigError("nothing to run: pass a command, e.g. `gpu run python x.py`")
|
|
685
|
+
|
|
686
|
+
state = self.ensure_ready()
|
|
687
|
+
manager = ExecutionManager(
|
|
688
|
+
self.store,
|
|
689
|
+
self.events,
|
|
690
|
+
beacon=self.beacon(state),
|
|
691
|
+
heartbeat_interval=self.config.heartbeat_interval_s,
|
|
692
|
+
)
|
|
693
|
+
record = manager.register(command, label=label)
|
|
694
|
+
workload = Workload(
|
|
695
|
+
id=record.id,
|
|
696
|
+
command=list(command),
|
|
697
|
+
label=record.label,
|
|
698
|
+
cwd=self.config.workload.workdir or str(self.config.project_dir),
|
|
699
|
+
env=self.workload_env(state, record.id),
|
|
700
|
+
mode=self.config.workload.mode,
|
|
701
|
+
)
|
|
702
|
+
|
|
703
|
+
returncode: Optional[int] = None
|
|
704
|
+
try:
|
|
705
|
+
with Heartbeat(
|
|
706
|
+
record.id,
|
|
707
|
+
self.config.heartbeat_interval_s,
|
|
708
|
+
self.store,
|
|
709
|
+
self.beacon(state),
|
|
710
|
+
):
|
|
711
|
+
if workload.mode == "remote":
|
|
712
|
+
self.provider.require_capability("remote_exec")
|
|
713
|
+
execution = self.provider.run(state.instance, workload)
|
|
714
|
+
else:
|
|
715
|
+
execution = manager.run_local(workload, record)
|
|
716
|
+
returncode = execution.returncode
|
|
717
|
+
finally:
|
|
718
|
+
manager.finish(record, returncode)
|
|
719
|
+
|
|
720
|
+
if returncode:
|
|
721
|
+
raise WorkloadFailed(
|
|
722
|
+
f"workload exited with code {returncode}", returncode=returncode
|
|
723
|
+
)
|
|
724
|
+
return 0
|
|
725
|
+
|
|
726
|
+
# ------------------------------------------------------------------
|
|
727
|
+
# status (spec section 18)
|
|
728
|
+
# ------------------------------------------------------------------
|
|
729
|
+
|
|
730
|
+
def status(self) -> dict[str, Any]:
|
|
731
|
+
with self.store.process_lock():
|
|
732
|
+
state = self._reconcile(self.store.load())
|
|
733
|
+
self.store.save(state)
|
|
734
|
+
|
|
735
|
+
cfg = self.policy_config(state.hourly_cost_usd)
|
|
736
|
+
snap = self.snapshot(state)
|
|
737
|
+
decision = policy.evaluate(snap, cfg)
|
|
738
|
+
remote = None
|
|
739
|
+
client = self.beacon(state)
|
|
740
|
+
if client is not None:
|
|
741
|
+
remote = client.try_state()
|
|
742
|
+
|
|
743
|
+
return {
|
|
744
|
+
"provider": self.provider.name,
|
|
745
|
+
"instance_id": state.instance_id,
|
|
746
|
+
"state": state.state if state.instance_id else "none",
|
|
747
|
+
"model": state.model_id or self.config.model.id,
|
|
748
|
+
"endpoint": state.endpoint,
|
|
749
|
+
"created_at": state.created_at,
|
|
750
|
+
"age_seconds": snap.age_seconds() if state.created_at else None,
|
|
751
|
+
"idle_seconds": snap.idle_seconds(cfg),
|
|
752
|
+
"locked": snap.lock_active(cfg),
|
|
753
|
+
"locked_until": state.locked_until,
|
|
754
|
+
"hourly_cost_usd": state.hourly_cost_usd,
|
|
755
|
+
"session_cost_usd": state.session_cost_usd,
|
|
756
|
+
"workloads": [
|
|
757
|
+
{
|
|
758
|
+
"id": w.id,
|
|
759
|
+
"label": w.label,
|
|
760
|
+
"state": w.state,
|
|
761
|
+
"started_at": w.started_at,
|
|
762
|
+
"last_heartbeat": w.last_heartbeat,
|
|
763
|
+
"returncode": w.returncode,
|
|
764
|
+
}
|
|
765
|
+
for w in state.workloads.values()
|
|
766
|
+
],
|
|
767
|
+
"decision": decision.to_dict(),
|
|
768
|
+
"policy": cfg.to_dict(),
|
|
769
|
+
"watchdog": {
|
|
770
|
+
"enabled": self.config.lifecycle.watchdog.enabled,
|
|
771
|
+
"installed": state.watchdog.installed,
|
|
772
|
+
"url": state.watchdog.url,
|
|
773
|
+
"reachable": remote is not None,
|
|
774
|
+
"last_error": state.watchdog.last_error,
|
|
775
|
+
"remote_state": remote,
|
|
776
|
+
},
|
|
777
|
+
"capabilities": self.provider.capabilities.__dict__,
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
# ------------------------------------------------------------------
|
|
781
|
+
# lock / unlock (spec section 11)
|
|
782
|
+
# ------------------------------------------------------------------
|
|
783
|
+
|
|
784
|
+
def lock(self, ttl_seconds: Optional[float], reason: str = "") -> dict[str, Any]:
|
|
785
|
+
if ttl_seconds is None and not self.config.lifecycle.allow_indefinite_lock:
|
|
786
|
+
raise ConfigError(
|
|
787
|
+
"indefinite locks are disabled. Pass `--for 2h`, or set "
|
|
788
|
+
"lifecycle.allow_indefinite_lock: true in jambu.yaml."
|
|
789
|
+
)
|
|
790
|
+
with self.store.process_lock():
|
|
791
|
+
state = self._reconcile(self.store.load())
|
|
792
|
+
if not state.has_instance():
|
|
793
|
+
raise StateError("no instance to lock")
|
|
794
|
+
until = utc_now() + ttl_seconds if ttl_seconds else None
|
|
795
|
+
client = self.beacon(state)
|
|
796
|
+
if client is not None and client.try_request(
|
|
797
|
+
"POST", "/lock", {"ttl_seconds": ttl_seconds, "reason": reason}
|
|
798
|
+
) is None:
|
|
799
|
+
state.watchdog.last_error = "unreachable"
|
|
800
|
+
self.echo(
|
|
801
|
+
"! the remote watchdog did not accept the lock; it may still stop "
|
|
802
|
+
"the instance. Check `gpu status`."
|
|
803
|
+
)
|
|
804
|
+
state.locked = True
|
|
805
|
+
state.locked_until = until
|
|
806
|
+
state.lock_reason = reason
|
|
807
|
+
state.touch()
|
|
808
|
+
self.store.save(state)
|
|
809
|
+
self.events.emit(
|
|
810
|
+
ev.INSTANCE_LOCKED,
|
|
811
|
+
instance_id=state.instance_id,
|
|
812
|
+
locked_until=until,
|
|
813
|
+
reason=reason,
|
|
814
|
+
)
|
|
815
|
+
return {"locked": True, "locked_until": until}
|
|
816
|
+
|
|
817
|
+
def unlock(self, reason: str = "manual") -> dict[str, Any]:
|
|
818
|
+
with self.store.process_lock():
|
|
819
|
+
state = self._reconcile(self.store.load())
|
|
820
|
+
client = self.beacon(state)
|
|
821
|
+
if client is not None:
|
|
822
|
+
client.try_request("POST", "/unlock", {"reason": reason})
|
|
823
|
+
state.locked = False
|
|
824
|
+
state.locked_until = None
|
|
825
|
+
state.lock_reason = ""
|
|
826
|
+
state.touch()
|
|
827
|
+
self.store.save(state)
|
|
828
|
+
self.events.emit(ev.INSTANCE_UNLOCKED, instance_id=state.instance_id, reason=reason)
|
|
829
|
+
return {"locked": False}
|
|
830
|
+
|
|
831
|
+
# ------------------------------------------------------------------
|
|
832
|
+
# stop / destroy
|
|
833
|
+
# ------------------------------------------------------------------
|
|
834
|
+
|
|
835
|
+
def stop(self, reason: str = "manual") -> dict[str, Any]:
|
|
836
|
+
with self.store.process_lock():
|
|
837
|
+
state = self._reconcile(self.store.load())
|
|
838
|
+
if not state.has_instance():
|
|
839
|
+
self.echo("= nothing to stop")
|
|
840
|
+
return {"changed": False, "state": state.state}
|
|
841
|
+
if state.state == InstanceState.STOPPED.value:
|
|
842
|
+
self.echo(f"= instance {state.instance_id} is already stopped")
|
|
843
|
+
return {"changed": False, "state": state.state}
|
|
844
|
+
action = (
|
|
845
|
+
policy.ACTION_STOP if self.provider.capabilities.stop else policy.ACTION_DESTROY
|
|
846
|
+
)
|
|
847
|
+
self._terminate(state, action=action, reason=reason)
|
|
848
|
+
self.store.save(state)
|
|
849
|
+
return {"changed": True, "state": state.state, "action": action}
|
|
850
|
+
|
|
851
|
+
def destroy(self, reason: str = "manual") -> dict[str, Any]:
|
|
852
|
+
with self.store.process_lock():
|
|
853
|
+
state = self._reconcile(self.store.load())
|
|
854
|
+
if not state.instance_id:
|
|
855
|
+
self.echo("= nothing to destroy")
|
|
856
|
+
return {"changed": False, "state": state.state}
|
|
857
|
+
self._terminate(state, action=policy.ACTION_DESTROY, reason=reason)
|
|
858
|
+
self.store.save(state)
|
|
859
|
+
return {"changed": True, "state": state.state}
|
|
860
|
+
|
|
861
|
+
def _terminate(self, state: RuntimeState, action: str, reason: str) -> None:
|
|
862
|
+
instance = state.instance or (
|
|
863
|
+
Instance(id=state.instance_id, provider=self.provider.name)
|
|
864
|
+
if state.instance_id
|
|
865
|
+
else None
|
|
866
|
+
)
|
|
867
|
+
if instance is None:
|
|
868
|
+
return
|
|
869
|
+
instance_id = instance.id
|
|
870
|
+
try:
|
|
871
|
+
if action == policy.ACTION_DESTROY:
|
|
872
|
+
self.provider.destroy(instance)
|
|
873
|
+
else:
|
|
874
|
+
self.provider.stop(instance)
|
|
875
|
+
except ProviderError as exc:
|
|
876
|
+
self.events.emit(
|
|
877
|
+
ev.PROVIDER_ERROR, operation=action, instance_id=instance_id, error=str(exc)
|
|
878
|
+
)
|
|
879
|
+
raise
|
|
880
|
+
|
|
881
|
+
if action == policy.ACTION_DESTROY:
|
|
882
|
+
state.clear_instance(InstanceState.DESTROYED.value)
|
|
883
|
+
self.events.emit(ev.INSTANCE_DESTROYED, instance_id=instance_id, reason=reason)
|
|
884
|
+
self.echo(f"- instance {instance_id} destroyed ({reason})")
|
|
885
|
+
else:
|
|
886
|
+
state.state = InstanceState.STOPPED.value
|
|
887
|
+
state.starting = False
|
|
888
|
+
state.mark_billable(False)
|
|
889
|
+
if state.hourly_cost_usd:
|
|
890
|
+
state.session_cost_usd = round(
|
|
891
|
+
state.hourly_cost_usd * state.accrued_billable_seconds() / 3600.0, 4
|
|
892
|
+
)
|
|
893
|
+
for record in state.workloads.values():
|
|
894
|
+
if record.state in ("running", "queued"):
|
|
895
|
+
record.state = "failed"
|
|
896
|
+
record.finished_at = utc_now()
|
|
897
|
+
self.events.emit(ev.INSTANCE_STOPPED, instance_id=instance_id, reason=reason)
|
|
898
|
+
self.echo(f"- instance {instance_id} stopped ({reason})")
|
|
899
|
+
|
|
900
|
+
# ------------------------------------------------------------------
|
|
901
|
+
# guard (external controller path, spec section 14 alternative)
|
|
902
|
+
# ------------------------------------------------------------------
|
|
903
|
+
|
|
904
|
+
def guard_tick(self, apply: bool = True) -> dict[str, Any]:
|
|
905
|
+
"""Evaluate the lifecycle rule locally and optionally enforce it.
|
|
906
|
+
|
|
907
|
+
The remote watchdog does this on the instance. This entry point exists
|
|
908
|
+
for a scheduled external controller (CI job, cron) and for `status`.
|
|
909
|
+
"""
|
|
910
|
+
with self.store.process_lock():
|
|
911
|
+
state = self._reconcile(self.store.load())
|
|
912
|
+
if not state.has_instance():
|
|
913
|
+
self.store.save(state)
|
|
914
|
+
return {"action": policy.ACTION_NONE, "reason": "no_instance"}
|
|
915
|
+
|
|
916
|
+
cfg = self.policy_config(state.hourly_cost_usd)
|
|
917
|
+
snap = self.snapshot(state)
|
|
918
|
+
|
|
919
|
+
if state.locked and snap.lock_expired(cfg):
|
|
920
|
+
state.locked = False
|
|
921
|
+
state.locked_until = None
|
|
922
|
+
self.events.emit(ev.INSTANCE_UNLOCKED, reason="lock_expired")
|
|
923
|
+
snap = self.snapshot(state)
|
|
924
|
+
|
|
925
|
+
decision = policy.evaluate(snap, cfg)
|
|
926
|
+
if decision.terminates and apply:
|
|
927
|
+
self.events.emit(
|
|
928
|
+
ev.LIFECYCLE_GUARD_TRIGGERED,
|
|
929
|
+
instance_id=state.instance_id,
|
|
930
|
+
action=decision.action,
|
|
931
|
+
reason=decision.reason,
|
|
932
|
+
**decision.detail,
|
|
933
|
+
)
|
|
934
|
+
self._terminate(state, action=decision.action, reason=decision.reason or "guard")
|
|
935
|
+
self.store.save(state)
|
|
936
|
+
return decision.to_dict()
|
|
937
|
+
|
|
938
|
+
# ------------------------------------------------------------------
|
|
939
|
+
# logs
|
|
940
|
+
# ------------------------------------------------------------------
|
|
941
|
+
|
|
942
|
+
def logs(self, limit: int = 200) -> list[str]:
|
|
943
|
+
with self.store.process_lock():
|
|
944
|
+
state = self._reconcile(self.store.load())
|
|
945
|
+
self.store.save(state)
|
|
946
|
+
if not state.instance:
|
|
947
|
+
raise StateError("no instance to read logs from")
|
|
948
|
+
self.provider.require_capability("logs")
|
|
949
|
+
stream = self.provider.logs(state.instance)
|
|
950
|
+
lines = list(stream)
|
|
951
|
+
return lines[-limit:]
|