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:
2026-04-29 11:28:53 -04:00
parent d314470d7f
commit 54b1fbed14
5 changed files with 330 additions and 3 deletions

View File

@@ -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}")

View File

@@ -1,6 +1,6 @@
import json
from pathlib import Path
from decnet.services.base import BaseService
from decnet.services.base import BaseService, ServiceConfigField
TEMPLATES_DIR = Path(__file__).parent.parent / "templates" / "http"
@@ -10,6 +10,41 @@ class HTTPService(BaseService):
ports = [80, 443]
default_image = "build"
config_schema = [
ServiceConfigField(
key="server_header",
label="Server header",
type="string",
placeholder="Apache/2.4.41 (Ubuntu)",
help="Value sent in the HTTP Server: response header.",
),
ServiceConfigField(
key="response_code",
label="Default response code",
type="int",
default=200,
),
ServiceConfigField(
key="fake_app",
label="Fake application",
type="enum",
enum=["none", "wordpress", "phpmyadmin", "tomcat", "jenkins"],
default="none",
help="Pre-baked application skin to render on the index page.",
),
ServiceConfigField(
key="extra_headers",
label="Extra headers (JSON or raw)",
type="textarea",
placeholder='{"X-Powered-By": "PHP/7.4.3"}',
),
ServiceConfigField(
key="custom_body",
label="Custom response body",
type="textarea",
),
]
def compose_fragment(
self,
decky_name: str,

View File

@@ -1,6 +1,6 @@
import json
from pathlib import Path
from decnet.services.base import BaseService
from decnet.services.base import BaseService, ServiceConfigField
TEMPLATES_DIR = Path(__file__).parent.parent / "templates" / "https"
@@ -10,6 +10,57 @@ class HTTPSService(BaseService):
ports = [443]
default_image = "build"
config_schema = [
ServiceConfigField(
key="server_header",
label="Server header",
type="string",
placeholder="nginx/1.18.0",
),
ServiceConfigField(
key="response_code",
label="Default response code",
type="int",
default=200,
),
ServiceConfigField(
key="fake_app",
label="Fake application",
type="enum",
enum=["none", "wordpress", "phpmyadmin", "tomcat", "jenkins"],
default="none",
),
ServiceConfigField(
key="extra_headers",
label="Extra headers (JSON or raw)",
type="textarea",
),
ServiceConfigField(
key="custom_body",
label="Custom response body",
type="textarea",
),
ServiceConfigField(
key="tls_cn",
label="TLS certificate CN",
type="string",
placeholder="mail.corp.local",
help="Common Name baked into the self-signed cert if no cert/key provided.",
),
ServiceConfigField(
key="tls_cert",
label="TLS certificate (PEM)",
type="textarea",
secret=True,
),
ServiceConfigField(
key="tls_key",
label="TLS private key (PEM)",
type="textarea",
secret=True,
),
]
def compose_fragment(
self,
decky_name: str,

View File

@@ -1,7 +1,7 @@
import os
from pathlib import Path
from decnet.services.base import BaseService
from decnet.services.base import BaseService, ServiceConfigField
TEMPLATES_DIR = Path(__file__).parent.parent / "templates" / "ssh"
ARTIFACTS_ROOT = os.environ.get("DECNET_ARTIFACTS_ROOT", "/var/lib/decnet/artifacts")
@@ -25,6 +25,27 @@ class SSHService(BaseService):
ports = [22]
default_image = "build"
config_schema = [
ServiceConfigField(
key="password",
label="Root password",
type="password",
default="admin",
secret=True,
help="Plaintext root password for the in-container sshd.",
),
ServiceConfigField(
key="hostname",
label="Container hostname",
type="string",
help=(
"Cosmetic override for the SSH banner/PS1 — keeps the decoy "
"looking heterogeneous. Decky identity (NODE_NAME) is unaffected."
),
placeholder="e.g. mail-01.corp.local",
),
]
def compose_fragment(
self,
decky_name: str,