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.
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:
-
In
alknet-docker— theDockerTtyBackendis a type in alknet-docker, behind attyfeature gate that pulls inalknet-tty(for theTtyBackendtrait) andbollard(for the attach/exec API). alknet-docker already depends on bollard for its call operations; thettyfeature adds only thealknet-ttyedge. -
In
alknet-tty-docker(a sibling adapter crate) — theDockerTtyBackendin its own crate, depending onalknet-tty(the trait) andbollard(the API). alknet-docker stays leaner (noalknet-ttyedge); the adapter is its own publishable unit. -
In
alknet-tty-local(extending the local sibling) — rejected immediately.alknet-tty-localis 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— addsDockerTtyBackend(implTtyBackend), pulling inalknet-ttyfor the trait. A deployment that wants interactive terminal sessions into docker containers enables this and registersDockerTtyBackendwith theTtyAdapter(alongside or instead ofLocalTtyBackend).
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:
attachmode — wrapsbollard::attach_container()(the reliable HTTP-upgrade path, per the POC and ADR-059 §3). ReturnsAttachContainerResults { output, input }.output→TtyHandle.stdout(as aStream<Bytes>, mappingLogOutput→Byteswith the stream_type flattened — PTY mode has no separate stderr, soTtyHandle.stderrisNone).input→TtyHandle.stdin(theAsyncWritebollard provides). Exit code: the output stream end +wait_container()orinspect_container()for the exit status.execmode — wrapsbollard::create_exec+start_execwithtty: true.start_execreturnsStartExecResults::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) orbollard::exec::resize_exec()(exec mode).signal()→ docker has no direct container signal API in bollard's stable surface. The POC usedkill_containerfor the attach case. For exec mode, there is no per-exec signal; the signal is delivered to the container's PID namespace viakill_containerwith the container's main PID, or the exec's PID if exposed. This is a best-effort mapping per theTtyControl::signalcontract ("best-effort delivery to the foreground process group"); unknown signals fall back tokill_containerwithSIGKILL. The exact signal-delivery path (bollard'skill_containervs 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(¶ms) 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
Dropissueskill_containerwith 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
DockerTtyBackendlives where its deps live (bollard in alknet-docker), matching the ADR-053 inversion principle. Noalknet-tty-dockersibling crate, no cycle risk. - The
ttyfeature gate keeps thealknet-ttyedge opt-in. A call-operations-only deployment (the common case) doesn't pull in alknet-tty. - The POC's
drive_attach_rawmaps cleanly toDockerTtyBackend::allocate()— the backend produces handles, theTtyAdapterpumps 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-ttydependency edge (behind thettyfeature). 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::signalcontract is already best-effort (ADR-053), so this is within the contract, but a user expectingCtrl-Cto 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
attachvsexecmode split inDockerBackendParamsis a backend-specific detail the caller must know. TheTtyParamsis opaque to the adapter (ADR-053), but the caller (the client opening the tty session) must setmode: "attach"ormode: "exec"in the negotiation frame's backend params. This is the same opacity as the local backend'scwd/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
TtyBackendtrait this backend implements; the inversion principle (backends live where their transport deps live) - ADR-052 — the wire
format the
TtyAdapterpumps (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_codefuture'sDropkills) - 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
websocketfeature — 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 forallocate())- 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