feat(deckies): generic file drops on fleet + MazeNET deckies
Extracts the docker-exec-with-base64-stdin pattern out of canary/planter and orchestrator/drivers/ssh into a shared decnet.decky_io package. Both consumers now delegate; the canary planter test still proves the contract end-to-end. Adds POST/DELETE /api/v1/deckies/files for arbitrary file drops. Container resolution is shared with the canary path: topology_id absent means fleet (<name>-ssh), present routes through resolve_decky_container which picks <name>-ssh when the topology decky exposes ssh, else the topology base container decnet_t_<id8>_<name>. Path validation rejects relative paths and '..' traversal at the request model layer. Bad base64 → 400; unknown topology → 404; decky not in topology → 422; docker exec failure → 409.
This commit is contained in:
@@ -63,6 +63,10 @@ from .deploy import (
|
||||
MutateIntervalRequest,
|
||||
PurgeResponse,
|
||||
)
|
||||
from .decky import (
|
||||
DeckyFileDeleteRequest,
|
||||
DeckyFileDropRequest,
|
||||
)
|
||||
from .fleet import (
|
||||
LOCAL_HOST_SENTINEL,
|
||||
FleetDecky,
|
||||
@@ -222,6 +226,8 @@ __all__ = [
|
||||
"PurgeResponse",
|
||||
# fleet
|
||||
"LOCAL_HOST_SENTINEL",
|
||||
"DeckyFileDeleteRequest",
|
||||
"DeckyFileDropRequest",
|
||||
"FleetDecky",
|
||||
# health
|
||||
"ComponentHealth",
|
||||
|
||||
61
decnet/web/db/models/decky.py
Normal file
61
decnet/web/db/models/decky.py
Normal file
@@ -0,0 +1,61 @@
|
||||
"""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 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 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
|
||||
Reference in New Issue
Block a user