Merge pull request #434 from ggozad/feat/secrets-from-env
Expand ${VAR} environment references in YAML config
This commit is contained in:
commit
77eb2fc2f9
5 changed files with 191 additions and 2 deletions
|
|
@ -4,6 +4,7 @@
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
- `ingester.api.root_path` (and `haiku-ingester serve --root-path`) serves the control plane under a sub-path for reverse-proxying; forwarded to FastAPI/uvicorn `root_path` and reflected in the dashboard's `<base href>`.
|
- `ingester.api.root_path` (and `haiku-ingester serve --root-path`) serves the control plane under a sub-path for reverse-proxying; forwarded to FastAPI/uvicorn `root_path` and reflected in the dashboard's `<base href>`.
|
||||||
|
- YAML config string values support `${VAR}` / `${VAR:-default}` environment-variable interpolation, expanded at load time. `${VAR}` referencing an unset variable raises `MissingEnvVarError`; `$$` is a literal `$`.
|
||||||
|
|
||||||
## [0.56.0] - 2026-06-09
|
## [0.56.0] - 2026-06-09
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,22 @@ This creates a `haiku.rag.yaml` file in your current directory with all availabl
|
||||||
- **macOS**: `~/Library/Application Support/haiku.rag/haiku.rag.yaml`
|
- **macOS**: `~/Library/Application Support/haiku.rag/haiku.rag.yaml`
|
||||||
- **Windows**: `C:/Users/<USER>/AppData/Roaming/haiku.rag/haiku.rag.yaml`
|
- **Windows**: `C:/Users/<USER>/AppData/Roaming/haiku.rag/haiku.rag.yaml`
|
||||||
|
|
||||||
|
## Environment Variables
|
||||||
|
|
||||||
|
Any string value can reference an environment variable, so secrets stay out of the file and one config can serve multiple deployments:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
ingester:
|
||||||
|
queue:
|
||||||
|
dburi: postgresql+asyncpg://haiku:${POSTGRES_PASSWORD}@db:5432/haiku_rag
|
||||||
|
```
|
||||||
|
|
||||||
|
- `${VAR}` is replaced with the value of `VAR`. If `VAR` is unset, loading fails with an error naming the variable.
|
||||||
|
- `${VAR:-default}` uses `default` when `VAR` is unset or empty.
|
||||||
|
- `$$` produces a literal `$`.
|
||||||
|
|
||||||
|
Substitution happens after the YAML is parsed, so a value containing `:`, `@`, or `#` fills the string verbatim and never changes the document structure.
|
||||||
|
|
||||||
## Minimal Configuration
|
## Minimal Configuration
|
||||||
|
|
||||||
A minimal configuration file with defaults:
|
A minimal configuration file with defaults:
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,6 @@
|
||||||
from haiku.rag.config.loader import (
|
from haiku.rag.config.loader import (
|
||||||
|
MissingEnvVarError,
|
||||||
|
expand_env_vars,
|
||||||
find_config_file,
|
find_config_file,
|
||||||
generate_default_config,
|
generate_default_config,
|
||||||
load_yaml_config,
|
load_yaml_config,
|
||||||
|
|
@ -57,6 +59,8 @@ __all__ = [
|
||||||
"StorageConfig",
|
"StorageConfig",
|
||||||
"WebDAVSourceConfig",
|
"WebDAVSourceConfig",
|
||||||
"WorkerConfig",
|
"WorkerConfig",
|
||||||
|
"MissingEnvVarError",
|
||||||
|
"expand_env_vars",
|
||||||
"find_config_file",
|
"find_config_file",
|
||||||
"generate_default_config",
|
"generate_default_config",
|
||||||
"get_config",
|
"get_config",
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
|
@ -7,6 +8,41 @@ import yaml
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_ENV_VAR_PATTERN = re.compile(r"\$\$|\$\{([A-Za-z_][A-Za-z0-9_]*)(?::-([^}]*))?\}")
|
||||||
|
|
||||||
|
|
||||||
|
class MissingEnvVarError(ValueError):
|
||||||
|
"""A ${VAR} in the config references an unset environment variable."""
|
||||||
|
|
||||||
|
|
||||||
|
def _expand_str(value: str) -> str:
|
||||||
|
def replace(match: re.Match[str]) -> str:
|
||||||
|
if match.group(0) == "$$":
|
||||||
|
return "$"
|
||||||
|
name, default = match.group(1), match.group(2)
|
||||||
|
if name in os.environ and (default is None or os.environ[name] != ""):
|
||||||
|
return os.environ[name]
|
||||||
|
if default is not None:
|
||||||
|
return default
|
||||||
|
raise MissingEnvVarError(
|
||||||
|
f"Config references unset environment variable ${{{name}}}. "
|
||||||
|
f"Set it, or use ${{{name}:-default}} to provide a fallback."
|
||||||
|
)
|
||||||
|
|
||||||
|
return _ENV_VAR_PATTERN.sub(replace, value)
|
||||||
|
|
||||||
|
|
||||||
|
def expand_env_vars(data: Any) -> Any:
|
||||||
|
"""Recursively expand ${VAR} / ${VAR:-default} references in string values.
|
||||||
|
Keys and non-string scalars are left untouched; $$ collapses to a literal $."""
|
||||||
|
if isinstance(data, dict):
|
||||||
|
return {key: expand_env_vars(value) for key, value in data.items()}
|
||||||
|
if isinstance(data, list):
|
||||||
|
return [expand_env_vars(item) for item in data]
|
||||||
|
if isinstance(data, str):
|
||||||
|
return _expand_str(data)
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
def find_config_file(cli_path: Path | None = None) -> Path | None:
|
def find_config_file(cli_path: Path | None = None) -> Path | None:
|
||||||
"""Find the YAML config file using the search path.
|
"""Find the YAML config file using the search path.
|
||||||
|
|
@ -45,10 +81,10 @@ def find_config_file(cli_path: Path | None = None) -> Path | None:
|
||||||
|
|
||||||
|
|
||||||
def load_yaml_config(path: Path) -> dict:
|
def load_yaml_config(path: Path) -> dict:
|
||||||
"""Load and parse a YAML config file."""
|
"""Load and parse a YAML config file, expanding ${VAR} references."""
|
||||||
with open(path) as f:
|
with open(path) as f:
|
||||||
data = yaml.safe_load(f)
|
data = yaml.safe_load(f)
|
||||||
return data or {}
|
return expand_env_vars(data or {})
|
||||||
|
|
||||||
|
|
||||||
def generate_default_config() -> dict:
|
def generate_default_config() -> dict:
|
||||||
|
|
|
||||||
|
|
@ -374,3 +374,135 @@ def test_redact_secrets_masks_nested_secret_keys():
|
||||||
assert redacted["sources"][0]["password"] == "***"
|
assert redacted["sources"][0]["password"] == "***"
|
||||||
assert redacted["sources"][0]["url"] == "http://x"
|
assert redacted["sources"][0]["url"] == "http://x"
|
||||||
assert redacted["storage_options"]["aws_secret_access_key"] == "***"
|
assert redacted["storage_options"]["aws_secret_access_key"] == "***"
|
||||||
|
|
||||||
|
|
||||||
|
def test_expand_env_var_set(tmp_path, monkeypatch):
|
||||||
|
"""A ${VAR} referencing a set variable is substituted."""
|
||||||
|
monkeypatch.setenv("HAIKU_TEST_MODEL", "my-model")
|
||||||
|
config_file = tmp_path / "test.yaml"
|
||||||
|
config_file.write_text("""
|
||||||
|
embeddings:
|
||||||
|
model:
|
||||||
|
name: ${HAIKU_TEST_MODEL}
|
||||||
|
""")
|
||||||
|
|
||||||
|
config = load_yaml_config(config_file)
|
||||||
|
assert config["embeddings"]["model"]["name"] == "my-model"
|
||||||
|
|
||||||
|
|
||||||
|
def test_expand_env_var_dburi_preserves_special_chars(tmp_path, monkeypatch):
|
||||||
|
"""A password containing : and @ fills the string without breaking the URL."""
|
||||||
|
monkeypatch.setenv("HAIKU_TEST_PGPW", "p@ss:word")
|
||||||
|
config_file = tmp_path / "test.yaml"
|
||||||
|
config_file.write_text("""
|
||||||
|
ingester:
|
||||||
|
queue:
|
||||||
|
dburi: "postgresql+asyncpg://user:${HAIKU_TEST_PGPW}@host/db"
|
||||||
|
""")
|
||||||
|
|
||||||
|
config = load_yaml_config(config_file)
|
||||||
|
assert (
|
||||||
|
config["ingester"]["queue"]["dburi"]
|
||||||
|
== "postgresql+asyncpg://user:p@ss:word@host/db"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_expand_env_var_unset_raises(tmp_path, monkeypatch):
|
||||||
|
"""An unset ${VAR} without a default raises, naming the variable."""
|
||||||
|
from haiku.rag.config.loader import MissingEnvVarError
|
||||||
|
|
||||||
|
monkeypatch.delenv("HAIKU_TEST_MISSING", raising=False)
|
||||||
|
config_file = tmp_path / "test.yaml"
|
||||||
|
config_file.write_text("environment: ${HAIKU_TEST_MISSING}")
|
||||||
|
|
||||||
|
with pytest.raises(MissingEnvVarError, match="HAIKU_TEST_MISSING"):
|
||||||
|
load_yaml_config(config_file)
|
||||||
|
|
||||||
|
|
||||||
|
def test_expand_env_var_default_when_unset(tmp_path, monkeypatch):
|
||||||
|
"""${VAR:-default} falls back to the default when VAR is unset."""
|
||||||
|
monkeypatch.delenv("HAIKU_TEST_MISSING", raising=False)
|
||||||
|
config_file = tmp_path / "test.yaml"
|
||||||
|
config_file.write_text("environment: ${HAIKU_TEST_MISSING:-production}")
|
||||||
|
|
||||||
|
config = load_yaml_config(config_file)
|
||||||
|
assert config["environment"] == "production"
|
||||||
|
|
||||||
|
|
||||||
|
def test_expand_env_var_default_when_empty(tmp_path, monkeypatch):
|
||||||
|
"""${VAR:-default} falls back to the default when VAR is set but empty."""
|
||||||
|
monkeypatch.setenv("HAIKU_TEST_EMPTY", "")
|
||||||
|
config_file = tmp_path / "test.yaml"
|
||||||
|
config_file.write_text("environment: ${HAIKU_TEST_EMPTY:-production}")
|
||||||
|
|
||||||
|
config = load_yaml_config(config_file)
|
||||||
|
assert config["environment"] == "production"
|
||||||
|
|
||||||
|
|
||||||
|
def test_expand_env_var_default_overridden_when_set(tmp_path, monkeypatch):
|
||||||
|
"""${VAR:-default} uses the variable when it is set and non-empty."""
|
||||||
|
monkeypatch.setenv("HAIKU_TEST_ENV", "staging")
|
||||||
|
config_file = tmp_path / "test.yaml"
|
||||||
|
config_file.write_text("environment: ${HAIKU_TEST_ENV:-production}")
|
||||||
|
|
||||||
|
config = load_yaml_config(config_file)
|
||||||
|
assert config["environment"] == "staging"
|
||||||
|
|
||||||
|
|
||||||
|
def test_expand_env_var_dollar_escape(tmp_path):
|
||||||
|
"""$$ collapses to a literal $, leaving ${...} text intact."""
|
||||||
|
config_file = tmp_path / "test.yaml"
|
||||||
|
config_file.write_text("environment: $${NOT_A_VAR}")
|
||||||
|
|
||||||
|
config = load_yaml_config(config_file)
|
||||||
|
assert config["environment"] == "${NOT_A_VAR}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_expand_env_var_nested_in_list_and_dict(tmp_path, monkeypatch):
|
||||||
|
"""Expansion recurses through lists and nested dicts."""
|
||||||
|
monkeypatch.setenv("HAIKU_TEST_TOKEN", "abc123")
|
||||||
|
monkeypatch.setenv("HAIKU_TEST_KEY", "AKIA")
|
||||||
|
config_file = tmp_path / "test.yaml"
|
||||||
|
config_file.write_text("""
|
||||||
|
ingester:
|
||||||
|
sources:
|
||||||
|
- type: http
|
||||||
|
id: arxiv
|
||||||
|
urls:
|
||||||
|
- https://example.com/${HAIKU_TEST_TOKEN}.pdf
|
||||||
|
storage_options:
|
||||||
|
aws_access_key_id: ${HAIKU_TEST_KEY}
|
||||||
|
""")
|
||||||
|
|
||||||
|
config = load_yaml_config(config_file)
|
||||||
|
source = config["ingester"]["sources"][0]
|
||||||
|
assert source["urls"][0] == "https://example.com/abc123.pdf"
|
||||||
|
assert source["storage_options"]["aws_access_key_id"] == "AKIA"
|
||||||
|
|
||||||
|
|
||||||
|
def test_expand_env_var_leaves_non_strings_untouched(tmp_path):
|
||||||
|
"""Non-string scalars pass through unchanged."""
|
||||||
|
config_file = tmp_path / "test.yaml"
|
||||||
|
config_file.write_text("""
|
||||||
|
embeddings:
|
||||||
|
model:
|
||||||
|
vector_dim: 1024
|
||||||
|
ingester:
|
||||||
|
sources:
|
||||||
|
- type: s3
|
||||||
|
storage_options:
|
||||||
|
allow_http: true
|
||||||
|
""")
|
||||||
|
|
||||||
|
config = load_yaml_config(config_file)
|
||||||
|
assert config["embeddings"]["model"]["vector_dim"] == 1024
|
||||||
|
assert config["ingester"]["sources"][0]["storage_options"]["allow_http"] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_expand_env_var_plain_string_unchanged(tmp_path):
|
||||||
|
"""A string with no ${...} reference is returned as-is."""
|
||||||
|
config_file = tmp_path / "test.yaml"
|
||||||
|
config_file.write_text("environment: production")
|
||||||
|
|
||||||
|
config = load_yaml_config(config_file)
|
||||||
|
assert config["environment"] == "production"
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue