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.
This commit is contained in:
@@ -0,0 +1,174 @@
|
||||
"""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 == ""
|
||||
Reference in New Issue
Block a user