- DeckyServiceAddRequest gains an optional `config: dict` field, validated
against the service's config_schema before any state mutation (400 on
bad type, no half-written rows).
- Engine: add_service threads `config` into _add_topology_service /
_add_fleet_service, persisting validated cfg to decky_config.service_config
BEFORE compose regen so the first `up -d --build` materialises the env on
the new container. No follow-up apply needed.
- Frontend: shared AddServiceConfigModal — same wizard accordion shape, used by:
* DeckyCard's ADD SERVICE picker (Fleet & MazeNET inspectors via shared component)
* MazeNET Inspector's ADD SERVICE picker
* MazeNET palette drag-drop onto a deployed decky
Empty-schema services short-circuit to a one-click add (no modal flash).
Operator can cancel; errors surface in the modal.
- Tests: add_service config plumbing — persist, drop unknown keys, 400-equivalent
on bad types, back-compat empty-config.
- Drive-by: fix stale repo-method names in test_services_live.py
(create_topology_decky → add_topology_decky, get_topology_decky → list+pick helper,
service.added → service_added topic).
131 lines
4.6 KiB
Python
131 lines
4.6 KiB
Python
"""DTOs for cross-cutting decky operations (file drops, etc.).
|
|
|
|
These don't bind to a single table — fleet deckies and MazeNET
|
|
(topology) deckies share the request shape, with ``topology_id``
|
|
discriminating. Following ``feedback_models_single_source`` we put
|
|
the request/response shapes alongside the rest of the API contracts
|
|
under ``decnet.web.db.models``.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from typing import Any, Optional
|
|
|
|
from pydantic import BaseModel, Field as PydanticField, field_validator
|
|
|
|
|
|
class DeckyFileDropRequest(BaseModel):
|
|
"""Drop arbitrary bytes at an absolute path inside a decky container.
|
|
|
|
``content_b64`` is the base64-encoded payload. Binary-safe.
|
|
|
|
``mode`` defaults to ``0o644`` (octal int). ``mtime_offset`` is a
|
|
seconds offset from now applied via ``touch -d`` so realistic-aged
|
|
files don't all stamp at wall-clock-now.
|
|
"""
|
|
decky_name: str = PydanticField(..., min_length=1)
|
|
topology_id: Optional[str] = None
|
|
path: str = PydanticField(..., min_length=1)
|
|
content_b64: str
|
|
mode: int = 0o644
|
|
mtime_offset: int = 0
|
|
|
|
@field_validator("path")
|
|
@classmethod
|
|
def _abs_no_traversal(cls, v: str) -> str:
|
|
if not v.startswith("/"):
|
|
raise ValueError("path must be absolute (start with '/')")
|
|
# Defense in depth: even though we run as root inside the
|
|
# container, ``..`` segments make the on-disk location depend
|
|
# on the cwd at exec-time and surprise both operators and the
|
|
# auditor reading the placement_path field later.
|
|
for seg in v.split("/"):
|
|
if seg == "..":
|
|
raise ValueError("path must not contain '..' segments")
|
|
return v
|
|
|
|
|
|
class DeckyServiceAddRequest(BaseModel):
|
|
"""Add a single service to an already-deployed decky.
|
|
|
|
The service must be registered (see :mod:`decnet.services.registry`)
|
|
and must NOT be ``fleet_singleton`` — those run once fleet-wide,
|
|
not per-decky. Validation happens server-side in the engine layer
|
|
and surfaces as 422.
|
|
|
|
``config`` carries optional initial per-service config (same shape as
|
|
DeckyServiceConfigRequest.config) so the freshly-added container
|
|
comes up with the operator's env from the start, no follow-up Apply
|
|
needed. Empty dict = build with defaults.
|
|
"""
|
|
name: str = PydanticField(..., min_length=1)
|
|
config: dict[str, Any] = PydanticField(default_factory=dict)
|
|
|
|
|
|
class DeckyServicesResponse(BaseModel):
|
|
"""Post-mutation services list, returned by the live add/remove API.
|
|
|
|
Lets the dashboard reflect the new shape without a follow-up GET.
|
|
"""
|
|
decky_name: str
|
|
topology_id: Optional[str] = None
|
|
services: list[str]
|
|
|
|
|
|
class ServiceConfigFieldDTO(BaseModel):
|
|
"""Serialized form of ``decnet.services.base.ServiceConfigField``.
|
|
|
|
The Inspector form (Fleet + MazeNET) renders inputs from this metadata.
|
|
"""
|
|
key: str
|
|
label: str
|
|
type: str
|
|
default: Optional[Any] = None
|
|
secret: bool = False
|
|
help: Optional[str] = None
|
|
enum: Optional[list[str]] = None
|
|
placeholder: Optional[str] = None
|
|
|
|
|
|
class ServiceSchemaResponse(BaseModel):
|
|
"""Per-service config schema returned by GET /services/{name}/schema."""
|
|
name: str
|
|
ports: list[int]
|
|
fleet_singleton: bool = False
|
|
fields: list[ServiceConfigFieldDTO] = PydanticField(default_factory=list)
|
|
|
|
|
|
class DeckyServiceConfigRequest(BaseModel):
|
|
"""Body for PUT/POST per-service config endpoints.
|
|
|
|
The dict is validated against the service's ``config_schema``
|
|
server-side: unknown keys are silently dropped, declared keys are
|
|
coerced to their declared type, and out-of-range values raise 400.
|
|
"""
|
|
config: dict[str, Any] = PydanticField(default_factory=dict)
|
|
|
|
|
|
class DeckyServiceConfigResponse(BaseModel):
|
|
"""Post-validation config + apply state for the form to re-sync from."""
|
|
decky_name: str
|
|
service_name: str
|
|
topology_id: Optional[str] = None
|
|
config: dict[str, Any] = PydanticField(default_factory=dict)
|
|
recreated: bool = False
|
|
|
|
|
|
class DeckyFileDeleteRequest(BaseModel):
|
|
"""Best-effort ``rm -f`` of an absolute path inside a decky container."""
|
|
decky_name: str = PydanticField(..., min_length=1)
|
|
topology_id: Optional[str] = None
|
|
path: str = PydanticField(..., min_length=1)
|
|
|
|
@field_validator("path")
|
|
@classmethod
|
|
def _abs_no_traversal(cls, v: str) -> str:
|
|
if not v.startswith("/"):
|
|
raise ValueError("path must be absolute (start with '/')")
|
|
for seg in v.split("/"):
|
|
if seg == "..":
|
|
raise ValueError("path must not contain '..' segments")
|
|
return v
|