From 7b43ebb6a2178d0390df19a98488fd118b9859d3 Mon Sep 17 00:00:00 2001 From: Jordan Dubrick Date: Mon, 19 Oct 2026 13:35:05 -0400 Subject: [PATCH 1/4] UIERFE-9: add environment reference normalization helpers --- src/utils/env_vars.py | 69 +++++++++ tests/unit/utils/test_env_vars.py | 240 ++++++++++++++++++++++++++++++ 2 files changed, 309 insertions(+) create mode 100644 src/utils/env_vars.py create mode 100644 tests/unit/utils/test_env_vars.py diff --git a/src/utils/env_vars.py b/src/utils/env_vars.py new file mode 100644 index 000000000..6d7e74a57 --- /dev/null +++ b/src/utils/env_vars.py @@ -0,0 +1,69 @@ +"""Normalize environment references and resolve them using OGX semantics.""" + +import re +from typing import Any, Final + +from ogx.core.stack import replace_env_vars + +# Match ${VAR} and ${env.VAR}, optionally with :=default or :+value. +# Names use OGX's uppercase/digit/underscore grammar; operands end at the first }. +_ENV_REFERENCE_PATTERN: Final[re.Pattern[str]] = re.compile( + r"\$\{(?:env\.)?[A-Z0-9_]+(?::[=+][^}]*)?\}" +) + + +def canonicalize_reference(match: re.Match[str]) -> str: + """Preserve OGX references and prefix short references with env. + + Parameters: + match: A supported reference found by the normalizer's regex. + + Returns: + The reference in canonical ${env.VAR} syntax, without resolving it. + + """ + # group(0) is the complete matched reference, including ${ and }. + reference = match.group(0) + if reference.startswith("${env."): + return reference + return reference.replace("${", "${env.", 1) + + +def normalize_env_references(config: Any) -> Any: + """Convert supported ${VAR} references to ${env.VAR} without resolving them. + + Recursively visit dictionary values and list elements, rebuilding each + container and normalizing its strings. Keys and existing OGX references are + unchanged. Variable names and operators follow OGX's resolver grammar. + + Parameters: + config: A configuration value, including nested containers and scalars. + + Returns: + The configuration with supported short references canonicalized. + + """ + if isinstance(config, str): + # sub calls the canonicalizer for each match, preserving surrounding text. + # For ${HOST}:${PORT}, each reference gets its own canonical replacement. + return _ENV_REFERENCE_PATTERN.sub(canonicalize_reference, config) + if isinstance(config, dict): + return {key: normalize_env_references(value) for key, value in config.items()} + if isinstance(config, list): + return [normalize_env_references(value) for value in config] + return config + + +def resolve_env_references(config: Any) -> Any: + """Normalize environment references, then delegate their resolution to OGX. + + Parameters: + config: A configuration value, including nested containers and scalars. + + Returns: + The recursively resolved configuration. OGX may convert substituted + strings to numbers, booleans, or None; other scalar values pass through. + + """ + normalized_config = normalize_env_references(config) + return replace_env_vars(normalized_config) diff --git a/tests/unit/utils/test_env_vars.py b/tests/unit/utils/test_env_vars.py new file mode 100644 index 000000000..72b661223 --- /dev/null +++ b/tests/unit/utils/test_env_vars.py @@ -0,0 +1,240 @@ +"""Unit tests for environment-reference normalization and OGX resolution.""" + +import copy +import os +from typing import Any, Final, Optional + +import pytest +from ogx.core.stack import EnvVarError +from pytest_mock import MockerFixture + +from utils.env_vars import ( + _ENV_REFERENCE_PATTERN, + canonicalize_reference, + normalize_env_references, + resolve_env_references, +) + +_COMMON_REFERENCE_CASES: Final[tuple[tuple[str, str], ...]] = ( + ("${HOST}", "${env.HOST}"), + ("${env.HOST}", "${env.HOST}"), +) + + +@pytest.fixture( + name="reference_prefix", params=["", "env."], ids=["short", "canonical"] +) +def reference_prefix_fixture(request: pytest.FixtureRequest) -> str: + """Exercise both environment-reference spellings.""" + return str(request.param) + + +@pytest.mark.parametrize( + ("source", "expected"), + [ + *_COMMON_REFERENCE_CASES, + ("${HOST:=localhost}", "${env.HOST:=localhost}"), + ("${HOST:=}", "${env.HOST:=}"), + ("${HOST:+enabled}", "${env.HOST:+enabled}"), + ("${HOST:+}", "${env.HOST:+}"), + ("${HOST:=local host #1}", "${env.HOST:=local host #1}"), + ("${HOST:+use #1}", "${env.HOST:+use #1}"), + ("${env.HOST:=localhost}", "${env.HOST:=localhost}"), + ("${env.HOST:=}", "${env.HOST:=}"), + ("${env.HOST:+enabled}", "${env.HOST:+enabled}"), + ("${env.HOST:+}", "${env.HOST:+}"), + ], +) +def test_canonicalize_reference(source: str, expected: str) -> None: + """Convert one supported regex match to its canonical reference.""" + match = _ENV_REFERENCE_PATTERN.fullmatch(source) + assert match is not None + + assert canonicalize_reference(match) == expected + + +@pytest.mark.parametrize( + ("source", "expected"), + [ + *_COMMON_REFERENCE_CASES, + ("${A}", "${env.A}"), + ("${1}", "${env.1}"), + ("${_}", "${env._}"), + ("${_HOST_2}", "${env._HOST_2}"), + ("${123}", "${env.123}"), + ("https://${HOST}:${PORT}/api", "https://${env.HOST}:${env.PORT}/api"), + ("${HOST}/${HOST}", "${env.HOST}/${env.HOST}"), + ("${HOST}/${env.PORT}", "${env.HOST}/${env.PORT}"), + ("${env.HOST:=${OTHER}}", "${env.HOST:=${OTHER}}"), + # Unsupported expressions remain literal; OGX does not reject them. + ("${}", "${}"), + ("${env.}", "${env.}"), + ("${host}", "${host}"), + ("${env.host}", "${env.host}"), + # OGX supports := and :+, but not the shell's :- default operator. + ("${HOST:-localhost}", "${HOST:-localhost}"), + ("${env.HOST:-localhost}", "${env.HOST:-localhost}"), + ("${HOST:+value", "${HOST:+value"), + ("${HOST:localhost}", "${HOST:localhost}"), + ("${HOST+enabled}", "${HOST+enabled}"), + ("${HOST-NAME}", "${HOST-NAME}"), + ("${other.HOST}", "${other.HOST}"), + ("{HOST}", "{HOST}"), + ("$HOST", "$HOST"), + ("plain text", "plain text"), + ("", ""), + ], +) +def test_normalize_env_references(source: str, expected: str) -> None: + """Match supported references in complete strings and preserve unsupported syntax.""" + assert normalize_env_references(source) == expected + + +@pytest.mark.parametrize("key", ["${KEY}", "${env.KEY}"]) +def test_normalize_nested_containers_without_mutating_input( + monkeypatch: pytest.MonkeyPatch, key: str +) -> None: + """Normalize dictionary values and lists while preserving keys and scalars.""" + monkeypatch.setenv("KEY", "must-not-replace-key") + config = { + key: ["${HOST}", {"port": "${PORT:=5432}"}], + "unchanged": [None, True, 42, 1.5, ("${TUPLE_VALUE}",)], + } + original = copy.deepcopy(config) + + result = normalize_env_references(config) + + assert result == { + key: ["${env.HOST}", {"port": "${env.PORT:=5432}"}], + "unchanged": [None, True, 42, 1.5, ("${TUPLE_VALUE}",)], + } + assert config == original + + +@pytest.mark.parametrize("value", [None, True, 42, 1.5, b"data", ("${HOST}",)]) +def test_non_container_values_pass_through(value: object) -> None: + """Keep non-string leaves unchanged through normalization and full resolution.""" + assert normalize_env_references(value) is value + assert resolve_env_references(value) is value + + +def test_normalize_does_not_read_environment( + monkeypatch: pytest.MonkeyPatch, mocker: MockerFixture +) -> None: + """Normalization leaves secrets and defaults deferred without environment I/O.""" + environment = mocker.MagicMock() + + with monkeypatch.context() as patch: + patch.setattr(os, "environ", environment) + result = normalize_env_references(["${SECRET}", "${HOST:=localhost}"]) + + assert result == ["${env.SECRET}", "${env.HOST:=localhost}"] + assert not environment.mock_calls + + +@pytest.mark.parametrize( + ("suffix", "environment_value", "expected"), + [ + ("", "configured", "configured"), + (":=fallback", None, "fallback"), + (":=fallback", "", "fallback"), + (":=fallback", "configured", "configured"), + (":=", None, None), + (":=", "", None), + (":=", "configured", "configured"), + (":+enabled", None, None), + (":+enabled", "", None), + (":+enabled", "configured", "enabled"), + (":+", None, None), + (":+", "", None), + (":+", "configured", None), + ("", "5432", 5432), + ("", "1.5", 1.5), + ("", "true", True), + ("", "FALSE", False), + ], +) +def test_resolve_matches_ogx_semantics( + monkeypatch: pytest.MonkeyPatch, + reference_prefix: str, + suffix: str, + environment_value: Optional[str], + expected: Any, +) -> None: + """Read environment values and apply OGX defaults, conditionals, and type conversion.""" + variable = "LCORE_ENV_REFERENCE_TEST" + monkeypatch.delenv(variable, raising=False) + if environment_value is not None: + monkeypatch.setenv(variable, environment_value) + + result = resolve_env_references(f"${{{reference_prefix}{variable}{suffix}}}") + + assert result == expected + assert type(result) is type(expected) + + +@pytest.mark.parametrize("environment_value", [None, ""]) +def test_resolve_missing_variable_preserves_ogx_error( + monkeypatch: pytest.MonkeyPatch, + reference_prefix: str, + environment_value: Optional[str], +) -> None: + """Preserve OGX's missing-variable error and nested configuration path.""" + variable = "LCORE_ENV_REFERENCE_TEST" + monkeypatch.delenv(variable, raising=False) + if environment_value is not None: + monkeypatch.setenv(variable, environment_value) + config = {"providers": [{"key": f"${{{reference_prefix}{variable}}}"}]} + original = copy.deepcopy(config) + + with pytest.raises(EnvVarError) as error: + resolve_env_references(config) + + assert error.value.var_name == variable + assert error.value.path == "providers[0].key" + assert config == original + + +def test_resolve_embedded_and_mixed_references( + monkeypatch: pytest.MonkeyPatch, reference_prefix: str +) -> None: + """Resolve nested references and numeric ports without changing keys or input.""" + monkeypatch.setenv("LCORE_ENV_REFERENCE_HOST", "example.test") + monkeypatch.setenv("LCORE_ENV_REFERENCE_PORT", "8443") + monkeypatch.setenv("LCORE_ENV_REFERENCE_KEY", "must-not-replace-key") + key = f"${{{reference_prefix}LCORE_ENV_REFERENCE_KEY}}" + config = { + key: [ + ( + f"https://${{{reference_prefix}LCORE_ENV_REFERENCE_HOST}}:" + "${env.LCORE_ENV_REFERENCE_PORT}/api" + ), + {"repeated": "${LCORE_ENV_REFERENCE_HOST}/${env.LCORE_ENV_REFERENCE_HOST}"}, + ], + "port": f"${{{reference_prefix}LCORE_ENV_REFERENCE_PORT}}", + "unchanged": [None, True, 42], + } + original = copy.deepcopy(config) + + assert resolve_env_references(config) == { + key: [ + "https://example.test:8443/api", + {"repeated": "example.test/example.test"}, + ], + "port": 8443, + "unchanged": [None, True, 42], + } + assert config == original + + +@pytest.mark.parametrize("environment_value", ["${OTHER}", "${env.OTHER}"]) +def test_resolve_does_not_expand_environment_values_again( + monkeypatch: pytest.MonkeyPatch, reference_prefix: str, environment_value: str +) -> None: + """Keep placeholder-like environment values literal, matching OGX's single pass.""" + monkeypatch.setenv("LCORE_ENV_REFERENCE_TEST", environment_value) + monkeypatch.setenv("OTHER", "must-not-expand") + + result = resolve_env_references(f"${{{reference_prefix}LCORE_ENV_REFERENCE_TEST}}") + + assert result == environment_value From 96f4bcc2775ef0fbfb5772a4f1537c80e3ff873b Mon Sep 17 00:00:00 2001 From: Jordan Dubrick Date: Mon, 19 Oct 2026 13:56:31 -0400 Subject: [PATCH 2/4] UIERFE-9: add focused environment reference process tests --- tests/conftest.py | 11 ++ tests/integration/test_unified_synthesis.py | 123 ++++++++++++++++++++ tests/unit/test_configuration.py | 57 +++++++++ tests/unit/test_ogx_configuration.py | 37 ++++++ 4 files changed, 228 insertions(+) create mode 100644 tests/conftest.py diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 000000000..62ca7745c --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,11 @@ +"""Fixtures shared by the unit and integration test suites.""" + +import pytest + + +@pytest.fixture( + name="reference_prefix", params=["", "env."], ids=["short", "canonical"] +) +def reference_prefix_fixture(request: pytest.FixtureRequest) -> str: + """Exercise both environment-reference spellings.""" + return str(request.param) diff --git a/tests/integration/test_unified_synthesis.py b/tests/integration/test_unified_synthesis.py index cc0fbe3cc..866d5cc71 100644 --- a/tests/integration/test_unified_synthesis.py +++ b/tests/integration/test_unified_synthesis.py @@ -602,3 +602,126 @@ def test_synthesized_config_output_override( assert Path(written) == custom_output assert isinstance(yaml.safe_load(custom_output.read_text(encoding="utf-8")), dict) assert not (tmp_path / constants.DEFAULT_SYNTHESIZED_CONFIG_PATH).exists() + + +@pytest.mark.parametrize("secret_is_set", [True, False], ids=["set", "unset"]) +def test_synthesis_defers_native_override_environment_references( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + reference_prefix: str, + secret_is_set: bool, +) -> None: + """Emit deferred references with secrets either set or absent during synthesis.""" + secret = "native-override-secret-must-stay-off-disk" + monkeypatch.delenv("AZURE_API_KEY", raising=False) + if secret_is_set: + monkeypatch.setenv("AZURE_API_KEY", secret) + override = copy.deepcopy(_OPERATOR_RUN_YAML) + override["providers"]["inference"][0]["config"][ + "api_key" + ] = f"${{{reference_prefix}AZURE_API_KEY}}" + config = _base_config_dict() + config["ogx"] = { + "use_as_library_client": True, + "config": {"baseline": "empty", "native_override": override}, + } + + synthesized, output = _load_and_synthesize(tmp_path, config) + + provider = synthesized["providers"]["inference"][0] + assert provider["config"]["api_key"] == "${env.AZURE_API_KEY}" + assert secret not in output.read_text(encoding="utf-8") + + +def test_synthesis_defers_inference_environment_references( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, reference_prefix: str +) -> None: + """Keep explicit API key and URL references canonical in generated config.""" + secret = "inference-secret-must-stay-off-disk" + base_url = "https://inference.example.test" + monkeypatch.setenv("LCORE_TEST_API_KEY", secret) + monkeypatch.setenv("LCORE_TEST_INFERENCE_URL", base_url) + config = _base_config_dict() + config["ogx"] = {"use_as_library_client": True, "config": {"baseline": "empty"}} + config["inference"] = { + "providers": [ + { + "type": "openai", + "extra": { + "api_key": f"${{{reference_prefix}LCORE_TEST_API_KEY}}", + "base_url": f"${{{reference_prefix}LCORE_TEST_INFERENCE_URL}}", + }, + } + ] + } + + synthesized, output = _load_and_synthesize(tmp_path, config) + + provider_config = synthesized["providers"]["inference"][0]["config"] + assert provider_config["api_key"] == "${env.LCORE_TEST_API_KEY}" + assert provider_config["base_url"] == "${env.LCORE_TEST_INFERENCE_URL}" + text = output.read_text(encoding="utf-8") + assert secret not in text + assert base_url not in text + + +@pytest.mark.parametrize("source", ["byok", "vector_store"]) +def test_synthesis_defers_pgvector_environment_references( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + reference_prefix: str, + source: str, +) -> None: + """Preserve canonical connection references through both pgvector enrichments.""" + variables = { + "host": ("LCORE_TEST_PG_HOST", "postgres.example.test"), + "port": ("LCORE_TEST_PG_PORT", "5432"), + "db": ("LCORE_TEST_PG_DATABASE", "knowledge"), + "user": ("LCORE_TEST_PG_USER", "lightspeed"), + "password": ("LCORE_TEST_PG_PASSWORD", "pg-secret-must-stay-off-disk"), + } + for variable, value in variables.values(): + monkeypatch.setenv(variable, value) + references = { + field: f"${{{reference_prefix}{variable}}}" + for field, (variable, _) in variables.items() + } + config = _base_config_dict() + config["ogx"] = {"use_as_library_client": True, "config": {"baseline": "empty"}} + if source == "byok": + config.setdefault("rag", {})["byok"] = { + "stores": [ + { + "rag_id": "pg", + "backend": "pgvector", + "vector_db_id": "knowledge-store", + "embedding_model": "test-model", + "embedding_dimension": 768, + **references, + } + ] + } + else: + config["vector_store"] = { + "default_provider": "pg", + "providers": [ + { + "id": "pg", + "type": "pgvector", + "embedding_model": "test-model", + "embedding_dimension": 768, + "config": references, + } + ], + } + + synthesized, output = _load_and_synthesize(tmp_path, config) + + provider = next( + provider + for provider in synthesized["providers"]["vector_io"] + if provider["provider_type"] == "remote::pgvector" + ) + for field, (variable, _) in variables.items(): + assert provider["config"][field] == f"${{env.{variable}}}" + assert variables["password"][1] not in output.read_text(encoding="utf-8") diff --git a/tests/unit/test_configuration.py b/tests/unit/test_configuration.py index 2f711a3aa..ce2539037 100644 --- a/tests/unit/test_configuration.py +++ b/tests/unit/test_configuration.py @@ -4245,3 +4245,60 @@ def test_replace_env_vars_without_native_override_resolves_all( config_dict = {"inference": {"default_model": "${env.LCORE_TEST_MODEL}"}} resolved = replace_env_vars_preserving_native_override(config_dict) assert resolved["inference"]["default_model"] == "gpt-5.6-luna" + + +def test_configuration_resolves_environment_reference_spellings( + monkeypatch: pytest.MonkeyPatch, reference_prefix: str +) -> None: + """Resolve either spelling through the configuration preprocessing helper.""" + monkeypatch.setenv("LCORE_TEST_MODEL", "gpt-4o-mini") + config = { + "inference": {"default_model": f"${{{reference_prefix}LCORE_TEST_MODEL}}"} + } + + resolved = replace_env_vars_preserving_native_override(config) + + assert resolved["inference"]["default_model"] == "gpt-4o-mini" + + +@pytest.mark.parametrize("ogx_key", ["ogx", "llama_stack"]) +@pytest.mark.parametrize("secret_is_set", [True, False], ids=["set", "unset"]) +def test_configuration_defers_native_override_environment_references( + monkeypatch: pytest.MonkeyPatch, + reference_prefix: str, + ogx_key: str, + secret_is_set: bool, +) -> None: + """Defer OGX references even when their secrets are absent from the environment.""" + secret = "native-override-secret" + monkeypatch.delenv("LCORE_TEST_SECRET", raising=False) + if secret_is_set: + monkeypatch.setenv("LCORE_TEST_SECRET", secret) + monkeypatch.setenv("LCORE_TEST_MODEL", "gpt-4o-mini") + config = { + "inference": {"default_model": f"${{{reference_prefix}LCORE_TEST_MODEL}}"}, + ogx_key: { + "config": { + "native_override": { + "providers": { + "inference": [ + { + "config": { + "api_key": f"${{{reference_prefix}LCORE_TEST_SECRET}}" + } + } + ] + } + } + } + }, + } + + resolved = replace_env_vars_preserving_native_override(config) + + api_key = resolved[ogx_key]["config"]["native_override"]["providers"]["inference"][ + 0 + ]["config"]["api_key"] + assert api_key == "${env.LCORE_TEST_SECRET}" + assert resolved["inference"]["default_model"] == "gpt-4o-mini" + assert secret not in str(resolved) diff --git a/tests/unit/test_ogx_configuration.py b/tests/unit/test_ogx_configuration.py index b9d021b4f..628ce1d65 100644 --- a/tests/unit/test_ogx_configuration.py +++ b/tests/unit/test_ogx_configuration.py @@ -1448,3 +1448,40 @@ def test_build_vector_io_config_rejects_unsupported_rag_type() -> None: """Test that an unsupported rag_type raises ValueError.""" with pytest.raises(ValueError, match="Unsupported rag_type 'remote::chromadb'"): _build_vector_io_config("remote::chromadb", "some_backend", {}) + + +def test_vector_store_deduplication_resolves_environment_references( + monkeypatch: pytest.MonkeyPatch, reference_prefix: str +) -> None: + """Compare resolved store IDs without replacing their deferred references.""" + monkeypatch.setenv("LCORE_TEST_STORE_ID", "existing-store") + reference = f"${{{reference_prefix}LCORE_TEST_STORE_ID}}" + existing = {"vector_store_id": reference, "provider_id": "faiss"} + config = {"registered_resources": {"vector_stores": [existing]}} + byok = [{"rag_id": "kb", "vector_db_id": "existing-store"}] + + stores = construct_vector_stores_section(config, byok) + + assert len(stores) == 1 + assert stores[0]["vector_store_id"] == reference + + +def test_solr_enrichment_resolves_environment_url_references( + monkeypatch: pytest.MonkeyPatch, reference_prefix: str +) -> None: + """Resolve either URL spelling before constructing the Solr endpoint.""" + monkeypatch.setenv("LCORE_TEST_OKP_URL", "https://okp.example.test") + config: dict[str, Any] = {} + + enrich_solr( + config, + _OKP_RAG_CONFIG, + {"rhokp_url": f"${{{reference_prefix}LCORE_TEST_OKP_URL}}"}, + ) + + provider = next( + provider + for provider in config["providers"]["vector_io"] + if provider["provider_id"] == "okp_solr" + ) + assert provider["config"]["solr_url"] == "https://okp.example.test/solr" From 46c78400163cc593248ae9fe1a20601e4f06d962 Mon Sep 17 00:00:00 2001 From: Jordan Dubrick Date: Mon, 19 Oct 2026 14:17:37 -0400 Subject: [PATCH 3/4] UIERFE-9: support short environment references in configuration paths --- src/configuration.py | 23 ++++++++++------------- src/ogx_configuration.py | 15 ++++++++++----- 2 files changed, 20 insertions(+), 18 deletions(-) diff --git a/src/configuration.py b/src/configuration.py index 9298f9e44..d954a309a 100644 --- a/src/configuration.py +++ b/src/configuration.py @@ -4,10 +4,6 @@ import yaml -# We want to support environment variable replacement in the configuration -# similarly to how it is done in OGX, so we use their function directly -from ogx.core.stack import replace_env_vars - import constants from cache.cache import Cache from cache.cache_factory import CacheFactory @@ -40,6 +36,7 @@ from quota.quota_limiter import QuotaLimiter from quota.quota_limiter_factory import QuotaLimiterFactory from quota.token_usage_history import TokenUsageHistory +from utils.env_vars import normalize_env_references, resolve_env_references logger = get_logger(__name__) @@ -47,18 +44,18 @@ def replace_env_vars_preserving_native_override( config_dict: dict[Any, Any], ) -> dict[Any, Any]: - """Resolve ${env.*} references in the config, except in native_override. + """Resolve ${env.VAR} and ${VAR} references, except in native_override. LCORE resolves environment-variable references throughout lightspeed-stack.yaml so typed fields receive concrete values. But ``ogx.config.native_override`` is raw OGX schema that OGX resolves itself, in memory, at its own startup. Resolving it eagerly - here would (a) defeat the ${env.*} pattern LCORE recommends for secrets and + here would (a) defeat the ${env.VAR} and ${VAR} patterns for secrets and (b) pull resolved secrets into the loaded Configuration model, which is logged at startup. So native_override is held aside, the rest of the config - is resolved, and the raw (unresolved) native_override is restored verbatim. - Synthesis reads native_override from the raw YAML, so this does not change - the generated run.yaml. + is resolved, and native_override is restored with ${VAR} normalized to + ${env.VAR} without resolving it. Synthesis reads native_override from the + raw YAML and applies the same normalization to the generated run.yaml. Parameters: config_dict: The parsed lightspeed-stack.yaml. @@ -69,24 +66,24 @@ def replace_env_vars_preserving_native_override( """ if not isinstance(config_dict, dict): - return replace_env_vars(config_dict) + return resolve_env_references(config_dict) ogx_section = config_dict.get("ogx") if ogx_section is None: ogx_section = config_dict.get("llama_stack") ogx_config = ogx_section.get("config") if isinstance(ogx_section, dict) else None if not (isinstance(ogx_config, dict) and "native_override" in ogx_config): - return replace_env_vars(config_dict) + return resolve_env_references(config_dict) raw_override = ogx_config["native_override"] ogx_config["native_override"] = {} # keep secrets out of env resolution - resolved = replace_env_vars(config_dict) + resolved = resolve_env_references(config_dict) ogx_config["native_override"] = raw_override # restore source dict if reused resolved_ogx = (resolved.get("ogx") or resolved.get("llama_stack") or {}).get( "config" ) if isinstance(resolved_ogx, dict): - resolved_ogx["native_override"] = raw_override + resolved_ogx["native_override"] = normalize_env_references(raw_override) return resolved diff --git a/src/ogx_configuration.py b/src/ogx_configuration.py index 85a94cb9f..970adc66d 100644 --- a/src/ogx_configuration.py +++ b/src/ogx_configuration.py @@ -26,11 +26,11 @@ from urllib.parse import urljoin import yaml -from ogx.core.stack import replace_env_vars from pydantic import SecretStr import constants from log import get_logger, setup_logging +from utils.env_vars import normalize_env_references, resolve_env_references logger = get_logger(__name__) @@ -315,10 +315,10 @@ def construct_vector_stores_section( output = ogx_config["registered_resources"]["vector_stores"].copy() # append new vector_stores entries, skipping duplicates - # Resolve ${env.VAR} patterns so comparisons work when existing entries + # Resolve ${env.VAR} and ${VAR} so comparisons work when existing entries # use environment variable references and new entries have resolved values. existing_store_ids = { - replace_env_vars(vs.get("vector_store_id", "")) for vs in output + resolve_env_references(vs.get("vector_store_id", "")) for vs in output } added = 0 for brag in byok_rag: @@ -879,8 +879,8 @@ def enrich_solr( # pylint: disable=too-many-locals,too-many-statements,too-many base_url_raw = ( str(rhokp_raw) if rhokp_raw is not None else constants.RH_SERVER_OKP_DEFAULT_URL ) - # Resolve environment variables in the URL (e.g., ${env.RH_SERVER_OKP}) - base_url = replace_env_vars(base_url_raw) + # Resolve ${env.RH_SERVER_OKP} and ${RH_SERVER_OKP} before joining the URL path. + base_url = resolve_env_references(base_url_raw) solr_url = urljoin(base_url, "/solr") logger.info("Enriching OGX config with OKP") @@ -1449,6 +1449,8 @@ def synthesize_configuration( # pylint: disable=too-many-locals dict[str, Any]: The synthesized OGX configuration. """ + # Keep raw references deferred for OGX startup while canonicalizing their syntax. + lcs_config = normalize_env_references(lcs_config) unified = ogx_config_section(lcs_config).get("config") # 1-2. Select the baseline. @@ -1682,6 +1684,9 @@ def generate_configuration( """ logger.info("Reading OGX configuration from file %s", input_file) + # Normalize raw LCORE enrichment references without looking up their values. + config = normalize_env_references(config) + with open(input_file, encoding="utf-8") as file: ogx_config = yaml.safe_load(file) From 015e44f0a38878572c1f70b02719ecde0f24269a Mon Sep 17 00:00:00 2001 From: Jordan Dubrick Date: Mon, 19 Oct 2026 14:51:20 -0400 Subject: [PATCH 4/4] UIERFE-9: document both environment reference syntaxes --- docs/user_doc/deployment_guide.md | 36 ++++++++++++++++++++++++++++++- 1 file changed, 35 insertions(+), 1 deletion(-) diff --git a/docs/user_doc/deployment_guide.md b/docs/user_doc/deployment_guide.md index 840a904f8..e03cc2f72 100644 --- a/docs/user_doc/deployment_guide.md +++ b/docs/user_doc/deployment_guide.md @@ -4,6 +4,8 @@ * [Preface](#preface) * [Deployment methods](#deployment-methods) +* [Configuration modes](#configuration-modes) + * [Environment variable references](#environment-variable-references) * [Integration with OGX framework](#integration-with-ogx-framework) * [OGX as a library](#ogx-as-a-library) * [OGX as a server](#ogx-as-a-server) @@ -108,6 +110,38 @@ fails if a unified synthesis input and `library_client_config_path` are both present. +### Environment variable references + +`lightspeed-stack.yaml` accepts `${VAR}` wherever `${env.VAR}` is supported. +Both forms behave the same. Variable names contain one or more uppercase +letters, digits, or underscores. + +Both spellings support these forms: + +- `${VAR}` or `${env.VAR}` uses the variable's value. Resolution fails if the + variable is missing or empty. +- `${VAR:=default}` or `${env.VAR:=default}` uses `default` when the variable + is missing or empty. +- `${VAR:+value}` or `${env.VAR:+value}` uses `value` when the variable is + non-empty. Otherwise, it substitutes an empty string. + +The shell-style `:-` default operator is not supported. OGX converts resolved +numeric strings to numbers and boolean strings to booleans. An empty result +becomes `null`. + +Both references below use defaults if their variables are missing or empty: + +```yaml +service: + host: "${SERVICE_HOST:=0.0.0.0}" + port: "${env.SERVICE_PORT:=8080}" +``` + +LCORE resolves its configuration values while loading. For deferred values, +including those in `ogx.config.native_override`, LCORE writes `${env.VAR}` +references to the generated `run.yaml`. OGX resolves them at startup. + + ## Integration with OGX framework @@ -283,7 +317,7 @@ llama_stack: ``` 3. Replace literal secrets. If your `run.yaml` contained secret values - directly, replace them with `${env.MY_VAR}` environment references — + directly, replace them with `${MY_VAR}` or `${env.MY_VAR}` environment references — the migrated file otherwise carries them onto disk verbatim (the synthesized output is written owner-only, mode 0600, as a safety net).