Files
alknet/docs/architecture/decisions/061-docker-tty-backend-in-alknet-docker.md
T
glm-5.2 1ce11717d8 docs(arch): draft alknet-docker architecture specs (ADRs 058-063, OQs 048-051)
Greenfield architecture spec set for the alknet-docker crate — a thin,
single-host bollard wrapper exposing docker container/image operations
as call-protocol ops on the shared alknet/call ALPN, plus a
DockerTtyBackend (impl TtyBackend) behind a tty feature for interactive
terminal sessions into containers over alknet/tty.

Six ADRs:
- 058: docker ops on alknet/call (no separate ALPN; raw-carriage handoff
  dissolved by alknet-tty extraction — interactive attach moved to
  alknet/tty via DockerTtyBackend, no carriage field on call.requested)
- 059: bollard 0.21 (verified current on crates.io) + feature selection
  (http+pipe+time; no ssl/ssh/websocket/buildkit)
- 060: container resource model (ADR-050 application) — alknet.managed/
  alknet.owner labels, list owned_only flag, hosted-services operator
  role via static-resource fallback, handler-driven revoke with
  autonomous-death tolerance, resource-action vocabulary
- 061: DockerTtyBackend in alknet-docker behind tty feature (attach vs
  exec mode; POC drive_attach_raw as reference)
- 062: Docker client + OwnershipStore injection via closure capture
  (not Capabilities, not OperationContext — matches from_openapi pattern)
- 063: exit code on terminal call.responded for non-interactive exec
  (call.completed stays empty, ADR-012 unchanged)

Four spec docs (crates/docker/): README, overview, docker-operations,
docker-tty-backend. Four deferred-scope OQs (048-051): network/volume
ops, buildkit, system events subscription, create options surface.

Updates the tty-backend.md Backend implementations table (DockerTtyBackend
row now specced) and the architecture README (doc table, ADR table,
current-state paragraph, OQ count).

Grounded in the alknet-docker POC (docs/research/alknet-docker/poc-summary.md)
which validated the hard parts; the remaining lifecycle ops are mechanical
bollard wrapping. Reviewed by architecture-reviewer subagent; criticals
(the docker_client injection model conflicting with the Capabilities
contract) resolved via ADR-062/063 before commit.
2026-07-08 09:29:22 +00:00

14 KiB

ADR-061: DockerTtyBackend in alknet-docker

Status

Accepted

Context

The alknet-tty spec (tty-backend.md §"Backend implementations") lists three TtyBackend implementations: LocalTtyBackend (in alknet-tty-local), DockerTtyBackend (location "future, out of scope here"), and SshTtyBackend (in alknet-ssh). The alknet-tty spec deliberately left the DockerTtyBackend location open — it committed the trait shape but not the crate placement.

alknet-docker is now being specified. The DockerTtyBackend — the impl TtyBackend that wraps bollard::attach_container() for interactive attach and bollard::exec::start_exec with tty: true for interactive exec — is the natural extension of the alknet-docker POC's drive_attach_raw (/workspace/alknet-docker-poc/src/ops.rs). The question is which crate it lives in.

The options

Three placements were considered:

  1. In alknet-docker — the DockerTtyBackend is a type in alknet-docker, behind a tty feature gate that pulls in alknet-tty (for the TtyBackend trait) and bollard (for the attach/exec API). alknet-docker already depends on bollard for its call operations; the tty feature adds only the alknet-tty edge.

  2. In alknet-tty-docker (a sibling adapter crate) — the DockerTtyBackend in its own crate, depending on alknet-tty (the trait) and bollard (the API). alknet-docker stays leaner (no alknet-tty edge); the adapter is its own publishable unit.

  3. In alknet-tty-local (extending the local sibling) — rejected immediately. alknet-tty-local is for local backends (portable_pty, std::process); docker is not local-process, and pulling bollard into the local sibling would violate the feature-gate rationale (a docker-only deployment pulling in PTY code, and a local-only deployment pulling in bollard).

Why option 1 (in alknet-docker)

The DockerTtyBackend is tightly coupled to alknet-docker's domain: it uses the same bollard client, the same container identity model (resource_id extraction from backend_params.container), and the same label/ownership reasoning (ADR-060) as the call operations. A separate alknet-tty-docker crate would duplicate the bollard client construction, the container-id validation, and the ownership wiring — or import them from alknet-docker, creating a cycle (alknet-tty-docker → alknet-docker for the client, alknet-docker → alknet-tty-docker for the backend registration).

The dependency edge from alknet-docker to alknet-tty is clean: alknet-docker depends on alknet-tty for the TtyBackend trait (and TtyHandle, TtyControl, TtyError, TtyParams). This is the same edge any backend crate has — alknet-tty-local depends on alknet-tty for the trait. The trait is the inversion point (ADR-053); the backend impls live where their transport deps live (bollard, for DockerTtyBackend). bollard already lives in alknet-docker; the DockerTtyBackend belongs with it.

