infer-stack 0.6.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.
- infer_stack/__init__.py +2 -0
- infer_stack/backends/__init__.py +7 -0
- infer_stack/backends/compose_renderer.py +243 -0
- infer_stack/backends/kubeai_renderer.py +202 -0
- infer_stack/benchmark.py +38 -0
- infer_stack/catalog.py +438 -0
- infer_stack/cli/__init__.py +169 -0
- infer_stack/cli/__main__.py +4 -0
- infer_stack/cli/commands_profile.py +467 -0
- infer_stack/cli/commands_runtime.py +719 -0
- infer_stack/cli/commands_smoke.py +691 -0
- infer_stack/cli/compose.py +755 -0
- infer_stack/cli/context.py +471 -0
- infer_stack/cli/options.py +134 -0
- infer_stack/cli/probes.py +178 -0
- infer_stack/config.py +450 -0
- infer_stack/contracts.py +223 -0
- infer_stack/diff_prompt.py +117 -0
- infer_stack/docker_utils.py +230 -0
- infer_stack/env_utils.py +97 -0
- infer_stack/experimental/model_catalog_discover.py +1155 -0
- infer_stack/experimental/model_memory_estimator.py +1264 -0
- infer_stack/experimental/stress_test_long_context.py +397 -0
- infer_stack/hardware.py +70 -0
- infer_stack/kubeai_ops.py +76 -0
- infer_stack/paths.py +87 -0
- infer_stack/profile_runtime.py +46 -0
- infer_stack/renderer.py +19 -0
- infer_stack/resolver.py +1092 -0
- infer_stack/templates/default-models.yaml +674 -0
- infer_stack/templates/default-ollama-models.yaml +31 -0
- infer_stack/templates/default-profiles.yaml +1731 -0
- infer_stack/templates/default-vllm-models.yaml +714 -0
- infer_stack/templates/docker-compose.yml.j2 +430 -0
- infer_stack/templates/litellm_config.yaml.j2 +44 -0
- infer_stack/templates/nginx.conf.j2 +84 -0
- infer_stack/tuning.py +3 -0
- infer_stack/validator.py +314 -0
- infer_stack/verification.py +46 -0
- infer_stack-0.6.0.dist-info/METADATA +1034 -0
- infer_stack-0.6.0.dist-info/RECORD +44 -0
- infer_stack-0.6.0.dist-info/WHEEL +5 -0
- infer_stack-0.6.0.dist-info/entry_points.txt +2 -0
- infer_stack-0.6.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,719 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from ..config import load_yaml
|
|
4
|
+
from ..config import normalized_state
|
|
5
|
+
from ..docker_utils import DockerCommandError
|
|
6
|
+
from ..docker_utils import compose_down
|
|
7
|
+
from ..docker_utils import compose_up
|
|
8
|
+
from ..docker_utils import docker_rm_dirs
|
|
9
|
+
from ..env_utils import parse_env_file
|
|
10
|
+
from ..kubeai_ops import CommandError
|
|
11
|
+
from ..kubeai_ops import deploy_rendered_artifacts
|
|
12
|
+
from ..kubeai_ops import print_status as kubeai_print_status
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any
|
|
15
|
+
import scriptconfig as scfg
|
|
16
|
+
import shlex
|
|
17
|
+
import subprocess
|
|
18
|
+
|
|
19
|
+
from .context import (
|
|
20
|
+
_apply_path_overrides,
|
|
21
|
+
_as_mapping,
|
|
22
|
+
backend_name,
|
|
23
|
+
config_for_runtime,
|
|
24
|
+
config_path,
|
|
25
|
+
config_root,
|
|
26
|
+
generated_dir,
|
|
27
|
+
has_runtime_overrides,
|
|
28
|
+
kubeai_generated_dir,
|
|
29
|
+
plan_path,
|
|
30
|
+
render_is_stale,
|
|
31
|
+
runtime_env_path,
|
|
32
|
+
)
|
|
33
|
+
from .compose import (
|
|
34
|
+
_compose_base_cmd,
|
|
35
|
+
_compose_up_with_router_recreate,
|
|
36
|
+
_kubeai_stub,
|
|
37
|
+
)
|
|
38
|
+
from .options import (
|
|
39
|
+
_BackendOverrideMixin,
|
|
40
|
+
_ClusterOverridesMixin,
|
|
41
|
+
_ComposeOverrideMixin,
|
|
42
|
+
_PathOverridesMixin,
|
|
43
|
+
_PlanOverridesCLI,
|
|
44
|
+
_PortOverridesMixin,
|
|
45
|
+
)
|
|
46
|
+
from .commands_profile import RenderCLI
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _maybe_rerender(config: Any, cfg: dict[str, Any]) -> None:
|
|
50
|
+
"""Re-run RenderCLI if runtime overrides changed or rendered outputs are stale.
|
|
51
|
+
|
|
52
|
+
Both ``up`` and ``deploy`` need this so the rendered artifacts always
|
|
53
|
+
match the current config + overrides before any container action.
|
|
54
|
+
"""
|
|
55
|
+
if has_runtime_overrides(config) or render_is_stale(cfg):
|
|
56
|
+
overrides = _as_mapping(config)
|
|
57
|
+
RenderCLI.main(
|
|
58
|
+
argv=False,
|
|
59
|
+
profile=overrides.get('profile'),
|
|
60
|
+
backend=overrides.get('backend'),
|
|
61
|
+
compose_cmd=overrides.get('compose_cmd'),
|
|
62
|
+
litellm_port=overrides.get('litellm_port'),
|
|
63
|
+
open_webui_port=overrides.get('open_webui_port'),
|
|
64
|
+
postgres_port=overrides.get('postgres_port'),
|
|
65
|
+
namespace=overrides.get('namespace'),
|
|
66
|
+
ingress_host=overrides.get('ingress_host'),
|
|
67
|
+
ingress_enabled=overrides.get('ingress_enabled'),
|
|
68
|
+
allow_unsupported=bool(overrides.get('allow_unsupported')),
|
|
69
|
+
simulate_hardware=overrides.get('simulate_hardware'),
|
|
70
|
+
allowed_gpus=overrides.get('allowed_gpus'),
|
|
71
|
+
yes=bool(overrides.get('yes')),
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
# ---------------------------------------------------------------------------
|
|
76
|
+
# Runtime commands
|
|
77
|
+
# ---------------------------------------------------------------------------
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class UpCLI(_PlanOverridesCLI):
|
|
81
|
+
"""Bring the rendered compose stack up. Re-renders first if anything changed."""
|
|
82
|
+
|
|
83
|
+
detach = scfg.Value(
|
|
84
|
+
False,
|
|
85
|
+
isflag=True,
|
|
86
|
+
short_alias=['d'],
|
|
87
|
+
help='Run in background instead of attaching to logs.',
|
|
88
|
+
)
|
|
89
|
+
yes = scfg.Value(
|
|
90
|
+
False,
|
|
91
|
+
isflag=True,
|
|
92
|
+
short_alias=['y'],
|
|
93
|
+
help='If `up` triggers a re-render, apply changes without prompting.',
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
@classmethod
|
|
97
|
+
def main(cls, argv=True, **kwargs):
|
|
98
|
+
config = cls.cli(argv=argv, data=kwargs)
|
|
99
|
+
_apply_path_overrides(config)
|
|
100
|
+
cfg = config_for_runtime(config)
|
|
101
|
+
if backend_name(cfg) != 'compose':
|
|
102
|
+
raise SystemExit(
|
|
103
|
+
'`up` only supports the compose backend. Use `deploy` for kubeai.'
|
|
104
|
+
)
|
|
105
|
+
_maybe_rerender(config, cfg)
|
|
106
|
+
_compose_up_with_router_recreate(cfg, detach=bool(config.detach))
|
|
107
|
+
return 0
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class DownCLI(
|
|
111
|
+
_PathOverridesMixin,
|
|
112
|
+
_BackendOverrideMixin,
|
|
113
|
+
_ComposeOverrideMixin,
|
|
114
|
+
_PortOverridesMixin,
|
|
115
|
+
):
|
|
116
|
+
"""Bring the rendered compose stack down (does not touch volumes)."""
|
|
117
|
+
|
|
118
|
+
@classmethod
|
|
119
|
+
def main(cls, argv=True, **kwargs):
|
|
120
|
+
config = cls.cli(argv=argv, data=kwargs)
|
|
121
|
+
_apply_path_overrides(config)
|
|
122
|
+
cfg = config_for_runtime(config)
|
|
123
|
+
if backend_name(cfg) != 'compose':
|
|
124
|
+
raise SystemExit('`down` only supports the compose backend.')
|
|
125
|
+
# Re-render before down so a stale/invalid compose file from an older
|
|
126
|
+
# package version does not strand containers. Compose's
|
|
127
|
+
# --remove-orphans still removes services from the previous profile
|
|
128
|
+
# when the project name / generated directory is unchanged.
|
|
129
|
+
_maybe_rerender(config, cfg)
|
|
130
|
+
try:
|
|
131
|
+
compose_down(
|
|
132
|
+
cfg['runtime']['compose_cmd'],
|
|
133
|
+
generated_dir(cfg) / 'docker-compose.yml',
|
|
134
|
+
runtime_env_path(cfg),
|
|
135
|
+
)
|
|
136
|
+
except DockerCommandError as ex:
|
|
137
|
+
raise SystemExit(
|
|
138
|
+
f'compose down failed after re-rendering the current stack: {ex}'
|
|
139
|
+
) from ex
|
|
140
|
+
return 0
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
class PurgeCLI(
|
|
144
|
+
_PathOverridesMixin,
|
|
145
|
+
_BackendOverrideMixin,
|
|
146
|
+
_ComposeOverrideMixin,
|
|
147
|
+
):
|
|
148
|
+
"""Stop the stack and delete all Docker-written state directories.
|
|
149
|
+
|
|
150
|
+
Uses a temporary Alpine container to remove directories that Docker wrote
|
|
151
|
+
as root, avoiding ``Permission denied`` errors from plain ``rm -rf``.
|
|
152
|
+
"""
|
|
153
|
+
|
|
154
|
+
yes = scfg.Value(
|
|
155
|
+
False, isflag=True, short_alias=['y'], help='Skip confirmation prompt.'
|
|
156
|
+
)
|
|
157
|
+
delete_cache = scfg.Value(
|
|
158
|
+
False,
|
|
159
|
+
isflag=True,
|
|
160
|
+
help='Also delete hf-cache and vllm-cache (model weights). By default those are preserved.',
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
@classmethod
|
|
164
|
+
def main(cls, argv=True, **kwargs):
|
|
165
|
+
config = cls.cli(argv=argv, data=kwargs)
|
|
166
|
+
_apply_path_overrides(config)
|
|
167
|
+
cfg = config_for_runtime(config, allow_missing=True)
|
|
168
|
+
|
|
169
|
+
state = normalized_state(cfg.get('state', {}))
|
|
170
|
+
always_delete = [
|
|
171
|
+
'postgres_litellm',
|
|
172
|
+
'postgres_open_webui',
|
|
173
|
+
'open_webui',
|
|
174
|
+
'ollama',
|
|
175
|
+
'runtime',
|
|
176
|
+
]
|
|
177
|
+
model_dirs = ['hf_cache', 'vllm_cache']
|
|
178
|
+
keys = (
|
|
179
|
+
always_delete + model_dirs if config.delete_cache else always_delete
|
|
180
|
+
)
|
|
181
|
+
dirs_to_delete = [
|
|
182
|
+
Path(state[k]) for k in keys if Path(state[k]).exists()
|
|
183
|
+
]
|
|
184
|
+
|
|
185
|
+
if not dirs_to_delete:
|
|
186
|
+
print('Nothing to purge — state directories do not exist.')
|
|
187
|
+
return 0
|
|
188
|
+
|
|
189
|
+
if not config.yes:
|
|
190
|
+
print(
|
|
191
|
+
'The following directories will be deleted (via Docker to handle root-owned files):'
|
|
192
|
+
)
|
|
193
|
+
for d in dirs_to_delete:
|
|
194
|
+
print(f' {d}')
|
|
195
|
+
answer = input('Proceed? [y/N] ').strip().lower()
|
|
196
|
+
if answer not in {'y', 'yes'}:
|
|
197
|
+
print('Aborted.')
|
|
198
|
+
return 1
|
|
199
|
+
|
|
200
|
+
compose_file = generated_dir(cfg) / 'docker-compose.yml'
|
|
201
|
+
if compose_file.exists() and backend_name(cfg) == 'compose':
|
|
202
|
+
try:
|
|
203
|
+
compose_down(
|
|
204
|
+
cfg['runtime']['compose_cmd'],
|
|
205
|
+
compose_file,
|
|
206
|
+
runtime_env_path(cfg),
|
|
207
|
+
)
|
|
208
|
+
except Exception as ex:
|
|
209
|
+
print(
|
|
210
|
+
f'Warning: compose down failed (containers may already be stopped): {ex}'
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
compose_cmd = cfg.get('runtime', {}).get(
|
|
214
|
+
'compose_cmd', 'docker compose'
|
|
215
|
+
)
|
|
216
|
+
docker_cmd = compose_cmd.split()[0]
|
|
217
|
+
docker_rm_dirs(dirs_to_delete, docker_cmd=docker_cmd)
|
|
218
|
+
print('Purge complete.')
|
|
219
|
+
return 0
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
class DeployCLI(_PlanOverridesCLI):
|
|
223
|
+
"""Apply the rendered stack to its backend (kubeai apply / compose up)."""
|
|
224
|
+
|
|
225
|
+
detach = scfg.Value(False, isflag=True, short_alias=['d'])
|
|
226
|
+
yes = scfg.Value(
|
|
227
|
+
False,
|
|
228
|
+
isflag=True,
|
|
229
|
+
short_alias=['y'],
|
|
230
|
+
help='If `deploy` triggers a re-render, apply changes without prompting.',
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
@classmethod
|
|
234
|
+
def main(cls, argv=True, **kwargs):
|
|
235
|
+
config = cls.cli(argv=argv, data=kwargs)
|
|
236
|
+
_apply_path_overrides(config)
|
|
237
|
+
cfg = config_for_runtime(config)
|
|
238
|
+
_maybe_rerender(config, cfg)
|
|
239
|
+
if backend_name(cfg) == 'kubeai':
|
|
240
|
+
plan = load_yaml(plan_path(cfg))
|
|
241
|
+
try:
|
|
242
|
+
deploy_rendered_artifacts(plan['deployment'])
|
|
243
|
+
except CommandError as ex:
|
|
244
|
+
namespace = cfg.get('cluster', {}).get('namespace', 'kubeai')
|
|
245
|
+
raise SystemExit(
|
|
246
|
+
f'Failed to deploy to namespace {namespace!r}. Confirm '
|
|
247
|
+
f'`infer-stack setup --backend kubeai --namespace {namespace}` '
|
|
248
|
+
'matches the namespace where the KubeAI Helm release is installed.\n'
|
|
249
|
+
f'Original error: {ex}'
|
|
250
|
+
) from ex
|
|
251
|
+
return 0
|
|
252
|
+
compose_up(
|
|
253
|
+
cfg['runtime']['compose_cmd'],
|
|
254
|
+
generated_dir(cfg) / 'docker-compose.yml',
|
|
255
|
+
generated_dir(cfg) / '.env',
|
|
256
|
+
detach=bool(config.detach),
|
|
257
|
+
remove_orphans=True,
|
|
258
|
+
)
|
|
259
|
+
return 0
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
class EnvCLI(
|
|
263
|
+
_PathOverridesMixin,
|
|
264
|
+
_BackendOverrideMixin,
|
|
265
|
+
):
|
|
266
|
+
"""Inspect the rendered .env file (path, single value, or eval-friendly export)."""
|
|
267
|
+
|
|
268
|
+
key = scfg.Value(
|
|
269
|
+
None,
|
|
270
|
+
type=str,
|
|
271
|
+
position=1,
|
|
272
|
+
help="Print only this variable's value. Empty = all.",
|
|
273
|
+
)
|
|
274
|
+
export = scfg.Value(
|
|
275
|
+
False,
|
|
276
|
+
isflag=True,
|
|
277
|
+
help='Print `export KEY=value` lines suitable for `eval`.',
|
|
278
|
+
)
|
|
279
|
+
path = scfg.Value(
|
|
280
|
+
False,
|
|
281
|
+
isflag=True,
|
|
282
|
+
help='Print only the absolute path to .env (default if no flags).',
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
@classmethod
|
|
286
|
+
def main(cls, argv=True, **kwargs):
|
|
287
|
+
config = cls.cli(argv=argv, data=kwargs)
|
|
288
|
+
_apply_path_overrides(config)
|
|
289
|
+
cfg = config_for_runtime(config)
|
|
290
|
+
if backend_name(cfg) != 'compose':
|
|
291
|
+
raise SystemExit('`env` only applies to the compose backend.')
|
|
292
|
+
env_file = runtime_env_path(cfg)
|
|
293
|
+
if not env_file.exists():
|
|
294
|
+
raise SystemExit(
|
|
295
|
+
f'No .env at {env_file}. Run `infer-stack render` first.'
|
|
296
|
+
)
|
|
297
|
+
# Default with no flags and no key: print the path so users can do
|
|
298
|
+
# `source $(infer-stack env)` (with `set -a` if they want export semantics).
|
|
299
|
+
if config.key is None and not config.export:
|
|
300
|
+
print(env_file)
|
|
301
|
+
return 0
|
|
302
|
+
env = parse_env_file(env_file)
|
|
303
|
+
if config.key:
|
|
304
|
+
if config.key not in env:
|
|
305
|
+
raise SystemExit(f'{config.key!r} not found in {env_file}')
|
|
306
|
+
print(env[config.key])
|
|
307
|
+
return 0
|
|
308
|
+
# --export: emit eval-friendly export lines.
|
|
309
|
+
for k, v in env.items():
|
|
310
|
+
print(f'export {k}={shlex.quote(v)}')
|
|
311
|
+
return 0
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def _enabled_components(deployment: dict[str, Any]) -> list[str]:
|
|
315
|
+
"""Names of the components the resolved plan turned on."""
|
|
316
|
+
out: list[str] = []
|
|
317
|
+
providers = deployment.get('providers', {}) or {}
|
|
318
|
+
if (providers.get('ollama') or {}).get('enabled'):
|
|
319
|
+
out.append('ollama')
|
|
320
|
+
vllm = providers.get('vllm') or {}
|
|
321
|
+
if vllm.get('enabled'):
|
|
322
|
+
runtimes = vllm.get('runtimes') or {}
|
|
323
|
+
out.append(f'vllm({len(runtimes)})' if runtimes else 'vllm')
|
|
324
|
+
if ((deployment.get('gateways', {}) or {}).get('litellm') or {}).get(
|
|
325
|
+
'enabled'
|
|
326
|
+
):
|
|
327
|
+
out.append('litellm')
|
|
328
|
+
frontends = deployment.get('frontends', {}) or {}
|
|
329
|
+
if (frontends.get('open_webui') or {}).get('enabled'):
|
|
330
|
+
out.append('open_webui')
|
|
331
|
+
if (frontends.get('reverse_proxy') or {}).get('enabled'):
|
|
332
|
+
out.append('reverse_proxy')
|
|
333
|
+
return out
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def _access_endpoints(access: dict[str, Any]) -> list[tuple[str, str]]:
|
|
337
|
+
"""(name, base_url) pairs from the resolved access map."""
|
|
338
|
+
out: list[tuple[str, str]] = []
|
|
339
|
+
for name, entry in (access or {}).items():
|
|
340
|
+
if isinstance(entry, dict) and entry.get('base_url'):
|
|
341
|
+
out.append((name, str(entry['base_url'])))
|
|
342
|
+
return out
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def _print_status_summary(cfg: dict[str, Any], *, initialized: bool) -> None:
|
|
346
|
+
"""Print cheap, no-network context about the current stack.
|
|
347
|
+
|
|
348
|
+
Everything here comes from config.yaml, on-disk artifact existence, and the
|
|
349
|
+
already-resolved plan.yaml, so it never touches Docker, the network, or
|
|
350
|
+
hardware detection.
|
|
351
|
+
"""
|
|
352
|
+
backend = backend_name(cfg)
|
|
353
|
+
print('infer-stack status')
|
|
354
|
+
print()
|
|
355
|
+
print(
|
|
356
|
+
f' initialized: {"yes" if initialized else "no"} ({config_path()})'
|
|
357
|
+
)
|
|
358
|
+
print(f' backend: {backend}')
|
|
359
|
+
print(f' active profile: {cfg.get("active_profile") or "<unset>"}')
|
|
360
|
+
print(f' config dir: {config_root()}')
|
|
361
|
+
|
|
362
|
+
out_dir = (
|
|
363
|
+
kubeai_generated_dir(cfg) if backend == 'kubeai' else generated_dir(cfg)
|
|
364
|
+
)
|
|
365
|
+
rendered_marker = out_dir / (
|
|
366
|
+
'models.yaml' if backend == 'kubeai' else 'docker-compose.yml'
|
|
367
|
+
)
|
|
368
|
+
print(f' generated dir: {out_dir}')
|
|
369
|
+
|
|
370
|
+
if not initialized:
|
|
371
|
+
print()
|
|
372
|
+
print(
|
|
373
|
+
' Not initialized — run '
|
|
374
|
+
'`infer-stack setup --backend compose --profile <profile>` '
|
|
375
|
+
'to create config.yaml.'
|
|
376
|
+
)
|
|
377
|
+
return
|
|
378
|
+
|
|
379
|
+
if rendered_marker.exists():
|
|
380
|
+
stale = render_is_stale(cfg)
|
|
381
|
+
print(
|
|
382
|
+
f' rendered: yes{" (stale — run `infer-stack render`)" if stale else ""}'
|
|
383
|
+
)
|
|
384
|
+
else:
|
|
385
|
+
print(' rendered: no (run `infer-stack render`)')
|
|
386
|
+
|
|
387
|
+
# The resolved view comes straight from plan.yaml (cheap file read; no
|
|
388
|
+
# hardware probe or re-resolution).
|
|
389
|
+
plan_file = plan_path(cfg)
|
|
390
|
+
if not plan_file.exists():
|
|
391
|
+
return
|
|
392
|
+
try:
|
|
393
|
+
plan = load_yaml(plan_file) or {}
|
|
394
|
+
except Exception:
|
|
395
|
+
return
|
|
396
|
+
deployment = plan.get('deployment', {}) or {}
|
|
397
|
+
validated = plan.get('validated', {}) or {}
|
|
398
|
+
|
|
399
|
+
description = (deployment.get('serving_profile', {}) or {}).get(
|
|
400
|
+
'description'
|
|
401
|
+
)
|
|
402
|
+
if description:
|
|
403
|
+
print(f' description: {description}')
|
|
404
|
+
|
|
405
|
+
if validated:
|
|
406
|
+
errors = validated.get('errors') or []
|
|
407
|
+
warnings = validated.get('warnings') or []
|
|
408
|
+
if errors:
|
|
409
|
+
vstate = f'{len(errors)} error(s)' + (
|
|
410
|
+
f', {len(warnings)} warning(s)' if warnings else ''
|
|
411
|
+
)
|
|
412
|
+
elif warnings:
|
|
413
|
+
vstate = f'ok, {len(warnings)} warning(s)'
|
|
414
|
+
else:
|
|
415
|
+
vstate = 'ok'
|
|
416
|
+
print(f' validation: {vstate}')
|
|
417
|
+
|
|
418
|
+
components = _enabled_components(deployment)
|
|
419
|
+
if components:
|
|
420
|
+
print(f' components: {", ".join(components)}')
|
|
421
|
+
|
|
422
|
+
endpoints = _access_endpoints(deployment.get('access', {}) or {})
|
|
423
|
+
if endpoints:
|
|
424
|
+
print(' endpoints:')
|
|
425
|
+
for name, url in endpoints:
|
|
426
|
+
print(f' {name}: {url}')
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
class StatusCLI(
|
|
430
|
+
_PathOverridesMixin,
|
|
431
|
+
_BackendOverrideMixin,
|
|
432
|
+
_ComposeOverrideMixin,
|
|
433
|
+
_PortOverridesMixin,
|
|
434
|
+
_ClusterOverridesMixin,
|
|
435
|
+
):
|
|
436
|
+
"""Show stack status: where config/artifacts live, the active profile,
|
|
437
|
+
whether it has been rendered, the resolved components/endpoints, and the
|
|
438
|
+
live container/cluster state."""
|
|
439
|
+
|
|
440
|
+
@classmethod
|
|
441
|
+
def main(cls, argv=True, **kwargs):
|
|
442
|
+
config = cls.cli(argv=argv, data=kwargs)
|
|
443
|
+
_apply_path_overrides(config)
|
|
444
|
+
initialized = config_path().exists()
|
|
445
|
+
cfg = config_for_runtime(config, allow_missing=True)
|
|
446
|
+
_print_status_summary(cfg, initialized=initialized)
|
|
447
|
+
if not initialized:
|
|
448
|
+
return 0
|
|
449
|
+
|
|
450
|
+
if backend_name(cfg) == 'kubeai':
|
|
451
|
+
namespace = cfg.get('cluster', {}).get('namespace', 'kubeai')
|
|
452
|
+
print()
|
|
453
|
+
print(f'cluster resources (namespace {namespace!r}):')
|
|
454
|
+
try:
|
|
455
|
+
kubeai_print_status(namespace)
|
|
456
|
+
except CommandError as ex:
|
|
457
|
+
raise SystemExit(
|
|
458
|
+
f'Failed to query KubeAI resources in namespace {namespace!r}. Confirm '
|
|
459
|
+
f'`infer-stack setup --backend kubeai --namespace {namespace}` '
|
|
460
|
+
'matches the namespace where the KubeAI Helm release is installed.\n'
|
|
461
|
+
f'Original error: {ex}'
|
|
462
|
+
) from ex
|
|
463
|
+
return 0
|
|
464
|
+
|
|
465
|
+
compose_file = generated_dir(cfg) / 'docker-compose.yml'
|
|
466
|
+
if not compose_file.exists():
|
|
467
|
+
return 0
|
|
468
|
+
print()
|
|
469
|
+
print('containers (docker compose ps):')
|
|
470
|
+
proc = subprocess.run(_compose_base_cmd(cfg) + ['ps'])
|
|
471
|
+
return int(proc.returncode)
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
# ---------------------------------------------------------------------------
|
|
475
|
+
# Compose day-2-ops wrappers (raise NotImplementedError on kubeai)
|
|
476
|
+
# ---------------------------------------------------------------------------
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
class _ComposeWrapperBase(
|
|
480
|
+
_PathOverridesMixin,
|
|
481
|
+
_BackendOverrideMixin,
|
|
482
|
+
_ComposeOverrideMixin,
|
|
483
|
+
):
|
|
484
|
+
"""Common fields for ``docker compose <subcmd>`` wrappers."""
|
|
485
|
+
|
|
486
|
+
services = scfg.Value(
|
|
487
|
+
None,
|
|
488
|
+
nargs='*',
|
|
489
|
+
position=1,
|
|
490
|
+
help='Optional service names to filter (empty = all).',
|
|
491
|
+
)
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
class LogsCLI(_ComposeWrapperBase):
|
|
495
|
+
"""Tail rendered Compose service logs without typing the full docker compose path."""
|
|
496
|
+
|
|
497
|
+
follow = scfg.Value(
|
|
498
|
+
False,
|
|
499
|
+
isflag=True,
|
|
500
|
+
short_alias=['f'],
|
|
501
|
+
help='Stream logs (docker compose logs -f).',
|
|
502
|
+
)
|
|
503
|
+
tail = scfg.Value(
|
|
504
|
+
None,
|
|
505
|
+
type=str,
|
|
506
|
+
help="Tail the last N lines (default: all). Pass a number or 'all'.",
|
|
507
|
+
)
|
|
508
|
+
timestamps = scfg.Value(False, isflag=True)
|
|
509
|
+
no_color = scfg.Value(False, isflag=True)
|
|
510
|
+
|
|
511
|
+
@classmethod
|
|
512
|
+
def main(cls, argv=True, **kwargs):
|
|
513
|
+
config = cls.cli(argv=argv, data=kwargs)
|
|
514
|
+
_apply_path_overrides(config)
|
|
515
|
+
cfg = config_for_runtime(config)
|
|
516
|
+
if backend_name(cfg) != 'compose':
|
|
517
|
+
_kubeai_stub('logs')
|
|
518
|
+
cmd = _compose_base_cmd(cfg) + ['logs']
|
|
519
|
+
if config.follow:
|
|
520
|
+
cmd.append('--follow')
|
|
521
|
+
if config.tail is not None:
|
|
522
|
+
cmd.extend(['--tail', str(config.tail)])
|
|
523
|
+
if config.no_color:
|
|
524
|
+
cmd.append('--no-color')
|
|
525
|
+
if config.timestamps:
|
|
526
|
+
cmd.append('--timestamps')
|
|
527
|
+
cmd.extend(config.services or [])
|
|
528
|
+
proc = subprocess.run(cmd)
|
|
529
|
+
return int(proc.returncode)
|
|
530
|
+
|
|
531
|
+
|
|
532
|
+
class PsCLI(_ComposeWrapperBase):
|
|
533
|
+
"""``docker compose ps`` for the rendered stack."""
|
|
534
|
+
|
|
535
|
+
all = scfg.Value(
|
|
536
|
+
False,
|
|
537
|
+
isflag=True,
|
|
538
|
+
short_alias=['a'],
|
|
539
|
+
help='Include stopped containers.',
|
|
540
|
+
)
|
|
541
|
+
services_only = scfg.Value(
|
|
542
|
+
False,
|
|
543
|
+
isflag=True,
|
|
544
|
+
help='Print only service names (passes --services to docker compose).',
|
|
545
|
+
)
|
|
546
|
+
quiet = scfg.Value(
|
|
547
|
+
False, isflag=True, short_alias=['q'], help='Print only container IDs.'
|
|
548
|
+
)
|
|
549
|
+
|
|
550
|
+
@classmethod
|
|
551
|
+
def main(cls, argv=True, **kwargs):
|
|
552
|
+
config = cls.cli(argv=argv, data=kwargs)
|
|
553
|
+
_apply_path_overrides(config)
|
|
554
|
+
cfg = config_for_runtime(config)
|
|
555
|
+
if backend_name(cfg) != 'compose':
|
|
556
|
+
_kubeai_stub('ps')
|
|
557
|
+
cmd = _compose_base_cmd(cfg) + ['ps']
|
|
558
|
+
if config.all:
|
|
559
|
+
cmd.append('--all')
|
|
560
|
+
if config.services_only:
|
|
561
|
+
cmd.append('--services')
|
|
562
|
+
if config.quiet:
|
|
563
|
+
cmd.append('--quiet')
|
|
564
|
+
cmd.extend(config.services or [])
|
|
565
|
+
proc = subprocess.run(cmd)
|
|
566
|
+
return int(proc.returncode)
|
|
567
|
+
|
|
568
|
+
|
|
569
|
+
class RestartCLI(_ComposeWrapperBase):
|
|
570
|
+
"""``docker compose restart [services...]``."""
|
|
571
|
+
|
|
572
|
+
timeout = scfg.Value(None, type=int, help='Stop timeout in seconds.')
|
|
573
|
+
|
|
574
|
+
@classmethod
|
|
575
|
+
def main(cls, argv=True, **kwargs):
|
|
576
|
+
config = cls.cli(argv=argv, data=kwargs)
|
|
577
|
+
_apply_path_overrides(config)
|
|
578
|
+
cfg = config_for_runtime(config)
|
|
579
|
+
if backend_name(cfg) != 'compose':
|
|
580
|
+
_kubeai_stub('restart')
|
|
581
|
+
cmd = _compose_base_cmd(cfg) + ['restart']
|
|
582
|
+
if config.timeout is not None:
|
|
583
|
+
cmd.extend(['--timeout', str(config.timeout)])
|
|
584
|
+
cmd.extend(config.services or [])
|
|
585
|
+
proc = subprocess.run(cmd)
|
|
586
|
+
return int(proc.returncode)
|
|
587
|
+
|
|
588
|
+
|
|
589
|
+
class PullCLI(_ComposeWrapperBase):
|
|
590
|
+
"""``docker compose pull [services...]``."""
|
|
591
|
+
|
|
592
|
+
quiet = scfg.Value(False, isflag=True, short_alias=['q'])
|
|
593
|
+
ignore_pull_failures = scfg.Value(False, isflag=True)
|
|
594
|
+
|
|
595
|
+
@classmethod
|
|
596
|
+
def main(cls, argv=True, **kwargs):
|
|
597
|
+
config = cls.cli(argv=argv, data=kwargs)
|
|
598
|
+
_apply_path_overrides(config)
|
|
599
|
+
cfg = config_for_runtime(config)
|
|
600
|
+
if backend_name(cfg) != 'compose':
|
|
601
|
+
_kubeai_stub('pull')
|
|
602
|
+
cmd = _compose_base_cmd(cfg) + ['pull']
|
|
603
|
+
if config.quiet:
|
|
604
|
+
cmd.append('--quiet')
|
|
605
|
+
if config.ignore_pull_failures:
|
|
606
|
+
cmd.append('--ignore-pull-failures')
|
|
607
|
+
cmd.extend(config.services or [])
|
|
608
|
+
proc = subprocess.run(cmd)
|
|
609
|
+
return int(proc.returncode)
|
|
610
|
+
|
|
611
|
+
|
|
612
|
+
class StartCLI(_ComposeWrapperBase):
|
|
613
|
+
"""``docker compose start [services...]``."""
|
|
614
|
+
|
|
615
|
+
@classmethod
|
|
616
|
+
def main(cls, argv=True, **kwargs):
|
|
617
|
+
config = cls.cli(argv=argv, data=kwargs)
|
|
618
|
+
_apply_path_overrides(config)
|
|
619
|
+
cfg = config_for_runtime(config)
|
|
620
|
+
if backend_name(cfg) != 'compose':
|
|
621
|
+
_kubeai_stub('start')
|
|
622
|
+
cmd = _compose_base_cmd(cfg) + ['start']
|
|
623
|
+
cmd.extend(config.services or [])
|
|
624
|
+
proc = subprocess.run(cmd)
|
|
625
|
+
return int(proc.returncode)
|
|
626
|
+
|
|
627
|
+
|
|
628
|
+
class StopCLI(_ComposeWrapperBase):
|
|
629
|
+
"""``docker compose stop [services...]``."""
|
|
630
|
+
|
|
631
|
+
timeout = scfg.Value(None, type=int)
|
|
632
|
+
|
|
633
|
+
@classmethod
|
|
634
|
+
def main(cls, argv=True, **kwargs):
|
|
635
|
+
config = cls.cli(argv=argv, data=kwargs)
|
|
636
|
+
_apply_path_overrides(config)
|
|
637
|
+
cfg = config_for_runtime(config)
|
|
638
|
+
if backend_name(cfg) != 'compose':
|
|
639
|
+
_kubeai_stub('stop')
|
|
640
|
+
cmd = _compose_base_cmd(cfg) + ['stop']
|
|
641
|
+
if config.timeout is not None:
|
|
642
|
+
cmd.extend(['--timeout', str(config.timeout)])
|
|
643
|
+
cmd.extend(config.services or [])
|
|
644
|
+
proc = subprocess.run(cmd)
|
|
645
|
+
return int(proc.returncode)
|
|
646
|
+
|
|
647
|
+
|
|
648
|
+
class OllamaPullCLI(
|
|
649
|
+
_PathOverridesMixin, _BackendOverrideMixin, _ComposeOverrideMixin
|
|
650
|
+
):
|
|
651
|
+
"""Pull an Ollama model into the rendered Ollama model store."""
|
|
652
|
+
|
|
653
|
+
__command__ = 'ollama-pull'
|
|
654
|
+
|
|
655
|
+
model = scfg.Value(
|
|
656
|
+
None,
|
|
657
|
+
type=str,
|
|
658
|
+
position=1,
|
|
659
|
+
help='Ollama model tag to pull, e.g. smollm2:135m.',
|
|
660
|
+
)
|
|
661
|
+
|
|
662
|
+
@classmethod
|
|
663
|
+
def main(cls, argv=True, **kwargs):
|
|
664
|
+
config = cls.cli(argv=argv, data=kwargs)
|
|
665
|
+
_apply_path_overrides(config)
|
|
666
|
+
if not config.model:
|
|
667
|
+
raise SystemExit(
|
|
668
|
+
'ollama-pull: missing required model tag, e.g. `infer-stack ollama-pull smollm2:135m`'
|
|
669
|
+
)
|
|
670
|
+
cfg = config_for_runtime(config)
|
|
671
|
+
if backend_name(cfg) != 'compose':
|
|
672
|
+
raise SystemExit('`ollama-pull` only supports the compose backend.')
|
|
673
|
+
cmd = _compose_base_cmd(cfg) + [
|
|
674
|
+
'exec',
|
|
675
|
+
'ollama',
|
|
676
|
+
'ollama',
|
|
677
|
+
'pull',
|
|
678
|
+
str(config.model),
|
|
679
|
+
]
|
|
680
|
+
proc = subprocess.run(cmd)
|
|
681
|
+
return int(proc.returncode)
|
|
682
|
+
|
|
683
|
+
|
|
684
|
+
class OllamaListCLI(
|
|
685
|
+
_PathOverridesMixin, _BackendOverrideMixin, _ComposeOverrideMixin
|
|
686
|
+
):
|
|
687
|
+
"""List models installed in the rendered Ollama service."""
|
|
688
|
+
|
|
689
|
+
__command__ = 'ollama-list'
|
|
690
|
+
|
|
691
|
+
@classmethod
|
|
692
|
+
def main(cls, argv=True, **kwargs):
|
|
693
|
+
config = cls.cli(argv=argv, data=kwargs)
|
|
694
|
+
_apply_path_overrides(config)
|
|
695
|
+
cfg = config_for_runtime(config)
|
|
696
|
+
if backend_name(cfg) != 'compose':
|
|
697
|
+
raise SystemExit('`ollama-list` only supports the compose backend.')
|
|
698
|
+
cmd = _compose_base_cmd(cfg) + ['exec', 'ollama', 'ollama', 'list']
|
|
699
|
+
proc = subprocess.run(cmd)
|
|
700
|
+
return int(proc.returncode)
|
|
701
|
+
|
|
702
|
+
|
|
703
|
+
class OllamaPsCLI(
|
|
704
|
+
_PathOverridesMixin, _BackendOverrideMixin, _ComposeOverrideMixin
|
|
705
|
+
):
|
|
706
|
+
"""Show loaded Ollama models for the rendered Ollama service."""
|
|
707
|
+
|
|
708
|
+
__command__ = 'ollama-ps'
|
|
709
|
+
|
|
710
|
+
@classmethod
|
|
711
|
+
def main(cls, argv=True, **kwargs):
|
|
712
|
+
config = cls.cli(argv=argv, data=kwargs)
|
|
713
|
+
_apply_path_overrides(config)
|
|
714
|
+
cfg = config_for_runtime(config)
|
|
715
|
+
if backend_name(cfg) != 'compose':
|
|
716
|
+
raise SystemExit('`ollama-ps` only supports the compose backend.')
|
|
717
|
+
cmd = _compose_base_cmd(cfg) + ['exec', 'ollama', 'ollama', 'ps']
|
|
718
|
+
proc = subprocess.run(cmd)
|
|
719
|
+
return int(proc.returncode)
|