Files
alknet/docs/architecture/decisions/058-alknet-docker-on-alknet-call.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-058: alknet-docker Registers on alknet/call (No Separate ALPN)

Status

Accepted

Context

The alknet-docker POC (docs/research/alknet-docker/poc-summary.md) left two integration questions open (its "Open Unknowns" #1 and #2):

  1. Raw-carriage handoff in the dispatcher. The POC's drive_attach_raw reads the call.requested frame itself, then switches the bidi stream to raw chunks for interactive attach. The POC noted this doesn't fit the call dispatcher's handle_stream → dispatch() → DispatchResult::Stream(ResponseStream) path, which pumps a stream of EventEnvelopes (JSON carriage). The POC offered two options: (a) branch handle_stream on a carriage field, handing raw streams to a RawHandler; or (b) a separate ALPN (alknet/docker-raw) that owns the whole stream.

  2. ALPN layout. Should docker ops register on the shared alknet/call ALPN (operations in the shared OperationRegistry) or get their own alknet/docker ALPN (as a ProtocolHandler)? The POC leaned shared but didn't decide.

Both questions were open before alknet-tty was extracted. The alknet-tty crate spec (ADR-052, ADR-053) resolves both by moving the one operation that needed raw carriage — interactive attach/exec with a PTY — out of the call protocol entirely and into its own ALPN (alknet/tty), backed by the TtyBackend trait.

Why the raw-carriage problem dissolved

The POC's raw-carriage handoff was hard because it tried to do two different things on one alknet/call stream:

  • Structured operations (lifecycle, logs, inspect) — naturally JSON-shaped, fit call.responded/call.completed exactly. This is what the call protocol is for.
  • Interactive attach — a bidirectional byte pump (stdin/stdout/stderr) with a control sideband (resize, signal, exit). This is not a request/response or subscription; it's a terminal session. Forcing it through EventEnvelope framing is "wasteful and lossy" (POC §"Why not JSON for everything?").

alknet-tty was created precisely to extract the second category into its own protocol (alknet/tty) with its own wire format (ADR-052). The docker POC's drive_attach_raw became the seed of alknet-tty's DockerTtyBackend (ADR-061). What remains in alknet-docker's call operations is the first category — structured operations that map cleanly to call.requested/call.responded/call.completed.

The POC's concern ("the dispatcher would need a carriage field and a RawHandler branch") is no longer operative: there is no raw-carriage operation on alknet/call. The raw byte pump moved to alknet/tty; the DockerTtyBackend implements TtyBackend and is reached through the TtyAdapter, not the CallAdapter. See ADR-061 for the backend placement.

Why shared alknet/call, not a separate alknet/docker ALPN

The remaining docker operations (lifecycle, logs, exec-with-exit-code, inspect, list, images) are ordinary call-protocol operations. They have:

  • A structured input (JSON), a structured output (JSON), or a stream of structured events (logs, image pull progress, exec output).
  • Natural OperationSpecs with input/output JSON Schemas.
  • AccessControl declarations against the ADR-050 container-as-resource model (resource_type: "container", resource_id_path: "$.containerId").
  • Service discovery through services/list / services/schema.

Putting them on a separate alknet/docker ALPN would mean a separate ProtocolHandler that re-implements framing, dispatch, ACL, and service discovery — or a thin wrapper that delegates to the call protocol's machinery. Either way, it's a parallel dispatch surface for no benefit. The shared registry is more composable: docker ops are callable from any call client, including peer routing (PeerRef / from_call re-export), which is the primary use case — a coordinator on the hub composing docker/container/exec on a worker spoke.

A separate ALPN is warranted when the protocol's wire format is incompatible with EventEnvelope framing (alknet-tty, alknet-ssh). The docker operations remaining on alknet/call are all EventEnvelope-shaped. The one that wasn't (raw attach) moved to alknet-tty. See ADR-052 §"Why not JSON for everything?" for the boundary criterion.

Logs and exec streaming — still JSON carriage, still alknet/call

Two operations look like they might need raw carriage but don't:

  • docker/container/logs — a Subscription operation. bollard's logs() returns a Stream<LogOutput>; each LogOutput becomes a call.responded carrying { "stream": "stdout"|"stderr", "text": "..." }; stream end → call.completed. This is the StreamingHandler shape (ADR-049). The POC validated this path (docker_logs_subscription_pumps_frames_and_completes). No raw carriage needed — each log line is naturally JSON-shaped.
  • docker/container/exec (non-interactive, no TTY) — a Subscription operation. bollard's start_exec returns StartExecResults::Attached { output, input }; the output stream is pumped as call.responded frames; after the stream ends, inspect_exec() gives the exit code, which rides on a final call.responded with { "exitCode": N, "terminal": true } before call.completed. The POC validated this (docker_exec_streams_output_and_exit_code). This is the exec path for tty: false — a captured command with separate stdout/stderr and an exit code, not an interactive terminal.

The interactive exec path (tty: true, bidirectional, resize/signal) is not a call operation — it's a DockerTtyBackend session on alknet/tty. ADR-061 covers that. The split is: non-interactive exec is a call Subscription; interactive exec is a tty session. The tty: bool field in the operation input selects the path; the caller picks.

Decision

1. alknet-docker registers its operations on the shared alknet/call ALPN

alknet-docker does not register a ProtocolHandler. It constructs an OperationRegistry (or a DockerOps registration bundle consumed by the assembly layer's OperationRegistryBuilder) and the existing CallAdapter dispatches docker operations through the shared OperationRegistry::invoke() / invoke_streaming() paths. There is no alknet/docker ALPN, no DockerProtocolHandler, and no parallel dispatch surface.

This makes docker operations first-class citizens of the call protocol: they appear in services/list, they have OperationSpecs with JSON Schemas, they go through the standard AccessControl::check, and they compose with from_call re-export and peer routing (ADR-029) like any other operation.

2. There is no raw-carriage operation on alknet/call

The carriage field the POC proposed for call.requested is not added. The call protocol's EventEnvelope framing is the only carriage on alknet/call. Interactive attach/exec (the one operation that needed raw carriage) is reached through alknet/tty via DockerTtyBackend (ADR-061), not through a call operation. The POC's open question #1 (raw-carriage handoff in the dispatcher) is resolved by removing the requirement, not by adding a branch.

3. The operation taxonomy

Operation Call op type bollard method Carriage
docker/container/list Query list_containers JSON
docker/container/inspect Query inspect_container JSON
docker/container/create Mutation create_container JSON
docker/container/start Mutation start_container JSON
docker/container/stop Mutation stop_container JSON
docker/container/remove Mutation remove_container JSON
docker/container/restart Mutation restart_container JSON
docker/container/logs Subscription logs JSON (StreamingHandler)
docker/container/exec (tty:false) Subscription create_exec + start_exec + inspect_exec JSON (StreamingHandler)
docker/image/list Query list_images JSON
docker/image/pull Subscription create_image JSON (StreamingHandler)
docker/image/inspect Query inspect_image JSON

Interactive exec (tty: true) and interactive attach are not in this table — they are alknet/tty sessions via DockerTtyBackend (ADR-061). A docker/container/exec call operation with tty: true in the input returns an call.error with code INVALID_INPUT directing the caller to use alknet/tty; the operation only accepts tty: false (or absent). This keeps the one-operation-per-carriage invariant clean: the call operation is captured output; the tty session is interactive.

The full operation surface (including which are in scope for v1 and which are deferred) is in docker-operations.md.

4. The assembly layer composes the registry

alknet-docker exports a DockerOps construct (a set of HandlerRegistration bundles keyed by operation name) or a register_docker_ops(&mut builder, docker_client, ownership, labels) function. The assembly layer (the CLI binary or a hub binary) calls this to add docker operations to the shared OperationRegistryBuilder alongside other operations (services discovery, agent ops, etc.). The Docker bollard client, the OwnershipStore, and the label namespace config are injected by the assembly layer. See overview.md §"Assembly Layer Wiring".

Consequences

Positive:

  • Docker operations inherit all call-protocol machinery for free: service discovery, JSON Schema validation, AccessControl, from_call re-export, peer routing, forwarded_for metadata, abort cascade. No parallel dispatch surface.
  • The POC's hardest open question (raw-carriage handoff) is resolved by removal — the operation that needed it moved to alknet-tty. No dispatcher change, no carriage field, no RawHandler trait.
  • The operation taxonomy is clean: Query/Mutation for lifecycle, Subscription (ADR-049 StreamingHandler) for logs/exec/pull. The exit-code-on-final-call.responded pattern the POC validated (POC target 3) works through the existing StreamingHandler → pump_stream path with no protocol change.
  • A hub that wants to manage docker on worker spokes composes docker/container/* through from_call (ADR-017) + peer routing (ADR-029) — the proxy pattern from ADR-050. This is the primary use case and it works by construction.

Negative:

  • The docker/container/exec operation has a tty field whose value constrains the dispatch path: tty: false → call Subscription; tty: true → reject, point to alknet/tty. This is a minor impedance — a caller wanting interactive exec must use a different protocol (alknet/tty) than a caller wanting captured exec. This is the correct split (interactive terminal ≠ captured command), but it means the "exec" concept spans two protocols. The DockerTtyBackend (ADR-061) and the docker/container/exec call operation share the underlying bollard::exec API but diverge at the wire format. This is the same divergence as "SSH exec" (call op) vs "SSH PTY session" (alknet-tty) — the boundary is principled, not accidental.
  • A deployment that wants docker operations must wire the OperationRegistry (the assembly layer's job). This is not a downside — it's the same wiring every other operation set requires — but it means alknet-docker is not a "drop-in ProtocolHandler" the way alknet-tty is. The trade is composability (shared registry, peer routing) for a slightly more involved assembly step.

Door type

One-way. The decision to register on alknet/call rather than a separate alknet/docker ALPN is a structural commitment: the operation names (docker/container/*), the OperationSpecs, the AccessControl shapes, and the from_call re-export surface all depend on docker ops being call-protocol operations. Reversing this — moving docker ops to their own ALPN — would be a rewrite of the operation surface and a break for any client that calls docker/container/* through the call protocol.

The decision not to add a carriage field to call.requested is also one-way: the call protocol's wire format (ADR-012) stays EventEnvelope-only on alknet/call. If a future operation needs raw carriage on alknet/call, it would require a wire-format change (new ALPN per ADR-006, or a call.requested field addition). The expected path for raw-carriage operations is a separate ALPN (as alknet-tty did), not a call.requested extension.

References

  • docs/research/alknet-docker/poc-summary.md §"Open Unknowns" #1 and #2 (the two questions this ADR resolves)
  • ADR-012 — the call protocol wire format this ADR keeps unchanged (no carriage field)
  • ADR-017 — from_call re-export, the proxy pattern's mechanism
  • ADR-024 — the registry layering docker ops register into
  • ADR-029 — peer routing, the head→worker docker management path
  • ADR-049 — StreamingHandler, the dispatch path for logs/exec/pull
  • ADR-050 — containers as resources, the AccessControl shape docker ops declare
  • ADR-052 — alknet-tty's wire format, where the raw-carriage operation moved
  • ADR-053 — TtyBackend, the trait DockerTtyBackend implements (ADR-061)
  • ADR-061 — DockerTtyBackend placement in alknet-docker
  • Spec documents: overview.md, docker-operations.md