Files
dashboards/tests/test_config.py
T
ducoterra 9757e3c947 phase: 03_system_per_host_panels
**Phase 03 final verification pass — all 4 tasks already in `complete/`; verified criteria, fixed 3 pre-existing defects.**

- **Verified live** (headless Chromium + curl against `:8090`, all 6 sources `ok`): 3 panels × six heroes in order `cpu,mem,temp,disk,gpus,net`; charts per panel `util,net[,gpu]` (gateway: no GPU chart/hole); legends name metrics only — no legend names a foreign host; every `<svg>` `role="img"` + host-scoped `aria-label`; 2 sections, 0 `<select>`; `grep -rn "renderCores|core_usage" src/dashboards/web/` → nothing; console error-free; new shot `data/logs/final-system-panels.png`.
- **Defect fixed (credential hygiene, invariant 6)**: `collectors.py` put the *dialed* URL into every `UpstreamError`, and that string lands in the health record (`/api/health`) and the `event` table (`/api/events` → page); `/api/config` also echoed `GUFO_BASE_URL`/`VLLM_BASE_URL` verbatim. Added `config.strip_userinfo()` (now what `Target.display_url` uses) and redacted all message/config URLs. Tests: `test_a_failing_host_never_echoes_its_token_in_the_message` (×3 paths), `test_a_failing_engine_never_echoes_credentials_in_its_url`, `test_a_dead_host_never_writes_its_token_into_the_feed`, `test_engine_urls_are_redacted_the_same_way_hosts_are`, `test_strip_userinfo_is_the_one_redaction_every_url_leaves_by`. (`parse_targets` already moves `HOST_TARGETS` userinfo into a header, so this is a backstop for hosts and a real fix for engine URLs.)
- **Defect fixed (invariant 2)**: `/system` footnote printed `Δ{HOST_INTERVAL}` as the measurement; it now prints each host's measured `window_seconds` (range when they disagree) — `rates over Δ10.0s · poll 10s`. Pinned by `test_system_footnote_prints_the_window_it_actually_measured`. Also fixed the page's stale "window in each header" note and the empty-state's `--token` (argv) advice → `--token-file`.
- `bash .agents/validate.sh` → **EXIT=0**: **472 passed, 7 skipped**, coverage **96.87%** (floor 94), `ruff check src tests agent` clean, `pyright` 0 errors, `node --check` clean · `uv run pytest -m live --live` → **7 passed** · `uv pip list --outdated` empty.
- Criteria: no multi-host legend ✅ · no per-core visual ✅ · six heroes/panel incl. no-GPU `n/a` uncoloured ✅ · per-core history still queryable ✅ (354 pts) · ≥3 shape screenshots ✅ (`system-driveripper/deskwork/gateway.png` + dark/light/stale) · pytest+coverage+lint ✅ · phases 01/02 unchanged (their pin tests + `/api/summary` shape tests pass; live UPS gap in AGENTS.md re-confirmed read-only against real upsd) ✅.
- **Docs**: AGENTS.md — invariant 6 rewritten to cover message/event redaction, `collectors.py` line, new "Δ is a measurement" rule, "hosts answering total" → "nine-figure fleet-totals panel" (the subtitle still counts hosts), test count 452→472, coverage 96.8→96.9, rewrapped an over-long line. README.md — same fleet-totals wording, `RATE_WINDOW_SECONDS` now covers every measured rate (not just tokens), agent route list completed (`/`, `/schema`, `?top=N`), `LIST CMD` in the diagram, new paragraph on URL redaction.
- **Deviation**: the criterion's literal `curl …/api/system/history?host=…&metric=cpu.usage_pct@core7` uses singular params that endpoint has never accepted (it takes `hosts=`/`metrics=` and ignores unknown ones). Verified 354 points via `…system/history?hosts=driveripper&metrics=cpu.usage_pct@core7&range=1h` and `/api/history?source=host&host=…&metric=…`; I did **not** add singular param aliases (unauthorized API surface). No live infra changes made — reads only.
- **Next pending phase**: `04_llm_chart_readability`.
2026-10-17 04:42:09 -04:00

197 lines
8.3 KiB
Python

