diff --git a/src/cerberus/app.py b/src/cerberus/app.py index b91b273..2834654 100644 --- a/src/cerberus/app.py +++ b/src/cerberus/app.py @@ -557,7 +557,9 @@ async def admin_config_stage(request: Request) -> JSONResponse: }, ) try: - candidate = apply_updates(lifecycle.active.config, updates) + candidate = apply_updates( + lifecycle.active.config, updates, taken_versions=lifecycle.known_versions + ) except ValueError as exc: return JSONResponse(status_code=422, content={"staged": False, "error": str(exc)}) except ValidationError as exc: diff --git a/src/cerberus/control/admin_fields.py b/src/cerberus/control/admin_fields.py index b034202..ea142b6 100644 --- a/src/cerberus/control/admin_fields.py +++ b/src/cerberus/control/admin_fields.py @@ -19,6 +19,7 @@ import re import time import uuid +from collections.abc import Iterable from pathlib import Path from typing import Any @@ -115,18 +116,33 @@ def build_schema(config: CerberusConfig) -> dict[str, Any]: return {"sections": [_SECTION], "fields": fields, "version": config.metadata.version} -def _next_version(current: str) -> str: +def _next_version(current: str, taken: Iterable[str] = ()) -> str: + """The next unused ``.N`` under the current version's date. + + Every version /admin/validate has seen is bound to its first checksum for + good, so numbering from the active version alone would hand a second, + different edit the number an abandoned candidate already holds — and every + later stage would collide with it too. + """ + m = _VERSION_RE.match(current) if m is None: raise ValueError(f"unrecognized version format: {current!r}") - return f"{m.group(1)}.{int(m.group(2)) + 1}" + prefix, highest = m.group(1), int(m.group(2)) + for version in taken: + other = _VERSION_RE.match(version) + if other is not None and other.group(1) == prefix: + highest = max(highest, int(other.group(2))) + return f"{prefix}.{highest + 1}" -def apply_updates(config: CerberusConfig, updates: dict[str, Any]) -> CerberusConfig: +def apply_updates( + config: CerberusConfig, updates: dict[str, Any], *, taken_versions: Iterable[str] = () +) -> CerberusConfig: """Apply an allow-listed set of edits on top of the active config and - return a new, re-validated CerberusConfig with its version auto-bumped — - never the same version string bound to two different byte-for-byte - documents (the exact drift this session found and flagged earlier).""" + return a new, re-validated CerberusConfig with its version auto-bumped past + every version in ``taken_versions`` — never the same version string bound + to two different byte-for-byte documents.""" allowed = editable_keys(config) rejected = set(updates) - allowed @@ -145,6 +161,9 @@ def apply_updates(config: CerberusConfig, updates: dict[str, Any]) -> CerberusCo else: coerced = value _set_path(dumped, key, coerced) + except OverflowError as exc: + # int(float("inf")): JSON accepts Infinity, and it must be a 422, not a 500 + raise ValueError(f"{key!r} must be a finite number") from exc except (KeyError, TypeError) as exc: # allow-listed keys are built from real provider names (editable_keys), # so this only fires if a provider name itself contains "." — an @@ -152,7 +171,7 @@ def apply_updates(config: CerberusConfig, updates: dict[str, Any]) -> CerberusCo # clean rather than let the traversal crash the request raise ValueError(f"cannot resolve editable field {key!r}: {exc}") from exc if updates: - dumped["metadata"]["version"] = _next_version(config.metadata.version) + dumped["metadata"]["version"] = _next_version(config.metadata.version, taken_versions) return CerberusConfig.model_validate(dumped) diff --git a/src/cerberus/control/lifecycle.py b/src/cerberus/control/lifecycle.py index 9a27814..e402051 100644 --- a/src/cerberus/control/lifecycle.py +++ b/src/cerberus/control/lifecycle.py @@ -40,6 +40,12 @@ def active(self) -> ConfigDocument: def shadow(self) -> ConfigDocument | None: return self._shadow + @property + def known_versions(self) -> frozenset[str]: + """Every version bound to a checksum, active or not.""" + + return frozenset(self._version_checksums) + def validate(self, path: str) -> ConfigDocument: """Validate and permanently bind a version to its first observed checksum.""" diff --git a/tests/test_admin_fields.py b/tests/test_admin_fields.py index c33beb1..3a0fc28 100644 --- a/tests/test_admin_fields.py +++ b/tests/test_admin_fields.py @@ -146,3 +146,43 @@ async def test_stage_validate_activate_loop_changes_live_behavior(env, tmp_path) active = await client.get("/admin/config/active") assert active.json()["providers"]["alpha"]["quota_cooldown_seconds"] == 45 + + +def test_version_skips_numbers_already_bound(config): + taken = {"cerberus-2026-07-16.2", "cerberus-2026-07-16.3", "cerberus-2026-01-01.9"} + updated = apply_updates(config, {"providers.alpha.quota_cooldown_seconds": 5}, taken_versions=taken) + assert updated.metadata.version == "cerberus-2026-07-16.4" + + +def test_non_finite_number_is_a_value_error(config): + with pytest.raises(ValueError, match="finite"): + apply_updates(config, {"providers.alpha.quota_cooldown_seconds": float("inf")}) + + +@pytest.mark.asyncio +async def test_restaging_after_an_abandoned_candidate_validates(env, tmp_path): + """Stage, validate, change your mind, stage again: the second candidate + must get a fresh version rather than the one the first is bound to.""" + + raw = raw_config("cerberus-2026-07-16.1") + raw["state"] = {"path": str(tmp_path / "state.sqlite3")} + doc = load_config_document(write_config(tmp_path, "cfg.yaml", raw)) + app = create_app(doc, http_transport=httpx.MockTransport(ok_upstream)) + key = "providers.alpha.quota_cooldown_seconds" + async with app.router.lifespan_context(app): + transport = httpx.ASGITransport(app=app) + async with httpx.AsyncClient(transport=transport, base_url="http://localhost") as client: + first = (await client.post("/admin/config/stage", json={"updates": {key: 100}}, headers=CSRF)).json() + assert (await client.post("/admin/validate", json={"path": first["path"]}, headers=CSRF)).status_code == 200 + + second = (await client.post("/admin/config/stage", json={"updates": {key: 200}}, headers=CSRF)).json() + assert second["version"] == "cerberus-2026-07-16.3" + validated = await client.post("/admin/validate", json={"path": second["path"]}, headers=CSRF) + assert validated.status_code == 200, validated.text + + infinite = await client.post( + "/admin/config/stage", + content='{"updates": {"%s": Infinity}}' % key, + headers={**CSRF, "content-type": "application/json"}, + ) + assert infinite.status_code == 422