ADR-023: Container Interaction in the Deployment Backend Protocol¶
Status¶
accepted
Date¶
2026-05-03
Context¶
ADR-013 introduced the
DeploymentBackend Protocol covering lab lifecycle operations (start,
stop, status, kill, pull_images) and explicitly listed container
interaction (exec, logs, inspect) as out of scope, noting it could be
"abstracted independently when needed." That moment arrived with
issue #138, which adds
three CLI commands required by CLI-004 (aptl container
list, aptl container shell, aptl container logs) and ships them
alongside the long-stubbed aptl config show/aptl config validate.
The CLI commands need a single uniform path that works against both the
local DockerComposeBackend and the SSH-remote SSHComposeBackend. The
SSH backend already centralises DOCKER_HOST=ssh:// env injection inside
its _run override, so any container-interaction methods added to the
Protocol pick up SSH-aware execution for free; the alternative (letting
the CLI shell out to raw docker compose itself) would route around the
backend entirely and silently break SSH-remote deployments.
Three core helpers were already issuing raw docker exec / docker
inspect calls outside the backend: core/snapshot.py (docker exec ×4
plus docker inspect <name>), core/flags.py (docker exec <c> cat), and
core/collectors.py (docker exec aptl-suricata cat … plus docker logs
<name> --since/--until). Leaving those untouched while adding new
backend methods would have left the very duplication this Protocol is
meant to eliminate.
Alternatives considered¶
- Sibling Protocol (
ContainerBackend) decoupled fromDeploymentBackend. Cleaner separation of concerns, but every caller (CLI, snapshot, flags, collectors) would have to instantiate and thread two backends and ensure they target the same Docker daemon. Buys nothing in practice; the SSH-remote case explicitly requires the same transport for both. Rejected. - Generic
run_docker(args)method instead of named methods. Smaller surface area but loses callsite intent ("inspect this container" vs "run an arbitrary docker command"), makes mocking harder, and invites future callers to bypass the typed methods. Rejected. - Use
docker compose exec/logsfor everything (compose-flavored). Works forcontainer_list(project-scoped enumeration), but the remaining commands take container names, which is whatcontainer_listshows the user. Forcing service names everywhere would be inconsistent. Rejected forlogs/shell/exec/inspect; accepted forlist.
Decision¶
Extend the existing DeploymentBackend Protocol with eleven new
methods: seven for container interaction (CLI surface) and four for
host inventory (snapshot capture). Both DockerComposeBackend and
SSHComposeBackend implement them. The SSH backend overrides only one
helper (_subprocess_kwargs) which threads DOCKER_HOST=ssh://… into
both captured (_run) and streaming (_run_streaming) execution paths
through a single env-construction site, instead of duplicating the
override across both methods.
class DeploymentBackend(Protocol):
# … existing lifecycle methods …
def container_list(
self, *, all_containers: bool = True
) -> list[dict]: ...
def container_logs(
self, name: str, *, follow: bool = False, tail: int | None = None
) -> int: ...
def container_logs_capture(
self, name: str, *,
since: str | None = None,
until: str | None = None,
) -> subprocess.CompletedProcess: ...
def container_shell(
self, name: str, *, shell: str | None = None
) -> int: ...
def container_exec(
self, name: str, cmd: list[str], *, timeout: int | None = None
) -> subprocess.CompletedProcess: ...
def container_inspect(self, name: str) -> dict: ...
def container_exists(self, name: str) -> bool: ...
# Host inventory (typed; no generic argv passthrough)
def host_versions(self) -> dict[str, str]: ...
def host_list_lab_containers(self) -> list[dict]: ...
def host_list_lab_networks(self, name_prefix: str) -> list[str]: ...
def host_inspect_network(self, name: str) -> dict: ...
Implementation rules:
container_listusesdocker compose ps -a --format jsonbecause enumeration is naturally project-scoped. Output is parsed as either a JSON array or NDJSON, matchingstatus()behaviour.container_logs/container_logs_capture/container_shell/container_exec/container_inspectuse rawdocker <op> <container>because they all take container names, the names users see incontainer_listoutput.DOCKER_HOST=ssh://…is honoured uniformly by bothdockeranddocker composeCLIs, so SSH-remote works without per-method special handling.container_shellwithshell=Noneprobes bash non-interactively viadocker exec <name> /bin/bash -c truefirst; if that exits 0 it launches an interactivebashTTY, if it exits 126/127 it falls back to/bin/sh, otherwise it surfaces the probe error code. The probe is required so the user's ownexit 126/exit 127from inside an interactive shell isn't misread as "bash is missing." An explicit--shellskips the probe entirely.- The host inventory methods replace what would otherwise be a generic
host_run(args)argv-passthrough escape hatch. Typed methods keep the Protocol Docker-shape-agnostic so a future non-Docker backend (for example, Podman, Kubernetes, Nomad) can implement them in its own terms instead of having to emulate Docker CLI behaviour. Snapshot capture (core/snapshot.py) is the only consumer today:_get_software_versionsuseshost_versions,_get_container_snapshotsuseshost_list_lab_containers(plus per-containercontainer_inspectfor network IPs), and_get_network_snapshotsuseshost_list_lab_networks host_inspect_network.- Streaming methods (
container_logs,container_shell) inherit the parent's stdin/stdout/stderr (no capture), so the user sees logs arrive live and shells get a real TTY. A new_run_streaminghelper alongside_runkeeps env construction in one place. container_execis non-interactive and captured (one-shot commands).container_logs_captureis the captured variant ofcontainer_logsfor programmatic consumers (collectors).container_inspectreturns the first element ofdocker inspect's JSON array as a plain dict, with{}on any failure.
The three previously raw callers (core/snapshot.py, core/flags.py,
core/collectors.py) are updated to take a backend parameter and route
container exec/inspect/logs through the Protocol. Truly host-level
calls (docker version, docker compose version, docker ps -a --filter
name=aptl- for project-wide enumeration, docker network ls/inspect)
remain raw subprocess calls—they target the daemon itself, not specific
containers, and don't fit the Protocol's container-interaction model.
Consequences¶
Positive¶
- CLI symmetry:
aptl container list/shell/logsbehaves identically on local Docker Compose and SSH-remote labs. No special-case code in the CLI layer. - SSH-remote correctness for snapshots, flags, and run-archive collectors: previously these would have silently targeted the local Docker daemon even when the lab was running on a remote host. They now route through the same backend the rest of the CLI uses.
- Single env-injection point:
_runand_run_streamingeach construct the env exactly once. Adding a future backend (Kubernetes, Podman) requires implementing those two helpers. - Testability: All three refactored callers can now be exercised with
a
MagicMock()backend instead ofsubprocesspatching. Tests stop caring about argv shape and focus on behaviour. - Eliminates raw
docker exec/docker inspectfromcore/(host-leveldocker version/docker networkcalls intentionally remain).
Negative¶
- Protocol grew from 5 to 15 methods. Adding a new backend now
involves implementing ten more methods (six container-interaction +
four host inventory). Mitigated by inheritance:
SSHComposeBackendoverrides only the two_run*helpers and inherits the rest fromDockerComposeBackend. - Two log methods (
container_logs/container_logs_capture). Streaming and captured semantics genuinely differ; collapsing them into a single method would have required either acaptureflag with a union return type or a buffer-and-return approach that defeats streaming's purpose.
Risks¶
docker compose ps -aschema drift. If Docker Compose changes the field names emitted by--format json,container_listconsumers (the CLI table renderer) will silently lose columns. Same risk asstatus()had pre-CLI-004; out of scope to address here./bin/bash→/bin/shfallback shells too eagerly. If a user intends bash but it's missing, they'll land in sh instead of getting a clear error. Acceptable: sh is functional and the user can rerun with--shell /bin/bashto see the explicit failure.
References¶
- ADR-013: original deployment abstraction; this ADR supersedes its "out of scope" clause for the six listed methods.
- Issue #138: the CLI work that drove this Protocol extension.
- CLI-002, CLI-004, CLI-007 in the GRC workflow platform.