-
Notifications
You must be signed in to change notification settings - Fork 132
Expand file tree
/
Copy pathcli.py
More file actions
1927 lines (1684 loc) · 72.3 KB
/
Copy pathcli.py
File metadata and controls
1927 lines (1684 loc) · 72.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
"""Entry point for the llmdbenchmark CLI.
Parses arguments, sets up the workspace, and dispatches to
plan / standup / teardown / run / experiment subcommands.
"""
import argparse
import logging
import os
import shutil
import sys
import json
import tempfile
import time
from pathlib import Path
import yaml as _yaml
from llmdbenchmark import __version__, __package_name__, __package_home__
from llmdbenchmark.interface.env import env, env_bool
from llmdbenchmark.config import config
from llmdbenchmark.logging.logger import get_logger
from llmdbenchmark.utilities.os.filesystem import (
create_workspace,
create_sub_dir_workload,
get_absolute_path,
resolve_specification_file,
)
from llmdbenchmark.interface.commands import Command
from llmdbenchmark.result_store.store import StoreManager
from llmdbenchmark.telemetry import init_telemetry, get_telemetry
import getpass
from llmdbenchmark.interface import plan, standup, teardown, run
from llmdbenchmark.interface import smoketest as smoketest_interface
from llmdbenchmark.interface import experiment as experiment_interface
from llmdbenchmark.interface import results
from llmdbenchmark.parser.render_specification import RenderSpecification
from llmdbenchmark.exceptions.exceptions import TemplateError
from llmdbenchmark.parser.render_plans import RenderPlans
from llmdbenchmark.parser.version_resolver import VersionResolver
from llmdbenchmark.parser.cluster_resource_resolver import ClusterResourceResolver
from llmdbenchmark.executor.step import Phase
from llmdbenchmark.executor.context import ExecutionContext
from llmdbenchmark.executor.step_executor import StepExecutor
from llmdbenchmark.standup.steps import get_standup_steps
from llmdbenchmark.smoketests.steps import get_smoketest_steps
from llmdbenchmark.teardown.steps import get_teardown_steps
from llmdbenchmark.run.steps import get_run_steps
from llmdbenchmark.executor.command import CommandExecutor
class PhaseError(Exception):
"""Raised when a lifecycle phase (standup/run/teardown) fails."""
pass
def setup_workspace(
workspace_path: Path,
plan_dir: Path,
log_dir: Path,
verbose: bool = False,
dry_run: bool = False,
) -> None:
"""Set workspace paths and runtime flags on the global config singleton."""
config.workspace = workspace_path
config.plan_dir = plan_dir
config.log_dir = log_dir
config.verbose = verbose
config.dry_run = dry_run
def dispatch_cli(args: argparse.Namespace, logger: logging.Logger) -> None:
"""Render plans and dispatch to the appropriate phase executor."""
# Experiment command manages its own rendering per setup treatment
if args.command == Command.EXPERIMENT.value:
_execute_experiment(args, logger)
return
if args.command in (
Command.PLAN.value,
Command.STANDUP.value,
Command.SMOKETEST.value,
Command.TEARDOWN.value,
Command.RUN.value,
):
# Resolve templates, scenarios, and values into the workspace
specification_as_dict = RenderSpecification(
specification_file=args.specification_file,
base_dir=args.base_dir,
).eval()
logger.log_info(
"Specification file rendered and validated successfully.",
emoji="✅",
)
logger.log_debug(
"Using specification file to fully render templates into complete system stack plans."
)
version_resolver = VersionResolver(logger=logger, dry_run=args.dry_run)
cluster_resource_resolver = ClusterResourceResolver(
logger=logger,
dry_run=args.dry_run,
)
render_plan_errors = RenderPlans(
template_dir=specification_as_dict["template_dir"]["path"],
defaults_file=specification_as_dict["values_file"]["path"],
scenarios_file=specification_as_dict["scenario_file"]["path"],
output_dir=config.plan_dir,
values_overlays=specification_as_dict["values_file"].get("overlays", []),
version_resolver=version_resolver,
cluster_resource_resolver=cluster_resource_resolver,
cli_namespace=getattr(args, "namespace", None),
cli_model=getattr(args, "models", None),
cli_methods=getattr(args, "methods", None),
cli_monitoring=getattr(args, "monitoring", None),
cli_wva=getattr(args, "wva", False),
cli_gateway_class=getattr(args, "gateway_class", None),
cli_stack_filter=_parse_stack_filter(getattr(args, "stack", None)),
).eval()
try:
if render_plan_errors.has_errors:
error_dump = json.dumps(render_plan_errors.to_dict(), indent=2)
raise TemplateError(
message="Errors occurred while rendering the specification.",
context={"\nrender_plan_errors": error_dump},
)
except TemplateError as e:
logger.log_error(f"Rendering failed: {e}")
sys.exit(1)
# Pre-render Helm chart manifests so the plan directory contains
# all K8s resources (both Jinja2-rendered and Helm-rendered).
# This enables kustomize overlays and full manifest inspection.
# Runs even in dry-run mode - helmfile template is purely local
# and does not touch the cluster.
_render_helm_manifests(config.plan_dir, logger)
if args.command == Command.STANDUP.value:
_execute_standup(args, logger, render_plan_errors)
if args.command == Command.SMOKETEST.value:
_execute_smoketest(args, logger, render_plan_errors)
if args.command == Command.TEARDOWN.value:
_execute_teardown(args, logger, render_plan_errors)
if args.command == Command.RUN.value:
_execute_run(args, logger, render_plan_errors)
def _render_helm_manifests(plan_dir: Path, logger) -> None:
"""Pre-render modelservice Helm chart manifests into each stack's plan directory.
For each rendered stack that deploys via ``modelservice``, runs
``helmfile template`` against the modelservice release to produce
the full K8s manifests the chart would create. Output is saved as
``helm-modelservice.yaml`` in the stack directory alongside the
Jinja2-rendered templates.
Stacks that deploy via ``standalone`` are skipped entirely - they
do not use the modelservice Helm chart, so pre-rendering it would
produce an empty helmfile and fail with "no top-level config keys".
This runs during the plan phase so that:
- Users can inspect exactly what Helm will apply
- Kustomize overlays can patch Helm-produced resources
- The plan directory contains 100% of K8s manifests
"""
if not plan_dir or not plan_dir.exists():
return
for stack_dir in sorted(plan_dir.iterdir()):
if not stack_dir.is_dir():
continue
helmfile_src = stack_dir / "10_helmfile-main.yaml"
ms_values = stack_dir / "13_ms-values.yaml"
if not helmfile_src.exists() or not ms_values.exists():
continue
# Read config once per stack - we need it both to decide
# whether modelservice rendering applies and to extract the
# model_id_label used by the helmfile selector.
config_file = stack_dir / "config.yaml"
cfg: dict = {}
if config_file.exists():
with open(config_file, encoding="utf-8") as f:
cfg = _yaml.safe_load(f) or {}
# Skip standalone-only stacks: the modelservice Helm chart is
# not used, and running `helmfile template` against a helmfile
# with no matching release yields an empty document that
# subsequently fails to parse.
modelservice_enabled = bool(
(cfg.get("modelservice") or {}).get("enabled", False)
)
if not modelservice_enabled:
logger.log_debug(
f"Skipping Helm pre-render for {stack_dir.name}: "
f"modelservice.enabled is false (standalone-only stack)"
)
continue
model_id = cfg.get("model_id_label", "")
if not model_id:
logger.log_debug(
f"Skipping Helm pre-render for {stack_dir.name}: "
f"model_id_label not found in config.yaml"
)
continue
# Output directory for pre-rendered Helm manifests
helm_dir = stack_dir / "helm"
helm_dir.mkdir(parents=True, exist_ok=True)
# helmfile expects values files with specific names relative to
# the helmfile location. Use the helm dir as the working
# directory and copy the values files with expected names.
shutil.copy2(helmfile_src, helm_dir / "helmfile.yaml")
# Only the modelservice values file is needed - the selector
# targets only the -ms release so infra/gaie values are not read.
shutil.copy2(ms_values, helm_dir / "ms-values.yaml")
# Use CommandExecutor for consistent logging and error handling
cmd = CommandExecutor(
work_dir=plan_dir,
dry_run=False,
verbose=False,
logger=logger,
)
result = cmd.helmfile(
"--selector",
f"name={model_id}-ms",
"template",
"-f",
str(helm_dir / "helmfile.yaml"),
"--skip-schema-validation",
use_kubeconfig=False,
)
if result.success and result.stdout.strip():
output_path = helm_dir / "modelservice.yaml"
output_path.write_text(result.stdout, encoding="utf-8")
line_count = len(result.stdout.splitlines())
logger.log_info(
f"📄 Pre-rendered modelservice Helm manifests "
f"({line_count} lines) \u2192 {stack_dir.name}/helm/modelservice.yaml"
)
elif not result.success:
logger.log_debug(
f"Could not pre-render modelservice manifests for "
f"{stack_dir.name}: {result.stderr[:200]}"
)
def _load_stack_info_from_config(config_file, stack_name=""):
"""Parse a single stack's config.yaml into a plan-info dict."""
import yaml as _yaml
try:
with open(config_file, encoding="utf-8") as f:
plan_config = _yaml.safe_load(f)
if plan_config:
return {
"stack_name": stack_name,
"namespace": (plan_config.get("namespace", {}).get("name")),
"harness_namespace": (plan_config.get("harness", {}).get("namespace")),
"model_name": (
plan_config.get("model", {}).get("huggingfaceId")
or plan_config.get("model", {}).get("name")
),
"hf_token": (plan_config.get("huggingface", {}).get("token")),
"release": plan_config.get("release"),
"standalone_enabled": (
plan_config.get("standalone", {}).get("enabled", False)
),
"fma_enabled": (plan_config.get("fma", {}).get("enabled", False)),
"modelservice_enabled": (
plan_config.get("modelservice", {}).get("enabled", False)
),
"kustomize_enabled": (
plan_config.get("kustomize", {}).get("enabled", False)
),
"harness": plan_config.get("harness", {}),
}
except (OSError, _yaml.YAMLError):
pass
return {}
def _load_all_stacks_info(rendered_paths):
"""Read configuration from every rendered stack's config.yaml.
Returns a list of per-stack info dicts (one per rendered path that
has a valid config.yaml).
"""
stacks_info = []
for stack_path in rendered_paths or []:
config_file = stack_path / "config.yaml"
if config_file.exists():
info = _load_stack_info_from_config(config_file, stack_name=stack_path.name)
if info:
stacks_info.append(info)
return stacks_info
def _load_plan_info(rendered_paths):
"""Read key configuration from the first rendered plan config.yaml.
Returns a dict with namespace, harness_namespace, model_name,
hf_token, and release -- or an empty dict if no config is found.
"""
all_info = _load_all_stacks_info(rendered_paths)
return all_info[0] if all_info else {}
def _parse_namespaces(
ns_str: str | None, plan_info: dict
) -> tuple[str | None, str | None]:
"""Parse the ``--namespace`` CLI value into (namespace, harness_namespace).
Supports two formats:
- ``"ns"`` -- both namespaces use the same value.
- ``"ns,harness_ns"`` -- first is the infra namespace, second is the
harness namespace.
Falls back to ``plan_info`` if *ns_str* is ``None``.
Returns:
(namespace, harness_namespace). Either may be ``None`` if
no value was provided anywhere.
"""
cli_namespace = None
cli_harness_namespace = None
if ns_str:
parts = [p.strip() for p in ns_str.split(",")]
cli_namespace = parts[0]
cli_harness_namespace = parts[1] if len(parts) > 1 else parts[0]
namespace = cli_namespace or plan_info.get("namespace")
harness_ns = (
cli_harness_namespace or plan_info.get("harness_namespace") or namespace
)
return namespace, harness_ns
def _resolve_deploy_methods(args, plan_info, logger, phase="standup"):
"""Determine deployment methods from CLI flag or plan config.
Priority: CLI --methods > auto-detect from plan config > phase-specific default.
standalone.enabled defaults to false, so if true the scenario explicitly chose it.
For teardown, no fallback -- user must specify --methods if config is missing.
"""
methods_str = getattr(args, "methods", None)
if methods_str:
return [m.strip() for m in methods_str.split(",")]
standalone = plan_info.get("standalone_enabled", False)
fma = plan_info.get("fma_enabled", False)
modelservice = plan_info.get("modelservice_enabled", False)
kustomize = plan_info.get("kustomize_enabled", False)
if phase == "run":
# Run phase returns all enabled methods for endpoint detection
methods = []
if standalone:
methods.append("standalone")
if fma:
methods.append("fma")
if modelservice:
methods.append("modelservice")
if kustomize:
methods.append("kustomize")
if methods:
logger.log_info(
f"Auto-detected deploy method(s) from plan: {', '.join(methods)}"
)
return methods
else:
# Standup/teardown: treat as mutually exclusive
if kustomize:
logger.log_info("Auto-detected deploy method from plan: kustomize")
return ["kustomize"]
if standalone:
logger.log_info("Auto-detected deploy method from plan: standalone")
return ["standalone"]
if fma:
logger.log_info("Auto-detected deploy method from plan: fma")
return ["fma"]
if modelservice:
logger.log_info("Auto-detected deploy method from plan: modelservice")
return ["modelservice"]
if phase == "teardown":
raise PhaseError(
"Cannot determine deployment method: no plan config found and "
"--methods not specified. Use --methods standalone or "
"--methods modelservice to specify what to tear down."
)
return ["modelservice"]
def _do_standup(args, logger, render_plan_errors):
"""Core standup logic. Returns (context, result). Raises PhaseError on failure."""
rendered_paths = getattr(render_plan_errors, "rendered_paths", [])
all_stacks_info = _load_all_stacks_info(rendered_paths)
plan_info = all_stacks_info[0] if all_stacks_info else {}
deployed_methods = _resolve_deploy_methods(args, plan_info, logger)
namespace, harness_ns = _parse_namespaces(
getattr(args, "namespace", None),
plan_info,
)
if not namespace:
raise PhaseError(
"No namespace specified. Set 'namespace.name' in your scenario "
"YAML, defaults.yaml, or pass --namespace on the CLI."
)
context = ExecutionContext(
plan_dir=config.plan_dir,
workspace=config.workspace,
specification_file=getattr(args, "specification_file", None),
rendered_stacks=rendered_paths,
dry_run=config.dry_run,
verbose=config.verbose,
non_admin=getattr(args, "non_admin", False),
current_phase=Phase.STANDUP,
kubeconfig=getattr(args, "kubeconfig", None),
deployed_methods=deployed_methods,
namespace=namespace,
harness_namespace=harness_ns,
model_name=plan_info.get("model_name"),
logger=logger,
standalone_deploy_timeout=int(
getattr(args, "standalone_deploy_timeout", 900) or 900
),
gateway_deploy_timeout=int(getattr(args, "gateway_deploy_timeout", 120) or 120),
modelservice_deploy_timeout=int(
getattr(args, "modelservice_deploy_timeout", 1500) or 1500
),
pvc_bind_timeout=int(getattr(args, "pvc_bind_timeout", 240) or 240),
kustomize_deploy_timeout=int(
getattr(args, "kustomize_deploy_timeout", 900) or 900
),
llmd_repo_path=getattr(args, "llmd_repo_path", None),
kustomize_skip_infra=not getattr(args, "full_infra", False),
stack_filter=_parse_stack_filter(getattr(args, "stack", None)),
)
_check_model_access(context, all_stacks_info, logger)
executor = StepExecutor(
steps=get_standup_steps(),
context=context,
logger=logger,
max_parallel_stacks=getattr(args, "parallel", 4),
)
step_spec = getattr(args, "step", None)
result = executor.execute(step_spec=step_spec)
if result.has_errors:
raise PhaseError(f"Standup failed:\n{result.summary()}")
return context, result
def _execute_standup(args, logger, render_plan_errors):
"""Build execution context and run standup steps."""
try:
context, result = _do_standup(args, logger, render_plan_errors)
except PhaseError as e:
logger.log_error(str(e))
sys.exit(1)
_print_standup_summary(context, result, logger)
# Auto-chain smoketest after standup unless --skip-smoketest
skip_smoketest = getattr(args, "skip_smoketest", False)
if not skip_smoketest:
logger.log_info("")
logger.log_info(
"Running smoketests...",
emoji="🔍",
)
try:
_do_smoketest(args, logger, render_plan_errors)
except PhaseError as e:
logger.log_error(str(e))
sys.exit(1)
def _do_smoketest(args, logger, render_plan_errors):
"""Core smoketest logic. Returns (context, result). Raises PhaseError on failure."""
rendered_paths = getattr(render_plan_errors, "rendered_paths", [])
all_stacks_info = _load_all_stacks_info(rendered_paths)
plan_info = all_stacks_info[0] if all_stacks_info else {}
deployed_methods = _resolve_deploy_methods(
args, plan_info, logger, phase="smoketest"
)
namespace, harness_ns = _parse_namespaces(
getattr(args, "namespace", None),
plan_info,
)
if not namespace:
raise PhaseError(
"No namespace specified. Set 'namespace.name' in your scenario "
"YAML, defaults.yaml, or pass --namespace on the CLI."
)
context = ExecutionContext(
plan_dir=config.plan_dir,
workspace=config.workspace,
specification_file=getattr(args, "specification_file", None),
rendered_stacks=rendered_paths,
dry_run=config.dry_run,
verbose=config.verbose,
non_admin=getattr(args, "non_admin", False),
current_phase=Phase.SMOKETEST,
kubeconfig=getattr(args, "kubeconfig", None),
deployed_methods=deployed_methods,
namespace=namespace,
harness_namespace=harness_ns,
model_name=plan_info.get("model_name"),
logger=logger,
stack_filter=_parse_stack_filter(getattr(args, "stack", None)),
)
# Smoketest runs per-stack checks sequentially (max_parallel_stacks=1):
# parallel runs would interleave /health + /v1/models probe logs across
# stacks, hammer the shared gateway with concurrent curls, and make
# multi-stack failures harder to debug. Standup/run stay at the
# user-configured default via --parallel; smoketest overrides.
executor = StepExecutor(
steps=get_smoketest_steps(),
context=context,
logger=logger,
max_parallel_stacks=1,
)
step_spec = getattr(args, "step", None)
result = executor.execute(step_spec=step_spec)
if result.has_errors:
raise PhaseError(f"Smoketest failed:\n{result.summary()}")
logger.log_info("All smoketest steps complete.", emoji="✅")
return context, result
def _execute_smoketest(args, logger, render_plan_errors):
"""Build execution context and run smoketest steps."""
try:
_do_smoketest(args, logger, render_plan_errors)
except PhaseError as e:
logger.log_error(str(e))
sys.exit(1)
def _check_model_access(context, all_stacks_info, logger):
"""Verify HuggingFace access for every unique model across stacks.
Exits immediately if any gated model is inaccessible. Skipped in dry-run.
"""
if context.dry_run:
return
from llmdbenchmark.utilities.huggingface import (
check_model_access,
GatedStatus,
)
checked: set[str] = set()
for stack_info in all_stacks_info:
model_id = stack_info.get("model_name")
if not model_id or model_id in checked:
continue
checked.add(model_id)
hf_token = stack_info.get("hf_token")
stack_name = stack_info.get("stack_name", "")
prefix = f"[{stack_name}] " if stack_name and len(all_stacks_info) > 1 else ""
logger.log_info(
f'{prefix}Checking HuggingFace access for "{model_id}"...',
emoji="🔑",
)
result = check_model_access(model_id, hf_token)
if result.ok:
if result.gated == GatedStatus.NOT_GATED:
logger.log_info(
f'{prefix}Model "{model_id}" is not gated -- '
f"access is authorized by default",
emoji="✅",
)
elif result.gated == GatedStatus.GATED:
logger.log_info(
f'{prefix}Verified access to gated model "{model_id}" '
f"is authorized",
emoji="✅",
)
else:
logger.log_warning(f"{prefix}{result.detail}")
else:
raise PhaseError(f"{prefix}{result.detail}")
def _print_standup_summary(context, result, logger):
"""Print the standup completion banner with namespace, method, and endpoint info."""
logger.line_break()
ns = context.namespace or "unknown"
harness_ns = context.harness_namespace or ns
username = context.username or "unknown"
platform = context.platform_type
methods = (
", ".join(context.deployed_methods) if context.deployed_methods else "default"
)
stacks = len(context.rendered_stacks)
mode = "dry-run" if context.dry_run else "live"
endpoints = context.deployed_endpoints or {}
# Aggregate per-stack models for the multi-stack case. context.model_name
# holds only a single value (first stack / CLI override) and would
# misrepresent the deployment otherwise. Honors --stack filter.
stack_models = _collect_stack_models(context)
W = 62
logger.log_info("=" * W)
logger.log_info(" STANDUP COMPLETE")
logger.log_info("=" * W)
logger.log_info(f" User: {username}")
logger.log_info(f" Platform: {platform}")
logger.log_info(f" Mode: {mode}")
if len(stack_models) > 1:
logger.log_info(f" Models: {len(stack_models)} (one per stack)")
for stack_name, model in stack_models:
logger.log_info(f" - {stack_name.ljust(20)} {model}")
else:
single_model = (
stack_models[0][1] if stack_models else (context.model_name or "unknown")
)
logger.log_info(f" Model: {single_model}")
logger.log_info(f" Namespace: {ns}")
if harness_ns != ns:
logger.log_info(f" Harness NS: {harness_ns}")
logger.log_info(f" Methods: {methods}")
# Gateway class only takes effect on the modelservice path; for the
# other deploy methods the label says "n/a (...)" so the operator
# isn't misled by the scenario's default value.
from llmdbenchmark.utilities.cluster import resolve_phase_gateway_label
gateway_label = resolve_phase_gateway_label(context)
if gateway_label:
logger.log_info(f" Gateway: {gateway_label}")
logger.log_info(f" Stacks: {stacks}")
total_steps = len(result.global_results)
for sr in result.stack_results:
total_steps += len(sr.step_results)
passed = sum(1 for r in result.global_results if r.success)
for sr in result.stack_results:
passed += sum(1 for r in sr.step_results if r.success)
skipped = sum(1 for r in result.global_results if r.message == "Skipped")
for sr in result.stack_results:
skipped += sum(1 for r in sr.step_results if r.message == "Skipped")
steps_summary = f"{passed}/{total_steps} passed"
if skipped:
steps_summary += f", {skipped} skipped"
logger.log_info(f" Steps: {steps_summary}")
if endpoints:
logger.log_info("-" * W)
logger.log_info(" Deployed Endpoints:")
for name, url in endpoints.items():
logger.log_info(f" {name}: {url}")
logger.log_info("=" * W)
logger.line_break()
logger.log_info(f"Workspace: {context.workspace}")
logger.log_info("All standup steps complete.", emoji="✅")
def _do_teardown(args, logger, render_plan_errors):
"""Core teardown logic. Returns (context, result). Raises PhaseError on failure."""
rendered_paths = getattr(render_plan_errors, "rendered_paths", [])
plan_info = _load_plan_info(rendered_paths)
deployed_methods = _resolve_deploy_methods(
args, plan_info, logger, phase="teardown"
)
namespace, harness_ns = _parse_namespaces(
getattr(args, "namespace", None),
plan_info,
)
if not namespace:
raise PhaseError(
"No namespace specified. Set 'namespace.name' in your scenario "
"YAML, defaults.yaml, or pass --namespace on the CLI."
)
context = ExecutionContext(
plan_dir=config.plan_dir,
workspace=config.workspace,
specification_file=getattr(args, "specification_file", None),
rendered_stacks=rendered_paths,
dry_run=config.dry_run,
verbose=config.verbose,
non_admin=getattr(args, "non_admin", False),
current_phase=Phase.TEARDOWN,
kubeconfig=getattr(args, "kubeconfig", None),
deployed_methods=deployed_methods,
deep_clean=getattr(args, "deep", False),
release=getattr(args, "release", "llmdbench"),
namespace=namespace,
harness_namespace=harness_ns,
model_name=plan_info.get("model_name"),
logger=logger,
fma_teardown_timeout=int(getattr(args, "fma_teardown_timeout", 120) or 120),
llmd_repo_path=getattr(args, "llmd_repo_path", None),
stack_filter=_parse_stack_filter(getattr(args, "stack", None)),
)
executor = StepExecutor(
steps=get_teardown_steps(),
context=context,
logger=logger,
)
step_spec = getattr(args, "step", None)
result = executor.execute(step_spec=step_spec)
if result.has_errors:
raise PhaseError(f"Teardown failed:\n{result.summary()}")
return context, result
def _execute_teardown(args, logger, render_plan_errors):
"""Build execution context and run teardown steps."""
try:
context, result = _do_teardown(args, logger, render_plan_errors)
except PhaseError as e:
logger.log_error(str(e))
sys.exit(1)
ns = context.namespace or "unknown"
harness_ns = context.harness_namespace or ns
mode = "deep clean" if context.deep_clean else "normal"
logger.line_break()
logger.log_info(
f"Teardown complete ({mode}). "
f'Namespaces: "{ns}", "{harness_ns}". '
f"Methods: {', '.join(context.deployed_methods)}. "
f"Release: {context.release}.",
emoji="✅",
)
def _do_run(args, logger, render_plan_errors, experiment_file_override=None):
"""Core run logic. Returns (context, result). Raises PhaseError on failure."""
rendered_paths = getattr(render_plan_errors, "rendered_paths", [])
all_stacks_info = _load_all_stacks_info(rendered_paths)
plan_info = all_stacks_info[0] if all_stacks_info else {}
deployed_methods = _resolve_deploy_methods(args, plan_info, logger, phase="run")
namespace, harness_ns = _parse_namespaces(
getattr(args, "namespace", None),
plan_info,
)
endpoint_url = getattr(args, "endpoint_url", None)
run_config_file = getattr(args, "run_config", None)
is_run_only = bool(endpoint_url or run_config_file)
if not namespace and not is_run_only:
raise PhaseError(
"No namespace specified. Set 'namespace.name' in your scenario "
"YAML, defaults.yaml, or pass --namespace on the CLI."
)
experiments_file = experiment_file_override or getattr(args, "experiments", None)
context = ExecutionContext(
plan_dir=config.plan_dir,
workspace=config.workspace,
specification_file=getattr(args, "specification_file", None),
rendered_stacks=rendered_paths,
dry_run=config.dry_run,
verbose=config.verbose,
non_admin=getattr(args, "non_admin", False),
current_phase=Phase.RUN,
kubeconfig=getattr(args, "kubeconfig", None),
deployed_methods=deployed_methods,
namespace=namespace,
harness_namespace=harness_ns,
model_name=getattr(args, "model", None) or plan_info.get("model_name"),
logger=logger,
harness_name=getattr(args, "harness", None),
harness_profile=getattr(args, "workload", None),
experiment_treatments_file=experiments_file,
profile_overrides=getattr(args, "overrides", None),
harness_output=getattr(args, "output", "local") or "local",
harness_parallelism=int(getattr(args, "parallelism", 1) or 1),
harness_wait_timeout=int(
getattr(args, "wait_timeout", None)
if getattr(args, "wait_timeout", None) is not None
else (plan_info.get("harness", {}) or {}).get("waitTimeout") or 3600
),
harness_debug=getattr(args, "debug", False),
harness_skip_run=getattr(args, "skip", False),
harness_service_account=getattr(args, "serviceaccount", None),
harness_envvars_to_pod=getattr(args, "envvarspod", None),
analyze_locally=getattr(args, "analyze", False),
endpoint_url=endpoint_url,
run_config_file=run_config_file,
generate_config_only=getattr(args, "generate_config", False),
dataset_url=getattr(args, "dataset", None),
harness_data_access_timeout=int(
getattr(args, "data_access_timeout", 120) or 120
),
pvc_bind_timeout=int(getattr(args, "pvc_bind_timeout", 240) or 240),
stack_filter=_parse_stack_filter(getattr(args, "stack", None)),
)
# --list-endpoints: detect endpoints (step 03 only), print a copy-paste
# table with per-stack routing URLs, and exit without deploying any
# harness pods. Useful for discovering what's live in a multi-stack
# scenario before picking an endpoint for a targeted `run`.
if getattr(args, "list_endpoints", False):
executor = StepExecutor(
steps=get_run_steps(),
context=context,
logger=logger,
max_parallel_stacks=1,
)
result = executor.execute(step_spec="3")
_print_endpoints_table(context, logger, args)
return context, result
executor = StepExecutor(
steps=get_run_steps(),
context=context,
logger=logger,
max_parallel_stacks=1,
)
step_spec = getattr(args, "step", None)
result = executor.execute(step_spec=step_spec)
if result.has_errors:
raise PhaseError(f"Run failed:\n{result.summary()}")
return context, result
def _collect_stack_models(context) -> list[tuple[str, str]]:
"""Return ``[(stack_name, model_name), ...]`` from rendered configs.
Honors the ``--stack`` filter so the benchmark summary reflects only
the stacks that actually ran. Returns an empty list when there are
no rendered stacks (run-only / endpoint-url mode), in which case the
summary falls back to ``context.model_name``.
"""
rendered = getattr(context, "rendered_stacks", []) or []
if not rendered:
return []
stack_filter = getattr(context, "stack_filter", None) or []
rows: list[tuple[str, str]] = []
for stack_path in rendered:
stack_name = stack_path.name
if stack_filter and stack_name not in stack_filter:
continue
cfg_file = stack_path / "config.yaml"
model_name = "?"
if cfg_file.exists():
try:
with open(cfg_file, encoding="utf-8") as fh:
cfg = _yaml.safe_load(fh) or {}
model_name = (cfg.get("model") or {}).get("name", "?") or "?"
except (OSError, _yaml.YAMLError):
pass
rows.append((stack_name, model_name))
return rows
def _parse_stack_filter(raw: str | None) -> list[str] | None:
"""Parse --stack / LLMDBENCH_STACK into a list of stack names, or None."""
if not raw:
return None
names = [n.strip() for n in str(raw).split(",") if n.strip()]
return names or None
def _print_endpoints_table(context, logger, args) -> None:
"""Print a table of per-stack endpoints + copy-paste `run` commands.
Called by --list-endpoints after step 03 has populated
context.deployed_endpoints. Output is human-readable AND machine-
friendly: the copy-paste block can be pasted as-is to benchmark a
specific pool.
"""
endpoints = context.deployed_endpoints or {}
if not endpoints:
logger.log_warning(
"No endpoints detected. Have you run standup first? "
"(`llmdbenchmark standup -p <namespace>` on this spec)"
)
return
rows: list[tuple[str, str, str]] = []
for stack_path in context.rendered_stacks or []:
stack_name = stack_path.name
cfg_file = stack_path / "config.yaml"
model_name = "?"
if cfg_file.exists():
try:
with open(cfg_file, encoding="utf-8") as fh:
cfg = _yaml.safe_load(fh) or {}
model_name = (cfg.get("model") or {}).get("name", "?") or "?"
except (OSError, _yaml.YAMLError):
pass
url = endpoints.get(stack_name, "<not detected>")
rows.append((stack_name, model_name, url))
# Pretty table
col_stack = max(len("STACK"), max(len(r[0]) for r in rows))
col_model = max(len("MODEL"), max(len(r[1]) for r in rows))
col_url = max(len("ENDPOINT URL"), max(len(r[2]) for r in rows))
logger.line_break()
logger.log_info("📋 Detected endpoints:")
logger.log_info(
f" {'STACK'.ljust(col_stack)} "
f"{'MODEL'.ljust(col_model)} {'ENDPOINT URL'.ljust(col_url)}"
)
logger.log_info(f" {'-' * col_stack} {'-' * col_model} {'-' * col_url}")
for stack_name, model_name, url in rows:
logger.log_info(
f" {stack_name.ljust(col_stack)} "
f"{model_name.ljust(col_model)} {url.ljust(col_url)}"
)
logger.line_break()
# Copy-paste block - one ready-to-run invocation per stack, with the
# flags the user most likely wants to customize (harness, workload,
# parallelism) left as placeholders.
spec_raw = getattr(args, "specification_file", None)
spec = str(spec_raw) if spec_raw else "<spec>"
if "/" in spec or spec.endswith(".yaml.j2"):
# Full path (e.g. /abs/path/config/specification/guides/multi-model-wva.yaml.j2)
# - trim to the friendly `category/name` form the CLI understands.
parent = os.path.basename(os.path.dirname(spec)) if "/" in spec else ""
stem = os.path.basename(spec)
if stem.endswith(".yaml.j2"):
stem = stem[: -len(".yaml.j2")]
spec = f"{parent}/{stem}" if parent else stem
namespace = context.namespace or "<namespace>"
logger.log_info("💡 Copy-paste to benchmark one pool:")
logger.line_break()
# log_plain writes to every logger handler (terminal + attached log
# files) without the timestamp / level prefix, so the block both
# copy-pastes cleanly from the terminal AND lands verbatim in the
# log file for later auditing.
for stack_name, model_name, url in rows:
logger.log_plain(f" # {stack_name} - {model_name}")
logger.log_plain(f" llmdbenchmark --spec {spec} run \\")
logger.log_plain(f" --namespace {namespace} \\")
logger.log_plain(f" --endpoint-url {url} \\")
logger.log_plain(f" --model {model_name} \\")
logger.log_plain(" -l <harness> -w <workload.yaml> -j <parallel-pods>")
logger.log_plain("")
def _execute_run(args, logger, render_plan_errors):
"""Build execution context and run experiment steps."""
try:
context, result = _do_run(args, logger, render_plan_errors)
except PhaseError as e:
logger.log_error(str(e))
sys.exit(1)