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:
Keysat
2026-06-18 13:41:28 -05:00
parent b67e001642
commit 7e0759846f
15 changed files with 797 additions and 268 deletions
+84 -58
View File
@@ -1,26 +1,28 @@
from __future__ import annotations
import logging
import os
from dataclasses import dataclass
from dataclasses import dataclass, fields
from pathlib import Path
from typing import Mapping
from . import app_settings
from .shellsafe import validate_container
log = logging.getLogger(__name__)
def _env(name: str, default: str = "") -> str:
return os.environ.get(name, default)
def _env(src: Mapping[str, str], name: str, default: str = "") -> str:
return src.get(name, default)
def _env_container(name: str, default: str) -> str:
def _env_container(src: Mapping[str, str], name: str, default: str) -> str:
"""Resolve a container-name env var, validating it at the config boundary.
The value flows into `docker logs`/`docker exec` over SSH, so it's quoted at
the sink — but per the repo's two-layer convention it's also whitelist-checked
here. A malformed optional value falls back to `default` rather than crashing
daemon startup (mirrors `_env_int` for VLLM_PORT)."""
val = os.environ.get(name, "") or default
daemon startup (mirrors `_env_int`)."""
val = src.get(name, "") or default
try:
return validate_container(val)
except ValueError:
@@ -28,23 +30,23 @@ def _env_container(name: str, default: str) -> str:
return default
def _env_set(name: str) -> frozenset[str]:
def _env_set(src: Mapping[str, str], name: str) -> frozenset[str]:
"""Parse a comma-separated env var into a lowercased frozenset of keys.
Used by DISABLED_SERVICES so an adopter whose cluster doesn't run a given
support service can switch its tile + probes off entirely (rather than have
the probe hit whatever else listens on that port — e.g. a vLLM sharing
Parakeet's default 8000)."""
raw = os.environ.get(name, "")
raw = src.get(name, "")
return frozenset(part.strip().lower() for part in raw.split(",") if part.strip())
def _env_int(name: str, default: int) -> int:
def _env_int(src: Mapping[str, str], name: str, default: int) -> int:
"""Parse an int env var, falling back to `default` when unset, blank, or
malformed. The StartOS Configure panel passes optional numeric fields as an
empty string when left blank, so a bare int("") would crash daemon startup."""
malformed. Optional numeric fields arrive as an empty string when left blank,
so a bare int("") would crash daemon startup."""
try:
return int(os.environ.get(name, "") or default)
return int(src.get(name, "") or default)
except (TypeError, ValueError):
return default
@@ -64,8 +66,23 @@ def _resolve_models_yaml() -> str:
return str(candidates[0]) # let load fail with a clear path
@dataclass(frozen=True)
def _effective_env() -> dict[str, str]:
"""The env Settings is built from: process env first, the in-app settings
overlay on top. The overlay (the dashboard 'gear') is keyed by the same env
var names, so a knob set in the UI overrides the value the StartOS action
injected — while an un-touched knob keeps falling through to the action's
value, then to the code default. See app_settings."""
return {**os.environ, **app_settings.load_overlay()}
@dataclass
class Settings:
# NOTE: intentionally NOT frozen. There is exactly one Settings instance,
# shared by reference across every router closure and manager (build_router,
# self.settings = settings). `reload()` mutates it in place so a change saved
# via the in-app settings gear goes live for all of them without rebuilding
# the app — the only window of inconsistency is the microseconds it takes to
# reassign the fields, acceptable for a single-operator config save.
spark1_host: str
spark1_user: str
spark2_host: str
@@ -107,73 +124,82 @@ class Settings:
swap_webhook_secret: str
@classmethod
def from_env(cls) -> "Settings":
spark2_host = _env("SPARK2_HOST")
spark2_user = _env("SPARK2_USER")
def from_env(cls, src: Mapping[str, str] | None = None) -> "Settings":
src = _effective_env() if src is None else src
spark2_host = _env(src, "SPARK2_HOST")
spark2_user = _env(src, "SPARK2_USER")
# Parakeet (STT) and Kokoro (TTS) default to Spark 2 unless overridden.
return cls(
spark1_host=_env("SPARK1_HOST"),
spark1_user=_env("SPARK1_USER"),
spark1_host=_env(src, "SPARK1_HOST"),
spark1_user=_env(src, "SPARK1_USER"),
spark2_host=spark2_host,
spark2_user=spark2_user,
parakeet_host=_env("PARAKEET_HOST") or spark2_host,
parakeet_user=_env("PARAKEET_USER") or spark2_user,
parakeet_container=_env("PARAKEET_CONTAINER") or "parakeet-asr",
kokoro_host=_env("KOKORO_HOST") or spark2_host,
kokoro_user=_env("KOKORO_USER") or spark2_user,
kokoro_container=_env("KOKORO_CONTAINER") or "kokoro-tts",
parakeet_host=_env(src, "PARAKEET_HOST") or spark2_host,
parakeet_user=_env(src, "PARAKEET_USER") or spark2_user,
parakeet_container=_env(src, "PARAKEET_CONTAINER") or "parakeet-asr",
kokoro_host=_env(src, "KOKORO_HOST") or spark2_host,
kokoro_user=_env(src, "KOKORO_USER") or spark2_user,
kokoro_container=_env(src, "KOKORO_CONTAINER") or "kokoro-tts",
# Embeddings (spark-embed: bge-m3 dense + reranker) and Qdrant
# (vector storage) default to Spark 2 unless overridden.
embed_host=_env("EMBED_HOST") or spark2_host,
embed_user=_env("EMBED_USER") or spark2_user,
embed_container=_env("EMBED_CONTAINER") or "spark-embed",
qdrant_host=_env("QDRANT_HOST") or spark2_host,
qdrant_user=_env("QDRANT_USER") or spark2_user,
qdrant_container=_env("QDRANT_CONTAINER") or "qdrant",
qdrant_collection=_env("QDRANT_COLLECTION", ""),
embed_host=_env(src, "EMBED_HOST") or spark2_host,
embed_user=_env(src, "EMBED_USER") or spark2_user,
embed_container=_env(src, "EMBED_CONTAINER") or "spark-embed",
qdrant_host=_env(src, "QDRANT_HOST") or spark2_host,
qdrant_user=_env(src, "QDRANT_USER") or spark2_user,
qdrant_container=_env(src, "QDRANT_CONTAINER") or "qdrant",
qdrant_collection=_env(src, "QDRANT_COLLECTION", ""),
# matrix-bridge bot container, driven as its own SSH user (the owner
# of the ~/matrix-bridge git clone) so git/docker run unprivileged.
# The user is BLANK by default and set via the "Configure Sparks"
# action; leaving it blank reports the service as unconfigured, which
# hides the tile. That keeps the shared package portable — a
# deployment without the bot never shows a stray tile or a hardcoded
# username. Host defaults to Spark 2 (same box); container/dir/branch
# are sensible defaults. All are env-overridable.
matrix_bridge_host=_env("MATRIX_BRIDGE_HOST") or spark2_host,
matrix_bridge_user=_env("MATRIX_BRIDGE_USER"),
matrix_bridge_container=_env("MATRIX_BRIDGE_CONTAINER") or "matrix-bridge",
matrix_bridge_dir=_env("MATRIX_BRIDGE_DIR") or "~/matrix-bridge",
matrix_bridge_branch=_env("MATRIX_BRIDGE_BRANCH") or "master",
# The user is BLANK by default and set via the settings gear; leaving
# it blank reports the service as unconfigured, which hides the tile.
# That keeps the shared package portable — a deployment without the
# bot never shows a stray tile or a hardcoded username. Host defaults
# to Spark 2 (same box); container/dir/branch are sensible defaults.
matrix_bridge_host=_env(src, "MATRIX_BRIDGE_HOST") or spark2_host,
matrix_bridge_user=_env(src, "MATRIX_BRIDGE_USER"),
matrix_bridge_container=_env(src, "MATRIX_BRIDGE_CONTAINER") or "matrix-bridge",
matrix_bridge_dir=_env(src, "MATRIX_BRIDGE_DIR") or "~/matrix-bridge",
matrix_bridge_branch=_env(src, "MATRIX_BRIDGE_BRANCH") or "master",
# Redaction gateway pseudonym-map store (server-held de-anon key).
redaction_map_db=_env("REDACTION_MAP_DB", "/data/redaction_maps.db"),
redaction_map_ttl=_env_int("REDACTION_MAP_TTL", 7200),
ssh_key_path=_env("SSH_KEY_PATH"),
ssh_known_hosts=_env("SSH_KNOWN_HOSTS"),
redaction_map_db=_env(src, "REDACTION_MAP_DB", "/data/redaction_maps.db"),
redaction_map_ttl=_env_int(src, "REDACTION_MAP_TTL", 7200),
ssh_key_path=_env(src, "SSH_KEY_PATH"),
ssh_known_hosts=_env(src, "SSH_KNOWN_HOSTS"),
models_yaml=_resolve_models_yaml(),
vllm_port=_env_int("VLLM_PORT", 8888),
vllm_port=_env_int(src, "VLLM_PORT", 8888),
# Container name for the swappable vLLM on Spark 1. Defaults to the
# bundled launch-cluster.sh container; override if you named yours
# something else (the swap log-tail and pre-flight validator exec
# into it by name).
vllm_container=_env_container("VLLM_CONTAINER", "vllm_node"),
vllm_container=_env_container(src, "VLLM_CONTAINER", "vllm_node"),
# Built-in support-service keys (parakeet, kokoro, embeddings,
# qdrant) the deployment doesn't run — hidden from the dashboard and
# never probed.
disabled_services=_env_set("DISABLED_SERVICES"),
parakeet_port=_env_int("PARAKEET_PORT", 8000),
kokoro_port=_env_int("KOKORO_PORT", 8880),
embed_port=_env_int("EMBED_PORT", 8088),
qdrant_port=_env_int("QDRANT_PORT", 6333),
bind_port=_env_int("BIND_PORT", 9999),
open_webui_url=_env("OPEN_WEBUI_URL", ""),
ngc_api_key=_env("NGC_API_KEY", ""),
disabled_services=_env_set(src, "DISABLED_SERVICES"),
parakeet_port=_env_int(src, "PARAKEET_PORT", 8000),
kokoro_port=_env_int(src, "KOKORO_PORT", 8880),
embed_port=_env_int(src, "EMBED_PORT", 8088),
qdrant_port=_env_int(src, "QDRANT_PORT", 6333),
bind_port=_env_int(src, "BIND_PORT", 9999),
open_webui_url=_env(src, "OPEN_WEBUI_URL", ""),
ngc_api_key=_env(src, "NGC_API_KEY", ""),
# Coordination layer: fire a swap-lifecycle webhook to this URL so
# downstream consumers re-point their model config on a swap. Blank
# ⇒ disabled. The optional secret HMAC-signs the body (X-Spark-Signature).
swap_webhook_url=_env("SWAP_WEBHOOK_URL", ""),
swap_webhook_secret=_env("SWAP_WEBHOOK_SECRET", ""),
swap_webhook_url=_env(src, "SWAP_WEBHOOK_URL", ""),
swap_webhook_secret=_env(src, "SWAP_WEBHOOK_SECRET", ""),
)
def reload(self) -> None:
"""Recompute every field from the current env + settings overlay and
assign it onto this same instance, so all holders of the reference see
the change without an app restart. Called after the gear writes the
overlay (see server.post_settings)."""
fresh = Settings.from_env()
for f in fields(self):
setattr(self, f.name, getattr(fresh, f.name))
@property
def configured(self) -> bool:
return bool(self.spark1_host)