feat(canary): planter (docker exec injector) + tests
Plant / revoke / seed_baseline using the same docker-exec-with-sh-c pattern proven by decnet/orchestrator/drivers/ssh.py:_run_file. Each plant call composes a single sh script: mkdir -p <dirname> && printf %s <base64> | base64 -d > <path> && chmod <mode> <path> && touch -d @<mtime> <path> Base64-on-the-host / decode-in-the-container keeps binary artifacts (DOCX/PDF/PNG) safe across the argv boundary; the placement_path, mode, and mtime are shlex-quoted. State transitions hit the repo: planted -> failed on docker error with stderr captured into last_error. Bus events fire on success (canary.<id>.placed) and on revoke (canary.<id>.revoked) — wrapped in try/except so a downed bus never blocks a placement. seed_baseline(decky_name, repo) is the deploy-hook entry point — reads DECNET_CANARY_BASELINE (default git_config,env_file,honeydoc, aws_creds), persists one row per generator, plants each. Failed placements are logged but do NOT abort; the deployer hook treats the return list as informational.
This commit is contained in:
293
decnet/canary/planter.py
Normal file
293
decnet/canary/planter.py
Normal file
@@ -0,0 +1,293 @@
|
||||
"""Plant / revoke canary artifacts inside running decky containers.
|
||||
|
||||
Single entry point per operation:
|
||||
|
||||
* :func:`plant` writes a :class:`CanaryArtifact` into one decky's
|
||||
filesystem via ``docker exec`` (mirroring the SSH driver's
|
||||
``_run_file`` pattern), backdates the mtime, sets the requested
|
||||
mode, and publishes ``canary.{token_id}.placed`` on the bus.
|
||||
* :func:`revoke` unlinks the file (best-effort) and publishes
|
||||
``canary.{token_id}.revoked``.
|
||||
* :func:`seed_baseline` is the deploy-hook helper: synthesises the
|
||||
configured baseline set for one decky, persists rows, plants each.
|
||||
Failures are logged but do **not** abort the deploy (the deployer
|
||||
hook calls this best-effort).
|
||||
|
||||
We don't reuse :class:`SSHDriver` directly because the orchestrator
|
||||
driver is tied to its action types (``FileAction`` carries str
|
||||
content; canary content is bytes). The planter takes the same
|
||||
shape but speaks bytes-via-base64 over the wire.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import os
|
||||
import shlex
|
||||
import time
|
||||
from secrets import token_urlsafe
|
||||
from typing import Any, Iterable, Optional
|
||||
|
||||
from decnet.bus import topics
|
||||
from decnet.bus.base import BaseBus
|
||||
from decnet.bus.factory import get_bus
|
||||
from decnet.canary.base import CanaryArtifact, CanaryContext
|
||||
from decnet.canary.factory import get_generator
|
||||
from decnet.canary.paths import default_path_for
|
||||
from decnet.logging import get_logger
|
||||
from decnet.web.db.repository import BaseRepository
|
||||
|
||||
log = get_logger("canary.planter")
|
||||
|
||||
_DOCKER = "docker"
|
||||
_TIMEOUT = 8.0
|
||||
# Container suffix — matches the orchestrator SSH driver's convention
|
||||
# (``<decky_name>-ssh``). Canary placement always happens through the
|
||||
# ssh container because every decky has one and it carries the most
|
||||
# realistic filesystem layout.
|
||||
_SSH_CONTAINER_SUFFIX = "-ssh"
|
||||
|
||||
|
||||
def _container_for(decky_name: str) -> str:
|
||||
return f"{decky_name}{_SSH_CONTAINER_SUFFIX}"
|
||||
|
||||
|
||||
def _dirname(path: str) -> str:
|
||||
idx = path.rfind("/")
|
||||
if idx <= 0:
|
||||
return "/"
|
||||
return path[:idx]
|
||||
|
||||
|
||||
async def _run(argv: list[str]) -> tuple[int, str, str]:
|
||||
try:
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
*argv,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
except FileNotFoundError as exc:
|
||||
return 127, "", f"argv[0] not found: {exc}"
|
||||
try:
|
||||
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=_TIMEOUT)
|
||||
except asyncio.TimeoutError:
|
||||
try:
|
||||
proc.kill()
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
return 124, "", "timeout"
|
||||
return (
|
||||
proc.returncode if proc.returncode is not None else -1,
|
||||
stdout.decode("utf-8", "replace"),
|
||||
stderr.decode("utf-8", "replace"),
|
||||
)
|
||||
|
||||
|
||||
def _build_plant_command(artifact: CanaryArtifact) -> str:
|
||||
"""Compose the ``sh -c`` script that writes one artifact.
|
||||
|
||||
Binary safety: we base64-encode on the host side and ``base64 -d``
|
||||
inside the container, so the bytes never touch a shell argv
|
||||
interpolation point. Both ``base64`` (coreutils) and ``touch -d
|
||||
@<unix_ts>`` are present on every Linux base image we ship, so
|
||||
there's no per-distro branching.
|
||||
"""
|
||||
encoded = base64.b64encode(artifact.content).decode("ascii")
|
||||
mtime = int(time.time() + artifact.mtime_offset)
|
||||
mode_str = oct(artifact.mode)[2:]
|
||||
parts = [
|
||||
f"mkdir -p {shlex.quote(_dirname(artifact.path))}",
|
||||
f"printf %s {shlex.quote(encoded)} | base64 -d > {shlex.quote(artifact.path)}",
|
||||
f"chmod {mode_str} {shlex.quote(artifact.path)}",
|
||||
f"touch -d @{mtime} {shlex.quote(artifact.path)}",
|
||||
]
|
||||
return " && ".join(parts)
|
||||
|
||||
|
||||
async def _publish(
|
||||
bus: Optional[BaseBus], topic: str, payload: dict[str, Any],
|
||||
) -> None:
|
||||
"""Best-effort publish — never raises.
|
||||
|
||||
When ``bus`` is None we resolve via :func:`get_bus`; either way
|
||||
bus-side failures are logged and swallowed (delivery is at-most-once
|
||||
by contract; the DB row is source of truth).
|
||||
"""
|
||||
try:
|
||||
owns_bus = bus is None
|
||||
target = bus if bus is not None else get_bus()
|
||||
if owns_bus:
|
||||
await target.connect()
|
||||
await target.publish(topic, payload)
|
||||
if owns_bus:
|
||||
await target.close()
|
||||
except Exception as e: # noqa: BLE001
|
||||
log.warning("canary bus publish failed topic=%s err=%s", topic, e)
|
||||
|
||||
|
||||
async def plant(
|
||||
decky_name: str,
|
||||
artifact: CanaryArtifact,
|
||||
*,
|
||||
token_uuid: str,
|
||||
repo: Optional[BaseRepository] = None,
|
||||
publish: bool = True,
|
||||
bus: Optional[BaseBus] = None,
|
||||
) -> tuple[bool, Optional[str]]:
|
||||
"""Write *artifact* into the decky's ssh container.
|
||||
|
||||
Returns ``(success, error_or_none)``. When ``repo`` is provided
|
||||
the token row's state is updated to ``planted`` / ``failed``
|
||||
accordingly. When ``publish`` is True a ``canary.<id>.placed``
|
||||
event is published on the bus on success.
|
||||
|
||||
The function never raises on docker errors — callers (the API,
|
||||
the deploy hook) treat the result as data.
|
||||
"""
|
||||
if not artifact.path:
|
||||
err = "planter requires a non-empty artifact.path"
|
||||
log.warning("canary.plant skipped: %s decky=%s token=%s", err, decky_name, token_uuid)
|
||||
if repo is not None:
|
||||
await repo.update_canary_token_state(token_uuid, "failed", err)
|
||||
return False, err
|
||||
|
||||
sh_cmd = _build_plant_command(artifact)
|
||||
argv = [_DOCKER, "exec", _container_for(decky_name), "sh", "-c", sh_cmd]
|
||||
rc, _stdout, stderr = await _run(argv)
|
||||
success = rc == 0
|
||||
error = None if success else (stderr.strip()[:256] or f"rc={rc}")
|
||||
|
||||
if repo is not None:
|
||||
if success:
|
||||
await repo.update_canary_token_state(token_uuid, "planted", None)
|
||||
else:
|
||||
await repo.update_canary_token_state(token_uuid, "failed", error)
|
||||
|
||||
if success and publish:
|
||||
await _publish(bus, topics.canary(token_uuid, topics.CANARY_PLACED), {
|
||||
"token_id": token_uuid,
|
||||
"decky_name": decky_name,
|
||||
"placement_path": artifact.path,
|
||||
"instrumenter": artifact.instrumenter,
|
||||
"generator": artifact.generator,
|
||||
})
|
||||
|
||||
if not success:
|
||||
log.warning(
|
||||
"canary.plant failed decky=%s token=%s rc=%d stderr=%r",
|
||||
decky_name, token_uuid, rc, stderr[:120],
|
||||
)
|
||||
return success, error
|
||||
|
||||
|
||||
async def revoke(
|
||||
decky_name: str,
|
||||
placement_path: str,
|
||||
*,
|
||||
token_uuid: str,
|
||||
repo: Optional[BaseRepository] = None,
|
||||
publish: bool = True,
|
||||
bus: Optional[BaseBus] = None,
|
||||
) -> tuple[bool, Optional[str]]:
|
||||
"""Best-effort unlink + state transition + bus publish.
|
||||
|
||||
Returns ``(success, error_or_none)``. ``success`` is True when
|
||||
the file is gone after the call (whether we deleted it or it was
|
||||
already missing); only docker / container-down errors return False.
|
||||
"""
|
||||
sh_cmd = f"rm -f {shlex.quote(placement_path)}"
|
||||
argv = [_DOCKER, "exec", _container_for(decky_name), "sh", "-c", sh_cmd]
|
||||
rc, _stdout, stderr = await _run(argv)
|
||||
success = rc == 0
|
||||
error = None if success else (stderr.strip()[:256] or f"rc={rc}")
|
||||
|
||||
if repo is not None:
|
||||
await repo.update_canary_token_state(token_uuid, "revoked", error if not success else None)
|
||||
|
||||
if publish:
|
||||
await _publish(bus, topics.canary(token_uuid, topics.CANARY_REVOKED), {
|
||||
"token_id": token_uuid,
|
||||
"decky_name": decky_name,
|
||||
"placement_path": placement_path,
|
||||
})
|
||||
|
||||
return success, error
|
||||
|
||||
|
||||
def _baseline_set() -> Iterable[str]:
|
||||
"""Return the configured baseline generator names.
|
||||
|
||||
Honors ``DECNET_CANARY_BASELINE`` (comma-separated). Default is
|
||||
a sensible mix that exercises every callback-bearing generator
|
||||
plus a passive aws_creds drop for realism.
|
||||
"""
|
||||
raw = os.environ.get(
|
||||
"DECNET_CANARY_BASELINE",
|
||||
"git_config,env_file,honeydoc,aws_creds",
|
||||
)
|
||||
return [n.strip() for n in raw.split(",") if n.strip()]
|
||||
|
||||
|
||||
def _ctx_for(slug: str) -> CanaryContext:
|
||||
"""Build a :class:`CanaryContext` from the canary worker config."""
|
||||
base = os.environ.get("DECNET_CANARY_HTTP_BASE", "http://localhost:8088")
|
||||
zone = os.environ.get("DECNET_CANARY_DNS_ZONE", "")
|
||||
return CanaryContext(callback_token=slug, http_base=base, dns_zone=zone)
|
||||
|
||||
|
||||
async def seed_baseline(
|
||||
decky_name: str,
|
||||
repo: BaseRepository,
|
||||
*,
|
||||
persona: str = "linux",
|
||||
created_by: str = "system",
|
||||
bus: Optional[BaseBus] = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Plant the configured baseline canary set on one decky.
|
||||
|
||||
Best-effort: any individual placement that fails is logged and
|
||||
the row is left in ``state=failed``; the deployer hook treats the
|
||||
return value as informational, not authoritative.
|
||||
|
||||
Returns the list of token rows created (whether their planting
|
||||
ultimately succeeded or not), so the caller can surface them in
|
||||
the deploy report.
|
||||
"""
|
||||
out: list[dict[str, Any]] = []
|
||||
for gen_name in _baseline_set():
|
||||
try:
|
||||
generator = get_generator(gen_name)
|
||||
except ValueError:
|
||||
log.warning("canary.seed_baseline: unknown generator %r — skipping", gen_name)
|
||||
continue
|
||||
slug = token_urlsafe(16)
|
||||
ctx = _ctx_for(slug)
|
||||
artifact = generator.generate(ctx)
|
||||
artifact.path = default_path_for(gen_name, persona)
|
||||
kind = "aws_passive" if gen_name == "aws_creds" else "http"
|
||||
# Persist first so the planter has a row to update; that way a
|
||||
# crash mid-plant leaves a recoverable failed-state row.
|
||||
from uuid import uuid4
|
||||
token_uuid = str(uuid4())
|
||||
await repo.create_canary_token({
|
||||
"uuid": token_uuid,
|
||||
"kind": kind,
|
||||
"decky_name": decky_name,
|
||||
"blob_uuid": None,
|
||||
"instrumenter": None,
|
||||
"generator": gen_name,
|
||||
"placement_path": artifact.path,
|
||||
"callback_token": slug,
|
||||
"secret_seed": slug,
|
||||
"created_by": created_by,
|
||||
"state": "planted", # optimistic — plant() flips to failed on error
|
||||
})
|
||||
await plant(
|
||||
decky_name, artifact,
|
||||
token_uuid=token_uuid, repo=repo, publish=True, bus=bus,
|
||||
)
|
||||
out.append({
|
||||
"token_uuid": token_uuid, "generator": gen_name, "kind": kind,
|
||||
"callback_token": slug, "placement_path": artifact.path,
|
||||
})
|
||||
return out
|
||||
233
tests/canary/test_planter.py
Normal file
233
tests/canary/test_planter.py
Normal file
@@ -0,0 +1,233 @@
|
||||
"""Coverage for the canary planter (docker exec wrapper).
|
||||
|
||||
We don't actually invoke docker — :func:`asyncio.create_subprocess_exec`
|
||||
is patched to record argv and return canned ``(rc, stdout, stderr)``
|
||||
triples. That lets us assert:
|
||||
|
||||
* the docker argv has the right shape (container = ``<decky>-ssh``,
|
||||
``sh -c <script>``);
|
||||
* the script base64-decodes the artifact bytes losslessly;
|
||||
* mtime is backdated by the right offset;
|
||||
* state transitions hit the repo on success/failure;
|
||||
* the bus event publishes on success.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import os
|
||||
import re
|
||||
from typing import AsyncIterator
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
|
||||
from decnet.bus import topics
|
||||
from decnet.bus.fake import FakeBus
|
||||
from decnet.canary import CanaryArtifact
|
||||
from decnet.canary import planter
|
||||
from decnet.web.db.sqlite.repository import SQLiteRepository
|
||||
import decnet.web.db.models # noqa: F401
|
||||
|
||||
|
||||
class _FakeProc:
|
||||
def __init__(self, rc: int, stdout: bytes = b"", stderr: bytes = b"") -> None:
|
||||
self.returncode = rc
|
||||
self._stdout = stdout
|
||||
self._stderr = stderr
|
||||
|
||||
async def communicate(self) -> tuple[bytes, bytes]:
|
||||
return self._stdout, self._stderr
|
||||
|
||||
def kill(self) -> None: # pragma: no cover — never reached in non-timeout tests
|
||||
pass
|
||||
|
||||
|
||||
def _patch_subprocess(rc: int = 0, stderr: bytes = b""):
|
||||
captured: list[list[str]] = []
|
||||
|
||||
async def _fake(*argv, **kw):
|
||||
captured.append(list(argv))
|
||||
return _FakeProc(rc, b"", stderr)
|
||||
|
||||
return patch.object(asyncio, "create_subprocess_exec", _fake), captured
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def repo(tmp_path) -> AsyncIterator[SQLiteRepository]:
|
||||
r = SQLiteRepository(str(tmp_path / "p.db"))
|
||||
await r.initialize()
|
||||
yield r
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def fake_bus() -> AsyncIterator[FakeBus]:
|
||||
bus = FakeBus()
|
||||
await bus.connect()
|
||||
yield bus
|
||||
await bus.close()
|
||||
|
||||
|
||||
# ---------------- argv shape + base64 round-trip --------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_plant_argv_and_base64_round_trip(repo: SQLiteRepository, fake_bus: FakeBus, tmp_path) -> None:
|
||||
art = CanaryArtifact(
|
||||
path="/home/admin/.aws/credentials",
|
||||
content=b"\x00binary\xffpayload",
|
||||
mode=0o600,
|
||||
mtime_offset=-86400,
|
||||
generator="aws_creds",
|
||||
)
|
||||
# Persist a token row so the state-update path has something to flip.
|
||||
await repo.create_canary_token({
|
||||
"uuid": "tok-1", "kind": "http", "decky_name": "web1",
|
||||
"generator": "aws_creds", "placement_path": art.path,
|
||||
"callback_token": "slug", "secret_seed": "s", "created_by": "u1",
|
||||
})
|
||||
patcher, captured = _patch_subprocess(rc=0)
|
||||
with patcher:
|
||||
ok, err = await planter.plant(
|
||||
"web1", art, token_uuid="tok-1", repo=repo, bus=fake_bus,
|
||||
)
|
||||
assert ok is True and err is None
|
||||
assert len(captured) == 1
|
||||
argv = captured[0]
|
||||
assert argv[:3] == ["docker", "exec", "web1-ssh"]
|
||||
assert argv[3:5] == ["sh", "-c"]
|
||||
script = argv[5]
|
||||
# base64-decoded payload appears in the script verbatim.
|
||||
encoded = base64.b64encode(art.content).decode()
|
||||
assert encoded 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
|
||||
# State transitioned to planted.
|
||||
row = await repo.get_canary_token("tok-1")
|
||||
assert row["state"] == "planted" and row["last_error"] is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_plant_records_failure_when_docker_returns_nonzero(repo: SQLiteRepository, fake_bus: FakeBus) -> None:
|
||||
await repo.create_canary_token({
|
||||
"uuid": "tok-2", "kind": "http", "decky_name": "web1",
|
||||
"generator": "env_file", "placement_path": "/x",
|
||||
"callback_token": "slug2", "secret_seed": "s", "created_by": "u1",
|
||||
})
|
||||
art = CanaryArtifact(path="/x", content=b"y", generator="env_file")
|
||||
patcher, _ = _patch_subprocess(rc=125, stderr=b"container not running")
|
||||
with patcher:
|
||||
ok, err = await planter.plant(
|
||||
"web1", art, token_uuid="tok-2", repo=repo, bus=fake_bus,
|
||||
)
|
||||
assert ok is False
|
||||
assert err and "not running" in err
|
||||
row = await repo.get_canary_token("tok-2")
|
||||
assert row["state"] == "failed" and row["last_error"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_plant_rejects_empty_path(repo: SQLiteRepository) -> None:
|
||||
await repo.create_canary_token({
|
||||
"uuid": "tok-3", "kind": "http", "decky_name": "web1",
|
||||
"generator": "env_file", "placement_path": "/x",
|
||||
"callback_token": "slug3", "secret_seed": "s", "created_by": "u1",
|
||||
})
|
||||
art = CanaryArtifact(path="", content=b"y")
|
||||
ok, err = await planter.plant("web1", art, token_uuid="tok-3", repo=repo)
|
||||
assert ok is False and err is not None
|
||||
row = await repo.get_canary_token("tok-3")
|
||||
assert row["state"] == "failed"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_plant_publishes_placed_event(repo: SQLiteRepository, fake_bus: FakeBus) -> None:
|
||||
await repo.create_canary_token({
|
||||
"uuid": "tok-4", "kind": "http", "decky_name": "web1",
|
||||
"generator": "env_file", "placement_path": "/x",
|
||||
"callback_token": "slug4", "secret_seed": "s", "created_by": "u1",
|
||||
})
|
||||
sub = fake_bus.subscribe("canary.>")
|
||||
art = CanaryArtifact(path="/x", content=b"y", generator="env_file")
|
||||
patcher, _ = _patch_subprocess(rc=0)
|
||||
with patcher:
|
||||
await planter.plant(
|
||||
"web1", art, token_uuid="tok-4", repo=repo, bus=fake_bus,
|
||||
)
|
||||
event = await asyncio.wait_for(sub.__anext__(), timeout=1.0)
|
||||
assert event.topic == topics.canary("tok-4", topics.CANARY_PLACED)
|
||||
assert event.payload["decky_name"] == "web1"
|
||||
assert event.payload["generator"] == "env_file"
|
||||
|
||||
|
||||
# ---------------- revoke --------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_revoke_unlinks_and_publishes(repo: SQLiteRepository, fake_bus: FakeBus) -> None:
|
||||
await repo.create_canary_token({
|
||||
"uuid": "tok-r", "kind": "http", "decky_name": "web1",
|
||||
"generator": "env_file", "placement_path": "/etc/x.env",
|
||||
"callback_token": "slugR", "secret_seed": "s", "created_by": "u1",
|
||||
})
|
||||
sub = fake_bus.subscribe("canary.>")
|
||||
patcher, captured = _patch_subprocess(rc=0)
|
||||
with patcher:
|
||||
ok, err = await planter.revoke(
|
||||
"web1", "/etc/x.env",
|
||||
token_uuid="tok-r", repo=repo, bus=fake_bus,
|
||||
)
|
||||
assert ok and not err
|
||||
assert "rm -f /etc/x.env" in captured[0][5]
|
||||
row = await repo.get_canary_token("tok-r")
|
||||
assert row["state"] == "revoked"
|
||||
event = await asyncio.wait_for(sub.__anext__(), timeout=1.0)
|
||||
assert event.topic == topics.canary("tok-r", topics.CANARY_REVOKED)
|
||||
|
||||
|
||||
# ---------------- seed_baseline ------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_seed_baseline_creates_one_token_per_generator(
|
||||
repo: SQLiteRepository, fake_bus: FakeBus, monkeypatch
|
||||
) -> None:
|
||||
monkeypatch.setenv("DECNET_CANARY_BASELINE", "git_config,env_file,aws_creds")
|
||||
monkeypatch.setenv("DECNET_CANARY_HTTP_BASE", "https://canary.test")
|
||||
patcher, captured = _patch_subprocess(rc=0)
|
||||
with patcher:
|
||||
rows = await planter.seed_baseline("web1", repo, bus=fake_bus)
|
||||
assert {r["generator"] for r in rows} == {"git_config", "env_file", "aws_creds"}
|
||||
# One docker exec per generator.
|
||||
assert len(captured) == 3
|
||||
# aws_creds ends up as kind=aws_passive; the other two are http.
|
||||
by_gen = {r["generator"]: r for r in rows}
|
||||
assert by_gen["aws_creds"]["kind"] == "aws_passive"
|
||||
assert by_gen["env_file"]["kind"] == "http"
|
||||
persisted = await repo.list_canary_tokens(decky_name="web1")
|
||||
assert len(persisted) == 3
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_seed_baseline_skips_unknown_generator(repo: SQLiteRepository, monkeypatch) -> None:
|
||||
monkeypatch.setenv("DECNET_CANARY_BASELINE", "env_file,bogus")
|
||||
patcher, _ = _patch_subprocess(rc=0)
|
||||
with patcher:
|
||||
rows = await planter.seed_baseline("web1", repo)
|
||||
assert {r["generator"] for r in rows} == {"env_file"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_seed_baseline_marks_failed_when_docker_errors(
|
||||
repo: SQLiteRepository, monkeypatch
|
||||
) -> None:
|
||||
monkeypatch.setenv("DECNET_CANARY_BASELINE", "env_file")
|
||||
patcher, _ = _patch_subprocess(rc=125, stderr=b"container down")
|
||||
with patcher:
|
||||
rows = await planter.seed_baseline("web1", repo)
|
||||
assert len(rows) == 1
|
||||
persisted = await repo.list_canary_tokens(decky_name="web1")
|
||||
assert persisted[0]["state"] == "failed"
|
||||
assert persisted[0]["last_error"]
|
||||
Reference in New Issue
Block a user