The tty feature gate means a deployment that wants only the call operations (no interactive terminal) doesn't pull in alknet-tty. The feature is opt-in, matching alknet-tty-local's local feature pattern (ADR-054).

The POC's drive_attach_raw as the reference

The POC's drive_attach_raw (/workspace/alknet-docker-poc/src/ops.rs) is the seed of DockerTtyBackend::allocate(). The mapping:

POC drive_attach_raw DockerTtyBackend::allocate()
reads call.requested frame, extracts container id TtyParams.backend_params → DockerBackendParams { container }
bollard::attach_container() → AttachContainerResults { output, input } same; output → TtyHandle.stdout, input → TtyHandle.stdin
ChunkReader/ChunkWriter on the bidi stream the TtyAdapter owns the chunk codec (ADR-052); the backend produces handles, not wire bytes
zero-length stdin chunk → container_input.shutdown() TtyControl / stdin EOF — the adapter signals; the backend's AsyncWrite close maps to shutdown()
completion: output stream ends → zero-length stdout sentinel → close TtyHandle.exit_code future — for attach, the exit comes from wait_container or the output stream end; for exec with tty: true, inspect_exec after the output stream ends
LogOutput → Chunk stream_type mapping (StdOut→1, StdErr→2) TtyHandle.stdout/stderr — PTY mode merges (stderr is None); the adapter pumps stdout only

The POC's drive_exec (non-interactive, exit code via inspect_exec) is the basis for the call operation docker/container/exec (tty: false), not the DockerTtyBackend (which is the tty: true interactive path). The split: non-interactive exec is a call Subscription (ADR-058); interactive exec is a DockerTtyBackend session. Both use bollard::exec; they diverge at the wire format.

Decision

1. DockerTtyBackend is a type in alknet-docker, behind a tty feature

# alknet-docker Cargo.toml
[features]
default = ["ops"]     # the call operations
ops = []               # docker/container/* and docker/image/* operations
tty = ["dep:alknet-tty"]  # DockerTtyBackend (impl TtyBackend)
  • default = ["ops"] — the call operations (lifecycle, logs, exec non-interactive, images). This is what a hub or coordinator wires to manage containers over the call protocol.
  • tty — adds DockerTtyBackend (impl TtyBackend), pulling in alknet-tty for the trait. A deployment that wants interactive terminal sessions into docker containers enables this and registers DockerTtyBackend with the TtyAdapter (alongside or instead of LocalTtyBackend).

The feature gate means a deployment that uses alknet-docker only for call operations (the common case — a hub managing dev containers) doesn't pull in alknet-tty. A deployment that wants interactive terminals into those containers enables tty and registers the backend.

2. DockerTtyBackend::allocate() wraps attach_container or exec-with-PTY