"""Configuration: env-only, no defaults that point at somebody else's machine."""
from __future__ import annotations
import importlib
import os
from dataclasses import FrozenInstanceError
from typing import Any
import pytest
from dashboards import config
from dashboards.config import Settings, parse_targets
def test_parses_whitespace_and_comma_separated_targets() -> None:
targets, problems = parse_targets("a=http://h1:9120 b=http://h2:9120,c=http://h3:9120")
assert [t.name for t in targets] == ["a", "b", "c"]
assert [t.url for t in targets] == ["http://h1:9120", "http://h2:9120", "http://h3:9120"]
assert problems == []
def test_trailing_slash_is_stripped_so_urls_do_not_double_up() -> None:
targets, _ = parse_targets("a=http://h1:9120/")
assert targets[0].url == "http://h1:9120"
def test_one_bad_entry_does_not_sink_the_others() -> None:
targets, problems = parse_targets("good=http://h:1 broken-no-equals another=ftp://h:1")
assert [t.name for t in targets] == ["good"]
assert len(problems) == 2
assert any("not name=url" in p for p in problems)
assert any("http://" in p for p in problems)
def test_empty_input_yields_no_targets_and_no_noise() -> None:
assert parse_targets("") == ([], [])
assert parse_targets(" \n ") == ([], [])
def test_token_in_url_becomes_a_bearer_header_and_stays_out_of_the_display_url() -> None:
targets, problems = parse_targets("driveripper=http://SEKRIT@driveripper:9120")
assert problems == []
target = targets[0]
assert target.headers == {"Authorization": "Bearer SEKRIT"}
assert target.url == "http://driveripper:9120", "the token must not stay in the URL"
assert target.display_url == "http://driveripper:9120"
assert "SEKRIT" not in target.display_url
@pytest.mark.parametrize("raw,shown", [
("http://SEKRIT@host:9120", "http://host:9120"),
("https://user:pass@host:8000/metrics", "https://host:8000/metrics"),
("HTTP://SEKRIT@host:1", "HTTP://host:1"),
("http://host:9120", "http://host:9120"), # clean URLs pass through untouched
("", ""),
])
def test_strip_userinfo_is_the_one_redaction_every_url_leaves_by(raw: str, shown: str) -> None:
"""`strip_userinfo` is what `/api/config` and every `UpstreamError` message use, so it has
to survive the shapes a URL actually comes in: both schemes, `user:pass@`, a path, and the
common no-credentials case where it must change nothing (invariant 6).
"""
assert config.strip_userinfo(raw) == shown
def test_target_names_are_restricted_because_they_are_db_keys() -> None:
"""Names key the history DB and appear in URLs, so they are validated, not guessed.
Whitespace separates entries, which means "Bad Name=http://h" is two entries — both
reported, because the operator needs to see exactly what was rejected.
"""
targets, problems = parse_targets("Bad=http://h:1 /=http://h:1 ok-1_2=http://h:1")
assert [t.name for t in targets] == ["ok-1_2"]
assert len(problems) == 2
assert any("Bad" in p for p in problems)
_targets, problems = parse_targets("Bad Name=http://h:1")
assert len(problems) == 2, "a space splits the entry, so both halves are rejected"
def test_settings_default_to_no_sources_configured() -> None:
settings = Settings()
assert settings.host_targets == ()
assert settings.gufo_url == "" and settings.vllm_url == ""
assert settings.nut_host == ""
assert settings.nut_enabled is False
assert settings.any_source_enabled is False
def test_actions_need_the_flag_and_are_dead_under_read_only() -> None:
assert Settings(nut_allow_actions=True, read_only=False).actions_allowed is True
assert Settings(nut_allow_actions=True, read_only=True).actions_allowed is False
assert Settings(nut_allow_actions=False, read_only=False).actions_allowed is False
def test_nut_host_accepts_a_nut_scheme_prefix() -> None:
assert Settings(nut_host="nut://ups-box:3493").nut_enabled is True
def test_load_reads_host_targets_from_the_environment(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("HOST_TARGETS", "deskwork=http://127.0.0.1:9122")
settings = config.load()
assert [t.name for t in settings.host_targets] == ["deskwork"]
def test_env_driven_scalars_are_read_at_import(monkeypatch: pytest.MonkeyPatch) -> None:
"""The dataclass defaults read os.environ, so a reload must pick up a changed env."""
monkeypatch.setenv("DASH_PORT", "8123")
monkeypatch.setenv("RETAIN_RAW_HOURS", "6")
monkeypatch.setenv("NUT_ALLOW_ACTIONS", "1")
reloaded = importlib.reload(config)
try:
assert reloaded.SETTINGS.port == 8123
assert reloaded.SETTINGS.raw_keep_hours == 6
assert reloaded.SETTINGS.actions_allowed is True
finally:
monkeypatch.delenv("DASH_PORT")
monkeypatch.delenv("RETAIN_RAW_HOURS")
monkeypatch.delenv("NUT_ALLOW_ACTIONS")
importlib.reload(config)
def test_flags_accept_the_usual_spellings(monkeypatch: pytest.MonkeyPatch) -> None:
for truthy in ("1", "true", "TRUE", "yes", "on"):
monkeypatch.setenv("DASH_READ_ONLY", truthy)
assert config._flag("DASH_READ_ONLY") is True, truthy
for falsey in ("0", "false", "", "no"):
monkeypatch.setenv("DASH_READ_ONLY", falsey)
assert config._flag("DASH_READ_ONLY") is False, falsey
def test_numeric_env_must_be_a_number_or_the_default_wins(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("HOST_INTERVAL_SECONDS", "not-a-number")
assert config._num("HOST_INTERVAL_SECONDS", 10.0) == 10.0
monkeypatch.setenv("HOST_INTERVAL_SECONDS", "2.5")
assert config._num("HOST_INTERVAL_SECONDS", 10.0) == 2.5
def test_settings_is_immutable_so_nobody_mutates_shared_state() -> None:
settings: Any = Settings()
with pytest.raises(FrozenInstanceError):
settings.port = 1 # type: ignore[misc]
# ---- the .env convenience -------------------------------------------------- #
def test_no_env_file_is_the_normal_case(monkeypatch: pytest.MonkeyPatch, tmp_path: Any) -> None:
monkeypatch.setenv("DASH_ENV_FILE", str(tmp_path / "absent.env"))
assert config.load_env_file() is None
def test_env_file_seeds_values_a_deployed_env_does_not_set(monkeypatch: pytest.MonkeyPatch,
tmp_path: Any) -> None:
env = tmp_path / ".env"
env.write_text("DASH_PORT=9999\nNUT_HOST=ups.internal\n")
monkeypatch.setenv("DASH_ENV_FILE", str(env))
monkeypatch.delenv("DASH_PORT", raising=False)
monkeypatch.delenv("NUT_HOST", raising=False)
assert config.load_env_file() == env
# Settings reads its defaults when the module is imported, so the reload is what a real
# process gets for free and what the test has to ask for explicitly
reloaded = importlib.reload(config)
assert reloaded.SETTINGS.port == 9999
assert reloaded.SETTINGS.nut_host == "ups.internal"
def test_a_real_environment_variable_beats_the_file(monkeypatch: pytest.MonkeyPatch,
tmp_path: Any) -> None:
"""The container/quadlet env is the source of truth; the file is for laptops."""
env = tmp_path / ".env"
env.write_text("DASH_PORT=9999\n")
monkeypatch.setenv("DASH_ENV_FILE", str(env))
monkeypatch.setenv("DASH_PORT", "7777")
reloaded = importlib.reload(config)
assert os.environ["DASH_PORT"] == "7777"
assert reloaded.SETTINGS.port == 7777
def test_env_file_expands_other_variables(monkeypatch: pytest.MonkeyPatch, tmp_path: Any) -> None:
"""`.env.example` shows `DASH_DB=${HOME}/...` — the file has to mean what it shows."""
env = tmp_path / ".env"
env.write_text("MY_BASE=/srv/dash\nDASH_DB=${MY_BASE}/dashboards.sqlite3\n")
monkeypatch.setenv("DASH_ENV_FILE", str(env))
monkeypatch.delenv("MY_BASE", raising=False)
monkeypatch.delenv("DASH_DB", raising=False)
reloaded = importlib.reload(config)
assert reloaded.SETTINGS.db_path == "/srv/dash/dashboards.sqlite3"
def test_the_example_file_is_never_loaded(monkeypatch: pytest.MonkeyPatch, tmp_path: Any) -> None:
example = tmp_path / ".env.example"
example.write_text("NUT_HOST=should-not-load\n")
monkeypatch.setenv("DASH_ENV_FILE", str(example))
monkeypatch.delenv("NUT_HOST", raising=False)
reloaded = importlib.reload(config)
assert reloaded.load_env_file() is None
assert reloaded.SETTINGS.nut_host == ""