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/__init__.py
ADDED
jambu_gpu/__main__.py
ADDED
|
File without changes
|
jambu_gpu/cli/_common.py
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""Shared CLI plumbing: config loading, output and error rendering."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json as jsonlib
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any, Optional
|
|
9
|
+
|
|
10
|
+
import typer
|
|
11
|
+
from rich.console import Console
|
|
12
|
+
from rich.table import Table
|
|
13
|
+
from rich.text import Text
|
|
14
|
+
|
|
15
|
+
from ..core.config import RuntimeConfig, load_config
|
|
16
|
+
from ..core.engine import Engine
|
|
17
|
+
from ..core.errors import JambuError
|
|
18
|
+
from ..core.state import to_iso
|
|
19
|
+
|
|
20
|
+
console = Console()
|
|
21
|
+
err_console = Console(stderr=True)
|
|
22
|
+
|
|
23
|
+
STATE_STYLES = {
|
|
24
|
+
"ready": "bold green",
|
|
25
|
+
"busy": "bold cyan",
|
|
26
|
+
"idle": "yellow",
|
|
27
|
+
"starting": "cyan",
|
|
28
|
+
"provisioning": "cyan",
|
|
29
|
+
"stopped": "dim",
|
|
30
|
+
"destroyed": "dim",
|
|
31
|
+
"failed": "bold red",
|
|
32
|
+
"unknown": "magenta",
|
|
33
|
+
"none": "dim",
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class Context:
|
|
38
|
+
"""Per-invocation state carried on the typer context object."""
|
|
39
|
+
|
|
40
|
+
def __init__(self) -> None:
|
|
41
|
+
self.config_path: Optional[Path] = None
|
|
42
|
+
self.json: bool = False
|
|
43
|
+
self.verbose: bool = False
|
|
44
|
+
self._config: Optional[RuntimeConfig] = None
|
|
45
|
+
self._engine: Optional[Engine] = None
|
|
46
|
+
|
|
47
|
+
def config(self) -> RuntimeConfig:
|
|
48
|
+
if self._config is None:
|
|
49
|
+
self._config = load_config(self.config_path)
|
|
50
|
+
return self._config
|
|
51
|
+
|
|
52
|
+
def engine(self) -> Engine:
|
|
53
|
+
if self._engine is None:
|
|
54
|
+
self._engine = Engine(self.config(), echo=self.echo)
|
|
55
|
+
return self._engine
|
|
56
|
+
|
|
57
|
+
def echo(self, message: str) -> None:
|
|
58
|
+
if self.json:
|
|
59
|
+
return
|
|
60
|
+
style = ""
|
|
61
|
+
if message.startswith("!"):
|
|
62
|
+
style = "yellow"
|
|
63
|
+
elif message.startswith("+"):
|
|
64
|
+
style = "green"
|
|
65
|
+
elif message.startswith("-"):
|
|
66
|
+
style = "red"
|
|
67
|
+
elif message.startswith(">"):
|
|
68
|
+
style = "cyan"
|
|
69
|
+
console.print(Text(message, style=style) if style else message)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def get_context(ctx: typer.Context) -> Context:
|
|
73
|
+
if ctx.obj is None:
|
|
74
|
+
ctx.obj = Context()
|
|
75
|
+
return ctx.obj
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def fail(message: str, code: int = 1) -> None:
|
|
79
|
+
err_console.print(Text(f"error: {message}", style="bold red"))
|
|
80
|
+
raise typer.Exit(code)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def handle(exc: Exception) -> None:
|
|
84
|
+
"""Render a JambuError the same way regardless of which provider raised it."""
|
|
85
|
+
if isinstance(exc, JambuError):
|
|
86
|
+
err_console.print(Text(f"error: {exc}", style="bold red"))
|
|
87
|
+
raise typer.Exit(getattr(exc, "exit_code", 1))
|
|
88
|
+
raise exc
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def print_json(payload: Any) -> None:
|
|
92
|
+
sys.stdout.write(jsonlib.dumps(payload, indent=2, default=str) + "\n")
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def state_text(state: str) -> Text:
|
|
96
|
+
return Text(state.upper(), style=STATE_STYLES.get(state, "white"))
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def kv_table(title: str, rows: list[tuple[str, Any]]) -> Table:
|
|
100
|
+
table = Table(title=title, show_header=False, box=None, title_justify="left", pad_edge=False)
|
|
101
|
+
table.add_column("field", style="dim", no_wrap=True)
|
|
102
|
+
table.add_column("value")
|
|
103
|
+
for key, value in rows:
|
|
104
|
+
if value is None:
|
|
105
|
+
continue
|
|
106
|
+
table.add_row(key, value if isinstance(value, Text) else str(value))
|
|
107
|
+
return table
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def fmt_time(ts: Optional[float]) -> Optional[str]:
|
|
111
|
+
return to_iso(ts) if ts else None
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def fmt_money(value: Optional[float], suffix: str = "") -> Optional[str]:
|
|
115
|
+
if value is None:
|
|
116
|
+
return None
|
|
117
|
+
return f"${value:.3f}{suffix}"
|
jambu_gpu/cli/inspect.py
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
"""`gpu inspect-model` - where jambu.yaml's numbers actually come from.
|
|
2
|
+
|
|
3
|
+
Fetches the two public HuggingFace documents that answer "what should
|
|
4
|
+
compute.gpu / runtime look like for this model" (see
|
|
5
|
+
jambu_gpu/core/model_inspect.py for exactly which fields), and prints a
|
|
6
|
+
ready-to-paste gpu_runtime: snippet plus the reasoning behind each value.
|
|
7
|
+
|
|
8
|
+
`--add-profile` closes the loop: instead of hand-writing a new full jambu.yaml
|
|
9
|
+
per model (which only ever grows, never shrinks, and duplicates across every
|
|
10
|
+
project directory), it writes the model-specific fragment straight into the
|
|
11
|
+
shared jambu.models.yaml catalog under a name - `model_profile: <name>` in a
|
|
12
|
+
project's jambu.yaml is then the only thing that ever needs to change.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Optional
|
|
19
|
+
|
|
20
|
+
import typer
|
|
21
|
+
import yaml
|
|
22
|
+
from rich.text import Text
|
|
23
|
+
|
|
24
|
+
from rich.table import Table
|
|
25
|
+
|
|
26
|
+
from ..core.config import CATALOG_FILENAMES, find_catalog, load_catalog
|
|
27
|
+
from ..core.credentials import CredentialResolver
|
|
28
|
+
from ..core.errors import ConfigError, JambuError
|
|
29
|
+
from ..core.model_inspect import inspect_model
|
|
30
|
+
from ._common import console, get_context, handle, print_json
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def inspect_model_command(
|
|
34
|
+
ctx: typer.Context,
|
|
35
|
+
repo_id: str = typer.Argument(
|
|
36
|
+
..., help="HuggingFace repo id to inspect, e.g. Qwen/Qwen2.5-7B-Instruct."
|
|
37
|
+
),
|
|
38
|
+
revision: str = typer.Option("main", "--revision", help="Repo revision/branch/tag."),
|
|
39
|
+
hf_token_env: str = typer.Option(
|
|
40
|
+
"HF_TOKEN", "--hf-token-env", help="Env var holding a token for gated repos."
|
|
41
|
+
),
|
|
42
|
+
add_profile: str = typer.Option(
|
|
43
|
+
"",
|
|
44
|
+
"--add-profile",
|
|
45
|
+
help=(
|
|
46
|
+
"Write the result straight into jambu.models.yaml under this name "
|
|
47
|
+
"instead of just printing a snippet (creates the catalog if none "
|
|
48
|
+
"exists yet). Use model_profile: <name> in jambu.yaml to consume it."
|
|
49
|
+
),
|
|
50
|
+
),
|
|
51
|
+
catalog_path_opt: Optional[Path] = typer.Option(
|
|
52
|
+
None,
|
|
53
|
+
"--catalog",
|
|
54
|
+
help=f"Catalog file to write to with --add-profile (default: nearest {CATALOG_FILENAMES[0]}, or a new one here).",
|
|
55
|
+
),
|
|
56
|
+
force: bool = typer.Option(
|
|
57
|
+
False, "--force", help="With --add-profile, overwrite an existing profile of that name."
|
|
58
|
+
),
|
|
59
|
+
) -> None:
|
|
60
|
+
"""Inspect a HuggingFace repo and suggest jambu.yaml values for it.
|
|
61
|
+
|
|
62
|
+
Reads exactly two public documents, cited in the output:
|
|
63
|
+
|
|
64
|
+
\b
|
|
65
|
+
https://huggingface.co/api/models/<repo> (param count, dtype)
|
|
66
|
+
https://huggingface.co/<repo>/raw/<rev>/config.json (architecture, context)
|
|
67
|
+
|
|
68
|
+
This is a starting point, not an oracle - the VRAM figure is weights plus
|
|
69
|
+
a flat margin for KV cache/activations (which depend on context length
|
|
70
|
+
and concurrency this command has no way to know). Confirm with
|
|
71
|
+
`gpu offers` and a real health check before trusting it in production.
|
|
72
|
+
"""
|
|
73
|
+
context = get_context(ctx)
|
|
74
|
+
resolver = CredentialResolver(Path.cwd())
|
|
75
|
+
token = resolver.get(hf_token_env)
|
|
76
|
+
|
|
77
|
+
try:
|
|
78
|
+
result = inspect_model(repo_id, revision=revision, token=token)
|
|
79
|
+
except JambuError as exc:
|
|
80
|
+
handle(exc)
|
|
81
|
+
return
|
|
82
|
+
|
|
83
|
+
if add_profile:
|
|
84
|
+
try:
|
|
85
|
+
target = _write_profile(result, add_profile, catalog_path_opt, force=force)
|
|
86
|
+
except JambuError as exc:
|
|
87
|
+
handle(exc)
|
|
88
|
+
return
|
|
89
|
+
if context.json:
|
|
90
|
+
print_json({**result.to_dict(), "profile_name": add_profile, "catalog_path": str(target)})
|
|
91
|
+
else:
|
|
92
|
+
console.print(Text(f"+ wrote profile '{add_profile}' to {target}", style="green"))
|
|
93
|
+
console.print(f" use it with: model_profile: {add_profile}")
|
|
94
|
+
return
|
|
95
|
+
|
|
96
|
+
if context.json:
|
|
97
|
+
print_json(result.to_dict())
|
|
98
|
+
return
|
|
99
|
+
|
|
100
|
+
console.print(f"[bold]{result.repo_id}[/bold] @ {result.revision}")
|
|
101
|
+
console.print(
|
|
102
|
+
f" architecture {', '.join(result.architectures) or '?'} "
|
|
103
|
+
f"(model_type: {result.model_type or '?'})"
|
|
104
|
+
)
|
|
105
|
+
if result.total_params:
|
|
106
|
+
console.print(
|
|
107
|
+
f" parameters {result.total_params / 1e9:.2f}B "
|
|
108
|
+
f"({result.param_dtype or '?'}"
|
|
109
|
+
+ (f", quantization: {result.quant_method}" if result.quant_method else "")
|
|
110
|
+
+ ")"
|
|
111
|
+
)
|
|
112
|
+
console.print(
|
|
113
|
+
f" weight size ~{result.weight_gb:.1f} GB "
|
|
114
|
+
f"-> suggested min_vram_gb: {result.suggested_min_vram_gb}"
|
|
115
|
+
)
|
|
116
|
+
else:
|
|
117
|
+
console.print(Text(" parameters unknown (no safetensors metadata)", style="yellow"))
|
|
118
|
+
console.print(f" context native {result.max_position_embeddings or '?'}")
|
|
119
|
+
console.print(
|
|
120
|
+
f" tool calling {result.suggested_tool_call_parser or '(no parser guessed)'}"
|
|
121
|
+
)
|
|
122
|
+
if result.gated:
|
|
123
|
+
console.print(Text(" gated yes - a real HF token is required", style="yellow"))
|
|
124
|
+
if result.is_hybrid_attention:
|
|
125
|
+
console.print(
|
|
126
|
+
Text(" hybrid attention yes - needs Ampere-or-newer GPU", style="yellow")
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
for note in result.notes:
|
|
130
|
+
console.print(Text(f" ! {note}", style="yellow"))
|
|
131
|
+
|
|
132
|
+
console.print("\n[bold]Sources[/bold]")
|
|
133
|
+
console.print(f" https://huggingface.co/api/models/{repo_id}")
|
|
134
|
+
console.print(f" https://huggingface.co/{repo_id}/raw/{revision}/config.json")
|
|
135
|
+
|
|
136
|
+
console.print("\n[bold]Suggested gpu_runtime: snippet[/bold]")
|
|
137
|
+
console.print(result.to_yaml_snippet())
|
|
138
|
+
console.print(
|
|
139
|
+
Text(
|
|
140
|
+
f"\nTip: `gpu inspect-model {repo_id} --add-profile <name>` writes this "
|
|
141
|
+
"straight into your model catalog instead of a one-off file.",
|
|
142
|
+
style="dim",
|
|
143
|
+
)
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def profiles_command(
|
|
148
|
+
ctx: typer.Context,
|
|
149
|
+
catalog_path_opt: Optional[Path] = typer.Option(
|
|
150
|
+
None, "--catalog", help=f"Catalog to list (default: nearest {CATALOG_FILENAMES[0]})."
|
|
151
|
+
),
|
|
152
|
+
) -> None:
|
|
153
|
+
"""List the model profiles available in the model catalog (jambu.models.yaml)."""
|
|
154
|
+
context = get_context(ctx)
|
|
155
|
+
target = catalog_path_opt or find_catalog(Path.cwd())
|
|
156
|
+
if target is None or not target.is_file():
|
|
157
|
+
if context.json:
|
|
158
|
+
print_json({"catalog": None, "profiles": {}})
|
|
159
|
+
else:
|
|
160
|
+
console.print(
|
|
161
|
+
Text(
|
|
162
|
+
f"no {CATALOG_FILENAMES[0]} found in this directory or any parent. "
|
|
163
|
+
"Create one with `gpu inspect-model <repo> --add-profile <name>`.",
|
|
164
|
+
style="yellow",
|
|
165
|
+
)
|
|
166
|
+
)
|
|
167
|
+
return
|
|
168
|
+
|
|
169
|
+
try:
|
|
170
|
+
catalog = load_catalog(target)
|
|
171
|
+
except JambuError as exc:
|
|
172
|
+
handle(exc)
|
|
173
|
+
return
|
|
174
|
+
|
|
175
|
+
if context.json:
|
|
176
|
+
print_json({"catalog": str(target), "profiles": catalog})
|
|
177
|
+
return
|
|
178
|
+
|
|
179
|
+
table = Table(title=f"Model profiles ({target})", title_justify="left")
|
|
180
|
+
for column in ("PROFILE", "MODEL", "MIN VRAM", "TOOL CALLING"):
|
|
181
|
+
table.add_column(column)
|
|
182
|
+
for name, fragment in sorted(catalog.items()):
|
|
183
|
+
if not isinstance(fragment, dict):
|
|
184
|
+
continue
|
|
185
|
+
model_id = (fragment.get("model") or {}).get("id", "?")
|
|
186
|
+
vram = ((fragment.get("compute") or {}).get("gpu") or {}).get("min_vram_gb", "-")
|
|
187
|
+
runtime = fragment.get("runtime") or {}
|
|
188
|
+
tool_calling = runtime.get("tool_call_parser") or runtime.get("tool_calling", "-")
|
|
189
|
+
table.add_row(name, str(model_id), str(vram), str(tool_calling))
|
|
190
|
+
console.print(table)
|
|
191
|
+
console.print(Text(f" use with: model_profile: <name> in jambu.yaml", style="dim"))
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _write_profile(result, name: str, catalog_path_opt: Optional[Path], *, force: bool) -> Path:
|
|
195
|
+
if catalog_path_opt:
|
|
196
|
+
target = catalog_path_opt
|
|
197
|
+
else:
|
|
198
|
+
target = find_catalog(Path.cwd()) or (Path.cwd() / CATALOG_FILENAMES[0])
|
|
199
|
+
|
|
200
|
+
catalog: dict = load_catalog(target) if target.is_file() else {}
|
|
201
|
+
if name in catalog and not force:
|
|
202
|
+
raise ConfigError(
|
|
203
|
+
f"profile '{name}' already exists in {target} (use --force to overwrite)"
|
|
204
|
+
)
|
|
205
|
+
catalog[name] = result.to_profile_fragment()
|
|
206
|
+
target.write_text(yaml.safe_dump(catalog, sort_keys=False))
|
|
207
|
+
return target
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""`gpu lock|unlock|stop|destroy|guard` - lifecycle control."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
from rich.text import Text
|
|
7
|
+
|
|
8
|
+
from ..core.durations import parse_duration
|
|
9
|
+
from ..core.errors import ConfigError, JambuError
|
|
10
|
+
from ..core.state import to_iso
|
|
11
|
+
from ._common import console, get_context, handle, print_json
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def lock_command(
|
|
15
|
+
ctx: typer.Context,
|
|
16
|
+
duration: str = typer.Option(
|
|
17
|
+
"", "--for", help="Lock TTL, e.g. 2h. Omit to use lifecycle.lock.default_ttl."
|
|
18
|
+
),
|
|
19
|
+
reason: str = typer.Option("", "--reason", help="Why the instance must stay up."),
|
|
20
|
+
) -> None:
|
|
21
|
+
"""Prevent automatic shutdown until the lock expires (spec section 11)."""
|
|
22
|
+
context = get_context(ctx)
|
|
23
|
+
try:
|
|
24
|
+
engine = context.engine()
|
|
25
|
+
if duration:
|
|
26
|
+
ttl = parse_duration(duration)
|
|
27
|
+
else:
|
|
28
|
+
ttl = engine.config.lock_default_ttl_s
|
|
29
|
+
result = engine.lock(ttl, reason=reason)
|
|
30
|
+
except ValueError as exc:
|
|
31
|
+
handle(ConfigError(str(exc)))
|
|
32
|
+
return
|
|
33
|
+
except JambuError as exc:
|
|
34
|
+
handle(exc)
|
|
35
|
+
return
|
|
36
|
+
|
|
37
|
+
if context.json:
|
|
38
|
+
print_json(result)
|
|
39
|
+
return
|
|
40
|
+
until = result.get("locked_until")
|
|
41
|
+
console.print(
|
|
42
|
+
Text(
|
|
43
|
+
f"+ locked until {to_iso(until)}" if until else "+ locked indefinitely",
|
|
44
|
+
style="yellow",
|
|
45
|
+
)
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def unlock_command(ctx: typer.Context) -> None:
|
|
50
|
+
"""Resume normal lifecycle rules."""
|
|
51
|
+
context = get_context(ctx)
|
|
52
|
+
try:
|
|
53
|
+
result = context.engine().unlock()
|
|
54
|
+
except JambuError as exc:
|
|
55
|
+
handle(exc)
|
|
56
|
+
return
|
|
57
|
+
if context.json:
|
|
58
|
+
print_json(result)
|
|
59
|
+
return
|
|
60
|
+
console.print(Text("+ unlocked; idle and lifetime rules resume", style="green"))
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def stop_command(
|
|
64
|
+
ctx: typer.Context,
|
|
65
|
+
reason: str = typer.Option("manual", "--reason", help="Recorded with the stop event."),
|
|
66
|
+
) -> None:
|
|
67
|
+
"""Stop the compute resource (idempotent)."""
|
|
68
|
+
context = get_context(ctx)
|
|
69
|
+
try:
|
|
70
|
+
result = context.engine().stop(reason=reason)
|
|
71
|
+
except JambuError as exc:
|
|
72
|
+
handle(exc)
|
|
73
|
+
return
|
|
74
|
+
if context.json:
|
|
75
|
+
print_json(result)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def destroy_command(
|
|
79
|
+
ctx: typer.Context,
|
|
80
|
+
yes: bool = typer.Option(False, "--yes", "-y", help="Skip the confirmation prompt."),
|
|
81
|
+
reason: str = typer.Option("manual", "--reason", help="Recorded with the destroy event."),
|
|
82
|
+
) -> None:
|
|
83
|
+
"""Destroy the remote resource (idempotent)."""
|
|
84
|
+
context = get_context(ctx)
|
|
85
|
+
try:
|
|
86
|
+
engine = context.engine()
|
|
87
|
+
if not yes and not context.json:
|
|
88
|
+
state = engine.store.load()
|
|
89
|
+
if state.instance_id:
|
|
90
|
+
confirm = typer.confirm(
|
|
91
|
+
f"destroy instance {state.instance_id} on {engine.provider.display_name}?"
|
|
92
|
+
)
|
|
93
|
+
if not confirm:
|
|
94
|
+
console.print("aborted")
|
|
95
|
+
raise typer.Exit(1)
|
|
96
|
+
result = engine.destroy(reason=reason)
|
|
97
|
+
except JambuError as exc:
|
|
98
|
+
handle(exc)
|
|
99
|
+
return
|
|
100
|
+
if context.json:
|
|
101
|
+
print_json(result)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def guard_command(
|
|
105
|
+
ctx: typer.Context,
|
|
106
|
+
dry_run: bool = typer.Option(
|
|
107
|
+
False, "--dry-run", help="Evaluate the policy without enforcing it."
|
|
108
|
+
),
|
|
109
|
+
) -> None:
|
|
110
|
+
"""Evaluate the lifecycle rule now (external controller path, spec section 14).
|
|
111
|
+
|
|
112
|
+
The remote watchdog already does this on the instance. Use this from cron
|
|
113
|
+
or CI as a second, independent enforcement path.
|
|
114
|
+
"""
|
|
115
|
+
context = get_context(ctx)
|
|
116
|
+
try:
|
|
117
|
+
result = context.engine().guard_tick(apply=not dry_run)
|
|
118
|
+
except JambuError as exc:
|
|
119
|
+
handle(exc)
|
|
120
|
+
return
|
|
121
|
+
if context.json:
|
|
122
|
+
print_json(result)
|
|
123
|
+
return
|
|
124
|
+
action = result.get("action")
|
|
125
|
+
reason = result.get("reason")
|
|
126
|
+
style = "yellow" if action != "none" else "green"
|
|
127
|
+
console.print(Text(f"{action}: {reason}", style=style))
|
jambu_gpu/cli/main.py
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""gpu entrypoint."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Optional
|
|
8
|
+
|
|
9
|
+
import typer
|
|
10
|
+
|
|
11
|
+
from ..core.errors import JambuError
|
|
12
|
+
from . import inspect as inspect_cli
|
|
13
|
+
from . import lifecycle, providers, run, setup, status, validate
|
|
14
|
+
from ._common import Context, err_console, get_context
|
|
15
|
+
|
|
16
|
+
app = typer.Typer(
|
|
17
|
+
name="gpu",
|
|
18
|
+
help=(
|
|
19
|
+
"Provider-agnostic CLI for provisioning and managing temporary GPU runtimes.\n\n"
|
|
20
|
+
"jambu.yaml is the desired-state source of truth; .jambu/state.json is what "
|
|
21
|
+
"was last observed."
|
|
22
|
+
),
|
|
23
|
+
no_args_is_help=True,
|
|
24
|
+
add_completion=False,
|
|
25
|
+
rich_markup_mode="rich",
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@app.callback()
|
|
30
|
+
def main_callback(
|
|
31
|
+
ctx: typer.Context,
|
|
32
|
+
config: Optional[Path] = typer.Option(
|
|
33
|
+
None, "--config", "-c", help="Path to jambu.yaml (default: nearest one upwards)."
|
|
34
|
+
),
|
|
35
|
+
json_output: bool = typer.Option(
|
|
36
|
+
False, "--json", help="Machine-readable output on stdout."
|
|
37
|
+
),
|
|
38
|
+
verbose: bool = typer.Option(False, "--verbose", "-v", help="Verbose diagnostics."),
|
|
39
|
+
) -> None:
|
|
40
|
+
context = Context()
|
|
41
|
+
context.config_path = config
|
|
42
|
+
context.json = json_output
|
|
43
|
+
context.verbose = verbose
|
|
44
|
+
ctx.obj = context
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
app.command("init")(setup.init_command)
|
|
48
|
+
app.command("inspect-model")(inspect_cli.inspect_model_command)
|
|
49
|
+
app.command("profiles")(inspect_cli.profiles_command)
|
|
50
|
+
app.command("providers")(providers.providers_command)
|
|
51
|
+
app.command("validate")(validate.validate_command)
|
|
52
|
+
app.command("offers")(setup.offers_command)
|
|
53
|
+
app.command("setup")(setup.setup_command)
|
|
54
|
+
app.command(
|
|
55
|
+
"run",
|
|
56
|
+
context_settings={"allow_extra_args": True, "ignore_unknown_options": True},
|
|
57
|
+
)(run.run_command)
|
|
58
|
+
app.command("status")(status.status_command)
|
|
59
|
+
app.command("logs")(status.logs_command)
|
|
60
|
+
app.command("events")(status.events_command)
|
|
61
|
+
app.command("lock")(lifecycle.lock_command)
|
|
62
|
+
app.command("unlock")(lifecycle.unlock_command)
|
|
63
|
+
app.command("stop")(lifecycle.stop_command)
|
|
64
|
+
app.command("destroy")(lifecycle.destroy_command)
|
|
65
|
+
app.command("guard")(lifecycle.guard_command)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@app.command("endpoint")
|
|
69
|
+
def endpoint_command(ctx: typer.Context) -> None:
|
|
70
|
+
"""Print the model endpoint URL (handy for shell pipelines)."""
|
|
71
|
+
context = get_context(ctx)
|
|
72
|
+
try:
|
|
73
|
+
state = context.engine().store.load()
|
|
74
|
+
except JambuError as exc:
|
|
75
|
+
err_console.print(f"error: {exc}")
|
|
76
|
+
raise typer.Exit(getattr(exc, "exit_code", 1)) from exc
|
|
77
|
+
if not state.endpoint:
|
|
78
|
+
err_console.print("error: no endpoint; run `gpu setup` first")
|
|
79
|
+
raise typer.Exit(3)
|
|
80
|
+
print(state.endpoint)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@app.command("version")
|
|
84
|
+
def version_command() -> None:
|
|
85
|
+
"""Print the CLI version."""
|
|
86
|
+
from .. import __version__
|
|
87
|
+
|
|
88
|
+
print(__version__)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def main() -> None:
|
|
92
|
+
try:
|
|
93
|
+
app()
|
|
94
|
+
except JambuError as exc: # safety net: errors are normalized, never tracebacks
|
|
95
|
+
err_console.print(f"error: {exc}")
|
|
96
|
+
sys.exit(getattr(exc, "exit_code", 1))
|
|
97
|
+
except KeyboardInterrupt:
|
|
98
|
+
err_console.print("interrupted")
|
|
99
|
+
sys.exit(130)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
if __name__ == "__main__":
|
|
103
|
+
main()
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""`gpu providers` - capability matrix (spec section 7)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
from rich.table import Table
|
|
7
|
+
from rich.text import Text
|
|
8
|
+
|
|
9
|
+
from ..core.credentials import CredentialResolver
|
|
10
|
+
from ..core.errors import ConfigError
|
|
11
|
+
from ..providers.registry import load_builtin_providers
|
|
12
|
+
from ._common import get_context, print_json
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def providers_command(ctx: typer.Context) -> None:
|
|
16
|
+
"""List available providers and what each one can do."""
|
|
17
|
+
context = get_context(ctx)
|
|
18
|
+
registry = load_builtin_providers()
|
|
19
|
+
|
|
20
|
+
try:
|
|
21
|
+
config = context.config()
|
|
22
|
+
project_dir = config.project_dir
|
|
23
|
+
profile = config.provider.profile
|
|
24
|
+
selected = config.provider.name
|
|
25
|
+
except ConfigError:
|
|
26
|
+
config = None
|
|
27
|
+
project_dir = None
|
|
28
|
+
profile = "default"
|
|
29
|
+
selected = None
|
|
30
|
+
|
|
31
|
+
resolver = CredentialResolver(project_dir, profile=profile)
|
|
32
|
+
rows = []
|
|
33
|
+
for provider_cls in registry.classes():
|
|
34
|
+
missing = [n for n in provider_cls.required_credentials if not resolver.get(n)]
|
|
35
|
+
rows.append(
|
|
36
|
+
{
|
|
37
|
+
"provider": provider_cls.name,
|
|
38
|
+
"display_name": provider_cls.display_name,
|
|
39
|
+
"selected": provider_cls.name == selected,
|
|
40
|
+
"status": "configured" if not missing else "unavailable",
|
|
41
|
+
"missing_credentials": missing,
|
|
42
|
+
"required_credentials": list(provider_cls.required_credentials),
|
|
43
|
+
"capabilities": dict(provider_cls.capabilities.__dict__),
|
|
44
|
+
}
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
if context.json:
|
|
48
|
+
print_json({"providers": rows})
|
|
49
|
+
return
|
|
50
|
+
|
|
51
|
+
table = Table(title="Providers", title_justify="left")
|
|
52
|
+
table.add_column("PROVIDER")
|
|
53
|
+
table.add_column("STATUS")
|
|
54
|
+
table.add_column("GPU")
|
|
55
|
+
table.add_column("STOP")
|
|
56
|
+
table.add_column("DESTROY")
|
|
57
|
+
table.add_column("SSH")
|
|
58
|
+
table.add_column("PORTS")
|
|
59
|
+
table.add_column("SPOT")
|
|
60
|
+
table.add_column("PRICING")
|
|
61
|
+
for row in rows:
|
|
62
|
+
caps = row["capabilities"]
|
|
63
|
+
name = Text(row["provider"] + (" *" if row["selected"] else ""))
|
|
64
|
+
status = Text(
|
|
65
|
+
row["status"],
|
|
66
|
+
style="green" if row["status"] == "configured" else "yellow",
|
|
67
|
+
)
|
|
68
|
+
table.add_row(
|
|
69
|
+
name,
|
|
70
|
+
status,
|
|
71
|
+
_yn(caps["gpu_selection"]),
|
|
72
|
+
_yn(caps["stop"]),
|
|
73
|
+
_yn(caps["destroy"]),
|
|
74
|
+
_yn(caps["ssh"]),
|
|
75
|
+
_yn(caps["public_ports"]),
|
|
76
|
+
_yn(caps["spot_instances"]),
|
|
77
|
+
_yn(caps["pricing_query"]),
|
|
78
|
+
)
|
|
79
|
+
from ._common import console
|
|
80
|
+
|
|
81
|
+
console.print(table)
|
|
82
|
+
for row in rows:
|
|
83
|
+
if row["missing_credentials"]:
|
|
84
|
+
console.print(
|
|
85
|
+
Text(
|
|
86
|
+
f" {row['provider']}: set "
|
|
87
|
+
+ ", ".join(row["missing_credentials"]),
|
|
88
|
+
style="yellow",
|
|
89
|
+
)
|
|
90
|
+
)
|
|
91
|
+
if selected:
|
|
92
|
+
console.print(Text(f" * selected in jambu.yaml ({selected})", style="dim"))
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _yn(value: bool) -> str:
|
|
96
|
+
return "yes" if value else "no"
|