allocate() branches on TtyParams.terminal and a DockerBackendParams field for the attach mode:

  • attach mode — wraps bollard::attach_container() (the reliable HTTP-upgrade path, per the POC and ADR-059 §3). Returns AttachContainerResults { output, input }. output → TtyHandle.stdout (as a Stream<Bytes>, mapping LogOutput → Bytes with the stream_type flattened — PTY mode has no separate stderr, so TtyHandle.stderr is None). input → TtyHandle.stdin (the AsyncWrite bollard provides). Exit code: the output stream end + wait_container() or inspect_container() for the exit status.
  • exec mode — wraps bollard::create_exec + start_exec with tty: true. start_exec returns StartExecResults::Attached { output, input }; same mapping as attach. Exit code: after the output stream ends, inspect_exec() for the exit code (the POC's pattern).

The mode is selected by a DockerBackendParams.mode field ("attach" | "exec", default "exec" for a new command, "attach" for attaching to a running container's primary process). For exec mode, TtyParams.cmd is the command vector; for attach mode, cmd is ignored (the primary process is already running).

3. TtyControl maps to bollard's resize and signal

  • resize() → bollard::container::resize_container_tty() (attach mode) or bollard::exec::resize_exec() (exec mode).
  • signal() → docker has no direct container signal API in bollard's stable surface. The POC used kill_container for the attach case. For exec mode, there is no per-exec signal; the signal is delivered to the container's PID namespace via kill_container with the container's main PID, or the exec's PID if exposed. This is a best-effort mapping per the TtyControl::signal contract ("best-effort delivery to the foreground process group"); unknown signals fall back to kill_container with SIGKILL. The exact signal-delivery path (bollard's kill_container vs a future exec-PID signal API) is a two-way-door implementation detail within the one-way backend placement.

4. resource_id() returns Some(("container", container_id))

DockerTtyBackend::resource_id(&params) extracts the container ID from backend_params and returns Some(("container", id)), per the tty-backend.md sketch. The TtyAdapter calls OwnershipProvider::owns(identity, "container", id, "tty") at negotiation (ADR-050, ADR-060). The backend owns the extraction; the adapter doesn't parse docker-specific JSON. This is the same shape the tty-backend.md sketch anticipates.

5. exit_code future's Drop kills the container/exec (ADR-056)

The TtyHandle.exit_code future's Drop-on-cancel (ADR-056) issues:

  • Attach mode — bollard::kill_container() (or the container's natural exit if the stream end already triggered). The container is not removed (attach doesn't own the container's lifecycle); it's killed so the process doesn't outlive the session.
  • Exec mode — the exec instance is not a separate killable process in docker's model; the signal goes to the container. The Drop issues kill_container with the container's main PID as a best-effort. A container running an exec that the terminal session cancels should have the exec's process terminated; docker's exec isolation means this is best-effort, not guaranteed.

This satisfies ADR-056's contract: dropping exit_code (cancel) kills the session target. The "session target" for docker is the container (attach) or the exec's process (exec, best-effort via the container).

Consequences

Positive:

  • The DockerTtyBackend lives where its deps live (bollard in alknet-docker), matching the ADR-053 inversion principle. No alknet-tty-docker sibling crate, no cycle risk.
  • The tty feature gate keeps the alknet-tty edge opt-in. A call-operations-only deployment (the common case) doesn't pull in alknet-tty.
  • The POC's drive_attach_raw maps cleanly to DockerTtyBackend::allocate() — the backend produces handles, the TtyAdapter pumps the wire format. The POC's three-pump driver (session.rs) is the reference; the backend extracts the bollard interaction from it.
  • The resource_id() delegation means the adapter's ownership check (ADR-060) works without the adapter parsing docker JSON — the backend extracts the container ID, the adapter checks the store.

Negative:

  • alknet-docker gains an alknet-tty dependency edge (behind the tty feature). This is the intended trade: the backend lives with its transport dep, and the trait edge is feature-gated. A deployment that wants interactive docker terminals accepts this; one that doesn't is unaffected.
  • Signal delivery in docker exec mode is best-effort (docker's exec isolation limits per-exec signal targeting). The TtyControl::signal contract is already best-effort (ADR-053), so this is within the contract, but a user expecting Ctrl-C to kill only the exec process (not the container) may see the container killed instead. This is a docker-semantics limitation, not an alknet limitation; the local backend (portable_pty) has precise process-group targeting (REQ-TTY-02). The docker backend's signal semantics are documented in the spec, not hidden.
  • The attach vs exec mode split in DockerBackendParams is a backend-specific detail the caller must know. The TtyParams is opaque to the adapter (ADR-053), but the caller (the client opening the tty session) must set mode: "attach" or mode: "exec" in the negotiation frame's backend params. This is the same opacity as the local backend's cwd/env — the caller knows their backend. No adapter change is needed for a new mode; the backend parses its own params.

Door type

One-way (crate placement) + two-way (feature gate, attach/exec mode split, signal path). The decision to put DockerTtyBackend in alknet-docker (not a sibling crate) is one-way: the backend type, its registration with the TtyAdapter, and its bollard/alknet-tty edges are structural. Reversing to a sibling crate would move the type and re-edge the dependencies.

The feature gate (tty opt-in), the attach/exec mode split in DockerBackendParams, and the exact signal-delivery path (kill_container vs a future exec-PID API) are two-way-door implementation details within the one-way placement. The feature can be renamed, the mode field can gain a third value, and the signal path can be refined — all without a structural change.

References

  • ADR-053 — the TtyBackend trait this backend implements; the inversion principle (backends live where their transport deps live)
  • ADR-052 — the wire format the TtyAdapter pumps (the backend produces handles, not wire)
  • ADR-054 — the sibling-crate pattern for LocalTtyBackend (the parallel this ADR follows, with the difference that docker's deps are already in alknet-docker)
  • ADR-056 — the cancel-cleanup contract (exit_code future's Drop kills)
  • ADR-050 — the ownership model resource_id() delegates to
  • ADR-058 — why the non-interactive exec is a call op, not this backend
  • ADR-059 — the bollard dependency (no websocket feature — the reliable attach path)
  • docs/research/alknet-docker/poc-summary.md §"POC Target 1" (interactive attach — the seed of this backend)
  • /workspace/alknet-docker-poc/src/ops.rs — drive_attach_raw (the reference for allocate())
  • tty-backend.md §"Backend implementations" (the row this ADR fills: "DockerTtyBackend | alknet-docker or sibling adapter | future, out of scope here")
  • Spec: docker-tty-backend.md