From fa8c3dd035b9f872ca488497606403d535044ec7 Mon Sep 17 00:00:00 2001 From: Maxim Svistunov Date: Mon, 22 Jun 2026 11:13:44 +0200 Subject: [PATCH 1/2] LCORE-2337: add dumb-mode config migration (migrate_config_dumb) Add migrate_config_dumb(), the lift-and-shift migration that converts a legacy two-file config (run.yaml + lightspeed-stack.yaml) into a unified single file. The operator's lightspeed-stack.yaml is kept verbatim except for its llama_stack section, where library_client_config_path is dropped and replaced by a config block that lifts the entire run.yaml body into native_override with baseline: empty. Synthesizing the result then starts from an empty baseline and deep-merges only the lifted run.yaml, so it reproduces the original run.yaml exactly (Decision T7) - a lossless round-trip - without trying to factor anything into high-level sections (that "smart" mode is deferred future work). All other lightspeed-stack.yaml content (name, service, byok_rag, ...) is preserved, so existing enrichment keeps working in unified mode as before. Add unit tests for the migrated structure and the migrate -> synthesize round-trip (synthesized dict equals the original run.yaml). The synthesizer module crossed pylint's 1000-line module limit, so disable too-many-lines at module scope, matching the precedent in src/models/config.py. --- src/llama_stack_configuration.py | 66 ++++++++++++++++++++ tests/unit/test_llama_stack_synthesize.py | 76 +++++++++++++++++++++++ 2 files changed, 142 insertions(+) diff --git a/src/llama_stack_configuration.py b/src/llama_stack_configuration.py index b61b959df..8ecc927ce 100644 --- a/src/llama_stack_configuration.py +++ b/src/llama_stack_configuration.py @@ -16,6 +16,8 @@ operator-facing artifact. """ +# pylint: disable=too-many-lines + import copy import os from argparse import ArgumentParser @@ -900,6 +902,70 @@ def synthesize_to_file( logger.info("Wrote synthesized Llama Stack configuration to %s (mode 0600)", path) +# ============================================================================= +# Migration: legacy two-file config -> unified single file (LCORE-2337) +# ============================================================================= + + +def migrate_config_dumb( + run_yaml_path: str, + lightspeed_yaml_path: str, + output_path: str, +) -> None: + """Migrate a legacy two-file config to a unified single file (dumb mode). + + "Dumb" lift-and-shift: the operator's ``lightspeed-stack.yaml`` is kept + verbatim except for its ``llama_stack`` section, where + ``library_client_config_path`` is dropped and replaced by a unified + ``config`` block that lifts the *entire* legacy ``run.yaml`` body into + ``native_override`` with ``baseline: empty``. Synthesizing the result then + starts from an empty baseline and deep-merges only the lifted run.yaml, so + it reproduces the original run.yaml (Decision T7) — a lossless round-trip, + without trying to factor anything into high-level sections (that "smart" + mode is deferred future work). + + All other ``lightspeed-stack.yaml`` content (name, service, byok_rag, …) is + preserved untouched, so any existing enrichment keeps working in unified + mode exactly as it did in legacy mode. + + Parameters: + run_yaml_path: Path to the legacy Llama Stack ``run.yaml``. + lightspeed_yaml_path: Path to the legacy ``lightspeed-stack.yaml``. + output_path: Path to write the unified ``lightspeed-stack.yaml``. + + Returns: + None. + + Raises: + OSError: If an input file cannot be read or the output cannot be + written. + yaml.YAMLError: If an input file is not valid YAML. + """ + with open(run_yaml_path, "r", encoding="utf-8") as file: + run_yaml = yaml.safe_load(file) + with open(lightspeed_yaml_path, "r", encoding="utf-8") as file: + lcs_config = yaml.safe_load(file) + + # Preserve the whole lightspeed-stack.yaml; only rewrite the llama_stack + # section: drop the legacy path, add the unified config block. + llama_stack = dict(lcs_config.get("llama_stack") or {}) + llama_stack.pop("library_client_config_path", None) + llama_stack["config"] = { + "baseline": "empty", + "native_override": run_yaml, + } + lcs_config["llama_stack"] = llama_stack + + logger.info( + "Migrating legacy config (%s + %s) to unified %s", + lightspeed_yaml_path, + run_yaml_path, + output_path, + ) + with open(output_path, "w", encoding="utf-8") as file: + yaml.dump(lcs_config, file, Dumper=YamlDumper, default_flow_style=False) + + # ============================================================================= # Main Generation Function (service/container mode only) # ============================================================================= diff --git a/tests/unit/test_llama_stack_synthesize.py b/tests/unit/test_llama_stack_synthesize.py index 1bb8f551b..183d4ba49 100644 --- a/tests/unit/test_llama_stack_synthesize.py +++ b/tests/unit/test_llama_stack_synthesize.py @@ -19,6 +19,7 @@ apply_high_level_inference, deep_merge_list_replace, load_default_baseline, + migrate_config_dumb, synthesize_configuration, synthesize_to_file, ) @@ -369,3 +370,78 @@ def test_synthesize_to_file_tightens_perms_on_overwrite(tmp_path: Path) -> None: synthesize_to_file(lcs, str(out), str(tmp_path)) assert stat.S_IMODE(os.stat(out).st_mode) == 0o600 assert yaml.safe_load(out.read_text(encoding="utf-8")) == {"v": 3} + + +# --------------------------------------------------------------------------- +# migrate_config_dumb (LCORE-2337) +# --------------------------------------------------------------------------- + + +# A representative legacy run.yaml body with nested maps, lists, and env refs. +_LEGACY_RUN_YAML: dict[str, Any] = { + "version": 2, + "apis": ["agents", "inference", "safety", "vector_io"], + "providers": { + "inference": [ + { + "provider_id": "openai", + "provider_type": "remote::openai", + "config": { + "api_key": "${env.OPENAI_API_KEY}", + "allowed_models": ["gpt-4o-mini"], + }, + }, + { + "provider_id": "sentence-transformers", + "provider_type": "inline::sentence-transformers", + }, + ], + }, + "safety": {"default_shield_id": "llama-guard", "excluded_categories": []}, +} + + +def _write_legacy_pair(tmp_path: Path) -> tuple[str, str]: + """Write a legacy run.yaml + lightspeed-stack.yaml pair, return their paths.""" + run_path = tmp_path / "run.yaml" + run_path.write_text(yaml.dump(_LEGACY_RUN_YAML), encoding="utf-8") + lcs = { + "name": "LCS", + "service": {"host": "localhost", "port": 8080}, + "llama_stack": { + "use_as_library_client": True, + "library_client_config_path": "run.yaml", + }, + } + lcs_path = tmp_path / "lightspeed-stack.yaml" + lcs_path.write_text(yaml.dump(lcs), encoding="utf-8") + return str(run_path), str(lcs_path) + + +def test_migrate_config_dumb_structure(tmp_path: Path) -> None: + """Dumb migration lifts run.yaml into native_override and drops the legacy path.""" + run_path, lcs_path = _write_legacy_pair(tmp_path) + out_path = tmp_path / "unified.yaml" + migrate_config_dumb(run_path, lcs_path, str(out_path)) + + migrated = yaml.safe_load(out_path.read_text(encoding="utf-8")) + # unrelated top-level content preserved + assert migrated["name"] == "LCS" + assert migrated["service"] == {"host": "localhost", "port": 8080} + # legacy path dropped; use_as_library_client preserved + assert "library_client_config_path" not in migrated["llama_stack"] + assert migrated["llama_stack"]["use_as_library_client"] is True + # whole run.yaml lifted into native_override with an empty baseline + assert migrated["llama_stack"]["config"]["baseline"] == "empty" + assert migrated["llama_stack"]["config"]["native_override"] == _LEGACY_RUN_YAML + + +def test_migrate_then_synthesize_reproduces_run_yaml(tmp_path: Path) -> None: + """Round-trip: migrate -> synthesize reproduces the original run.yaml (R4).""" + run_path, lcs_path = _write_legacy_pair(tmp_path) + out_path = tmp_path / "unified.yaml" + migrate_config_dumb(run_path, lcs_path, str(out_path)) + + migrated = yaml.safe_load(out_path.read_text(encoding="utf-8")) + synthesized = synthesize_configuration(migrated) + assert synthesized == _LEGACY_RUN_YAML From fa53efeb721f76ad3e930de3c4805cbdbb13c6d1 Mon Sep 17 00:00:00 2001 From: Maxim Svistunov Date: Mon, 22 Jun 2026 11:13:55 +0200 Subject: [PATCH 2/2] LCORE-2337: add --migrate-config CLI flags and dispatch Expose the migration on the lightspeed-stack CLI: --migrate-config plus --run-yaml and --migrate-output. main() dispatches to migrate_config_dumb and exits early, before load_configuration, because the input -c file still uses the legacy library_client_config_path shape and is read raw rather than validated against the current schema. Missing --run-yaml/--migrate-output exits with status 1, and the help text advises replacing literal secrets with ${env.VAR} references. Add CLI tests: flag parsing, the main() happy path (migrates the legacy pair and returns without starting the service), and the missing-flag exit. --- src/lightspeed_stack.py | 51 ++++++++++++++++- tests/unit/test_lightspeed_stack.py | 85 ++++++++++++++++++++++++++++- 2 files changed, 132 insertions(+), 4 deletions(-) diff --git a/src/lightspeed_stack.py b/src/lightspeed_stack.py index a53d80a94..e94e63d3b 100644 --- a/src/lightspeed_stack.py +++ b/src/lightspeed_stack.py @@ -10,6 +10,7 @@ import constants from configuration import configuration from constants import LIGHTSPEED_STACK_LOG_LEVEL_ENV_VAR +from llama_stack_configuration import migrate_config_dumb from log import get_logger, setup_logging from runners.quota_scheduler import start_quota_scheduler from runners.uvicorn import start_uvicorn @@ -75,6 +76,31 @@ def create_argument_parser() -> ArgumentParser: f"{constants.DEFAULT_SYNTHESIZED_CONFIG_PATH})", default=None, ) + parser.add_argument( + "--migrate-config", + dest="migrate_config", + help="migrate a legacy two-file config to a unified single file and " + "exit. Lifts the run.yaml given by --run-yaml into the " + "llama_stack.config.native_override of the -c lightspeed-stack.yaml " + "and writes the result to --migrate-output. Replace literal secrets " + "with ${env.VAR} references before or after migrating.", + action="store_true", + default=False, + ) + parser.add_argument( + "--run-yaml", + dest="run_yaml", + help="path to the legacy Llama Stack run.yaml to migrate " + "(used with --migrate-config)", + default=None, + ) + parser.add_argument( + "--migrate-output", + dest="migrate_output", + help="path to write the unified lightspeed-stack.yaml " + "(used with --migrate-config)", + default=None, + ) return parser @@ -90,9 +116,10 @@ def main() -> None: configuration.json and exits (exits with status 1 on failure). - If --dump-schema is provided, writes the active configuration schema to schema.json and exits (exits with status 1 on failure). - - If --generate-llama-stack-configuration is provided, generates and stores - the Llama Stack configuration to the specified output file and exits - (exits with status 1 on failure). + - If --migrate-config is provided, migrates the legacy two-file config + (--run-yaml plus the -c lightspeed-stack.yaml) into a unified single + file at --migrate-output and exits (status 1 on failure or missing + flags). - Otherwise, sets LIGHTSPEED_STACK_CONFIG_PATH for worker processes, starts the quota scheduler, and starts the Uvicorn web service. @@ -108,6 +135,24 @@ def main() -> None: os.environ[LIGHTSPEED_STACK_LOG_LEVEL_ENV_VAR] = "DEBUG" setup_logging() + # --migrate-config converts a legacy two-file config to a unified single + # file and exits. It reads the legacy files raw (the -c config still uses + # the legacy library_client_config_path shape), so it must run before + # load_configuration, which would validate against the current schema. + if args.migrate_config: + if args.run_yaml is None or args.migrate_output is None: + logger.error("--migrate-config requires --run-yaml and --migrate-output") + raise SystemExit(1) + try: + migrate_config_dumb(args.run_yaml, args.config_file, args.migrate_output) + logger.info( + "Migrated unified configuration written to %s", args.migrate_output + ) + except Exception as e: + logger.error("Failed to migrate configuration: %s", e) + raise SystemExit(1) from e + return + configuration.load_configuration(args.config_file) logger.info("Configuration: %s", configuration.configuration) logger.info( diff --git a/tests/unit/test_lightspeed_stack.py b/tests/unit/test_lightspeed_stack.py index 2bd946969..901377fdd 100644 --- a/tests/unit/test_lightspeed_stack.py +++ b/tests/unit/test_lightspeed_stack.py @@ -1,6 +1,11 @@ """Unit tests for functions defined in src/lightspeed_stack.py.""" -from lightspeed_stack import create_argument_parser +from pathlib import Path + +import pytest +import yaml + +from lightspeed_stack import create_argument_parser, main def test_create_argument_parser() -> None: @@ -14,3 +19,81 @@ def test_create_argument_parser() -> None: arg_parser = create_argument_parser() # nothing more to test w/o actual parsing is done assert arg_parser is not None + + +def test_argument_parser_accepts_migrate_flags() -> None: + """The parser accepts --migrate-config, --run-yaml, and --migrate-output.""" + args = create_argument_parser().parse_args( + [ + "--migrate-config", + "--run-yaml", + "run.yaml", + "-c", + "lightspeed-stack.yaml", + "--migrate-output", + "unified.yaml", + ] + ) + assert args.migrate_config is True + assert args.run_yaml == "run.yaml" + assert args.config_file == "lightspeed-stack.yaml" + assert args.migrate_output == "unified.yaml" + + +def test_main_migrate_config_writes_unified_file( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """`--migrate-config` migrates the legacy pair and exits without starting the service.""" + run_path = tmp_path / "run.yaml" + run_path.write_text( + yaml.dump({"version": 2, "apis": ["inference"]}), encoding="utf-8" + ) + lcs_path = tmp_path / "lightspeed-stack.yaml" + lcs_path.write_text( + yaml.dump( + { + "name": "LCS", + "llama_stack": { + "use_as_library_client": True, + "library_client_config_path": "run.yaml", + }, + } + ), + encoding="utf-8", + ) + out_path = tmp_path / "unified.yaml" + monkeypatch.setattr( + "sys.argv", + [ + "lightspeed-stack", + "--migrate-config", + "--run-yaml", + str(run_path), + "-c", + str(lcs_path), + "--migrate-output", + str(out_path), + ], + ) + + main() # returns early, never starts uvicorn + + migrated = yaml.safe_load(out_path.read_text(encoding="utf-8")) + assert "library_client_config_path" not in migrated["llama_stack"] + assert migrated["llama_stack"]["config"]["baseline"] == "empty" + assert migrated["llama_stack"]["config"]["native_override"] == { + "version": 2, + "apis": ["inference"], + } + + +def test_main_migrate_config_requires_run_yaml_and_output( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """`--migrate-config` without --run-yaml / --migrate-output exits with status 1.""" + monkeypatch.setattr( + "sys.argv", + ["lightspeed-stack", "--migrate-config", "-c", "lightspeed-stack.yaml"], + ) + with pytest.raises(SystemExit): + main()