**Phase 10 final pass — repo green; live criteria blocked off-repo.**
- Verified: `gateway` runs `dashboards-agent` (active/enabled, `10.1.1.27:9120` → `/health` 200, `/snapshot` 401 no-token / 200 bearer on-host, token matches `/etc/dashboards-agent.token`); `driveripper`/`deskwork` inactive, `sudo -n` → "a password is required", `:9120` empty.
- New measurement (transient runtime rule on gateway `firewall-cmd --add-port=9120/tcp`, **reverted** — runtime+permanent port lists verified back to the original set, no rich rules): laptop still gets no SYN through, while `:22`/`:80` on the same address answer in ms ⇒ the block is the lab gateway `10.2.0.1` port filter, not this app/host firewall/agent. No other live change made (no sudoers, no upsd, no UPS command).
- Tests/lint (exact): `bash .agents/validate.sh` → **EXIT 0** — `uv run pytest --cov --cov-fail-under=94` **793 passed, 11 skipped, coverage 98.33 %**; `ruff check src tests agent` clean; `pyright` 0 errors; `node --check` clean; hygiene clean; `uv pip list --outdated` empty (bumped fastapi 0.143.3→0.143.5, `uv.lock` only).
- `uv run pytest -m live --live` (`.env.dev` sourced): **4 passed, 1 skipped, 6 failed** — all six are `:9120`/`:3051` connect timeouts; zero parse/contract failures.
- Dev server restarted on current code: engines `ok`; 3×`host` + 3×`disks` + `nut` `DOWN` with real reasons (designed degradation, stub rows intact, no credential leakage).
- Completion criteria: screenshots ✓ (`data/logs/phase10-{index,system,disks,llm,ups}-live.png`); tunnel-vocabulary grep ✓ outside `.agents/` history (PLAN.md locked line, phase overviews, past reports; fixtures/`uv.lock` hits are digit substrings — `tests/test_deploy_scripts.py` guards operational files); no page-payload change ✓ (contract tests). **Not met, operator-side:** agents on all three hosts, `LIST UPS` from the laptop, `dev-server.sh status` all-ok, live suite green.
- Fixed in this pass: `sources/nut.py` unreachable-upsd message no longer prints a bare `(TimeoutError: )` (now `(timed out after 4s)` / `(ConnectionRefusedError: …)`), pinned by a new test + extended refused-port test.
- Docs: README verified consistent (every env var in `config.py` documented, routes/flags/`--with-llm`/pager sizes checked) — no changes needed. AGENTS.md fixed 4 inconsistencies: per-host sudo reality (gateway grants `NOPASSWD ALL`, which is why it installed without `agent-sudoers`; driveripper scoped; deskwork unrelated), the now-proven firewall finding, `/system` table typo ("agent per host"), gap-4 DB/WAL sizes + test count. `.env.example` `upsd.users` sample now one `instcmds` per line (it contradicted README and the in-app copy). `.env.dev` (gitignored) comment corrected to match the evidence.
- Flagged, untouched (locked): `.agents/PLAN.md` L92–95 still says upsd is "forwarded to `127.0.0.1:3494` in dev" — false after this phase; evidence + exact operator commands appended to `data/logs/NOTES-verify.md` §11.
- Next pending phase: `11_admin_login`.
219 lines
9.3 KiB
Python
219 lines
9.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_the_disk_source_defaults_to_a_five_minute_cadence() -> None:
|
|
"""SMART is not a per-second signal (phase 09), and the fetch waits for the agent's
|
|
own 20 s smartctl budget. Both are env-only, like every other interval."""
|
|
settings = Settings()
|
|
assert settings.disks_interval == 300.0
|
|
assert settings.disks_timeout == 30.0
|
|
|
|
|
|
def test_the_disk_cadence_is_an_env_var_like_every_other_one(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""`DISKS_INTERVAL_SECONDS=60` is how an operator watches a failing drive closely."""
|
|
monkeypatch.setenv("DISKS_INTERVAL_SECONDS", "60")
|
|
monkeypatch.setenv("DISKS_TIMEOUT_SECONDS", "45")
|
|
reloaded = importlib.reload(config)
|
|
try:
|
|
assert reloaded.SETTINGS.disks_interval == 60.0
|
|
assert reloaded.SETTINGS.disks_timeout == 45.0
|
|
finally:
|
|
monkeypatch.delenv("DISKS_INTERVAL_SECONDS")
|
|
monkeypatch.delenv("DISKS_TIMEOUT_SECONDS")
|
|
importlib.reload(config)
|
|
|
|
|
|
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://tok@deskwork.example.net:9120")
|
|
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 == ""
|