feat(services): declarative config_schema on BaseService + SSH/HTTP/HTTPS descriptors
ServiceConfigField dataclass + BaseService.validate_cfg coerce/drop submitted service_cfg dicts against per-service typed schemas. SSH/HTTP/HTTPS now declare the keys they already read in compose_fragment, so the upcoming Inspector form has metadata to render from instead of hardcoded inputs per service.
This commit is contained in:
@@ -1,5 +1,39 @@
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import asdict, dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Literal
|
||||
|
||||
FieldType = Literal["string", "password", "int", "bool", "textarea", "enum"]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ServiceConfigField:
|
||||
"""
|
||||
Declarative descriptor for one user-editable knob on a service.
|
||||
|
||||
The Inspector form (Fleet + MazeNET) renders inputs from this metadata,
|
||||
and BaseService.validate_cfg coerces submitted values against it.
|
||||
"""
|
||||
|
||||
key: str
|
||||
label: str
|
||||
type: FieldType = "string"
|
||||
default: Any = None
|
||||
secret: bool = False
|
||||
help: str | None = None
|
||||
enum: list[str] | None = None
|
||||
placeholder: str | None = None
|
||||
|
||||
def to_json(self) -> dict:
|
||||
d = asdict(self)
|
||||
# Frontend doesn't need a None enum dangling on non-enum fields
|
||||
if self.enum is None:
|
||||
d.pop("enum", None)
|
||||
return d
|
||||
|
||||
|
||||
class ConfigValidationError(ValueError):
|
||||
"""Raised when a submitted service_cfg value cannot be coerced to its declared type."""
|
||||
|
||||
|
||||
class BaseService(ABC):
|
||||
@@ -15,6 +49,10 @@ class BaseService(ABC):
|
||||
default_image: str # Docker image tag, or "build" if a Dockerfile is needed
|
||||
fleet_singleton: bool = False # True = runs once fleet-wide, not per-decky
|
||||
|
||||
# Per-service customizable fields exposed to the Inspector UI.
|
||||
# Subclasses override; default empty -> "No customizable fields".
|
||||
config_schema: list[ServiceConfigField] = []
|
||||
|
||||
@abstractmethod
|
||||
def compose_fragment(
|
||||
self,
|
||||
@@ -41,3 +79,53 @@ class BaseService(ABC):
|
||||
image built. Return None if default_image is used directly.
|
||||
"""
|
||||
return None
|
||||
|
||||
def validate_cfg(self, cfg: dict | None) -> dict:
|
||||
"""
|
||||
Coerce a user-submitted dict against this service's config_schema.
|
||||
|
||||
Unknown keys are silently dropped. Declared keys are coerced to their
|
||||
declared type (raising ConfigValidationError on bad values). Empty
|
||||
strings on optional fields drop the key entirely so compose_fragment's
|
||||
existing `if "X" in cfg` guards keep working.
|
||||
"""
|
||||
out: dict[str, Any] = {}
|
||||
if not cfg:
|
||||
return out
|
||||
by_key = {f.key: f for f in self.config_schema}
|
||||
for key, raw in cfg.items():
|
||||
spec = by_key.get(key)
|
||||
if spec is None:
|
||||
continue # drop unknown keys
|
||||
if raw is None or raw == "":
|
||||
continue
|
||||
out[key] = _coerce(spec, raw)
|
||||
return out
|
||||
|
||||
|
||||
def _coerce(spec: ServiceConfigField, raw: Any) -> Any:
|
||||
t = spec.type
|
||||
if t in ("string", "password", "textarea"):
|
||||
return str(raw)
|
||||
if t == "int":
|
||||
try:
|
||||
return int(raw)
|
||||
except (TypeError, ValueError) as e:
|
||||
raise ConfigValidationError(f"{spec.key}: expected int, got {raw!r}") from e
|
||||
if t == "bool":
|
||||
if isinstance(raw, bool):
|
||||
return raw
|
||||
if isinstance(raw, str):
|
||||
if raw.lower() in ("true", "1", "yes", "on"):
|
||||
return True
|
||||
if raw.lower() in ("false", "0", "no", "off"):
|
||||
return False
|
||||
raise ConfigValidationError(f"{spec.key}: expected bool, got {raw!r}")
|
||||
if t == "enum":
|
||||
s = str(raw)
|
||||
if spec.enum and s not in spec.enum:
|
||||
raise ConfigValidationError(
|
||||
f"{spec.key}: {s!r} not in allowed values {spec.enum}"
|
||||
)
|
||||
return s
|
||||
raise ConfigValidationError(f"{spec.key}: unknown field type {t!r}")
|
||||
|
||||
Reference in New Issue
Block a user