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.
141 lines
4.6 KiB
Python
141 lines
4.6 KiB
Python
"""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"
|