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.
272 lines
14 KiB
Markdown
272 lines
14 KiB
Markdown
# ADR-061: DockerTtyBackend in alknet-docker
|
|
|
|
## Status
|
|
|
|
Accepted
|
|
|
|
## Context
|
|
|
|
The alknet-tty spec ([tty-backend.md](../crates/tty/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
|
|
|
|
```toml
|
|
# 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(¶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
|
|
`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](053-ttybackend-trait-and-ttyhandle.md) — the
|
|
`TtyBackend` trait this backend implements; the inversion principle
|
|
(backends live where their transport deps live)
|
|
- [ADR-052](052-alknet-tty-wire-format-and-two-carriage.md) — the wire
|
|
format the `TtyAdapter` pumps (the backend produces handles, not wire)
|
|
- [ADR-054](054-local-tty-backend-sibling-crate.md) — 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](056-backend-cleanup-on-session-cancel.md) — the
|
|
cancel-cleanup contract (`exit_code` future's `Drop` kills)
|
|
- [ADR-050](050-dynamic-resource-ownership-for-runtime-spawned-resources.md)
|
|
— the ownership model `resource_id()` delegates to
|
|
- [ADR-058](058-alknet-docker-on-alknet-call.md) — why the
|
|
non-interactive exec is a call op, not this backend
|
|
- [ADR-059](059-bollard-021-dependency-and-features.md) — 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](../crates/tty/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](../crates/docker/docker-tty-backend.md) |