Files
spark-control/image/tests/test_app_settings.py
T
Keysat 7e0759846f v0.27.0:0 - in-app settings gear + swap-lock route fix
Move the ~20 optional cluster knobs out of the StartOS "Configure Sparks"
action (now just the 4 required fields) and into a dashboard ⚙ Settings gear,
backed by a /data/app_settings.json overlay keyed by env-var names. One shared
mutable Settings instance + Settings.reload() applies edits live without a
restart; existing installs' values migrate automatically on first boot.

Also: support-service ports (parakeet/kokoro/embed/qdrant + vllm) are now
configurable, and GET /api/swap/lock no longer 404s (it was shadowed by the
/api/swap/{job_id} catch-all). WebhookNotifier is re-pointed on save so its
url/secret reload live too.
2026-06-18 13:41:28 -05:00

175 lines
7.0 KiB
Python

"""In-app settings overlay (the dashboard 'gear') + swap-lock routing regression.
Covers app_settings (the /data overlay backing the gear): first-run seeding from
env (the migration path), known-key filtering, apply() validation, secret
masking — and, end-to-end via TestClient, that POST /api/settings reloads the
shared Settings instance live, and that GET /api/swap/lock is no longer shadowed
by /api/swap/{job_id}.
"""
import json
import pytest
from app import app_settings
@pytest.fixture
def overlay_file(tmp_path, monkeypatch):
p = tmp_path / "app_settings.json"
monkeypatch.setenv("APP_SETTINGS_FILE", str(p))
return p
# ---- overlay store ----
def test_seed_from_env_filters_unknown_and_blank(overlay_file):
# An existing install upgrading in: values previously set via the StartOS
# action arrive as env; only known, non-empty keys migrate into the overlay.
app_settings.seed_from_env({
"VLLM_PORT": "8000",
"QDRANT_COLLECTION": "", # blank → skipped
"TOTALLY_UNKNOWN": "x", # not a gear key → skipped
"PARAKEET_PORT": "8010",
})
expected = {"VLLM_PORT": "8000", "PARAKEET_PORT": "8010"}
assert app_settings.load_overlay() == expected
assert json.loads(overlay_file.read_text()) == expected
def test_seed_is_a_one_time_noop_when_file_present(overlay_file):
overlay_file.write_text(json.dumps({"VLLM_PORT": "8000", "BOGUS": "y", "NGC_API_KEY": ""}))
app_settings.seed_from_env({"VLLM_PORT": "9999"}) # file exists ⇒ no-op
# unknown + blank keys dropped on read; existing value untouched by the seed.
assert app_settings.load_overlay() == {"VLLM_PORT": "8000"}
def test_no_file_is_empty_and_seed_of_blank_env_writes_nothing(overlay_file):
assert app_settings.load_overlay() == {}
app_settings.seed_from_env({"VLLM_PORT": "", "QDRANT_COLLECTION": ""})
assert not overlay_file.exists() # nothing worth seeding ⇒ no file
assert app_settings.load_overlay() == {}
def test_apply_set_then_blank_deletes(overlay_file):
app_settings.apply({"VLLM_PORT": "8000"})
assert app_settings.load_overlay()["VLLM_PORT"] == "8000"
app_settings.apply({"VLLM_PORT": ""}) # blank non-secret ⇒ revert to default
assert "VLLM_PORT" not in app_settings.load_overlay()
def test_apply_rejects_unknown_key(overlay_file):
with pytest.raises(app_settings.SettingsError):
app_settings.apply({"NOT_A_KNOB": "x"})
def test_apply_rejects_non_numeric_port(overlay_file):
with pytest.raises(app_settings.SettingsError):
app_settings.apply({"PARAKEET_PORT": "80x0"})
def test_apply_rejects_control_chars(overlay_file):
with pytest.raises(app_settings.SettingsError):
app_settings.apply({"QDRANT_COLLECTION": "a\nb"})
def test_secret_blank_keeps_existing(overlay_file):
app_settings.apply({"NGC_API_KEY": "nvapi-abc"})
app_settings.apply({"NGC_API_KEY": ""}) # blank secret ⇒ leave it in place
assert app_settings.load_overlay()["NGC_API_KEY"] == "nvapi-abc"
def test_apply_rejects_out_of_range_port(overlay_file):
for bad in ("0", "99999", "65536"):
with pytest.raises(app_settings.SettingsError):
app_settings.apply({"VLLM_PORT": bad})
def test_apply_accepts_port_bounds(overlay_file):
app_settings.apply({"VLLM_PORT": "1", "PARAKEET_PORT": "65535"})
o = app_settings.load_overlay()
assert o["VLLM_PORT"] == "1" and o["PARAKEET_PORT"] == "65535"
def test_secret_clear_sentinel_removes(overlay_file):
app_settings.apply({"NGC_API_KEY": "nvapi-abc"})
app_settings.apply({"NGC_API_KEY": app_settings.CLEAR_SENTINEL})
assert "NGC_API_KEY" not in app_settings.load_overlay()
def test_seed_skips_invalid_and_strips(overlay_file):
app_settings.seed_from_env({
"VLLM_PORT": "8000\n", # trailing newline → stripped
"PARAKEET_PORT": "99999", # out of range → skipped, not written
"QDRANT_COLLECTION": "crm",
})
o = app_settings.load_overlay()
assert o["VLLM_PORT"] == "8000"
assert "PARAKEET_PORT" not in o
assert o["QDRANT_COLLECTION"] == "crm"
def test_public_view_exposes_clear_sentinel(overlay_file):
assert app_settings.public_view()["clear_sentinel"] == app_settings.CLEAR_SENTINEL
def test_public_view_masks_secrets_and_groups(overlay_file):
app_settings.apply({"NGC_API_KEY": "nvapi-abc", "VLLM_PORT": "8000"})
view = app_settings.public_view()
fields = {f["key"]: f for g in view["groups"] for f in g["fields"]}
# Secret: value never echoed to the browser, only a set flag.
assert "value" not in fields["NGC_API_KEY"]
assert fields["NGC_API_KEY"]["set"] is True
# Non-secret: current value present for prefill.
assert fields["VLLM_PORT"]["value"] == "8000"
assert {g["name"] for g in view["groups"]} >= {"vLLM (Spark 1)", "Integrations"}
# The previously-missing support-service ports are now exposed.
assert {"PARAKEET_PORT", "KOKORO_PORT", "EMBED_PORT", "QDRANT_PORT"} <= set(fields)
# ---- end-to-end (TestClient): live reload + route order ----
# TestClient is created without the `with` context manager so app startup events
# (the deep-health poll loop) don't run — these stay fully offline.
def _client(monkeypatch, tmp_path):
monkeypatch.setenv("APP_SETTINGS_FILE", str(tmp_path / "live.json"))
from fastapi.testclient import TestClient
from app import server
return TestClient(server.app)
def test_swap_lock_get_is_not_shadowed(monkeypatch, tmp_path):
client = _client(monkeypatch, tmp_path)
r = client.get("/api/swap/lock")
# Regression: must hit get_swap_lock (200, {"held": False}), NOT the
# /api/swap/{job_id} catch-all that returns 404 "no such job".
assert r.status_code == 200
assert r.json() == {"held": False}
def test_settings_apply_is_live_without_restart(monkeypatch, tmp_path):
client = _client(monkeypatch, tmp_path)
r = client.post("/api/settings", json={"values": {"VLLM_PORT": "8123"}})
assert r.status_code == 200
# Settings reloaded in place ⇒ /api/config reflects it immediately.
assert client.get("/api/config").json()["vllm_port"] == 8123
# And clearing it reverts to the default, still live.
client.post("/api/settings", json={"values": {"VLLM_PORT": ""}})
assert client.get("/api/config").json()["vllm_port"] == 8888
def test_settings_post_rejects_bad_value(monkeypatch, tmp_path):
client = _client(monkeypatch, tmp_path)
r = client.post("/api/settings", json={"values": {"PARAKEET_PORT": "nope"}})
assert r.status_code == 422
def test_webhook_notifier_repoints_live(monkeypatch, tmp_path):
# WebhookNotifier snapshots url/secret, so reload() alone can't reach it;
# post_settings must re-point it. Regression for that P1.
client = _client(monkeypatch, tmp_path)
from app import server
client.post("/api/settings", json={"values": {"SWAP_WEBHOOK_URL": "https://example.test/hook"}})
assert server.swap_webhook.url == "https://example.test/hook"
assert server.swap_webhook.enabled
client.post("/api/settings", json={"values": {"SWAP_WEBHOOK_URL": ""}})
assert server.swap_webhook.url == ""