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:
2026-04-28 22:43:34 -04:00
parent 3fe999d706
commit 0bc4b05c73
19 changed files with 1047 additions and 176 deletions

View File

View File

@@ -0,0 +1,252 @@
"""End-to-end coverage for /api/v1/deckies/files via the live FastAPI app.
The docker subprocess is stubbed; everything else (DB, repo, auth)
runs for real.
"""
from __future__ import annotations
import asyncio
import base64
from unittest.mock import patch
import httpx
import pytest
_BASE = "/api/v1/deckies/files"
class _FakeProc:
def __init__(self, rc: int = 0, stderr: bytes = b"") -> None:
self.returncode = rc
self._stderr = stderr
async def communicate(self, input: bytes | None = None) -> tuple[bytes, bytes]:
return b"", self._stderr
def kill(self) -> None: # pragma: no cover
pass
def _patch_subprocess_capture(rc: int = 0, stderr: bytes = b""):
captured: list[list[str]] = []
async def _fake(*argv, **kw):
captured.append(list(argv))
return _FakeProc(rc, stderr)
return patch.object(asyncio, "create_subprocess_exec", _fake), captured
def _hdr(token: str) -> dict[str, str]:
return {"Authorization": f"Bearer {token}"}
def _hydrate_returning(deckies: list[dict]):
async def _fake(_repo, _topo_id):
return {
"topology": {"id": _topo_id},
"lans": [], "edges": [], "deckies": deckies,
}
return _fake
# ---------------- POST: drop file -----------------------------------------
@pytest.mark.asyncio
async def test_drop_file_on_fleet_decky_uses_ssh_container(
client: httpx.AsyncClient, auth_token: str
) -> None:
patcher, captured = _patch_subprocess_capture()
body_b64 = base64.b64encode(b"hello world").decode()
with patcher:
res = await client.post(
_BASE,
json={
"decky_name": "web1",
"path": "/root/note.txt",
"content_b64": body_b64,
},
headers=_hdr(auth_token),
)
assert res.status_code == 201, res.text
# docker exec -i web1-ssh sh -c <script>
assert captured and captured[0][3] == "web1-ssh"
@pytest.mark.asyncio
async def test_drop_file_on_topology_decky_with_ssh_service(
client: httpx.AsyncClient, auth_token: str, monkeypatch
) -> None:
monkeypatch.setattr(
"decnet.topology.persistence.hydrate",
_hydrate_returning([{
"uuid": "u1", "name": "web1",
"decky_config": {"name": "web1"},
"services": ["ssh", "http"],
}]),
)
patcher, captured = _patch_subprocess_capture()
with patcher:
res = await client.post(
_BASE,
json={
"decky_name": "web1",
"topology_id": "abcdef0123456789",
"path": "/etc/synthetic.conf",
"content_b64": base64.b64encode(b"x").decode(),
},
headers=_hdr(auth_token),
)
assert res.status_code == 201, res.text
assert captured[0][3] == "web1-ssh"
@pytest.mark.asyncio
async def test_drop_file_on_topology_decky_without_ssh_uses_base_container(
client: httpx.AsyncClient, auth_token: str, monkeypatch
) -> None:
monkeypatch.setattr(
"decnet.topology.persistence.hydrate",
_hydrate_returning([{
"uuid": "u1", "name": "router",
"decky_config": {"name": "router"},
"services": ["dns"],
}]),
)
patcher, captured = _patch_subprocess_capture()
with patcher:
res = await client.post(
_BASE,
json={
"decky_name": "router",
"topology_id": "fedcba9876543210",
"path": "/etc/synthetic.conf",
"content_b64": base64.b64encode(b"x").decode(),
},
headers=_hdr(auth_token),
)
assert res.status_code == 201, res.text
assert captured[0][3] == "decnet_t_fedcba98_router"
@pytest.mark.asyncio
async def test_drop_file_404_when_topology_unknown(
client: httpx.AsyncClient, auth_token: str, monkeypatch
) -> None:
async def _none(_repo, _topo_id):
return None
monkeypatch.setattr("decnet.topology.persistence.hydrate", _none)
res = await client.post(
_BASE,
json={
"decky_name": "web1", "topology_id": "ghost",
"path": "/etc/x.conf",
"content_b64": base64.b64encode(b"x").decode(),
},
headers=_hdr(auth_token),
)
assert res.status_code == 404
@pytest.mark.asyncio
async def test_drop_file_422_for_relative_path(
client: httpx.AsyncClient, auth_token: str
) -> None:
res = await client.post(
_BASE,
json={
"decky_name": "web1",
"path": "etc/x.conf",
"content_b64": base64.b64encode(b"x").decode(),
},
headers=_hdr(auth_token),
)
assert res.status_code == 422
@pytest.mark.asyncio
async def test_drop_file_422_for_traversal(
client: httpx.AsyncClient, auth_token: str
) -> None:
res = await client.post(
_BASE,
json={
"decky_name": "web1",
"path": "/etc/../root/.ssh/authorized_keys",
"content_b64": base64.b64encode(b"x").decode(),
},
headers=_hdr(auth_token),
)
assert res.status_code == 422
@pytest.mark.asyncio
async def test_drop_file_400_on_bad_base64(
client: httpx.AsyncClient, auth_token: str
) -> None:
res = await client.post(
_BASE,
json={
"decky_name": "web1",
"path": "/etc/x.conf",
"content_b64": "%%%not-base64%%%",
},
headers=_hdr(auth_token),
)
assert res.status_code == 400
@pytest.mark.asyncio
async def test_drop_file_409_when_docker_exec_fails(
client: httpx.AsyncClient, auth_token: str
) -> None:
patcher, _captured = _patch_subprocess_capture(
rc=1, stderr=b"container not running",
)
with patcher:
res = await client.post(
_BASE,
json={
"decky_name": "web1",
"path": "/etc/x.conf",
"content_b64": base64.b64encode(b"x").decode(),
},
headers=_hdr(auth_token),
)
assert res.status_code == 409
# ---------------- DELETE --------------------------------------------------
@pytest.mark.asyncio
async def test_delete_file_round_trip(
client: httpx.AsyncClient, auth_token: str
) -> None:
patcher, captured = _patch_subprocess_capture()
with patcher:
res = await client.request(
"DELETE", _BASE,
json={"decky_name": "web1", "path": "/etc/x.conf"},
headers=_hdr(auth_token),
)
assert res.status_code == 200, res.text
# docker exec web1-ssh sh -c "rm -f /etc/x.conf"
assert captured[0][2] == "web1-ssh"
assert "rm -f /etc/x.conf" in captured[0][5]
# ---------------- auth ----------------------------------------------------
@pytest.mark.asyncio
async def test_unauthenticated_drop_rejected(
client: httpx.AsyncClient,
) -> None:
res = await client.post(_BASE, json={
"decky_name": "web1", "path": "/x",
"content_b64": base64.b64encode(b"x").decode(),
})
assert res.status_code in (401, 403)

