Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 48 additions & 3 deletions src/lightspeed_stack.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand All @@ -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.

Expand All @@ -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(
Expand Down
66 changes: 66 additions & 0 deletions src/llama_stack_configuration.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@
operator-facing artifact.
"""

# pylint: disable=too-many-lines

import copy
import os
from argparse import ArgumentParser
Expand Down Expand Up @@ -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
Comment on lines +946 to +957

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Empty lightspeed-stack.yaml will raise an opaque AttributeError/TypeError.

yaml.safe_load returns None for an empty or comment-only file. Line 951 (lcs_config.get(...)) then raises AttributeError, and line 957 (lcs_config["llama_stack"] = ...) would raise TypeError — neither is among the documented OSError/YAMLError. Guard for the None case to fail clearly.

🛡️ Proposed guard
     with open(lightspeed_yaml_path, "r", encoding="utf-8") as file:
         lcs_config = yaml.safe_load(file)
+    if not isinstance(lcs_config, dict):
+        raise ValueError(
+            f"{lightspeed_yaml_path} did not parse to a mapping; cannot migrate."
+        )
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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
with open(lightspeed_yaml_path, "r", encoding="utf-8") as file:
lcs_config = yaml.safe_load(file)
if not isinstance(lcs_config, dict):
raise ValueError(
f"{lightspeed_yaml_path} did not parse to a mapping; cannot migrate."
)
# 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
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/llama_stack_configuration.py` around lines 946 - 957, Handle the
empty/comment-only lightspeed-stack.yaml case in the configuration load path by
checking the result of yaml.safe_load before using lcs_config; in the
llama_stack configuration block, treat None as an invalid/empty config and fail
with a clear, documented error instead of letting dict access or assignment on
lcs_config raise AttributeError/TypeError. Keep the fix localized around the
existing lcs_config, llama_stack, and lightspeed_yaml_path handling so the
behavior stays consistent for valid YAML inputs.


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)
Comment on lines +965 to +966

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟡 Minor | ⚡ Quick win

Consider restricting permissions on the migrated output file.

The lifted run.yaml may contain literal secrets, and the unified output is written with the default umask. The synthesized-config path in this same codebase is written with mode 0600; applying the same here would keep parity. (The --help guidance to use ${env.VAR} mitigates but doesn't enforce this.)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/llama_stack_configuration.py` around lines 965 - 966, The migrated output
written in the configuration export path currently uses the default file mode,
which can leave `run.yaml` contents overly permissive; update the write logic
around `output_path`/`yaml.dump` to explicitly set restrictive permissions
consistent with the synthesized-config path (mode 0600) so secrets in the
generated file are protected. Use the existing output-writing flow in
`llama_stack_configuration.py` as the place to apply the permission change,
keeping the behavior aligned with the other config export path.



# =============================================================================
# Main Generation Function (service/container mode only)
# =============================================================================
Expand Down
85 changes: 84 additions & 1 deletion tests/unit/test_lightspeed_stack.py
Original file line number Diff line number Diff line change
@@ -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:
Expand All @@ -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()
76 changes: 76 additions & 0 deletions tests/unit/test_llama_stack_synthesize.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
apply_high_level_inference,
deep_merge_list_replace,
load_default_baseline,
migrate_config_dumb,
synthesize_configuration,
synthesize_to_file,
)
Expand Down Expand Up @@ -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
Loading