457 lines
17 KiB
Python
457 lines
17 KiB
Python
"""Tests for the config_version stamp and version-gated config migrations.
|
|
|
|
migrate_config_payload runs two classes of transforms: always-run compat
|
|
normalizations (deprecated-field strips, renames, range clamps) and
|
|
version-gated one-time value migrations walked from ``_MIGRATIONS``. Every
|
|
returned payload is stamped with ``LATEST_CONFIG_VERSION``, but stamping
|
|
alone never marks the result as changed, so a current config file is never
|
|
rewritten just to receive the stamp.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import tomllib
|
|
import warnings
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
import pytest
|
|
import tomli_w
|
|
|
|
import opensquilla.gateway.config_migration as migration_module
|
|
import opensquilla.gateway.rpc_config # noqa: F401 ensures RPC registration
|
|
from opensquilla.gateway.auth import Principal
|
|
from opensquilla.gateway.config import GatewayConfig
|
|
from opensquilla.gateway.config_migration import (
|
|
LATEST_CONFIG_VERSION,
|
|
migrate_config_payload,
|
|
)
|
|
from opensquilla.gateway.rpc import RpcContext, get_dispatcher
|
|
from opensquilla.onboarding import config_store
|
|
from opensquilla.search.types import MAX_SEARCH_RESULTS
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
_LEGACY_ENSEMBLE_TIMEOUTS = {
|
|
"enabled": True,
|
|
"proposer_timeout_seconds": 300.0,
|
|
"aggregator_timeout_seconds": 300.0,
|
|
}
|
|
|
|
|
|
def _write_toml(path: Path, payload: dict[str, Any]) -> Path:
|
|
with path.open("wb") as fh:
|
|
tomli_w.dump(payload, fh)
|
|
return path
|
|
|
|
|
|
def _reset_legacy_warn_state(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
|
monkeypatch.setattr(migration_module, "_LEGACY_MEMORY_FIELDS_WARNED", False)
|
|
monkeypatch.setattr(migration_module, "_LEGACY_MEMORY_FIELDS_SEEN", set())
|
|
monkeypatch.setattr(migration_module, "_LEGACY_AGENT_TOKEN_SAVING_FIELDS_WARNED", False)
|
|
monkeypatch.setattr(migration_module, "_LEGACY_AGENT_TOKEN_SAVING_FIELDS_SEEN", set())
|
|
# Keep legacy-field detail logs inside tmp_path.
|
|
monkeypatch.setattr(migration_module, "default_opensquilla_home", lambda: tmp_path)
|
|
|
|
|
|
def _admin_ctx(config: GatewayConfig) -> RpcContext:
|
|
return RpcContext(
|
|
conn_id="t",
|
|
config=config,
|
|
principal=Principal(
|
|
role="operator",
|
|
scopes=frozenset({"operator.admin"}),
|
|
is_owner=True,
|
|
authenticated=True,
|
|
),
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Framework consistency
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_config_version_field_default_matches_latest_constant() -> None:
|
|
"""GatewayConfig.config_version and LATEST_CONFIG_VERSION must agree."""
|
|
assert GatewayConfig.model_fields["config_version"].default == LATEST_CONFIG_VERSION
|
|
assert GatewayConfig().config_version == LATEST_CONFIG_VERSION
|
|
|
|
|
|
def test_migration_walk_is_strictly_increasing_and_capped_at_latest() -> None:
|
|
versions = [version for version, _ in migration_module._MIGRATIONS]
|
|
assert versions == sorted(versions)
|
|
assert len(set(versions)) == len(versions)
|
|
assert versions[-1] == LATEST_CONFIG_VERSION
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Stamping without change
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_unstamped_current_payload_is_stamped_but_unchanged(tmp_path: Path) -> None:
|
|
payload = {
|
|
"host": "127.0.0.1",
|
|
"port": 18791,
|
|
"memory": {"capture_mode": "turn_pair"},
|
|
"llm_ensemble": {"enabled": False},
|
|
}
|
|
|
|
result = migrate_config_payload(payload)
|
|
|
|
assert result.changed is False
|
|
assert result.changes == ()
|
|
assert result.removed_fields == ()
|
|
assert result.payload["config_version"] == LATEST_CONFIG_VERSION
|
|
cfg = GatewayConfig.model_validate(result.payload)
|
|
assert cfg.config_version == LATEST_CONFIG_VERSION
|
|
|
|
# A load of the same payload from disk must not rewrite the file just to
|
|
# persist the stamp.
|
|
toml_path = _write_toml(tmp_path / "config.toml", payload)
|
|
original_bytes = toml_path.read_bytes()
|
|
loaded = GatewayConfig.load(toml_path)
|
|
assert loaded.config_version == LATEST_CONFIG_VERSION
|
|
assert toml_path.read_bytes() == original_bytes
|
|
assert "config_version" not in toml_path.read_text(encoding="utf-8")
|
|
assert not list(tmp_path.glob("config.toml.backup.*"))
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Version-gated migration: llm_ensemble legacy timeout bump
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_unstamped_legacy_ensemble_timeouts_bump_exactly_once() -> None:
|
|
result = migrate_config_payload({"llm_ensemble": dict(_LEGACY_ENSEMBLE_TIMEOUTS)})
|
|
|
|
assert result.changed is True
|
|
assert result.payload["llm_ensemble"]["proposer_timeout_seconds"] == 3600.0
|
|
assert result.payload["llm_ensemble"]["aggregator_timeout_seconds"] == 3600.0
|
|
assert result.payload["config_version"] == LATEST_CONFIG_VERSION
|
|
|
|
# Feeding the stamped result back through must be a no-op.
|
|
second = migrate_config_payload(result.payload)
|
|
assert second.changed is False
|
|
assert second.payload == result.payload
|
|
|
|
|
|
def test_stamped_payload_skips_ensemble_timeout_bump() -> None:
|
|
"""Post-migration, an operator deliberately re-setting 300/300 keeps it."""
|
|
result = migrate_config_payload(
|
|
{
|
|
"config_version": LATEST_CONFIG_VERSION,
|
|
"llm_ensemble": dict(_LEGACY_ENSEMBLE_TIMEOUTS),
|
|
}
|
|
)
|
|
|
|
assert result.changed is False
|
|
assert result.payload["llm_ensemble"]["proposer_timeout_seconds"] == 300.0
|
|
assert result.payload["llm_ensemble"]["aggregator_timeout_seconds"] == 300.0
|
|
|
|
|
|
def test_stamped_file_load_keeps_deliberate_legacy_timeouts(tmp_path: Path) -> None:
|
|
toml_path = _write_toml(
|
|
tmp_path / "config.toml",
|
|
{
|
|
"config_version": LATEST_CONFIG_VERSION,
|
|
"llm_ensemble": dict(_LEGACY_ENSEMBLE_TIMEOUTS),
|
|
},
|
|
)
|
|
|
|
cfg = GatewayConfig.load(toml_path)
|
|
|
|
assert cfg.llm_ensemble.proposer_timeout_seconds == 300.0
|
|
assert cfg.llm_ensemble.aggregator_timeout_seconds == 300.0
|
|
assert not list(tmp_path.glob("config.toml.backup.*"))
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Always-run compat normalizations still fire on stamped payloads
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_stamped_payload_still_strips_deprecated_memory_field() -> None:
|
|
result = migrate_config_payload(
|
|
{
|
|
"config_version": LATEST_CONFIG_VERSION,
|
|
"memory": {"index_captured_turns": False},
|
|
}
|
|
)
|
|
|
|
assert result.changed is True
|
|
assert "memory.index_captured_turns" in result.removed_fields
|
|
cfg = GatewayConfig.model_validate(result.payload)
|
|
assert cfg.config_version == LATEST_CONFIG_VERSION
|
|
|
|
|
|
def test_stamped_payload_still_normalizes_capture_mode_and_clamps_search() -> None:
|
|
result = migrate_config_payload(
|
|
{
|
|
"config_version": LATEST_CONFIG_VERSION,
|
|
"memory": {"capture_mode": "archive_turn_pair"},
|
|
"search_max_results": MAX_SEARCH_RESULTS + 100,
|
|
}
|
|
)
|
|
|
|
assert result.changed is True
|
|
assert result.payload["memory"]["capture_mode"] == "turn_pair"
|
|
assert result.payload["search_max_results"] == MAX_SEARCH_RESULTS
|
|
GatewayConfig.model_validate(result.payload)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Env exclusion: the payload stamp always outranks the environment
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_env_config_version_never_overrides_payload_stamp(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
monkeypatch.setenv("OPENSQUILLA_GATEWAY_CONFIG_VERSION", "99")
|
|
toml_path = _write_toml(tmp_path / "config.toml", {"port": 18791})
|
|
|
|
assert GatewayConfig.load_from_toml(toml_path).config_version == LATEST_CONFIG_VERSION
|
|
assert GatewayConfig.load(toml_path).config_version == LATEST_CONFIG_VERSION
|
|
assert config_store.load_config(toml_path).config_version == LATEST_CONFIG_VERSION
|
|
|
|
# A persist must write the real stamp, never the env value.
|
|
cfg = config_store.load_config(toml_path)
|
|
persist_target = tmp_path / "persisted.toml"
|
|
config_store.persist_config(cfg, path=persist_target, backup=False)
|
|
text = persist_target.read_text(encoding="utf-8")
|
|
assert "config_version = 1" in text
|
|
assert "config_version = 99" not in text
|
|
|
|
|
|
def test_env_config_version_is_excluded_for_bare_construction(
|
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
) -> None:
|
|
"""Bare GatewayConfig() has no payload, so the env var is filtered at the
|
|
settings-source level (see _EnvWithoutConfigVersion in gateway/config.py);
|
|
without that filter OPENSQUILLA_GATEWAY_CONFIG_VERSION would leak into the
|
|
no-file default branches of GatewayConfig.load and config_store.load_config.
|
|
"""
|
|
monkeypatch.setenv("OPENSQUILLA_GATEWAY_CONFIG_VERSION", "99")
|
|
|
|
assert GatewayConfig().config_version == LATEST_CONFIG_VERSION
|
|
assert (
|
|
config_store.load_config(tmp_path / "missing.toml").config_version
|
|
== LATEST_CONFIG_VERSION
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Persist round-trip
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_round_trip_persists_stamp_and_stays_unchanged(tmp_path: Path) -> None:
|
|
toml_path = _write_toml(tmp_path / "config.toml", {"port": 18791})
|
|
|
|
cfg = config_store.load_config(toml_path)
|
|
toml_dict = config_store._config_to_toml_dict(cfg)
|
|
assert toml_dict["config_version"] == LATEST_CONFIG_VERSION
|
|
assert cfg.to_toml_dict()["config_version"] == LATEST_CONFIG_VERSION
|
|
|
|
persist_target = tmp_path / "roundtrip.toml"
|
|
config_store.persist_config(cfg, path=persist_target, backup=False)
|
|
|
|
reloaded = config_store.load_config(persist_target)
|
|
assert reloaded.config_version == LATEST_CONFIG_VERSION
|
|
with persist_target.open("rb") as fh:
|
|
raw = tomllib.load(fh)
|
|
assert migrate_config_payload(raw).changed is False
|
|
|
|
|
|
def test_migrating_load_writes_stamp_into_rewritten_file(tmp_path: Path) -> None:
|
|
"""When a real migration fires, the rewritten file must carry the stamp;
|
|
a missed stamp would re-run the one-time migration on every future load."""
|
|
toml_path = _write_toml(
|
|
tmp_path / "config.toml",
|
|
{"llm_ensemble": dict(_LEGACY_ENSEMBLE_TIMEOUTS)},
|
|
)
|
|
|
|
cfg = GatewayConfig.load(toml_path)
|
|
|
|
assert cfg.llm_ensemble.proposer_timeout_seconds == 3600.0
|
|
assert list(tmp_path.glob("config.toml.backup.*"))
|
|
text = toml_path.read_text(encoding="utf-8")
|
|
assert "config_version = 1" in text
|
|
assert "proposer_timeout_seconds = 3600.0" in text
|
|
|
|
# The stamped file must load cleanly with no further rewrite.
|
|
stamped_bytes = toml_path.read_bytes()
|
|
again = GatewayConfig.load(toml_path)
|
|
assert again.llm_ensemble.proposer_timeout_seconds == 3600.0
|
|
assert toml_path.read_bytes() == stamped_bytes
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Idempotency across fixture shapes
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"payload",
|
|
[
|
|
{"port": 18791},
|
|
{"llm_ensemble": dict(_LEGACY_ENSEMBLE_TIMEOUTS)},
|
|
{"memory": {"capture_mode": "archive_turn_pair", "index_captured_turns": True}},
|
|
{"memory": {"prefetch_enabled": True, "cost": {"embedding_cache": "true"}}},
|
|
{
|
|
"agent_token_saving": {
|
|
"tool_result_compression_enabled": False,
|
|
"tool_result_compression_summary_input_max_chars": 43210,
|
|
}
|
|
},
|
|
{"search_max_results": MAX_SEARCH_RESULTS + 5},
|
|
],
|
|
ids=[
|
|
"clean",
|
|
"ensemble-timeouts",
|
|
"memory-capture",
|
|
"memory-deprecated",
|
|
"token-saving",
|
|
"search-clamp",
|
|
],
|
|
)
|
|
def test_migrate_twice_equals_migrate_once(
|
|
payload: dict[str, Any], tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
_reset_legacy_warn_state(monkeypatch, tmp_path)
|
|
|
|
with warnings.catch_warnings():
|
|
warnings.simplefilter("always")
|
|
once = migrate_config_payload(payload)
|
|
twice = migrate_config_payload(once.payload)
|
|
|
|
assert once.payload["config_version"] == LATEST_CONFIG_VERSION
|
|
assert twice.changed is False
|
|
assert twice.payload == once.payload
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# RPC guard: config_version is read-only for clients
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
async def test_config_set_rejects_config_version(tmp_path: Path) -> None:
|
|
cfg = GatewayConfig(config_path=str(tmp_path / "c.toml"))
|
|
|
|
res = await get_dispatcher().dispatch(
|
|
"r1",
|
|
"config.set",
|
|
{"path": "config_version", "value": 99},
|
|
_admin_ctx(cfg),
|
|
)
|
|
|
|
assert res.error is not None
|
|
assert "read-only" in res.error.message
|
|
assert cfg.config_version == LATEST_CONFIG_VERSION
|
|
|
|
|
|
async def test_config_patch_skips_config_version(tmp_path: Path) -> None:
|
|
config_path = tmp_path / "c.toml"
|
|
cfg = GatewayConfig(config_path=str(config_path))
|
|
|
|
res = await get_dispatcher().dispatch(
|
|
"r1",
|
|
"config.patch",
|
|
{"patches": {"config_version": 99, "diagnostics_enabled": True}},
|
|
_admin_ctx(cfg),
|
|
)
|
|
|
|
assert res.error is None, res.error
|
|
assert cfg.config_version == LATEST_CONFIG_VERSION
|
|
assert cfg.diagnostics_enabled is True
|
|
persisted = config_path.read_text(encoding="utf-8")
|
|
assert "config_version = 1" in persisted
|
|
assert "config_version = 99" not in persisted
|
|
|
|
|
|
async def test_config_patch_merge_form_honors_readonly_paths(tmp_path: Path) -> None:
|
|
config_path = tmp_path / "c.toml"
|
|
cfg = GatewayConfig(config_path=str(config_path))
|
|
cfg.auth = cfg.auth.model_copy(update={"mode": "token", "token": "BOOT_SECRET"})
|
|
cfg.mark_runtime_secret("auth.token")
|
|
|
|
res = await get_dispatcher().dispatch(
|
|
"r1",
|
|
"config.patch",
|
|
{"patch": {"auth": {"token": "CLIENT_CHOSEN"}, "config_version": 987654}},
|
|
_admin_ctx(cfg),
|
|
)
|
|
|
|
assert res.error is None, res.error
|
|
assert cfg.auth.token == "BOOT_SECRET"
|
|
assert cfg.config_version == LATEST_CONFIG_VERSION
|
|
persisted = config_path.read_text(encoding="utf-8")
|
|
assert "CLIENT_CHOSEN" not in persisted
|
|
assert "987654" not in persisted
|
|
|
|
|
|
@pytest.mark.parametrize("replacement", [None, "disabled", []])
|
|
async def test_config_patch_merge_form_drops_scalar_readonly_parent(
|
|
tmp_path: Path, replacement: object
|
|
) -> None:
|
|
config_path = tmp_path / "c.toml"
|
|
cfg = GatewayConfig(config_path=str(config_path))
|
|
cfg.auth = cfg.auth.model_copy(update={"mode": "token", "token": "BOOT_SECRET"})
|
|
cfg.mark_runtime_secret("auth.token")
|
|
|
|
res = await get_dispatcher().dispatch(
|
|
"r1",
|
|
"config.patch",
|
|
{"patch": {"auth": replacement, "diagnostics_enabled": True}},
|
|
_admin_ctx(cfg),
|
|
)
|
|
|
|
assert res.error is None, res.error
|
|
assert cfg.auth.mode == "token"
|
|
assert cfg.auth.token == "BOOT_SECRET"
|
|
assert "auth.token" in cfg._runtime_secret_paths
|
|
assert cfg.diagnostics_enabled is True
|
|
|
|
|
|
async def test_config_set_rejects_parent_of_readonly_auth_secret(tmp_path: Path) -> None:
|
|
config_path = tmp_path / "c.toml"
|
|
cfg = GatewayConfig(config_path=str(config_path))
|
|
cfg.auth = cfg.auth.model_copy(update={"mode": "token", "token": "BOOT_SECRET"})
|
|
cfg.mark_runtime_secret("auth.token")
|
|
|
|
res = await get_dispatcher().dispatch(
|
|
"r1",
|
|
"config.set",
|
|
{"path": "auth", "value": {"mode": "token", "token": "CLIENT_CHOSEN"}},
|
|
_admin_ctx(cfg),
|
|
)
|
|
|
|
assert res.error is not None
|
|
assert "read-only" in res.error.message
|
|
assert cfg.auth.token == "BOOT_SECRET"
|
|
assert "auth.token" in cfg._runtime_secret_paths
|
|
assert not config_path.exists()
|
|
|
|
|
|
async def test_config_patch_dot_parent_skips_readonly_auth_secret(tmp_path: Path) -> None:
|
|
config_path = tmp_path / "c.toml"
|
|
cfg = GatewayConfig(config_path=str(config_path))
|
|
cfg.auth = cfg.auth.model_copy(update={"mode": "token", "token": "BOOT_SECRET"})
|
|
cfg.mark_runtime_secret("auth.token")
|
|
|
|
res = await get_dispatcher().dispatch(
|
|
"r1",
|
|
"config.patch",
|
|
{"patches": {"auth": {"mode": "token", "token": "CLIENT_CHOSEN"}}},
|
|
_admin_ctx(cfg),
|
|
)
|
|
|
|
assert res.error is None, res.error
|
|
assert cfg.auth.token == "BOOT_SECRET"
|
|
assert "auth.token" in cfg._runtime_secret_paths
|
|
assert "CLIENT_CHOSEN" not in config_path.read_text(encoding="utf-8")
|