mirror of
https://github.com/headroomlabs-ai/headroom.git
synced 2026-08-27 14:17:10 -04:00
## Description Establish one centrally resolved, observable, deterministic, versioned runtime rollout-control mechanism for Headroom. Runtime rollout controls which behaviors an already-built artifact may expose; it does not select or qualify a Headroom release/version. ## Type of Change - [x] New feature (non-breaking change that adds functionality) - [x] Bug fix (non-breaking change that fixes rollout enforcement regressions) - [x] Documentation update - [x] Code refactoring (no functional changes) ## Changes Made - Added `RolloutChannel`, `HEADROOM_ROLLOUT_CHANNEL`, `--rollout-channel`, and a versioned immutable `RolloutSnapshot` shared by Python configuration boundaries. - Added schema/policy versions, canonical registry and snapshot SHA-256 identities, per-feature decision reasons, disable precedence, unsafe qualification poisoning, strict CLI validation, and fail-closed environment handling. - Added `headroom rollout status --json`, Python `/stats.rollout`, and Rust `/rollout/status` runtime provenance. - Added equivalent Rust snapshot semantics and shared Python/Rust policy vectors while retaining language-specific feature registries. - Enforced rollout policy at alternate Python server composition roots so `HEADROOM_READ_MATURATION=1` cannot bypass its beta gate. - Preserved typed rollout snapshots across multi-worker serialization with schema, policy, registry, snapshot-digest, type, and feature-name validation. - Made loopback runtime output-shaper updates replace the immutable snapshot atomically for request readers, retain explicit request/disable provenance, preserve channel and kill-switch precedence, invalidate cached stats, and return the effective rollout decision. - Made `headroom learn --verbosity --apply` report a channel-blocked update instead of claiming the shaper is live. - Made explicit CLI feature flags fail loudly when their current channel blocks them. - Made persistent interceptor installation select canary automatically, or reject an explicitly insufficient channel unless the break-glass override is set. - Updated architecture, proxy, rollout, learn, and output-shaper documentation with required channels and hot-reload semantics. ## Testing - [x] Unit tests pass - [x] Linting passes (`ruff check .` and `ruff format --check .`) - [x] Type checking passes (`mypy headroom --ignore-missing-imports`) - [x] New regression tests added for every corrected behavior - [x] Rust tests and production-target Clippy pass - [x] Documentation build passes ### Test Output ```text Focused rollout coverage suite 57 passed; headroom.rollout + rollout CLI: 98% coverage Affected proxy/rollout/transform/governance suites 222 passed; 0 failed Final changed regression suites 100 passed; 0 failed Cross-module hot-reload isolation regression 6 passed; 0 failed cargo test -p headroom-core -p headroom-proxy --quiet headroom-core: 924 passed; 1 ignored headroom-proxy and integration suites: all passed cargo clippy -p headroom-core -p headroom-proxy --lib --bins -- -D warnings cargo fmt --all -- --check ruff check . ruff format --check . mypy headroom --ignore-missing-imports git diff --check All passed cd docs && npm run build Compiled successfully; 164 static pages generated ``` The unsharded Windows-only CI selection exposed unrelated baseline failures, principally the existing `sqlite:///C:\\...` URL parser producing an invalid `\\C:\\...` path. At commit `8e793a80`, all 52 completed GitHub checks passed; the only other conclusions are expected skips and superseded governance jobs. ## Real Behavior Proof - **Environment:** Windows checkout on Python 3.13.3 and the current Rust workspace, based on upstream `main` at `93f2d7a2`. - **Exact command / steps:** Exercised canary and beta feature requests through CLI status, Python `/stats.rollout`, Rust `/rollout/status`, multi-worker payload round trips, loopback `/admin/runtime-env`, real proxy request shaping before/after hot reload, installer manifest generation, and shared Python/Rust policy vectors. - **Observed result:** Stable blocks unstable requests; disable wins over explicit/default/legacy/unsafe paths; unsafe state reports `qualification_eligible=false`; worker handoff rejects tampering; running output shaping changes only when the effective beta policy permits it; explicit blocked flags fail with actionable diagnostics. - **Not tested:** Live production traffic requiring provider credentials, or future artifact qualification/promotion automation (intentionally out of scope). ## Runtime Rollout Safety - **Rollout-managed features:** Python `tool_result_interceptors`, `proxy_output_shaper`, `read_maturation`; Rust `native_bedrock`, `openai_responses_streaming`, `canary_probe`. - **Minimum rollout channel:** Registry-defined per feature; process default is `stable`. - **Stable/default behavior changed:** No unstable feature becomes enabled by default. Explicit blocked CLI flags now fail instead of silently doing nothing. - **Kill switch / disable path:** `HEADROOM_DISABLE_FEATURES=<comma-separated feature names>`; explicit disable has highest precedence, including over the unsafe override. - **Unsafe override required:** No. `HEADROOM_UNSAFE_ALLOW_UNSTABLE_FEATURES=1` is break-glass only and makes qualification evidence ineligible. - **Qualification impact:** Adds machine-readable policy/snapshot identities and eligibility; does not implement qualification itself. - **Rollback path:** Set the named disable list for operational rollback, lower the channel, or revert this PR. ## Review Readiness - [x] I have performed a full diff review - [x] This PR is ready for human review ## Checklist - [x] My code follows the project's style guidelines - [x] I have performed a self-review of my code - [x] I have commented hard-to-understand areas - [x] I have made corresponding documentation changes - [x] My changes generate no new warnings - [x] I added tests that reproduce and prevent every regression fixed during review - [x] New and existing affected tests pass locally - [x] I did **not** edit `CHANGELOG.md`; release-please generates it from the Conventional Commit PR title ## Additional Notes Out of scope: artifact candidates, benchmark orchestration, qualification manifests/gates, promotion automation, release branches, publication guards, and release-risk classification. Those workflows can consume the rollout registry digest, runtime snapshot digest, decision reasons, and qualification eligibility through supported black-box interfaces. --------- Co-authored-by: JD Davis <jd@jds-macbook-air.tail2a279.ts.net> Co-authored-by: JD Davis <jd@JDH-AIR-00.local>
323 lines
11 KiB
Python
323 lines
11 KiB
Python
"""Tests for the live runtime-env registry, override store, hot-reload endpoint,
|
|
and the wrap-side push that keeps a reused proxy in sync without a restart.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import pytest
|
|
|
|
from headroom.proxy import runtime_env as rt
|
|
|
|
pytest.importorskip("fastapi")
|
|
pytest.importorskip("httpx")
|
|
|
|
from fastapi.testclient import TestClient # noqa: E402
|
|
|
|
from headroom.proxy.server import ProxyConfig, create_app # noqa: E402
|
|
from headroom.rollout import resolve_rollout # noqa: E402
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _clean_runtime_env(monkeypatch):
|
|
"""Each test starts with no overrides and no knob env vars set."""
|
|
for knob in rt.RUNTIME_ENV_KNOBS:
|
|
monkeypatch.delenv(knob.env, raising=False)
|
|
rt.clear_overrides()
|
|
yield
|
|
rt.clear_overrides()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Registry + override store
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_getenv_falls_back_to_environment(monkeypatch):
|
|
monkeypatch.setenv("HEADROOM_OUTPUT_SHAPER", "1")
|
|
assert rt.getenv("HEADROOM_OUTPUT_SHAPER") == "1"
|
|
assert rt.getenv("HEADROOM_VERBOSITY_LEVEL", "2") == "2" # unset -> default
|
|
assert rt.getenv("HEADROOM_VERBOSITY_LEVEL") is None
|
|
|
|
|
|
def test_getenv_override_wins_over_environment(monkeypatch):
|
|
monkeypatch.setenv("HEADROOM_OUTPUT_SHAPER", "0")
|
|
rt.set_overrides({"HEADROOM_OUTPUT_SHAPER": "1"})
|
|
assert rt.getenv("HEADROOM_OUTPUT_SHAPER") == "1"
|
|
|
|
|
|
def test_set_overrides_ignores_unknown_keys_and_non_strings():
|
|
applied = rt.set_overrides(
|
|
{
|
|
"HEADROOM_OUTPUT_SHAPER": "1",
|
|
"NOT_A_KNOB": "x",
|
|
"HEADROOM_VERBOSITY_LEVEL": 3, # non-string ignored
|
|
}
|
|
)
|
|
assert applied == {"HEADROOM_OUTPUT_SHAPER": "1"}
|
|
assert rt.getenv("NOT_A_KNOB") is None
|
|
# The rejected non-string did not become an override.
|
|
assert rt.getenv("HEADROOM_VERBOSITY_LEVEL") is None
|
|
|
|
|
|
def test_explicit_env_returns_only_explicitly_set_knobs():
|
|
environ = {
|
|
"HEADROOM_OUTPUT_SHAPER": "1",
|
|
"HEADROOM_MECHANICAL_EFFORT": "low",
|
|
"HEADROOM_VERBOSITY_LEVEL": " ", # blank -> not "explicitly set"
|
|
"PATH": "/usr/bin", # not a knob
|
|
}
|
|
assert rt.explicit_env(environ) == {
|
|
"HEADROOM_OUTPUT_SHAPER": "1",
|
|
"HEADROOM_MECHANICAL_EFFORT": "low",
|
|
}
|
|
|
|
|
|
def test_effective_runtime_env_reports_override_or_none(monkeypatch):
|
|
monkeypatch.setenv("HEADROOM_EFFORT_ROUTER", "0")
|
|
rt.set_overrides({"HEADROOM_OUTPUT_SHAPER": "1"})
|
|
eff = rt.effective_runtime_env()
|
|
assert eff["HEADROOM_OUTPUT_SHAPER"] == "1" # from override
|
|
assert eff["HEADROOM_EFFORT_ROUTER"] == "0" # from env
|
|
assert eff["HEADROOM_VERBOSITY_LEVEL"] is None # unset
|
|
# Every registered knob is reported.
|
|
assert set(eff) == {knob.env for knob in rt.RUNTIME_ENV_KNOBS}
|
|
|
|
|
|
def test_clear_overrides_resets(monkeypatch):
|
|
rt.set_overrides({"HEADROOM_OUTPUT_SHAPER": "1"})
|
|
rt.clear_overrides()
|
|
assert rt.getenv("HEADROOM_OUTPUT_SHAPER") is None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Overrides reach the live readers (the whole point)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_override_enables_output_shaper_without_env():
|
|
from headroom.proxy.output_shaper import OutputShaperSettings
|
|
|
|
assert OutputShaperSettings.from_env().enabled is False
|
|
rt.set_overrides({"HEADROOM_OUTPUT_SHAPER": "1", "HEADROOM_VERBOSITY_LEVEL": "3"})
|
|
settings = OutputShaperSettings.from_env()
|
|
assert settings.enabled is True
|
|
assert settings.verbosity_level == 3
|
|
|
|
|
|
def test_override_changes_astgrep_threshold_without_env():
|
|
from headroom.proxy.interceptors import astgrep
|
|
|
|
assert astgrep._min_chars_to_rewrite() == 500
|
|
rt.set_overrides({"HEADROOM_INTERCEPT_READ_MIN_CHARS": "999"})
|
|
assert astgrep._min_chars_to_rewrite() == 999
|
|
# Bad value falls back to the documented default rather than raising.
|
|
rt.set_overrides({"HEADROOM_INTERCEPT_READ_MIN_CHARS": "not-an-int"})
|
|
assert astgrep._min_chars_to_rewrite() == 500
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# /health surface + /admin/runtime-env hot-reload endpoint
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.fixture
|
|
def loopback_client(monkeypatch):
|
|
monkeypatch.setenv("HEADROOM_SKIP_UPSTREAM_CHECK", "1")
|
|
config = ProxyConfig(
|
|
optimize=False,
|
|
cache_enabled=False,
|
|
rate_limit_enabled=False,
|
|
cost_tracking_enabled=False,
|
|
)
|
|
app = create_app(config)
|
|
with TestClient(app, base_url="http://127.0.0.1", client=("127.0.0.1", 12345)) as c:
|
|
yield c
|
|
|
|
|
|
def test_health_exposes_runtime_env(loopback_client):
|
|
config = loopback_client.get("/health").json()["config"]
|
|
assert "runtime_env" in config
|
|
assert set(config["runtime_env"]) == {knob.env for knob in rt.RUNTIME_ENV_KNOBS}
|
|
assert config["runtime_env"]["HEADROOM_OUTPUT_SHAPER"] is None
|
|
|
|
|
|
def test_admin_runtime_env_applies_and_reflects_in_health(loopback_client):
|
|
resp = loopback_client.post(
|
|
"/admin/runtime-env",
|
|
json={"HEADROOM_OUTPUT_SHAPER": "1", "HEADROOM_VERBOSITY_LEVEL": "3", "BOGUS": "x"},
|
|
)
|
|
assert resp.status_code == 200
|
|
body = resp.json()
|
|
assert body["applied"] == {"HEADROOM_OUTPUT_SHAPER": "1", "HEADROOM_VERBOSITY_LEVEL": "3"}
|
|
assert body["runtime_env"]["HEADROOM_OUTPUT_SHAPER"] == "1"
|
|
# And it is observable on the live /health surface.
|
|
health = loopback_client.get("/health").json()["config"]["runtime_env"]
|
|
assert health["HEADROOM_OUTPUT_SHAPER"] == "1"
|
|
assert health["HEADROOM_VERBOSITY_LEVEL"] == "3"
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("rollout", "expected_enabled", "expected_reason"),
|
|
[
|
|
(resolve_rollout({"HEADROOM_ROLLOUT_CHANNEL": "beta"}), True, "legacy_alias"),
|
|
(resolve_rollout({}), False, "blocked_by_channel"),
|
|
(
|
|
resolve_rollout(
|
|
{
|
|
"HEADROOM_ROLLOUT_CHANNEL": "beta",
|
|
"HEADROOM_DISABLE_FEATURES": "proxy_output_shaper",
|
|
}
|
|
),
|
|
False,
|
|
"disabled",
|
|
),
|
|
],
|
|
)
|
|
def test_admin_runtime_env_reresolves_running_rollout_without_weakening_policy(
|
|
rollout, expected_enabled, expected_reason
|
|
):
|
|
app = create_app(
|
|
ProxyConfig(
|
|
rollout=rollout,
|
|
optimize=False,
|
|
cache_enabled=False,
|
|
rate_limit_enabled=False,
|
|
cost_tracking_enabled=False,
|
|
)
|
|
)
|
|
with TestClient(app, base_url="http://127.0.0.1", client=("127.0.0.1", 12345)) as client:
|
|
before = client.get("/stats?cached=1").json()["rollout"]
|
|
response = client.post("/admin/runtime-env", json={"HEADROOM_OUTPUT_SHAPER": "1"})
|
|
after = client.get("/stats?cached=1").json()["rollout"]
|
|
|
|
decision = next(item for item in after["features"] if item["name"] == "proxy_output_shaper")
|
|
assert response.status_code == 200
|
|
assert response.json()["rollout"] == after
|
|
assert decision["enabled"] is expected_enabled
|
|
assert decision["decision"] == expected_reason
|
|
assert after["snapshot_digest"] != before["snapshot_digest"]
|
|
|
|
|
|
def test_admin_runtime_env_rejects_non_object(loopback_client):
|
|
resp = loopback_client.post("/admin/runtime-env", json=["not", "a", "dict"])
|
|
assert resp.status_code == 400
|
|
|
|
|
|
def test_admin_runtime_env_rejects_process_local_update_with_multiple_workers(monkeypatch):
|
|
monkeypatch.setenv("HEADROOM_SKIP_UPSTREAM_CHECK", "1")
|
|
rollout = resolve_rollout({"HEADROOM_ROLLOUT_CHANNEL": "beta"})
|
|
config = ProxyConfig(
|
|
worker_processes=2,
|
|
rollout=rollout,
|
|
optimize=False,
|
|
cache_enabled=False,
|
|
rate_limit_enabled=False,
|
|
cost_tracking_enabled=False,
|
|
)
|
|
app = create_app(config)
|
|
before_digest = rollout.snapshot_digest
|
|
|
|
with TestClient(app, base_url="http://127.0.0.1", client=("127.0.0.1", 12345)) as client:
|
|
response = client.post("/admin/runtime-env", json={"HEADROOM_OUTPUT_SHAPER": "1"})
|
|
after = client.get("/stats").json()["rollout"]
|
|
|
|
assert response.status_code == 409
|
|
assert response.json()["worker_processes"] == 2
|
|
assert "restart" in response.json()["error"]
|
|
assert rt.getenv("HEADROOM_OUTPUT_SHAPER") is None
|
|
assert after["snapshot_digest"] == before_digest
|
|
|
|
|
|
def test_admin_runtime_env_is_loopback_only():
|
|
config = ProxyConfig(
|
|
optimize=False,
|
|
cache_enabled=False,
|
|
rate_limit_enabled=False,
|
|
cost_tracking_enabled=False,
|
|
)
|
|
app = create_app(config)
|
|
with TestClient(app, base_url="http://127.0.0.1", client=("10.0.0.1", 54321)) as external:
|
|
resp = external.post("/admin/runtime-env", json={"HEADROOM_OUTPUT_SHAPER": "1"})
|
|
assert resp.status_code == 404 # invisible to non-loopback callers
|
|
assert rt.getenv("HEADROOM_OUTPUT_SHAPER") is None # nothing applied
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# wrap-side push
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_push_runtime_env_posts_explicit_env(monkeypatch):
|
|
import urllib.request
|
|
|
|
from headroom.cli import wrap
|
|
|
|
monkeypatch.setenv("HEADROOM_OUTPUT_SHAPER", "1")
|
|
monkeypatch.setenv("HEADROOM_VERBOSITY_LEVEL", "3")
|
|
|
|
captured = {}
|
|
|
|
class _Resp:
|
|
def __enter__(self):
|
|
return self
|
|
|
|
def __exit__(self, *a):
|
|
return False
|
|
|
|
def read(self):
|
|
return b"{}"
|
|
|
|
def fake_urlopen(request, timeout=None):
|
|
captured["url"] = request.full_url
|
|
captured["body"] = request.data
|
|
return _Resp()
|
|
|
|
monkeypatch.setattr(urllib.request, "urlopen", fake_urlopen)
|
|
wrap._push_runtime_env(8787, no_proxy=False)
|
|
|
|
assert captured["url"] == "http://127.0.0.1:8787/admin/runtime-env"
|
|
import json
|
|
|
|
assert json.loads(captured["body"]) == {
|
|
"HEADROOM_OUTPUT_SHAPER": "1",
|
|
"HEADROOM_VERBOSITY_LEVEL": "3",
|
|
}
|
|
|
|
|
|
def test_push_runtime_env_noop_when_nothing_set(monkeypatch):
|
|
import urllib.request
|
|
|
|
from headroom.cli import wrap
|
|
|
|
def boom(*a, **k): # must never be called
|
|
raise AssertionError("should not POST when nothing is explicitly set")
|
|
|
|
monkeypatch.setattr(urllib.request, "urlopen", boom)
|
|
wrap._push_runtime_env(8787, no_proxy=False) # no env set -> no-op
|
|
|
|
|
|
def test_push_runtime_env_noop_when_no_proxy(monkeypatch):
|
|
import urllib.request
|
|
|
|
from headroom.cli import wrap
|
|
|
|
monkeypatch.setenv("HEADROOM_OUTPUT_SHAPER", "1")
|
|
monkeypatch.setattr(
|
|
urllib.request, "urlopen", lambda *a, **k: (_ for _ in ()).throw(AssertionError("no POST"))
|
|
)
|
|
wrap._push_runtime_env(8787, no_proxy=True) # --no-proxy -> no-op
|
|
|
|
|
|
def test_push_runtime_env_swallows_unreachable_proxy(monkeypatch):
|
|
import urllib.request
|
|
|
|
from headroom.cli import wrap
|
|
|
|
monkeypatch.setenv("HEADROOM_OUTPUT_SHAPER", "1")
|
|
|
|
def refused(*a, **k):
|
|
raise OSError("connection refused")
|
|
|
|
monkeypatch.setattr(urllib.request, "urlopen", refused)
|
|
# Best-effort: an unreachable / old proxy must not raise.
|
|
wrap._push_runtime_env(8787, no_proxy=False)
|