From 86a6418e4737fbfa4433e7ce2d57e82c09c4040c Mon Sep 17 00:00:00 2001 From: Maxim Svistunov Date: Mon, 3 Aug 2026 13:20:46 +0200 Subject: [PATCH 1/2] LCORE-2338: auto-detect unified vs legacy mode in config CLI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The llama_stack_configuration.py CLI previously only performed legacy enrichment: it always read the --input run.yaml and enriched it, so a container handed a unified lightspeed-stack.yaml (with no external run.yaml) could not start. The spec's server-mode trigger mechanism requires the CLI to auto-detect the configuration shape. main() now dispatches on a new has_synthesis_input() helper that mirrors the root Configuration.check_unified_vs_legacy detection on the raw YAML dict (non-empty inference.providers, non-empty vector_store.providers, or a llama_stack.config block). Unified configs are synthesized via synthesize_to_file — --input is ignored and need not exist, relative profile: paths resolve against the --config directory (R8), and the output keeps the 0600 secret-safety mode (R10). Legacy configs enrich the --input run.yaml exactly as before, so existing container layouts and CI provider-matrix runs are unaffected. Tests cover the detection matrix (all three synthesis inputs, empty provider lists, null sections, legacy path) and both CLI dispatch paths, including synthesis with a nonexistent --input and relative-profile resolution. --- src/llama_stack_configuration.py | 50 ++++++++- tests/unit/test_llama_stack_synthesize.py | 122 ++++++++++++++++++++++ 2 files changed, 167 insertions(+), 5 deletions(-) diff --git a/src/llama_stack_configuration.py b/src/llama_stack_configuration.py index 766f04fb4..e76d91e72 100644 --- a/src/llama_stack_configuration.py +++ b/src/llama_stack_configuration.py @@ -1346,10 +1346,45 @@ def generate_configuration( # ============================================================================= +def has_synthesis_input(lcs_config: dict[str, Any]) -> bool: + """Return True when a raw lightspeed config carries a synthesis input. + + Mirrors the unified-vs-legacy detection of the root ``Configuration`` + model (``check_unified_vs_legacy``) for callers that work with the raw + YAML dict, such as the CLI: a non-empty top-level ``inference.providers``, + a non-empty ``vector_store.providers``, or a ``llama_stack.config`` block + signal unified mode (R11). + + Parameters: + lcs_config: The ``lightspeed-stack.yaml`` contents parsed into a dict. + + Returns: + bool: True when any synthesis input is present. + """ + inference = lcs_config.get("inference") or {} + vector_store = lcs_config.get("vector_store") or {} + llama_stack = lcs_config.get("llama_stack") or {} + return ( + bool(inference.get("providers")) + or bool(vector_store.get("providers")) + or llama_stack.get("config") is not None + ) + + def main() -> None: - """CLI entry point.""" + """CLI entry point with unified-vs-legacy auto-detection. + + Auto-detects the configuration shape from the ``--config`` file (spec + "Trigger mechanism"): when it carries a synthesis input the full run.yaml + is synthesized from it and ``--input`` is ignored, so no external + run.yaml needs to exist; otherwise the legacy path enriches the + ``--input`` run.yaml in place. Server-mode container entrypoints rely on + this dispatch to serve both modes with a single invocation. + """ parser = ArgumentParser( - description="Enrich Llama Stack config with Lightspeed values", + description="Generate the Llama Stack run configuration from a " + "lightspeed-stack.yaml: synthesized in unified mode, or enriched " + "from --input in legacy mode (auto-detected)", ) parser.add_argument( "-c", @@ -1361,20 +1396,25 @@ def main() -> None: "-i", "--input", default="run.yaml", - help="Input Llama Stack config (default: run.yaml)", + help="Input Llama Stack config enriched in legacy mode; ignored in " + "unified mode (default: run.yaml)", ) parser.add_argument( "-o", "--output", default="run_.yaml", - help="Output enriched config (default: run_.yaml)", + help="Output generated config (default: run_.yaml)", ) args = parser.parse_args() with open(args.config, "r", encoding="utf-8") as f: config = yaml.safe_load(f) - generate_configuration(args.input, args.output, config) + if has_synthesis_input(config): + config_file_dir = os.path.dirname(os.path.abspath(args.config)) + synthesize_to_file(config, args.output, config_file_dir) + else: + generate_configuration(args.input, args.output, config) if __name__ == "__main__": diff --git a/tests/unit/test_llama_stack_synthesize.py b/tests/unit/test_llama_stack_synthesize.py index c03fb604c..cadd2403b 100644 --- a/tests/unit/test_llama_stack_synthesize.py +++ b/tests/unit/test_llama_stack_synthesize.py @@ -8,6 +8,7 @@ import os import stat +import sys from pathlib import Path from typing import Any, Optional, get_args @@ -19,7 +20,9 @@ apply_high_level_inference, deep_merge_list_replace, ensure_mcp_tool_runtime, + has_synthesis_input, load_default_baseline, + main, migrate_config_dumb, synthesize_configuration, synthesize_to_file, @@ -811,3 +814,122 @@ def test_reference_profile_loads_via_synthesizer(profile_path: Path) -> None: assert result["version"] == 2 assert "inference" in result["apis"] assert result["providers"]["inference"], "profile must configure inference" + + +# --------------------------------------------------------------------------- +# CLI auto-detection (LCORE-2338): main() dispatches unified vs legacy +# --------------------------------------------------------------------------- + + +def test_has_synthesis_input_detection() -> None: + """The raw-dict detection mirrors the root-model synthesis-input check.""" + assert has_synthesis_input({"llama_stack": {"config": {"baseline": "default"}}}) + assert has_synthesis_input({"inference": {"providers": [{"type": "openai"}]}}) + assert has_synthesis_input({"vector_store": {"providers": [{"id": "nb"}]}}) + assert not has_synthesis_input({}) + assert not has_synthesis_input( + {"llama_stack": {"library_client_config_path": "run.yaml"}} + ) + # empty provider lists and null sections are not synthesis inputs + assert not has_synthesis_input({"inference": {"providers": []}}) + assert not has_synthesis_input({"inference": None, "llama_stack": None}) + + +def _run_main(monkeypatch: pytest.MonkeyPatch, argv: list[str]) -> None: + """Invoke the module CLI with the given arguments.""" + monkeypatch.setattr(sys, "argv", ["llama_stack_configuration.py", *argv]) + main() + + +def test_main_unified_config_synthesizes_without_input_file( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """A unified config is synthesized; the legacy --input file is ignored. + + This is the server-mode contract (spec "Trigger mechanism"): the container + entrypoint always passes -i, but in unified mode no run.yaml mount needs + to exist. + """ + lcs = { + "llama_stack": { + "config": { + "baseline": "empty", + "native_override": {"version": 2, "marker": "synthesized"}, + } + } + } + cfg_path = tmp_path / "lightspeed-stack.yaml" + cfg_path.write_text(yaml.dump(lcs), encoding="utf-8") + out_path = tmp_path / "generated-run.yaml" + + _run_main( + monkeypatch, + [ + "-c", + str(cfg_path), + "-i", + str(tmp_path / "does-not-exist.yaml"), + "-o", + str(out_path), + ], + ) + + assert yaml.safe_load(out_path.read_text(encoding="utf-8")) == { + "version": 2, + "marker": "synthesized", + } + # synthesized output carries the 0600 secret-safety contract (R10) + assert stat.S_IMODE(os.stat(out_path).st_mode) == 0o600 + + +def test_main_unified_config_resolves_relative_profile( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """A relative profile: in the config resolves against the config's dir (R8).""" + profile = {"version": 2, "apis": ["inference"], "marker": "from-profile"} + (tmp_path / "my-profile.yaml").write_text(yaml.dump(profile), encoding="utf-8") + lcs = {"llama_stack": {"config": {"profile": "my-profile.yaml"}}} + cfg_path = tmp_path / "lightspeed-stack.yaml" + cfg_path.write_text(yaml.dump(lcs), encoding="utf-8") + out_path = tmp_path / "generated-run.yaml" + + _run_main(monkeypatch, ["-c", str(cfg_path), "-o", str(out_path)]) + + assert yaml.safe_load(out_path.read_text(encoding="utf-8"))["marker"] == ( + "from-profile" + ) + + +def test_main_legacy_config_enriches_input_file( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """A legacy config (no synthesis input) enriches the --input run.yaml.""" + run_yaml = {"version": 2, "apis": ["inference"]} + run_path = tmp_path / "run.yaml" + run_path.write_text(yaml.dump(run_yaml), encoding="utf-8") + lcs = { + "llama_stack": {"library_client_config_path": str(run_path)}, + "byok_rag": [ + { + "rag_id": "kb1", + "vector_db_id": "kb1", + "db_path": "/var/lib/kb1/faiss.db", + "embedding_model": "nomic-ai/nomic-embed-text-v1.5", + "embedding_dimension": 768, + } + ], + } + cfg_path = tmp_path / "lightspeed-stack.yaml" + cfg_path.write_text(yaml.dump(lcs), encoding="utf-8") + out_path = tmp_path / "enriched-run.yaml" + + _run_main( + monkeypatch, + ["-c", str(cfg_path), "-i", str(run_path), "-o", str(out_path)], + ) + + enriched = yaml.safe_load(out_path.read_text(encoding="utf-8")) + # original content survives and BYOK enrichment was applied to the input + assert enriched["version"] == 2 + vector_io_ids = {p["provider_id"] for p in enriched["providers"]["vector_io"]} + assert "byok_kb1" in vector_io_ids From 2f565093a907ec5f84186f67e750a6a4d3cc2c0b Mon Sep 17 00:00:00 2001 From: Maxim Svistunov Date: Mon, 3 Aug 2026 13:21:42 +0200 Subject: [PATCH 2/2] LCORE-2338: deployment artifacts for unified server mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Make server mode work end to end from a single unified lightspeed-stack.yaml, relying on the CLI's unified-vs-legacy auto-detection: - scripts/llama-stack-entrypoint.sh: document the two startup modes in the header (synthesis from a unified config vs legacy run.yaml enrichment — the Python CLI decides), rename the intermediate file to generated-run.yaml, and fail with a clear error when generation fails and no fallback run.yaml is mounted, instead of handing llama-stack a nonexistent path. - deploy/llama-stack/test.containerfile: ship src/data/ to /opt/app-root/data so load_default_baseline() resolves next to the standalone-copied script (it reads ./data/default_run.yaml relative to its own location); chown it for the runtime user. - docker-compose.yaml: mount the src/data host copy beside the existing script host-copies, and document that the run.yaml mount is only consumed in legacy mode — one compose file serves both modes, with the mode chosen by the content of lightspeed-stack.yaml, so the CI provider-matrix workflows that pair run-*.yaml files with this compose file keep working unchanged. - deploy/llama-stack/README.md: document what the image bundles, the two startup modes, a minimal unified-only compose snippet (no run.yaml mount), and when a rebuild is needed vs covered by the host-copy mounts. .tekton/ pipelines reference no compose files and need no changes. --- deploy/llama-stack/README.md | 52 +++++++++++++++++++++++++++ deploy/llama-stack/test.containerfile | 8 +++-- docker-compose.yaml | 5 +++ scripts/llama-stack-entrypoint.sh | 37 ++++++++++++++----- 4 files changed, 91 insertions(+), 11 deletions(-) create mode 100644 deploy/llama-stack/README.md diff --git a/deploy/llama-stack/README.md b/deploy/llama-stack/README.md new file mode 100644 index 000000000..b164810f0 --- /dev/null +++ b/deploy/llama-stack/README.md @@ -0,0 +1,52 @@ +# Llama Stack container image + +`test.containerfile` builds the Llama Stack server image used by +`docker-compose.yaml` (server mode, e.g. for the e2e suite). Besides the +Llama Stack distribution itself, the image bundles the pieces needed to +generate its run configuration at container start: + +- `/opt/app-root/llama_stack_configuration.py` — the config-generation + script (copied from `src/llama_stack_configuration.py`). +- `/opt/app-root/data/default_run.yaml` — the shipped default baseline + (copied from `src/data/`), resolved by the script as `./data/` relative + to its own location. +- `/opt/app-root/enrich-entrypoint.sh` — the entrypoint (copied from + `scripts/llama-stack-entrypoint.sh`). + +## Startup modes + +The entrypoint invokes the script against the mounted +`lightspeed-stack.yaml`, and the script auto-detects the configuration +shape: + +- **Unified mode** — the `lightspeed-stack.yaml` carries a *synthesis + input* (a non-empty `inference.providers` or `vector_store.providers`, + or a `llama_stack.config` block). The full `run.yaml` is synthesized + from it; no external `run.yaml` mount is needed. +- **Legacy mode** — no synthesis input present. The mounted `run.yaml` + (`$LLAMA_STACK_CONFIG`, default `/opt/app-root/run.yaml`) is enriched + with lightspeed dynamic values (BYOK RAG, Solr/OKP, Azure Entra ID). + +The repository `docker-compose.yaml` mounts both files and works for +either mode — with a unified `lightspeed-stack.yaml` the `run.yaml` +mount is simply ignored. A unified-only deployment needs just: + +```yaml +services: + llama-stack: + build: + context: . + dockerfile: deploy/llama-stack/test.containerfile + ports: + - "8321:8321" + volumes: + - ./lightspeed-stack.yaml:/opt/app-root/lightspeed-stack.yaml:ro,z +``` + +## Rebuilding + +The compose file also mounts host copies of the script, the baseline +data directory, and the entrypoint over their baked-in counterparts, so +`docker compose up` picks up local changes to any of them without an +image rebuild. Rebuild (`docker compose build llama-stack`) when +dependencies (`pyproject.toml` / `uv.lock`) or the providers change. diff --git a/deploy/llama-stack/test.containerfile b/deploy/llama-stack/test.containerfile index 92d4d649e..8d1ad0f58 100644 --- a/deploy/llama-stack/test.containerfile +++ b/deploy/llama-stack/test.containerfile @@ -39,11 +39,15 @@ RUN mkdir -p /opt/app-root/src/.llama/storage \ chown -R 1001:0 /opt/app-root && \ chmod -R 775 /opt/app-root -# Copy enrichment scripts for runtime config enrichment +# Copy the config-generation script for runtime synthesis (unified mode) or +# enrichment (legacy mode), plus the shipped default baseline it resolves as +# ./data/default_run.yaml relative to its own location (load_default_baseline) COPY src/llama_stack_configuration.py /opt/app-root/llama_stack_configuration.py +COPY src/data /opt/app-root/data COPY scripts/llama-stack-entrypoint.sh /opt/app-root/enrich-entrypoint.sh RUN chmod +x /opt/app-root/enrich-entrypoint.sh && \ - chown 1001:0 /opt/app-root/enrich-entrypoint.sh /opt/app-root/llama_stack_configuration.py + chown -R 1001:0 /opt/app-root/enrich-entrypoint.sh \ + /opt/app-root/llama_stack_configuration.py /opt/app-root/data # Switch back to the original user USER 1001 diff --git a/docker-compose.yaml b/docker-compose.yaml index 18e43be2d..4db1349d4 100755 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -12,10 +12,15 @@ services: mock-tls-inference: condition: service_healthy volumes: + # Used in legacy mode only: with a unified lightspeed-stack.yaml (one + # carrying inference.providers / vector_store.providers / a + # llama_stack.config block) the entrypoint synthesizes the run config + # from lightspeed-stack.yaml and this mount is ignored - ./run.yaml:/opt/app-root/run.yaml:z # Host copies so `docker compose up` picks up script changes without rebuilding llama-stack - ./scripts/llama-stack-entrypoint.sh:/opt/app-root/enrich-entrypoint.sh:ro,z - ./src/llama_stack_configuration.py:/opt/app-root/llama_stack_configuration.py:ro,z + - ./src/data:/opt/app-root/data:ro,z - ${GCP_KEYS_PATH:-./tmp/.gcp-keys-dummy}:/opt/app-root/.gcp-keys:ro - ./lightspeed-stack.yaml:/opt/app-root/lightspeed-stack.yaml:ro,z - llama-storage:/opt/app-root/src/.llama/storage diff --git a/scripts/llama-stack-entrypoint.sh b/scripts/llama-stack-entrypoint.sh index 2ddcfd2e8..cc3f34fc6 100755 --- a/scripts/llama-stack-entrypoint.sh +++ b/scripts/llama-stack-entrypoint.sh @@ -1,27 +1,46 @@ #!/bin/bash # Entrypoint for llama-stack container. -# Enriches config with lightspeed dynamic values, then starts llama-stack. +# +# Generates the run configuration from the mounted lightspeed-stack.yaml and +# starts llama-stack. The Python CLI (llama_stack_configuration.py) +# auto-detects the configuration shape: +# - unified mode: the lightspeed config carries a synthesis input (a +# non-empty inference.providers or vector_store.providers, or a +# llama_stack.config block). The full run.yaml is synthesized from it — +# no external run.yaml mount is needed, and $LLAMA_STACK_CONFIG is +# ignored. The shipped default baseline is read from +# /opt/app-root/data/default_run.yaml. +# - legacy mode: the mounted run.yaml ($LLAMA_STACK_CONFIG) is enriched +# with lightspeed dynamic values (BYOK RAG, Solr/OKP, Azure Entra ID). set -e INPUT_CONFIG="${LLAMA_STACK_CONFIG:-/opt/app-root/run.yaml}" -ENRICHED_CONFIG="/tmp/enriched-run.yaml" +GENERATED_CONFIG="/tmp/generated-run.yaml" LIGHTSPEED_CONFIG="${LIGHTSPEED_CONFIG:-/opt/app-root/lightspeed-stack.yaml}" -# Enrich config if lightspeed config exists +# Generate config (synthesis or enrichment) if lightspeed config exists if [ -f "$LIGHTSPEED_CONFIG" ]; then - echo "Enriching llama-stack config..." - ENRICHMENT_FAILED=0 + echo "Generating llama-stack config from $LIGHTSPEED_CONFIG (mode auto-detected)..." + GENERATION_FAILED=0 /opt/app-root/.venv/bin/python3 /opt/app-root/llama_stack_configuration.py \ -c "$LIGHTSPEED_CONFIG" \ -i "$INPUT_CONFIG" \ - -o "$ENRICHED_CONFIG" 2>&1 || ENRICHMENT_FAILED=1 + -o "$GENERATED_CONFIG" 2>&1 || GENERATION_FAILED=1 - if [ -f "$ENRICHED_CONFIG" ] && [ "$ENRICHMENT_FAILED" -eq 0 ]; then - echo "Using enriched config: $ENRICHED_CONFIG" - exec ogx stack run "$ENRICHED_CONFIG" + if [ -f "$GENERATED_CONFIG" ] && [ "$GENERATION_FAILED" -eq 0 ]; then + echo "Using generated config: $GENERATED_CONFIG" + exec ogx stack run "$GENERATED_CONFIG" fi fi +# Fallback: run the mounted run.yaml directly. In unified mode there may be +# no run.yaml at all — fail with a clear message instead of a confusing +# llama-stack file-not-found error. +if [ ! -f "$INPUT_CONFIG" ]; then + echo "ERROR: config generation failed and no fallback run.yaml exists at $INPUT_CONFIG" >&2 + exit 1 +fi + echo "Using original config: $INPUT_CONFIG" exec ogx stack run "$INPUT_CONFIG"