94b8d5d118
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-slim name:slim]) (push) Waiting to run
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-slim-nonroot name:slim-nonroot]) (push) Waiting to run
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime name:]) (push) Waiting to run
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-code name:code]) (push) Waiting to run
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime name:]) (push) Waiting to run
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-code name:code]) (push) Waiting to run
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-code-nonroot name:code-nonroot]) (push) Waiting to run
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-code-slim name:code-slim]) (push) Waiting to run
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-code-slim-nonroot name:code-slim-nonroot]) (push) Waiting to run
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-nonroot name:nonroot]) (push) Waiting to run
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-code-nonroot name:code-nonroot]) (push) Waiting to run
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-code-slim name:code-slim]) (push) Waiting to run
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-slim name:slim]) (push) Waiting to run
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-slim-nonroot name:slim-nonroot]) (push) Waiting to run
Docker / docker-manifest (map[bake_target:runtime name:]) (push) Blocked by required conditions
Docker / docker-manifest (map[bake_target:runtime-code name:code]) (push) Blocked by required conditions
Docker / docker-manifest (map[bake_target:runtime-code-nonroot name:code-nonroot]) (push) Blocked by required conditions
Docker / docker-manifest (map[bake_target:runtime-code-slim name:code-slim]) (push) Blocked by required conditions
Docker / docker-manifest (map[bake_target:runtime-code-slim-nonroot name:code-slim-nonroot]) (push) Blocked by required conditions
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-code-slim-nonroot name:code-slim-nonroot]) (push) Waiting to run
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-nonroot name:nonroot]) (push) Waiting to run
Docker / docker-manifest (map[bake_target:runtime-nonroot name:nonroot]) (push) Blocked by required conditions
Docker / docker-manifest (map[bake_target:runtime-slim name:slim]) (push) Blocked by required conditions
Docker / docker-manifest (map[bake_target:runtime-slim-nonroot name:slim-nonroot]) (push) Blocked by required conditions
Docker / promote-latest (push) Blocked by required conditions
Init Native E2E / init-native (macos-latest, claude) (push) Waiting to run
Init Native E2E / init-native (macos-latest, codex) (push) Waiting to run
Init Native E2E / init-native (macos-latest, copilot) (push) Waiting to run
Install Native E2E / install-native (macos-latest) (push) Waiting to run
Wrap Native E2E / wrap-native (macos-latest) (push) Waiting to run
Security / Dependency audit (pip-audit) (push) Has been cancelled
Security / CodeQL (javascript-typescript) (push) Has been cancelled
Security / CodeQL (python) (push) Has been cancelled
Security / Secret scan (gitleaks) (push) Has been cancelled
rust / test (ubuntu) (push) Has been cancelled
rust / simulator e2e (macos-latest) (push) Has been cancelled
rust / simulator e2e (ubuntu-latest) (push) Has been cancelled
rust / simulator e2e (windows-latest) (push) Has been cancelled
rust / wheels (aarch64-apple-darwin) (push) Has been cancelled
rust / wheels (x86_64-unknown-linux-gnu) (push) Has been cancelled
rust / wheels (x86_64-apple-darwin) (push) Has been cancelled
rust / audit (push) Has been cancelled
rust / parity (nightly, allowed to fail during Phase 0) (push) Has been cancelled
CI / changes (push) Failing after 2s
Deploy Documentation / deploy (push) Failing after 0s
CI / commitlint (push) Has been skipped
Dev Containers / validate (.devcontainer/devcontainer.json, default) (push) Failing after 1s
Dev Containers / validate-worktree (push) Failing after 1s
Dev Containers / validate (.devcontainer/memory-stack/devcontainer.json, memory-stack) (push) Failing after 1s
Wrap Native E2E / wrap-native (ubuntu-latest) (push) Failing after 4s
Deploy Documentation / validate (push) Has been skipped
Init E2E / docker-init-e2e (push) Failing after 1s
Init Native E2E / init-native (ubuntu-latest, claude) (push) Failing after 1s
Init Native E2E / init-native (ubuntu-latest, codex) (push) Failing after 0s
Install Native E2E / install-native (ubuntu-latest) (push) Failing after 1s
Release Please / release-please (push) Failing after 0s
Wrap E2E / docker-wrap-e2e (push) Failing after 1s
Merge Conflicts / merge-conflicts (push) Failing after 2s
OpenCode Plugin / typecheck + build + test (push) Failing after 2s
Init Native E2E / init-native (ubuntu-latest, copilot) (push) Failing after 3s
CI / test (1) (push) Has been cancelled
CI / test (2) (push) Has been cancelled
CI / test (3) (push) Has been cancelled
CI / test (4) (push) Has been cancelled
CI / test-extras (push) Has been cancelled
CI / test-agno (push) Has been cancelled
CI / test-dashboard-ui (push) Has been cancelled
CI / docker-native-e2e (push) Has been cancelled
CI / lint (push) Has been cancelled
CI / build-wheel (push) Has been cancelled
CI / build-wheel-windows (push) Has been cancelled
CI / prefetch-model (push) Has been cancelled
CI / windows-native-wrapper (push) Has been cancelled
CI / build (push) Has been cancelled
CI / workflow-validation (push) Has been cancelled
CI / macos-native-wrapper (push) Has been cancelled
256 lines
8.6 KiB
Python
256 lines
8.6 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
|
|
|
|
|
|
@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"
|
|
|
|
|
|
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_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)
|