7e0759846f
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.
175 lines
7.0 KiB
Python
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 == ""
|