View File

@@ -115,9 +115,9 @@ async def test_plant_argv_and_base64_round_trip(repo: SQLiteRepository, fake_bus
assert stdin_seen[0] == base64.b64encode(art.content)
assert "base64 -d > /home/admin/.aws/credentials" in script
assert base64.b64encode(art.content).decode() not in script
# touch -d @<mtime> with negative offset → an int strictly less than now.
m = re.search(r"touch -d @(\d+) ", script)
assert m and int(m.group(1)) > 0
# touch -d 'YYYY-MM-DD HH:MM:SS UTC' — backdated via mtime_offset.
m = re.search(r"touch -d '(\d{4}-\d{2}-\d{2}) ", script)
assert m
# State transitioned to planted.
row = await repo.get_canary_token("tok-1")
assert row["state"] == "planted" and row["last_error"] is None

View File

View File

@@ -0,0 +1,93 @@
"""Unit coverage for decnet.decky_io.resolve — container-name helpers."""
from __future__ import annotations
import pytest
from decnet.decky_io import (
resolve_decky_container,
resolve_topology_container,
)
def test_resolve_topology_container_prefers_ssh_service() -> None:
assert resolve_topology_container(
"abc123def456", "web1", services=["ssh", "http"],
) == "web1-ssh"
def test_resolve_topology_container_falls_back_to_base_when_no_ssh() -> None:
assert resolve_topology_container(
"abc123def456789", "router", services=["dns"],
) == "decnet_t_abc123de_router"
@pytest.mark.asyncio
async def test_resolve_decky_container_fleet_path_returns_ssh_suffix() -> None:
# Fleet path needs no I/O — repo can be anything.
container = await resolve_decky_container(None, "web1")
assert container == "web1-ssh"
@pytest.mark.asyncio
async def test_resolve_decky_container_topology_path_uses_services_list(
monkeypatch,
) -> None:
async def _fake_hydrate(_repo, _topo_id):
return {
"topology": {"id": _topo_id},
"lans": [],
"deckies": [
{
"uuid": "u1", "name": "web1",
"decky_config": {"name": "web1"},
"services": ["ssh"],
},
{
"uuid": "u2", "name": "router",
"decky_config": {"name": "router"},
"services": ["dns"],
},
],
"edges": [],
}
monkeypatch.setattr(
"decnet.topology.persistence.hydrate", _fake_hydrate,
)
assert await resolve_decky_container(
None, "web1", topology_id="abcdef0123456789",
) == "web1-ssh"
assert await resolve_decky_container(
None, "router", topology_id="abcdef0123456789",
) == "decnet_t_abcdef01_router"
@pytest.mark.asyncio
async def test_resolve_decky_container_raises_when_topology_missing(
monkeypatch,
) -> None:
async def _none(_repo, _topo_id):
return None
monkeypatch.setattr("decnet.topology.persistence.hydrate", _none)
with pytest.raises(LookupError, match="topology .* not found"):
await resolve_decky_container(None, "x", topology_id="ghost")
@pytest.mark.asyncio
async def test_resolve_decky_container_raises_when_decky_not_in_topology(
monkeypatch,
) -> None:
async def _fake(_repo, _topo_id):
return {
"topology": {"id": _topo_id},
"lans": [], "edges": [],
"deckies": [{
"uuid": "u1", "name": "other",
"decky_config": {"name": "other"},
"services": [],
}],
}
monkeypatch.setattr("decnet.topology.persistence.hydrate", _fake)
with pytest.raises(LookupError, match="not in topology"):
await resolve_decky_container(
None, "missing", topology_id="abcdef0123456789",
)

View File

@@ -0,0 +1,140 @@
"""Unit coverage for decnet.decky_io.write — the docker-exec wrapper.
Mirrors the canary planter's subprocess-mock pattern: we patch
:func:`asyncio.create_subprocess_exec` so the tests don't require a
docker daemon, then assert argv shape, stdin payload, and the
``mtime`` / ``mode`` knobs land in the rendered ``sh -c`` script.
"""
from __future__ import annotations
import asyncio
import base64
import re
from datetime import datetime, timezone
from unittest.mock import patch
import pytest
from decnet.decky_io import (
delete_file_from_container,
write_file_to_container,
)
class _FakeProc:
def __init__(self, rc: int = 0, stderr: bytes = b"") -> None:
self.returncode = rc
self._stderr = stderr
async def communicate(self, input: bytes | None = None) -> tuple[bytes, bytes]:
return b"", self._stderr
def kill(self) -> None: # pragma: no cover
pass
def _patch_subprocess(rc: int = 0, stderr: bytes = b""):
captured: list[list[str]] = []
stdin_seen: list[bytes | None] = []
async def _fake(*argv, **kw):
captured.append(list(argv))
proc = _FakeProc(rc, stderr)
orig = proc.communicate
async def communicate(input: bytes | None = None) -> tuple[bytes, bytes]:
stdin_seen.append(input)
return await orig(None)
proc.communicate = communicate # type: ignore[assignment]
return proc
return patch.object(asyncio, "create_subprocess_exec", _fake), captured, stdin_seen
@pytest.mark.asyncio
async def test_write_file_emits_correct_docker_argv_and_sh_script() -> None:
patcher, captured, stdin_seen = _patch_subprocess(rc=0)
with patcher:
success, error = await write_file_to_container(
"web1-ssh", "/etc/secrets.json", b'{"key":"value"}',
mode=0o600,
)
assert success is True and error is None
argv = captured[0]
assert argv[:4] == ["docker", "exec", "-i", "web1-ssh"]
assert argv[4:6] == ["sh", "-c"]
script = argv[6]
# Composed in fixed order: mkdir -p, base64 -d > path, chmod, [touch].
assert "mkdir -p /etc" in script
assert "base64 -d > /etc/secrets.json" in script
assert "chmod 600 /etc/secrets.json" in script
# Without explicit mtime, no touch -d is emitted.
assert "touch -d" not in script
# Stdin carries the base64 payload — never the argv (ARG_MAX safety).
assert stdin_seen[0] == base64.b64encode(b'{"key":"value"}')
@pytest.mark.asyncio
async def test_write_file_round_trips_arbitrary_binary() -> None:
patcher, _captured, stdin_seen = _patch_subprocess(rc=0)
payload = bytes(range(256)) * 8 # 2 KB of every byte value
with patcher:
success, _err = await write_file_to_container(
"web1-ssh", "/tmp/bin.dat", payload,
)
assert success is True
assert base64.b64decode(stdin_seen[0]) == payload
@pytest.mark.asyncio
async def test_write_file_backdates_mtime_via_iso_touch() -> None:
patcher, captured, _stdin = _patch_subprocess(rc=0)
mtime = datetime(2026, 4, 20, 11, 30, 0, tzinfo=timezone.utc)
with patcher:
await write_file_to_container(
"web1-ssh", "/etc/x.conf", b"hello", mtime=mtime,
)
script = captured[0][6]
assert "touch -d '2026-04-20 11:30:00 UTC' /etc/x.conf" in script
@pytest.mark.asyncio
async def test_write_file_returns_failure_with_stderr_on_nonzero_rc() -> None:
patcher, _captured, _stdin = _patch_subprocess(rc=125, stderr=b"container down")
with patcher:
success, error = await write_file_to_container(
"web1-ssh", "/etc/x.conf", b"y",
)
assert success is False
assert error and "container down" in error
@pytest.mark.asyncio
async def test_write_file_rejects_empty_path() -> None:
success, error = await write_file_to_container(
"web1-ssh", "", b"y",
)
assert success is False and error == "empty path"
@pytest.mark.asyncio
async def test_delete_file_emits_rm_minus_f() -> None:
patcher, captured, _stdin = _patch_subprocess(rc=0)
with patcher:
success, _err = await delete_file_from_container(
"web1-ssh", "/etc/secrets.json",
)
assert success is True
argv = captured[0]
assert argv[:3] == ["docker", "exec", "web1-ssh"]
assert "rm -f /etc/secrets.json" in argv[5]
@pytest.mark.asyncio
async def test_delete_file_returns_failure_on_docker_error() -> None:
patcher, _captured, _stdin = _patch_subprocess(rc=1, stderr=b"oops")
with patcher:
success, error = await delete_file_from_container(
"web1-ssh", "/etc/x.conf",
)
assert success is False and error == "oops"

View File

@@ -58,15 +58,30 @@ async def test_traffic_failure_when_banner_missing(monkeypatch):
async def test_file_action_invokes_docker_exec_on_dst(monkeypatch):
captured: list[tuple[list[str], bytes | None]] = []
async def fake_run_with_stdin(argv, stdin_bytes):
captured.append((argv, stdin_bytes))
return 0, "", ""
class _FakeProc:
returncode = 0
async def communicate(self, input=None):
return b"", b""
def kill(self): # pragma: no cover
pass
async def fake_create(*argv, **kw):
captured.append((list(argv), None))
proc = _FakeProc()
orig = proc.communicate
async def communicate(input=None):
captured[-1] = (captured[-1][0], input)
return await orig(None)
proc.communicate = communicate
return proc
# plant_file streams base64 content via stdin to avoid ARG_MAX
# (mirrors decnet.canary.planter; see commit c17b9e0). The test
# patches _run_with_stdin instead of _run because that's the
# codepath FileAction now exercises.
monkeypatch.setattr(ssh_driver, "_run_with_stdin", fake_run_with_stdin)
# (mirrors decnet.canary.planter; see commit c17b9e0). The driver
# now delegates to decky_io.write_file_to_container, which calls
# asyncio.create_subprocess_exec — patch that.
import asyncio as _asyncio
monkeypatch.setattr(_asyncio, "create_subprocess_exec", fake_create)
drv = ssh_driver.SSHDriver()
action = FileAction(
dst_uuid="u2", dst_name="decky-02",
@@ -107,13 +122,21 @@ async def test_run_handles_missing_docker_binary(monkeypatch):
@pytest.mark.asyncio
async def test_plant_file_applies_mtime_via_touch_d(monkeypatch):
from datetime import datetime, timezone
captured: list[tuple[list[str], bytes | None]] = []
captured: list[list[str]] = []
async def fake_run_with_stdin(argv, stdin_bytes):
captured.append((argv, stdin_bytes))
return 0, "", ""
class _FakeProc:
returncode = 0
async def communicate(self, input=None):
return b"", b""
def kill(self): # pragma: no cover
pass
monkeypatch.setattr(ssh_driver, "_run_with_stdin", fake_run_with_stdin)
async def fake_create(*argv, **kw):
captured.append(list(argv))
return _FakeProc()
import asyncio as _asyncio
monkeypatch.setattr(_asyncio, "create_subprocess_exec", fake_create)
drv = ssh_driver.SSHDriver()
mtime = datetime(2026, 4, 20, 11, 30, 0, tzinfo=timezone.utc)
result = await drv.plant_file(
@@ -121,7 +144,7 @@ async def test_plant_file_applies_mtime_via_touch_d(monkeypatch):
mode=0o644, mtime=mtime,
)
assert result.success is True
sh_cmd = captured[0][0][6]
sh_cmd = captured[0][6]
# Backdated mtime appears in the touch -d argument.
assert "touch -d '2026-04-20 11:30:00 UTC'" in sh_cmd
assert "chmod 644" in sh_cmd

View File

@@ -73,12 +73,14 @@ async def test_one_tick_records_event_and_publishes(repo, fake_bus, monkeypatch)
monkeypatch.setattr(ssh_driver, "_run", fake_run)
async def fake_run_with_stdin(argv, stdin_bytes):
# plant_file takes the base64-streaming path; treat any docker
# exec write as a successful no-op for the integration test.
return 0, "", ""
# plant_file delegates to decky_io.write_file_to_container; treat
# any docker exec write as a successful no-op for the integration
# test.
async def fake_write_file(*a, **kw):
return True, None
monkeypatch.setattr(ssh_driver, "_run_with_stdin", fake_run_with_stdin)
import decnet.decky_io.write as _decky_io_write
monkeypatch.setattr(_decky_io_write, "write_file_to_container", fake_write_file)
received: list = []
@@ -140,12 +142,14 @@ async def test_one_tick_picks_fleet_deckies(repo, fake_bus, monkeypatch):
monkeypatch.setattr(ssh_driver, "_run", fake_run)
async def fake_run_with_stdin(argv, stdin_bytes):
# plant_file takes the base64-streaming path; treat any docker
# exec write as a successful no-op for the integration test.
return 0, "", ""
# plant_file delegates to decky_io.write_file_to_container; treat
# any docker exec write as a successful no-op for the integration
# test.
async def fake_write_file(*a, **kw):
return True, None
monkeypatch.setattr(ssh_driver, "_run_with_stdin", fake_run_with_stdin)
import decnet.decky_io.write as _decky_io_write
monkeypatch.setattr(_decky_io_write, "write_file_to_container", fake_write_file)
await orch_worker._one_tick(repo, fake_bus)
@@ -282,12 +286,14 @@ async def test_tick_is_noop_when_no_running_deckies(repo, fake_bus, monkeypatch)
monkeypatch.setattr(ssh_driver, "_run", fake_run)
async def fake_run_with_stdin(argv, stdin_bytes):
# plant_file takes the base64-streaming path; treat any docker
# exec write as a successful no-op for the integration test.
return 0, "", ""
# plant_file delegates to decky_io.write_file_to_container; treat
# any docker exec write as a successful no-op for the integration
# test.
async def fake_write_file(*a, **kw):
return True, None
monkeypatch.setattr(ssh_driver, "_run_with_stdin", fake_run_with_stdin)
import decnet.decky_io.write as _decky_io_write
monkeypatch.setattr(_decky_io_write, "write_file_to_container", fake_write_file)
await orch_worker._one_tick(repo, fake_bus)
assert called is False