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.
Files changed (44) hide show
  1. infer_stack/__init__.py +2 -0
  2. infer_stack/backends/__init__.py +7 -0
  3. infer_stack/backends/compose_renderer.py +243 -0
  4. infer_stack/backends/kubeai_renderer.py +202 -0
  5. infer_stack/benchmark.py +38 -0
  6. infer_stack/catalog.py +438 -0
  7. infer_stack/cli/__init__.py +169 -0
  8. infer_stack/cli/__main__.py +4 -0
  9. infer_stack/cli/commands_profile.py +467 -0
  10. infer_stack/cli/commands_runtime.py +719 -0
  11. infer_stack/cli/commands_smoke.py +691 -0
  12. infer_stack/cli/compose.py +755 -0
  13. infer_stack/cli/context.py +471 -0
  14. infer_stack/cli/options.py +134 -0
  15. infer_stack/cli/probes.py +178 -0
  16. infer_stack/config.py +450 -0
  17. infer_stack/contracts.py +223 -0
  18. infer_stack/diff_prompt.py +117 -0
  19. infer_stack/docker_utils.py +230 -0
  20. infer_stack/env_utils.py +97 -0
  21. infer_stack/experimental/model_catalog_discover.py +1155 -0
  22. infer_stack/experimental/model_memory_estimator.py +1264 -0
  23. infer_stack/experimental/stress_test_long_context.py +397 -0
  24. infer_stack/hardware.py +70 -0
  25. infer_stack/kubeai_ops.py +76 -0
  26. infer_stack/paths.py +87 -0
  27. infer_stack/profile_runtime.py +46 -0
  28. infer_stack/renderer.py +19 -0
  29. infer_stack/resolver.py +1092 -0
  30. infer_stack/templates/default-models.yaml +674 -0
  31. infer_stack/templates/default-ollama-models.yaml +31 -0
  32. infer_stack/templates/default-profiles.yaml +1731 -0
  33. infer_stack/templates/default-vllm-models.yaml +714 -0
  34. infer_stack/templates/docker-compose.yml.j2 +430 -0
  35. infer_stack/templates/litellm_config.yaml.j2 +44 -0
  36. infer_stack/templates/nginx.conf.j2 +84 -0
  37. infer_stack/tuning.py +3 -0
  38. infer_stack/validator.py +314 -0
  39. infer_stack/verification.py +46 -0
  40. infer_stack-0.6.0.dist-info/METADATA +1034 -0
  41. infer_stack-0.6.0.dist-info/RECORD +44 -0
  42. infer_stack-0.6.0.dist-info/WHEEL +5 -0
  43. infer_stack-0.6.0.dist-info/entry_points.txt +2 -0
  44. 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)