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

267 lines
14 KiB
Markdown

# 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 `EventEnvelope`s (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 `OperationSpec`s 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 `OperationSpec`s 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](../crates/docker/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](../crates/docker/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 `OperationSpec`s, 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](012-call-protocol-stream-model.md) — the call protocol wire
format this ADR keeps unchanged (no `carriage` field)
- [ADR-017](017-call-protocol-client-and-adapter-contract.md) — `from_call`
re-export, the proxy pattern's mechanism
- [ADR-024](024-operation-registry-layering.md) — the registry layering
docker ops register into
- [ADR-029](029-peer-graph-routing-model.md) — peer routing, the
head→worker docker management path
- [ADR-049](049-streaming-handler-for-subscriptions.md) —
`StreamingHandler`, the dispatch path for logs/exec/pull
- [ADR-050](050-dynamic-resource-ownership-for-runtime-spawned-resources.md)
— containers as resources, the `AccessControl` shape docker ops declare
- [ADR-052](052-alknet-tty-wire-format-and-two-carriage.md) — alknet-tty's
wire format, where the raw-carriage operation moved
- [ADR-053](053-ttybackend-trait-and-ttyhandle.md) — `TtyBackend`, the
trait `DockerTtyBackend` implements (ADR-061)
- [ADR-061](061-docker-tty-backend-in-alknet-docker.md) —
`DockerTtyBackend` placement in alknet-docker
- Spec documents: [overview.md](../crates/docker/overview.md),
[docker-operations.md](../crates/docker/docker-operations.md)