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/cli/run.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""`gpu run` - execute a workload against a guaranteed runtime (spec section 16)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import List
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
|
|
9
|
+
from ..core.errors import JambuError, WorkloadFailed
|
|
10
|
+
from ._common import get_context, handle, print_json
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def run_command(
|
|
14
|
+
ctx: typer.Context,
|
|
15
|
+
command: List[str] = typer.Argument(
|
|
16
|
+
...,
|
|
17
|
+
help=(
|
|
18
|
+
"Command to execute, e.g. `python experiments/run.py`. Flags are passed "
|
|
19
|
+
"through; put `--` first if one collides with a gpu option."
|
|
20
|
+
),
|
|
21
|
+
),
|
|
22
|
+
label: str = typer.Option("", "--label", help="Human label recorded with the workload."),
|
|
23
|
+
) -> None:
|
|
24
|
+
"""Ensure the runtime is READY, then run the command with heartbeat tracking."""
|
|
25
|
+
context = get_context(ctx)
|
|
26
|
+
try:
|
|
27
|
+
engine = context.engine()
|
|
28
|
+
engine.run(list(command), label=label)
|
|
29
|
+
except WorkloadFailed as exc:
|
|
30
|
+
if context.json:
|
|
31
|
+
print_json({"ok": False, "returncode": exc.returncode})
|
|
32
|
+
raise typer.Exit(exc.returncode) from exc
|
|
33
|
+
except JambuError as exc:
|
|
34
|
+
handle(exc)
|
|
35
|
+
return
|
|
36
|
+
if context.json:
|
|
37
|
+
print_json({"ok": True, "returncode": 0})
|
jambu_gpu/cli/setup.py
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
"""`gpu setup` / `init` / `offers` - provisioning entry points."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
from rich.table import Table
|
|
9
|
+
from rich.text import Text
|
|
10
|
+
|
|
11
|
+
from ..core.config import (
|
|
12
|
+
DEFAULT_CONFIG_TEMPLATE,
|
|
13
|
+
DEFAULT_CONFIG_TEMPLATE_FLAT,
|
|
14
|
+
CONFIG_FILENAMES,
|
|
15
|
+
)
|
|
16
|
+
from ..core.errors import JambuError
|
|
17
|
+
from ._common import console, fmt_money, get_context, handle, print_json
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def init_command(
|
|
21
|
+
ctx: typer.Context,
|
|
22
|
+
model: str = typer.Option("empero-ai/Qwythos-9B-v2", "--model", help="HuggingFace model id."),
|
|
23
|
+
force: bool = typer.Option(False, "--force", help="Overwrite an existing config file."),
|
|
24
|
+
flat: bool = typer.Option(
|
|
25
|
+
False,
|
|
26
|
+
"--flat",
|
|
27
|
+
help=(
|
|
28
|
+
"Write a legacy, gpu-runtime-only config.yml (no gpu_runtime: "
|
|
29
|
+
"wrapper) instead of the shared jambu.yaml."
|
|
30
|
+
),
|
|
31
|
+
),
|
|
32
|
+
) -> None:
|
|
33
|
+
"""Write a starter jambu.yaml (and .env.example) in the current directory."""
|
|
34
|
+
if flat:
|
|
35
|
+
target = Path.cwd() / "config.yml"
|
|
36
|
+
content = DEFAULT_CONFIG_TEMPLATE_FLAT.format(model_id=model)
|
|
37
|
+
else:
|
|
38
|
+
target = Path.cwd() / CONFIG_FILENAMES[0] # jambu.yaml
|
|
39
|
+
content = DEFAULT_CONFIG_TEMPLATE.format(model_id=model)
|
|
40
|
+
|
|
41
|
+
if target.exists() and not force:
|
|
42
|
+
console.print(Text(f"! {target} already exists (use --force)", style="yellow"))
|
|
43
|
+
raise typer.Exit(1)
|
|
44
|
+
target.write_text(content)
|
|
45
|
+
console.print(Text(f"+ wrote {target}", style="green"))
|
|
46
|
+
if not flat:
|
|
47
|
+
console.print(
|
|
48
|
+
Text(
|
|
49
|
+
" gpu-runtime config lives under the gpu_runtime: key - other "
|
|
50
|
+
"Jambu Lab tools can share this file",
|
|
51
|
+
style="dim",
|
|
52
|
+
)
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
env_example = Path.cwd() / ".env.example"
|
|
56
|
+
if not env_example.exists():
|
|
57
|
+
env_example.write_text(
|
|
58
|
+
"# Credentials never belong in jambu.yaml / config.yml.\n"
|
|
59
|
+
"VAST_API_KEY=\n"
|
|
60
|
+
"# Optional, for gated HuggingFace models:\n"
|
|
61
|
+
"HF_TOKEN=\n"
|
|
62
|
+
)
|
|
63
|
+
console.print(Text(f"+ wrote {env_example}", style="green"))
|
|
64
|
+
console.print("next: export VAST_API_KEY=... && gpu validate")
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def setup_command(
|
|
68
|
+
ctx: typer.Context,
|
|
69
|
+
recreate: bool = typer.Option(
|
|
70
|
+
False, "--recreate", help="Replace an existing instance that no longer matches jambu.yaml."
|
|
71
|
+
),
|
|
72
|
+
allow_cost_override: bool = typer.Option(
|
|
73
|
+
False, "--allow-cost-override", help="Provision even above budget.max_hourly_cost_usd."
|
|
74
|
+
),
|
|
75
|
+
no_wait: bool = typer.Option(
|
|
76
|
+
False, "--no-wait", help="Return as soon as the instance is created."
|
|
77
|
+
),
|
|
78
|
+
) -> None:
|
|
79
|
+
"""Provision the instance, install the watchdog and start the model server."""
|
|
80
|
+
context = get_context(ctx)
|
|
81
|
+
try:
|
|
82
|
+
engine = context.engine()
|
|
83
|
+
state = engine.setup(
|
|
84
|
+
recreate=recreate, allow_cost_override=allow_cost_override, wait=not no_wait
|
|
85
|
+
)
|
|
86
|
+
except JambuError as exc:
|
|
87
|
+
handle(exc)
|
|
88
|
+
return
|
|
89
|
+
|
|
90
|
+
payload = {
|
|
91
|
+
"instance_id": state.instance_id,
|
|
92
|
+
"state": state.state,
|
|
93
|
+
"endpoint": state.endpoint,
|
|
94
|
+
"model": state.model_id,
|
|
95
|
+
"hourly_cost_usd": state.hourly_cost_usd,
|
|
96
|
+
"watchdog": state.watchdog.to_dict(),
|
|
97
|
+
}
|
|
98
|
+
if context.json:
|
|
99
|
+
print_json(payload)
|
|
100
|
+
return
|
|
101
|
+
if state.endpoint:
|
|
102
|
+
console.print(
|
|
103
|
+
f"\n endpoint [bold]{state.endpoint}[/bold]\n"
|
|
104
|
+
f" openai [bold]{state.endpoint.rstrip('/')}/v1[/bold]\n"
|
|
105
|
+
f" model [bold]{state.model_id}[/bold]"
|
|
106
|
+
)
|
|
107
|
+
console.print("\nnext: gpu run python your_experiment.py")
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def offers_command(
|
|
111
|
+
ctx: typer.Context,
|
|
112
|
+
limit: int = typer.Option(10, "--limit", "-n", help="How many offers to show."),
|
|
113
|
+
) -> None:
|
|
114
|
+
"""Show matching capacity and its price before committing to it."""
|
|
115
|
+
context = get_context(ctx)
|
|
116
|
+
try:
|
|
117
|
+
engine = context.engine()
|
|
118
|
+
engine.provider.require_capability("pricing_query")
|
|
119
|
+
spec = engine.build_spec()
|
|
120
|
+
offers = engine.provider.find_offers(spec, limit=limit)
|
|
121
|
+
except JambuError as exc:
|
|
122
|
+
handle(exc)
|
|
123
|
+
return
|
|
124
|
+
|
|
125
|
+
rows = [
|
|
126
|
+
{
|
|
127
|
+
"id": o.id,
|
|
128
|
+
"gpu": o.gpu_name,
|
|
129
|
+
"count": o.gpu_count,
|
|
130
|
+
"vram_gb": o.gpu_vram_gb,
|
|
131
|
+
"disk_gb": o.disk_gb,
|
|
132
|
+
"hourly_cost_usd": o.hourly_cost_usd,
|
|
133
|
+
"region": o.region,
|
|
134
|
+
}
|
|
135
|
+
for o in offers
|
|
136
|
+
]
|
|
137
|
+
if context.json:
|
|
138
|
+
print_json({"offers": rows})
|
|
139
|
+
return
|
|
140
|
+
|
|
141
|
+
if not rows:
|
|
142
|
+
console.print(Text("no matching offers", style="yellow"))
|
|
143
|
+
return
|
|
144
|
+
|
|
145
|
+
ceiling = engine.config.budget.max_hourly_cost_usd
|
|
146
|
+
table = Table(title=f"{engine.provider.display_name} offers", title_justify="left")
|
|
147
|
+
for column in ("OFFER", "GPU", "N", "VRAM", "DISK", "$/H", "REGION"):
|
|
148
|
+
table.add_column(column)
|
|
149
|
+
for row in rows:
|
|
150
|
+
over = ceiling is not None and row["hourly_cost_usd"] > float(ceiling)
|
|
151
|
+
table.add_row(
|
|
152
|
+
row["id"],
|
|
153
|
+
row["gpu"],
|
|
154
|
+
str(row["count"]),
|
|
155
|
+
f"{row['vram_gb']:.0f} GB",
|
|
156
|
+
f"{row['disk_gb']:.0f} GB",
|
|
157
|
+
Text(
|
|
158
|
+
fmt_money(row["hourly_cost_usd"]) or "-",
|
|
159
|
+
style="red" if over else "green",
|
|
160
|
+
),
|
|
161
|
+
row["region"] or "-",
|
|
162
|
+
)
|
|
163
|
+
console.print(table)
|
|
164
|
+
if ceiling:
|
|
165
|
+
console.print(Text(f" budget ceiling: ${float(ceiling):.3f}/h", style="dim"))
|
jambu_gpu/cli/status.py
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
"""`gpu status` / `logs` / `events` - reconciled observability."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
from rich.table import Table
|
|
9
|
+
from rich.text import Text
|
|
10
|
+
|
|
11
|
+
from ..core.durations import format_duration
|
|
12
|
+
from ..core.errors import JambuError
|
|
13
|
+
from ._common import (
|
|
14
|
+
console,
|
|
15
|
+
fmt_money,
|
|
16
|
+
fmt_time,
|
|
17
|
+
get_context,
|
|
18
|
+
handle,
|
|
19
|
+
kv_table,
|
|
20
|
+
print_json,
|
|
21
|
+
state_text,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def status_command(
|
|
26
|
+
ctx: typer.Context,
|
|
27
|
+
watch: float = typer.Option(
|
|
28
|
+
0, "--watch", "-w", help="Refresh every N seconds (0 = once)."
|
|
29
|
+
),
|
|
30
|
+
) -> None:
|
|
31
|
+
"""Reconcile local and provider state, then report the normalized result."""
|
|
32
|
+
context = get_context(ctx)
|
|
33
|
+
while True:
|
|
34
|
+
try:
|
|
35
|
+
payload = context.engine().status()
|
|
36
|
+
except JambuError as exc:
|
|
37
|
+
handle(exc)
|
|
38
|
+
return
|
|
39
|
+
|
|
40
|
+
if context.json:
|
|
41
|
+
print_json(payload)
|
|
42
|
+
return
|
|
43
|
+
|
|
44
|
+
_render(payload)
|
|
45
|
+
if not watch:
|
|
46
|
+
return
|
|
47
|
+
time.sleep(max(2.0, watch))
|
|
48
|
+
console.rule(style="dim")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _render(payload: dict) -> None:
|
|
52
|
+
decision = payload.get("decision") or {}
|
|
53
|
+
watchdog = payload.get("watchdog") or {}
|
|
54
|
+
rows = [
|
|
55
|
+
("state", state_text(payload.get("state", "unknown"))),
|
|
56
|
+
("provider", payload.get("provider")),
|
|
57
|
+
("instance", payload.get("instance_id") or "-"),
|
|
58
|
+
("model", payload.get("model")),
|
|
59
|
+
("endpoint", payload.get("endpoint") or "-"),
|
|
60
|
+
("created", fmt_time(payload.get("created_at"))),
|
|
61
|
+
(
|
|
62
|
+
"age",
|
|
63
|
+
format_duration(payload.get("age_seconds"))
|
|
64
|
+
if payload.get("age_seconds") is not None
|
|
65
|
+
else None,
|
|
66
|
+
),
|
|
67
|
+
("idle", format_duration(payload.get("idle_seconds"))),
|
|
68
|
+
("cost", _cost(payload)),
|
|
69
|
+
("locked", _lock(payload)),
|
|
70
|
+
(
|
|
71
|
+
"watchdog",
|
|
72
|
+
_watchdog(watchdog),
|
|
73
|
+
),
|
|
74
|
+
("guard", _guard(decision, payload)),
|
|
75
|
+
]
|
|
76
|
+
console.print(kv_table("Runtime", rows))
|
|
77
|
+
|
|
78
|
+
workloads = [w for w in payload.get("workloads", []) if w["state"] in ("running", "queued")]
|
|
79
|
+
recent = sorted(
|
|
80
|
+
payload.get("workloads", []), key=lambda w: w.get("started_at") or 0, reverse=True
|
|
81
|
+
)[:5]
|
|
82
|
+
if recent:
|
|
83
|
+
table = Table(title="Workloads", title_justify="left", box=None)
|
|
84
|
+
for column in ("ID", "STATE", "LABEL", "STARTED", "RC"):
|
|
85
|
+
table.add_column(column)
|
|
86
|
+
for record in recent:
|
|
87
|
+
table.add_row(
|
|
88
|
+
record["id"],
|
|
89
|
+
Text(
|
|
90
|
+
record["state"],
|
|
91
|
+
style="cyan" if record["state"] == "running" else "dim",
|
|
92
|
+
),
|
|
93
|
+
(record.get("label") or "")[:48],
|
|
94
|
+
fmt_time(record.get("started_at")) or "-",
|
|
95
|
+
"-" if record.get("returncode") is None else str(record["returncode"]),
|
|
96
|
+
)
|
|
97
|
+
console.print(table)
|
|
98
|
+
if workloads:
|
|
99
|
+
console.print(
|
|
100
|
+
Text(f" {len(workloads)} active workload(s) hold the instance open", style="cyan")
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _cost(payload: dict) -> str:
|
|
105
|
+
hourly = payload.get("hourly_cost_usd")
|
|
106
|
+
session = payload.get("session_cost_usd")
|
|
107
|
+
if hourly is None and session is None:
|
|
108
|
+
return "-"
|
|
109
|
+
parts = []
|
|
110
|
+
if hourly is not None:
|
|
111
|
+
parts.append(f"{fmt_money(hourly)}/h")
|
|
112
|
+
if session:
|
|
113
|
+
parts.append(f"{fmt_money(session)} this session")
|
|
114
|
+
return " ".join(parts) or "-"
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _lock(payload: dict) -> Text:
|
|
118
|
+
if not payload.get("locked"):
|
|
119
|
+
return Text("no", style="dim")
|
|
120
|
+
until = fmt_time(payload.get("locked_until"))
|
|
121
|
+
return Text(f"yes (until {until})" if until else "yes (indefinite)", style="yellow")
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _watchdog(watchdog: dict) -> Text:
|
|
125
|
+
if not watchdog.get("enabled"):
|
|
126
|
+
return Text("disabled - shutdown depends on this CLI staying alive", style="yellow")
|
|
127
|
+
if watchdog.get("reachable"):
|
|
128
|
+
return Text(f"live at {watchdog.get('url')}", style="green")
|
|
129
|
+
if watchdog.get("installed"):
|
|
130
|
+
return Text(
|
|
131
|
+
f"unreachable ({watchdog.get('last_error') or 'no answer'})", style="yellow"
|
|
132
|
+
)
|
|
133
|
+
return Text("not installed", style="yellow")
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _guard(decision: dict, payload: dict) -> Text:
|
|
137
|
+
action = decision.get("action", "none")
|
|
138
|
+
reason = decision.get("reason") or "-"
|
|
139
|
+
if action == "none":
|
|
140
|
+
policy = payload.get("policy") or {}
|
|
141
|
+
idle_timeout = policy.get("idle_timeout_s")
|
|
142
|
+
idle = payload.get("idle_seconds") or 0
|
|
143
|
+
if idle_timeout and reason == "within_policy":
|
|
144
|
+
remaining = max(0.0, float(idle_timeout) - float(idle))
|
|
145
|
+
return Text(
|
|
146
|
+
f"ok ({reason}); auto-stop in {format_duration(remaining)} if idle",
|
|
147
|
+
style="green",
|
|
148
|
+
)
|
|
149
|
+
return Text(f"ok ({reason})", style="green")
|
|
150
|
+
return Text(f"{action} pending: {reason}", style="bold yellow")
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def logs_command(
|
|
154
|
+
ctx: typer.Context,
|
|
155
|
+
lines: int = typer.Option(200, "--lines", "-n", help="How many lines to show."),
|
|
156
|
+
) -> None:
|
|
157
|
+
"""Show provider-side container logs for the instance."""
|
|
158
|
+
context = get_context(ctx)
|
|
159
|
+
try:
|
|
160
|
+
output = context.engine().logs(limit=lines)
|
|
161
|
+
except JambuError as exc:
|
|
162
|
+
handle(exc)
|
|
163
|
+
return
|
|
164
|
+
if context.json:
|
|
165
|
+
print_json({"lines": output})
|
|
166
|
+
return
|
|
167
|
+
for line in output:
|
|
168
|
+
console.print(line, markup=False, highlight=False)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def events_command(
|
|
172
|
+
ctx: typer.Context,
|
|
173
|
+
limit: int = typer.Option(40, "--limit", "-n", help="How many events to show."),
|
|
174
|
+
event: str = typer.Option("", "--event", help="Filter by event name."),
|
|
175
|
+
) -> None:
|
|
176
|
+
"""Show the structured lifecycle event log (spec section 24)."""
|
|
177
|
+
context = get_context(ctx)
|
|
178
|
+
try:
|
|
179
|
+
engine = context.engine()
|
|
180
|
+
except JambuError as exc:
|
|
181
|
+
handle(exc)
|
|
182
|
+
return
|
|
183
|
+
records = engine.events.tail(limit=limit, event=event or None)
|
|
184
|
+
if context.json:
|
|
185
|
+
print_json({"events": records})
|
|
186
|
+
return
|
|
187
|
+
if not records:
|
|
188
|
+
console.print(Text("no events recorded yet", style="dim"))
|
|
189
|
+
return
|
|
190
|
+
table = Table(title="Lifecycle events", title_justify="left", box=None)
|
|
191
|
+
for column in ("TS", "EVENT", "DETAIL"):
|
|
192
|
+
table.add_column(column)
|
|
193
|
+
for record in records:
|
|
194
|
+
detail = {
|
|
195
|
+
k: v
|
|
196
|
+
for k, v in record.items()
|
|
197
|
+
if k not in ("ts", "event", "pid", "provider", "source")
|
|
198
|
+
}
|
|
199
|
+
table.add_row(
|
|
200
|
+
str(record.get("ts", "")),
|
|
201
|
+
Text(str(record.get("event", "")), style=_event_style(record.get("event", ""))),
|
|
202
|
+
", ".join(f"{k}={v}" for k, v in detail.items())[:110],
|
|
203
|
+
)
|
|
204
|
+
console.print(table)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _event_style(event: str) -> str:
|
|
208
|
+
if "error" in event or "failed" in event:
|
|
209
|
+
return "red"
|
|
210
|
+
if "guard" in event or "stopped" in event or "destroyed" in event:
|
|
211
|
+
return "yellow"
|
|
212
|
+
if "ready" in event or "created" in event:
|
|
213
|
+
return "green"
|
|
214
|
+
return "white"
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""`gpu validate` - fail before provisioning (spec sections 5, 15)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
from rich.text import Text
|
|
7
|
+
|
|
8
|
+
from ..core.errors import JambuError
|
|
9
|
+
from ._common import console, get_context, handle, print_json
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def validate_command(
|
|
13
|
+
ctx: typer.Context,
|
|
14
|
+
offline: bool = typer.Option(
|
|
15
|
+
False, "--offline", help="Skip provider API checks (schema and policy only)."
|
|
16
|
+
),
|
|
17
|
+
) -> None:
|
|
18
|
+
"""Validate jambu.yaml, credentials, capabilities and available capacity."""
|
|
19
|
+
context = get_context(ctx)
|
|
20
|
+
try:
|
|
21
|
+
engine = context.engine()
|
|
22
|
+
result = engine.validate(check_provider=not offline)
|
|
23
|
+
except JambuError as exc:
|
|
24
|
+
if context.json:
|
|
25
|
+
print_json({"ok": False, "errors": [{"message": str(exc)}]})
|
|
26
|
+
raise typer.Exit(getattr(exc, "exit_code", 1)) from exc
|
|
27
|
+
handle(exc)
|
|
28
|
+
return
|
|
29
|
+
|
|
30
|
+
payload = {
|
|
31
|
+
"ok": result.ok,
|
|
32
|
+
"provider": engine.provider.name,
|
|
33
|
+
"model": engine.config.model.id,
|
|
34
|
+
"runtime": engine.config.runtime.engine,
|
|
35
|
+
"fingerprint": engine.config.fingerprint(),
|
|
36
|
+
"errors": [{"field": i.field, "message": i.message} for i in result.errors],
|
|
37
|
+
"warnings": [{"field": i.field, "message": i.message} for i in result.warnings],
|
|
38
|
+
}
|
|
39
|
+
if context.json:
|
|
40
|
+
print_json(payload)
|
|
41
|
+
raise typer.Exit(0 if result.ok else 2)
|
|
42
|
+
|
|
43
|
+
console.print(
|
|
44
|
+
f"config: [bold]{engine.config.source_path}[/bold]\n"
|
|
45
|
+
f"provider: [bold]{engine.provider.display_name}[/bold] "
|
|
46
|
+
f"runtime: [bold]{engine.runtime.describe()}[/bold]"
|
|
47
|
+
)
|
|
48
|
+
for issue in result.warnings:
|
|
49
|
+
console.print(Text(f"! {issue.field or 'config'}: {issue.message}", style="yellow"))
|
|
50
|
+
for issue in result.errors:
|
|
51
|
+
console.print(Text(f"x {issue.field or 'config'}: {issue.message}", style="bold red"))
|
|
52
|
+
|
|
53
|
+
if result.ok:
|
|
54
|
+
console.print(Text("+ configuration is valid", style="bold green"))
|
|
55
|
+
else:
|
|
56
|
+
console.print(Text(f"{len(result.errors)} error(s) must be fixed", style="bold red"))
|
|
57
|
+
raise typer.Exit(2)
|
|
File without changes
|
jambu_gpu/core/beacon.py
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""Client for the remote lifecycle watchdog.
|
|
2
|
+
|
|
3
|
+
The watchdog is the authority on activity: it holds the heartbeat state and
|
|
4
|
+
performs the shutdown. The CLI only reports activity to it and reads it back.
|
|
5
|
+
Every call is best-effort - losing the watchdog must never break a workload,
|
|
6
|
+
and an unreachable watchdog is surfaced as a warning, not a crash.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import Any, Optional
|
|
12
|
+
|
|
13
|
+
import httpx
|
|
14
|
+
|
|
15
|
+
from .errors import JambuError
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class WatchdogUnavailable(JambuError):
|
|
19
|
+
"""The remote watchdog could not be reached."""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class WatchdogClient:
|
|
23
|
+
def __init__(self, base_url: str, token: Optional[str], timeout: float = 10.0) -> None:
|
|
24
|
+
self.base_url = base_url.rstrip("/")
|
|
25
|
+
self.token = token
|
|
26
|
+
self.timeout = timeout
|
|
27
|
+
|
|
28
|
+
@property
|
|
29
|
+
def headers(self) -> dict[str, str]:
|
|
30
|
+
return {"X-Jambu-Token": self.token} if self.token else {}
|
|
31
|
+
|
|
32
|
+
# -- transport ----------------------------------------------------------
|
|
33
|
+
|
|
34
|
+
def _request(self, method: str, path: str, payload: Optional[dict] = None) -> dict[str, Any]:
|
|
35
|
+
url = f"{self.base_url}{path}"
|
|
36
|
+
try:
|
|
37
|
+
response = httpx.request(
|
|
38
|
+
method,
|
|
39
|
+
url,
|
|
40
|
+
json=payload,
|
|
41
|
+
headers=self.headers,
|
|
42
|
+
timeout=self.timeout,
|
|
43
|
+
)
|
|
44
|
+
except httpx.HTTPError as exc:
|
|
45
|
+
raise WatchdogUnavailable(f"watchdog unreachable at {url}: {exc}") from exc
|
|
46
|
+
if response.status_code == 401:
|
|
47
|
+
raise WatchdogUnavailable("watchdog rejected the control token")
|
|
48
|
+
if response.status_code >= 400:
|
|
49
|
+
raise WatchdogUnavailable(
|
|
50
|
+
f"watchdog returned {response.status_code}: {response.text[:200]}"
|
|
51
|
+
)
|
|
52
|
+
try:
|
|
53
|
+
return response.json()
|
|
54
|
+
except ValueError:
|
|
55
|
+
return {}
|
|
56
|
+
|
|
57
|
+
def try_request(self, method: str, path: str, payload: Optional[dict] = None) -> Optional[dict]:
|
|
58
|
+
try:
|
|
59
|
+
return self._request(method, path, payload)
|
|
60
|
+
except WatchdogUnavailable:
|
|
61
|
+
return None
|
|
62
|
+
|
|
63
|
+
# -- routes -------------------------------------------------------------
|
|
64
|
+
|
|
65
|
+
def ping(self) -> bool:
|
|
66
|
+
try:
|
|
67
|
+
self._request("GET", "/health")
|
|
68
|
+
return True
|
|
69
|
+
except WatchdogUnavailable:
|
|
70
|
+
return False
|
|
71
|
+
|
|
72
|
+
def state(self) -> dict[str, Any]:
|
|
73
|
+
return self._request("GET", "/state")
|
|
74
|
+
|
|
75
|
+
def try_state(self) -> Optional[dict[str, Any]]:
|
|
76
|
+
return self.try_request("GET", "/state")
|
|
77
|
+
|
|
78
|
+
def events(self, limit: int = 100) -> list[dict[str, Any]]:
|
|
79
|
+
data = self.try_request("GET", f"/events?limit={limit}") or {}
|
|
80
|
+
return data.get("events", [])
|
|
81
|
+
|
|
82
|
+
def identify(
|
|
83
|
+
self, instance_id: str, hourly_cost_usd: Optional[float] = None
|
|
84
|
+
) -> Optional[dict]:
|
|
85
|
+
return self.try_request(
|
|
86
|
+
"POST",
|
|
87
|
+
"/identify",
|
|
88
|
+
{"instance_id": instance_id, "hourly_cost_usd": hourly_cost_usd},
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
def activity(self) -> Optional[dict]:
|
|
92
|
+
return self.try_request("POST", "/activity")
|
|
93
|
+
|
|
94
|
+
def mark_starting(self) -> Optional[dict]:
|
|
95
|
+
return self.try_request("POST", "/starting")
|
|
96
|
+
|
|
97
|
+
def mark_ready(self) -> Optional[dict]:
|
|
98
|
+
return self.try_request("POST", "/ready")
|
|
99
|
+
|
|
100
|
+
def workload_start(self, workload_id: str, label: str = "") -> Optional[dict]:
|
|
101
|
+
return self.try_request(
|
|
102
|
+
"POST", "/workload/start", {"id": workload_id, "label": label}
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
def workload_heartbeat(self, workload_id: str) -> Optional[dict]:
|
|
106
|
+
return self.try_request("POST", "/workload/heartbeat", {"id": workload_id})
|
|
107
|
+
|
|
108
|
+
def workload_end(
|
|
109
|
+
self, workload_id: str, returncode: Optional[int], state: Optional[str] = None
|
|
110
|
+
) -> Optional[dict]:
|
|
111
|
+
return self.try_request(
|
|
112
|
+
"POST",
|
|
113
|
+
"/workload/end",
|
|
114
|
+
{"id": workload_id, "returncode": returncode, "state": state},
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
def lock(self, ttl_seconds: Optional[float], reason: str = "") -> dict:
|
|
118
|
+
return self._request("POST", "/lock", {"ttl_seconds": ttl_seconds, "reason": reason})
|
|
119
|
+
|
|
120
|
+
def unlock(self, reason: str = "manual") -> dict:
|
|
121
|
+
return self._request("POST", "/unlock", {"reason": reason})
|
|
122
|
+
|
|
123
|
+
def shutdown(self, action: str = "stop", reason: str = "manual") -> Optional[dict]:
|
|
124
|
+
return self.try_request("POST", "/shutdown", {"action": action, "reason": reason})
|