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.
This commit is contained in:
1 parent
5873fa22d6
commit
1ce11717d8
17 files changed
+3196
-9
No files matched your search
@@ -0,0 +1,267 @@
|
||||
# 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)
|
||||
Reference in new issue
Block a user