Files
DECNET/decnet/web/router/deckies/api_file_drop.py
anti 0bc4b05c73 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.
2026-04-28 22:43:34 -04:00

127 lines
4.5 KiB
Python

"""POST/DELETE /api/v1/deckies/files — generic file drops on deckies.
Wraps :func:`decnet.decky_io.write_file_to_container` /
:func:`decnet.decky_io.delete_file_from_container` so admins can drop
arbitrary bytes at arbitrary paths inside a running decky container —
fleet OR MazeNET — without going through the canary surface.
Auth: ``require_admin`` everywhere (matches every other write op on
deckies; see :mod:`decnet.web.router.fleet.api_mutate_decky`).
Container resolution mirrors the canary path: ``topology_id`` absent
means fleet (``<decky>-ssh``), present routes through
:func:`decnet.decky_io.resolve_decky_container` for the MazeNET
``<decky>-ssh`` / ``decnet_t_<id8>_<decky>`` distinction.
"""
from __future__ import annotations
import base64
from datetime import datetime, timedelta, timezone
from fastapi import APIRouter, Depends, HTTPException
from decnet.decky_io import (
delete_file_from_container,
resolve_decky_container,
write_file_to_container,
)
from decnet.logging import get_logger
from decnet.web.db.models import (
DeckyFileDeleteRequest,
DeckyFileDropRequest,
MessageResponse,
)
from decnet.web.dependencies import repo, require_admin
log = get_logger("api.deckies.files")
router = APIRouter(prefix="/deckies/files", tags=["Deckies"])
async def _resolve_container_or_4xx(
decky_name: str, topology_id: str | None,
) -> str:
"""Resolve to a docker container, mapping LookupError → 404/422."""
try:
return await resolve_decky_container(
repo, decky_name, topology_id=topology_id,
)
except LookupError as exc:
msg = str(exc)
if topology_id and "topology" in msg and "not found" in msg:
raise HTTPException(status_code=404, detail=msg) from exc
raise HTTPException(status_code=422, detail=msg) from exc
@router.post(
"",
response_model=MessageResponse,
status_code=201,
responses={
400: {"description": "Invalid request body (bad base64, etc.)"},
401: {"description": "Could not validate credentials"},
403: {"description": "Insufficient permissions"},
404: {"description": "Topology not found"},
409: {"description": "docker exec failed (container down or path unwritable)"},
422: {"description": "Path validation failed or decky not in topology"},
},
)
async def api_drop_file(
req: DeckyFileDropRequest,
admin: dict = Depends(require_admin),
) -> MessageResponse:
try:
content = base64.b64decode(req.content_b64, validate=True)
except (ValueError, TypeError) as exc:
raise HTTPException(
status_code=400, detail=f"content_b64 is not valid base64: {exc}",
) from exc
container = await _resolve_container_or_4xx(req.decky_name, req.topology_id)
mtime = (
datetime.now(timezone.utc) + timedelta(seconds=req.mtime_offset)
if req.mtime_offset
else None
)
success, error = await write_file_to_container(
container, req.path, content, mode=req.mode, mtime=mtime,
)
if not success:
raise HTTPException(status_code=409, detail=error or "docker exec failed")
log.info(
"decky.file.drop decky=%s topology=%s container=%s path=%s bytes=%d by=%s",
req.decky_name, req.topology_id, container, req.path,
len(content), admin.get("uuid", "unknown"),
)
return MessageResponse(message="ok")
@router.delete(
"",
response_model=MessageResponse,
responses={
401: {"description": "Could not validate credentials"},
403: {"description": "Insufficient permissions"},
404: {"description": "Topology not found"},
422: {"description": "Path validation failed or decky not in topology"},
},
)
async def api_delete_file(
req: DeckyFileDeleteRequest,
admin: dict = Depends(require_admin),
) -> MessageResponse:
container = await _resolve_container_or_4xx(req.decky_name, req.topology_id)
success, error = await delete_file_from_container(container, req.path)
# ``rm -f`` returns 0 even when the file is already gone, so a
# False here means the docker exec itself failed. Don't 404 — the
# caller asked us to ensure absence and we couldn't reach the
# container. Surface it as 409.
if not success:
raise HTTPException(status_code=409, detail=error or "docker exec failed")
log.info(
"decky.file.delete decky=%s topology=%s container=%s path=%s by=%s",
req.decky_name, req.topology_id, container, req.path,
admin.get("uuid", "unknown"),
)
return MessageResponse(message="ok")