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)
|
||||
@@ -0,0 +1,195 @@
|
||||
# ADR-059: bollard 0.21 Dependency and Feature Selection
|
||||
|
||||
## Status
|
||||
|
||||
Accepted
|
||||
|
||||
## Context
|
||||
|
||||
The alknet-docker POC (`docs/research/alknet-docker/poc-summary.md`)
|
||||
depended on a local bollard checkout at `/workspace/bollard` (version
|
||||
0.21.0). Its "Open Unknowns" #5 noted: "The real crate should depend on
|
||||
published 0.21 from crates.io (the dispatch POC pinned 0.18 — a
|
||||
3-version jump). The `websocket` feature is optional; the `http` and
|
||||
`pipe` features are needed for socket/http connect. Confirm the
|
||||
published 0.21 has the same API surface as the checkout."
|
||||
|
||||
Two concerns motivate an explicit version check:
|
||||
|
||||
1. **Agent training-data drift.** Implementation agents tend to default
|
||||
to the bollard version represented in their training data unless
|
||||
explicitly told to check. The dispatch POC used bollard 0.18 (3
|
||||
versions behind); an agent that pattern-matched dispatch would pin
|
||||
0.18 and silently use a 3-version-stale API. The cost of checking is
|
||||
low (one crates.io lookup); the cost of using a stale version is
|
||||
potentially high (API mismatches, missing methods, security regressions).
|
||||
|
||||
2. **API surface stability across the path-vs-published boundary.** The
|
||||
POC used a path dependency; the crate uses a published version. A
|
||||
version number match (0.21.0) should mean the API surface is
|
||||
identical, but this is worth confirming rather than assuming.
|
||||
|
||||
### Version verification
|
||||
|
||||
A check against crates.io / docs.rs confirmed:
|
||||
|
||||
- **bollard 0.21.0 is the latest published version** on crates.io (as
|
||||
of 2026-07-08). The local checkout at `/workspace/bollard` (0.21.0)
|
||||
matches the published version. No version jump is needed; the POC's
|
||||
dependency is current.
|
||||
- The published 0.21.0 `Cargo.toml` (verified via docs.rs source) has
|
||||
the same API surface the POC used: `attach_container` (container.rs:540),
|
||||
`logs` (container.rs:928), `create_exec`/`start_exec`/`inspect_exec`
|
||||
(exec.rs:172/225/315), `AttachContainerResults` (container.rs:80),
|
||||
`StartExecResults` enum (exec.rs:99), `LogOutput` (container.rs:96),
|
||||
`NewlineLogOutputDecoder` (read.rs:32). The POC's code-to-concept
|
||||
mappings hold against the published version.
|
||||
- bollard-stubs is pinned at `=1.53.1-rc.29.3.1` (the Docker API 1.53
|
||||
schema), matching the Docker Engine 29.2.1 / API 1.53 the POC tested
|
||||
against.
|
||||
|
||||
### Feature selection
|
||||
|
||||
bollard 0.21's feature surface (from the published `Cargo.toml`):
|
||||
|
||||
| Feature | What it pulls | Needed for alknet-docker? |
|
||||
|---------|---------------|--------------------------|
|
||||
| `default = ["http", "pipe"]` | hyper-util (http), hyperlocal + hyper-named-pipe (pipe) | **Yes** — the default. `pipe` connects to the unix socket (`/var/run/docker.sock`); `http` connects to a TCP/HTTP daemon. Both are the standard local-daemon paths. |
|
||||
| `ssl` | rustls TLS for remote daemon connections | No — alknet-docker talks to a local daemon; remote daemons are reached over the call protocol (the fleet layer, not bollard's TLS). |
|
||||
| `ssh` | openssh for SSH-tunneled daemons | No — the dispatch POC's SSH-tunnel model is exactly what alknet removes; the worker dials the hub over the call protocol instead of the hub SSHing into the worker. |
|
||||
| `websocket` | tokio-tungstenite for the websocket attach endpoint | No — the POC deliberately used the reliable `attach_container()` (HTTP upgrade to TCP), not the websocket path (bollard's own docs warn of RFC 6455 compatibility issues). The websocket feature is for browser-attach scenarios alknet-docker doesn't have. |
|
||||
| `buildkit` / `buildkit_providerless` | tonic + bollard-buildkit-proto for buildkit image builds | No — image build is deferred (POC "What the POC Does NOT Validate" #5; OQ-049 scopes this). |
|
||||
| `chrono` / `time` | timestamp parsing for log timestamps | **Optional** — `docker/container/logs` sets `timestamps: true`; the `chrono` or `time` feature types the timestamp field. The choice between them is a two-way-door implementation detail (both are serde-compatible). `time` is the lighter dependency. |
|
||||
| `json_data_content` | serde for body content types | No — alknet-docker serializes its own JSON shapes from bollard's types; it doesn't need bollard's content-type tagging. |
|
||||
| `aws-lc-rs` / `webpki` / `test_*` | TLS provider variants and test-only features | No — not for production deps. |
|
||||
|
||||
The POC's `Cargo.toml` depended on bollard with default features (path
|
||||
dependency). The crate does the same: `bollard = { version = "0.21",
|
||||
default-features = true }`, with the `time` feature added for log
|
||||
timestamp typing. No `ssl`, `ssh`, `websocket`, or `buildkit` features.
|
||||
|
||||
## Decision
|
||||
|
||||
### 1. Depend on published bollard 0.21 from crates.io
|
||||
|
||||
```toml
|
||||
[dependencies]
|
||||
bollard = { version = "0.21", features = ["time"] }
|
||||
```
|
||||
|
||||
`default-features = true` (the default) pulls `http` + `pipe`, the two
|
||||
local-daemon connect paths. The `time` feature adds timestamp typing
|
||||
for `docker/container/logs`. No other features are enabled.
|
||||
|
||||
The local-checkout path dependency the POC used is retired. The
|
||||
published 0.21.0 has the identical API surface (confirmed via docs.rs
|
||||
source — same version number, same method signatures, same stubs pin).
|
||||
|
||||
### 2. Connect via `connect_with_local_defaults()` (unix socket)
|
||||
|
||||
The standard deployment talks to a local docker daemon over
|
||||
`/var/run/docker.sock` (Unix) or the named pipe on Windows.
|
||||
`Docker::connect_with_local_defaults()` handles both. The `Docker`
|
||||
client is constructed once at assembly-layer startup and injected into
|
||||
the docker ops registration. See
|
||||
[overview.md](../crates/docker/overview.md) §"Assembly Layer Wiring".
|
||||
|
||||
A TCP daemon path (`connect_with_http_defaults`) is available if a
|
||||
deployment runs the docker daemon on a different host — but this is the
|
||||
exception, not the default. The fleet case (multiple docker daemons on
|
||||
different machines) is handled at the call-protocol layer (a `CallClient`
|
||||
per remote daemon, each running alknet-docker locally), not by pointing
|
||||
one bollard client at a remote daemon over TCP. See ADR-058 §"Why shared
|
||||
`alknet/call`" and the POC §6 (the normalization crate boundary).
|
||||
|
||||
### 3. Do not enable `websocket`, `ssl`, `ssh`, or `buildkit`
|
||||
|
||||
- **`websocket`** — the reliable `attach_container()` (HTTP upgrade to
|
||||
TCP) is the attach path for `DockerTtyBackend` (ADR-061). The websocket
|
||||
attach endpoint has documented reliability issues (bollard
|
||||
`container.rs:577`) and is for browser-attach scenarios alknet-docker
|
||||
doesn't have. The raw chunk format (ADR-052) is layered on top of the
|
||||
reliable attach, not the websocket.
|
||||
- **`ssl`** — remote daemons are reached over the call protocol (the
|
||||
fleet layer), not bollard's TLS. alknet-docker is single-host by
|
||||
construction (POC §6).
|
||||
- **`ssh`** — the SSH-tunnel model is what the call-protocol fleet
|
||||
layer replaces. The dispatch POC's `DockerProvider` SSHed into workers;
|
||||
alknet-docker's workers dial the hub over `alknet/call` and expose
|
||||
their local docker ops directly.
|
||||
- **`buildkit`** — image build is deferred (OQ-049). The `build_image`
|
||||
method is in bollard but the buildkit feature pulls tonic +
|
||||
bollard-buildkit-proto, a heavy dependency for an out-of-scope feature.
|
||||
|
||||
### 4. `time` feature for log timestamps
|
||||
|
||||
`docker/container/logs` sets `timestamps: true` on the bollard `logs()`
|
||||
query. Each `LogOutput` then carries a docker timestamp; the
|
||||
`call.responded` frame separates `timestamp` and `text` into distinct
|
||||
JSON fields (the POC noted this refinement over its single-`text`-field
|
||||
POC shape). The `time` feature types the timestamp field as
|
||||
`time::OffsetDateTime` (serde-compatible). The `chrono` feature is the
|
||||
alternative; `time` is lighter and sufficient.
|
||||
|
||||
The timestamp-feature choice (`time` vs `chrono`) is a two-way-door
|
||||
implementation detail within the one-way version-pin decision. Switching
|
||||
features is a `Cargo.toml` change, not an API break.
|
||||
|
||||
## Consequences
|
||||
|
||||
**Positive:**
|
||||
|
||||
- bollard 0.21 is current (verified against crates.io); no stale-version
|
||||
risk. The POC's API-surface mappings hold against the published version.
|
||||
- The feature set is minimal: `http` + `pipe` (default) + `time`. No
|
||||
`ssl`/`ssh`/`websocket`/`buildkit` weight. The compile-time and
|
||||
dependency surface stays small.
|
||||
- The single-host contract is clear: alknet-docker talks to one local
|
||||
daemon over the unix socket. The fleet case is a call-protocol concern,
|
||||
not a bollard-feature concern.
|
||||
|
||||
**Negative:**
|
||||
|
||||
- The `websocket` feature being disabled means the browser-attach
|
||||
scenario (bollard's websocket attach for a browser that speaks docker's
|
||||
websocket framing directly) is not available. This is intentional —
|
||||
browsers reach docker through `alknet/tty` (via `DockerTtyBackend`) or
|
||||
the call protocol, not bollard's websocket endpoint. If a future use
|
||||
case forces the websocket path, enabling the feature is a `Cargo.toml`
|
||||
addition (two-way door).
|
||||
- The `ssh` feature being disabled means bollard's SSH-tunneled daemon
|
||||
path is not available. This is intentional — the dispatch POC's
|
||||
SSH-tunnel model is what alknet's call-protocol fleet layer replaces
|
||||
(POC §6, "Prior art"). Enabling `ssh` would reintroduce the friction
|
||||
(SSH key injection, port binding) the fleet layer exists to remove.
|
||||
|
||||
## Door type
|
||||
|
||||
**One-way (version pin) + two-way (feature set).** The version pin
|
||||
(bollard 0.21, not 0.18 or a future 0.22) is a one-way commitment for
|
||||
the implementation: the operation handlers are written against 0.21's
|
||||
API surface, and a major-version bump is a migration. The feature set
|
||||
(`http` + `pipe` + `time`, no `ssl`/`ssh`/`websocket`/`buildkit`) is
|
||||
two-way-door: features can be added or removed in `Cargo.toml` without
|
||||
an API break. The decision to *not* enable `ssl`/`ssh`/`buildkit` is a
|
||||
scope decision (not needed for the current scope), not a capability
|
||||
closure — the features can be added later if a use case forces them.
|
||||
|
||||
The `time`-vs-`chrono` choice is a two-way-door implementation detail.
|
||||
|
||||
## References
|
||||
|
||||
- `docs/research/alknet-docker/poc-summary.md` §"Open Unknowns" #5
|
||||
(bollard version pinning)
|
||||
- `docs/research/alknet-docker/poc-summary.md` §"What the POC Does NOT
|
||||
Validate" #5 (image management / buildkit deferred)
|
||||
- bollard 0.21.0 published `Cargo.toml` (verified via docs.rs source)
|
||||
- bollard source: `src/container.rs` (`attach_container` :540, `logs`
|
||||
:928), `src/exec.rs` (`create_exec` :172, `start_exec` :225,
|
||||
`inspect_exec` :315), `src/read.rs` (`NewlineLogOutputDecoder` :32)
|
||||
- [ADR-058](058-alknet-docker-on-alknet-call.md) — the single-host /
|
||||
call-protocol-fleet contract this feature set reflects
|
||||
- [ADR-061](061-docker-tty-backend-in-alknet-docker.md) — the
|
||||
`DockerTtyBackend` that uses the reliable (non-websocket) attach path
|
||||
- OQ-049 (build/image scope, deferred)
|
||||
- Spec: [overview.md](../crates/docker/overview.md) §"Dependencies"
|
||||
@@ -0,0 +1,338 @@
|
||||
# ADR-060: Container Resource Model and Label Namespace
|
||||
|
||||
## Status
|
||||
|
||||
Accepted
|
||||
|
||||
## Context
|
||||
|
||||
ADR-050 established the dynamic resource ownership model for
|
||||
runtime-spawned resources: a `OwnershipProvider` (read, sync) +
|
||||
`OwnershipStore` (write, async) trait pair in alknet-core, with the
|
||||
spawner owning the resource, proxy-to-share, teardown-revoke. ADR-050
|
||||
explicitly named alknet-docker as the first consumer: containers as
|
||||
`AccessControl` resources, `docker/container/exec` requiring
|
||||
`resource: container/<id>:exec`, `docker/container/create` calling
|
||||
`OwnershipStore::record` on success, `docker/container/remove` calling
|
||||
`OwnershipStore::revoke` on teardown.
|
||||
|
||||
ADR-050 resolved the *model*. This ADR resolves the *application* of
|
||||
that model to alknet-docker's concrete operation surface — the three
|
||||
things ADR-050 left to the crate spec:
|
||||
|
||||
1. **The label namespace.** The dispatch POC (`/workspace/@alkdev/dispatch`)
|
||||
used `dispatch.managed=true` to mark containers it created. The POC
|
||||
summary §"What the POC Does NOT Validate" #6 noted the real crate
|
||||
"needs a configurable label prefix and ownership mapping
|
||||
(`alknet.owner=<peer-id>`) tied to the call protocol's identity model."
|
||||
What is the label scheme, and how does it relate to the ownership store?
|
||||
|
||||
2. **The `list` result-filter.** ADR-050 specific #4a says the `list`
|
||||
case (resource_type set, resource_id_path absent) is "scope-gate +
|
||||
result-filter": the scope check gates the call, and the handler
|
||||
filters the result via `OwnershipProvider::owned_resources`. How
|
||||
does `docker/container/list` apply this against bollard's
|
||||
`list_containers` (which returns all containers the daemon sees)?
|
||||
|
||||
3. **Teardown coupling.** ADR-050 specific #4b says teardown is
|
||||
handler-driven: the docker handler calls `revoke` on container
|
||||
removal. But docker containers can exit on their own (a process
|
||||
that finishes, a `--rm` container). How does the ownership store
|
||||
learn about autonomous container death, or does it rely solely on
|
||||
explicit `docker/container/remove`?
|
||||
|
||||
### The two use cases and their ownership profiles
|
||||
|
||||
The user's brief named two container use cases:
|
||||
|
||||
- **Disposable dev containers** (the common case by volume) — a
|
||||
coordinator spawns a container for an implementation agent or an
|
||||
isolated env. The container is short-lived; the coordinator owns it
|
||||
for its lifetime; it's removed when the agent is done. Ownership is
|
||||
per-session, per-coordinator.
|
||||
- **Long-running hosted services** (less common but important) — the
|
||||
production server (`/workspace/system/dev1`) hosts rarely-changing
|
||||
services (reverse-proxy, postgres, redis, gitea) in docker. These
|
||||
containers are created by an operator (via `docker compose`), not by
|
||||
a call-protocol coordinator. They're managed via alknet-docker's
|
||||
operations (start/stop/restart/inspect/logs) but their *ownership* is
|
||||
not in the alknet ownership store — they predate the connection.
|
||||
|
||||
These two cases have different ownership profiles, and the model must
|
||||
handle both without forcing the hosted-services case through the
|
||||
disposable-container path.
|
||||
|
||||
## Decision
|
||||
|
||||
### 1. Label namespace: `alknet.*` prefix, configurable, two labels
|
||||
|
||||
alknet-docker applies two labels to containers it creates:
|
||||
|
||||
| Label | Value | Purpose |
|
||||
|-------|-------|---------|
|
||||
| `alknet.managed` | `"true"` | Marks the container as alknet-managed. The `list` filter and the ownership-existence check key on this label. |
|
||||
| `alknet.owner` | `<peer_id>` | The `Identity.id` (stable logical peer id, per ADR-030) of the spawner. For composing handlers, the *handler's* identity (the coordinator's `Identity`), not the end user's — the proxy pattern (ADR-050 §3). |
|
||||
|
||||
The label prefix (`alknet`) is **configurable** at assembly-layer wiring.
|
||||
A deployment that runs alongside another alknet-managed fleet (or wants
|
||||
to avoid collisions with its own `managed` labels) sets a different
|
||||
prefix. The default is `alknet`. This is a two-way-door config choice;
|
||||
the label *schema* (two labels, `<prefix>.managed` + `<prefix>.owner`)
|
||||
is the one-way commitment.
|
||||
|
||||
The labels serve two purposes:
|
||||
|
||||
- **Ownership-store cross-check.** When `docker/container/exec` checks
|
||||
`OwnershipProvider::owns(identity, "container", id, "exec")`, the
|
||||
ownership store is the source of truth. The label is a *secondary*
|
||||
signal: if the store says "yes" but the label is absent or names a
|
||||
different owner, the container's ownership state is stale (the
|
||||
container was removed and its ID reused, or the store and docker
|
||||
diverged). The label is not authoritative — the store is — but it's a
|
||||
debugging aid and a consistency check.
|
||||
- **`list` filter.** `docker/container/list` with an `owned_only: true`
|
||||
input flag filters to containers whose `alknet.owner` label matches
|
||||
the caller's `Identity.id`. This is the result-filter path (ADR-050
|
||||
#4a). Without the flag, the list returns all containers the daemon
|
||||
sees (the hosted-services case — see §3 below).
|
||||
|
||||
**`alknet.*` label reservation.** The `<prefix>.*` label keys are
|
||||
reserved: the `docker/container/create` handler overwrites
|
||||
`<prefix>.managed` and `<prefix>.owner` on the container's labels
|
||||
regardless of what the caller provided, preventing a caller from
|
||||
spoofing ownership (setting `alknet.owner` to another peer's id). A
|
||||
caller's own labels (non-`<prefix>.*` keys) are preserved. This is a
|
||||
security property: ownership is set by the handler (from the
|
||||
caller's `Identity.id`), not by the caller's input.
|
||||
|
||||
**Resource action vocabulary.** The `resource_action` field on
|
||||
`AccessControl` for containers uses these actions:
|
||||
|
||||
| Action | Used by | Scope name |
|
||||
|--------|---------|-----------|
|
||||
| `exec` | `docker/container/exec` (call op, non-interactive) | `container:exec` |
|
||||
| `tty` | `DockerTtyBackend` sessions (`alknet/tty`) | `container:tty` |
|
||||
| `start` | `docker/container/start` | `container:start` |
|
||||
| `stop` | `docker/container/stop` | `container:stop` |
|
||||
| `remove` | `docker/container/remove` | `container:remove` |
|
||||
| `restart` | `docker/container/restart` | `container:restart` |
|
||||
| `manage` | (static, operator role) `Identity.resources["container"]` | `container:manage` |
|
||||
| `list` | `docker/container/list` (scope-gate only, no resource_id) | `container:list` |
|
||||
| `create` | `docker/container/create` (scope-gate only, no resource_id) | `container:create` |
|
||||
|
||||
The `tty` action is distinct from `exec` (ADR-061 §4): a caller
|
||||
authorized for non-interactive exec (`container:exec`) is not
|
||||
automatically authorized for an interactive terminal session
|
||||
(`container:tty`). The `manage` action is the static operator-role
|
||||
resource (§3) that subsumes the per-container actions for pre-existing
|
||||
hosted-service containers. The `list` and `create` actions are
|
||||
scope-gate-only (no `resource_id_path`); the rest target a specific
|
||||
container via `resource_id_path: "$.containerId"`.
|
||||
|
||||
### 2. `docker/container/list`: scope-gate + optional result-filter
|
||||
|
||||
`docker/container/list` is a `Query` operation with
|
||||
`resource_type: "container"`, no `resource_id_path` (the `list` case per
|
||||
ADR-050 #4a). The input accepts an optional `owned_only: bool` flag
|
||||
(default `false`):
|
||||
|
||||
- **`owned_only: false`** (default) — the handler calls
|
||||
`bollard::list_containers()` and returns all containers the daemon
|
||||
sees. The scope check gates the call (the caller needs
|
||||
`container:list`); there is no result-filter. This is the
|
||||
hosted-services case: an operator listing all containers on dev1,
|
||||
including ones they didn't spawn through alknet.
|
||||
- **`owned_only: true`** — the handler calls
|
||||
`bollard::list_containers()` with a label filter
|
||||
(`label: alknet.owner=<caller_peer_id>`), returning only the
|
||||
containers the caller owns. This is the disposable-dev-container
|
||||
case: a coordinator listing its own workspaces. The scope check still
|
||||
gates the call; the label filter is the result-filter.
|
||||
|
||||
The two paths use the same bollard method (`list_containers` with
|
||||
different `ListContainersOptions`), differing only in the label filter.
|
||||
The `owned_only` flag is the caller's choice; the scope check is the
|
||||
server's. This matches ADR-050 #4a's "allow if scoped, filter to owned"
|
||||
default, with the filter opt-in (the hosted-services case needs the
|
||||
unfiltered list).
|
||||
|
||||
### 3. Hosted services: ownership is not required for pre-existing containers
|
||||
|
||||
The hosted-services case (dev1's reverse-proxy, postgres, redis, gitea)
|
||||
involves containers created by an operator via `docker compose`, not via
|
||||
`docker/container/create`. These containers have no `alknet.owner`
|
||||
label and no ownership-store entry. Operations on them (`start`,
|
||||
`stop`, `inspect`, `logs`, `restart`) must still work — but
|
||||
`AccessControl::check` with `resource_type: "container"` and a
|
||||
`resource_id` would consult the ownership store, find no entry, and
|
||||
deny.
|
||||
|
||||
The resolution: **the `AccessControl` for operations on a specific
|
||||
container (`exec`, `stop`, `remove`, `inspect` with `resource_id_path`)
|
||||
declares `resource_type: "container"` and `resource_action`, but the
|
||||
ownership check is against the store, and the store's "no entry" result
|
||||
falls through to the static `Identity.resources` path (ADR-050 §2
|
||||
backward-compat).** A peer whose `Identity.resources["container"]`
|
||||
includes `"manage"` (a deployment-configured scope for the operator
|
||||
role) passes the check for any container, owned or not. A peer without
|
||||
that resource but with `container:exec` scope passes only for containers
|
||||
the ownership store says they own.
|
||||
|
||||
Concretely, the two roles:
|
||||
|
||||
| Role | Scope | Owns a specific container? | Can exec into container C? |
|
||||
|------|-------|---------------------------|-----------------------------|
|
||||
| Operator (manages hosted services) | `container:manage` in `Identity.resources["container"]` | No (no ownership entry) | Yes — the static-resource fallback passes (`manage` ⊇ `exec`) |
|
||||
| Coordinator (spawns dev containers) | `container:exec` scope | Yes (ownership store: coordinator owns C) | Yes — the ownership check passes |
|
||||
| Random peer | `container:exec` scope | No | No — ownership check fails, static fallback fails |
|
||||
|
||||
The operator role is configured statically (the dev1 operator's
|
||||
`PeerEntry.resources` includes `container:manage`); the coordinator
|
||||
role is dynamic (the ownership store records the spawn). Both paths
|
||||
reach the same `AccessControl::check`; the difference is which branch
|
||||
satisfies.
|
||||
|
||||
This means `docker/container/exec` and friends work on hosted-service
|
||||
containers *for the operator role* without the operator having to
|
||||
"claim" ownership of containers they didn't spawn. The
|
||||
`docker/container/create` operation (the spawner path) always records
|
||||
ownership; `docker/container/start` on a pre-existing container does
|
||||
not (it's not a spawn). The model is: **spawn → own; manage (operator) →
|
||||
static-resource-pass; neither → deny.**
|
||||
|
||||
### 4. Teardown coupling: handler-driven revoke + autonomous-death tolerance
|
||||
|
||||
ADR-050 #4b says teardown is handler-driven: `docker/container/remove`
|
||||
calls `OwnershipStore::revoke("container", id)`. But containers can die
|
||||
autonomously:
|
||||
|
||||
- A `--rm` container exits and is auto-removed by the daemon.
|
||||
- A container crashes and an operator removes it via `docker rm` (not
|
||||
through alknet-docker).
|
||||
- The daemon restarts (all containers stop; container IDs are stale).
|
||||
|
||||
alknet-docker's teardown coupling is **handler-driven revoke on the
|
||||
alknet-managed remove path, with autonomous-death tolerance**:
|
||||
|
||||
- **`docker/container/remove` (the alknet operation)** calls
|
||||
`bollard::remove_container()`, and on success calls
|
||||
`OwnershipStore::revoke("container", id)`. This is the ADR-050 #4b
|
||||
contract: the handler that manages the lifecycle revokes.
|
||||
- **Autonomous container death** (a `--rm` exit, an external `docker rm`,
|
||||
a daemon restart) leaves a stale ownership-store entry. The entry is
|
||||
not proactively cleaned up — there is no reaper, no event subscription
|
||||
to the docker daemon's death events. Instead, the entry is cleaned up
|
||||
lazily: the next operation against that container ID fails at the
|
||||
bollard layer (the container doesn't exist), the error propagates as
|
||||
a `call.error`, and a subsequent `docker/container/list` with
|
||||
`owned_only: true` for the stale owner filters the container out
|
||||
(it's not in the daemon's list). The ownership store's
|
||||
`owned_resources` can be cross-checked against the live daemon list
|
||||
on read, or left to be overwritten on the next spawn — the container ID
|
||||
is unique per daemon run; a reused ID would be a new container.
|
||||
|
||||
The tolerance for stale entries is intentional: ownership is runtime
|
||||
state (ADR-050 assumption 4), meaningless across restarts. The
|
||||
in-memory default store loses all entries on restart; a persistence
|
||||
adapter would cache and invalidate. A reaper that subscribes to docker
|
||||
daemon death events would be more prompt but adds a subscription
|
||||
surface for a marginal gain (the stale entry is inert — it can't grant
|
||||
access to a container that doesn't exist, and a reused container ID
|
||||
gets a fresh `record` on its next `create`).
|
||||
|
||||
A future "prompt stale-entry cleanup" feature is additive: a
|
||||
`docker/system/events` subscription operation (deferred, OQ-050) that
|
||||
the ownership store could subscribe to. The base model tolerates stale
|
||||
entries; the prompt-cleanup path is a refinement.
|
||||
|
||||
### 5. `docker/container/create` records ownership; `start`/`stop`/`restart` do not
|
||||
|
||||
Only `docker/container/create` calls `OwnershipStore::record`. The
|
||||
lifecycle operations (`start`, `stop`, `restart`, `remove`) do not
|
||||
record — they act on an existing container whose ownership was recorded
|
||||
at create time (or which pre-exists and is reached via the
|
||||
static-resource operator path). `remove` calls `revoke` (the teardown
|
||||
half); the others neither record nor revoke.
|
||||
|
||||
This matches ADR-050's "spawner owns" model: the *create* is the spawn
|
||||
event; the lifecycle operations are management of an existing resource.
|
||||
|
||||
## Consequences
|
||||
|
||||
**Positive:**
|
||||
|
||||
- The two use cases (disposable dev containers, hosted services) both
|
||||
work through one `AccessControl` model. The operator role reaches
|
||||
hosted-service containers via the static-resource fallback; the
|
||||
coordinator role reaches spawned containers via the ownership store.
|
||||
No special-casing, no "is this a managed container?" branch in the
|
||||
handlers.
|
||||
- The label namespace is configurable and minimal (two labels). The
|
||||
`managed` flag marks alknet-spawned containers; the `owner` label
|
||||
carries the peer id for the `list` filter and the cross-check.
|
||||
- Teardown is handler-driven on the alknet path and tolerant of
|
||||
autonomous death on the non-alknet path. No reaper subscription
|
||||
surface in the base model.
|
||||
- ADR-050's model is applied without amendment. The `list` case
|
||||
(specific #4a), the teardown coupling (#4b), and the
|
||||
backward-compat static-resource fallback (#2) all map cleanly to
|
||||
bollard's API (`list_containers` with a label filter, `remove_container`
|
||||
+ `revoke`, the static `Identity.resources` path).
|
||||
|
||||
**Negative:**
|
||||
|
||||
- Stale ownership entries from autonomous container death are not
|
||||
promptly cleaned up. An `owned_only: true` list could theoretically
|
||||
return a container ID that no longer exists — but bollard's
|
||||
`list_containers` returns *live* containers, so the list is always
|
||||
accurate against the daemon; the stale entry only affects the
|
||||
ownership store's internal map, not the list result. The cross-check
|
||||
(label vs store) could flag divergence; this is a debugging aid, not
|
||||
a correctness issue.
|
||||
- The operator role requires static configuration
|
||||
(`Identity.resources["container"] ⊇ "manage"`). A deployment that
|
||||
wants the operator to manage hosted services must configure the
|
||||
operator's `PeerEntry.resources`. This is not a downside — it's the
|
||||
same static configuration every role requires — but it means the
|
||||
hosted-services case isn't "zero-config"; the operator peer must be
|
||||
declared.
|
||||
- The `owned_only` flag on `list` is an opt-in. A coordinator that
|
||||
forgets to set it gets all containers, not just its own. The default
|
||||
(`false`) is correct for the hosted-services case (operator lists
|
||||
all), but a coordinator expecting isolation must set the flag. This
|
||||
is a caller-side convention, enforced by the scope check (a
|
||||
non-operator peer without `container:manage` can't call `list` at all
|
||||
— the scope gates it), but within the `container:list` scope, the
|
||||
filter is the caller's choice.
|
||||
|
||||
## Door type
|
||||
|
||||
**One-way (label schema, ownership model application) + two-way (label
|
||||
prefix, stale-entry policy).** The label schema (two labels,
|
||||
`<prefix>.managed` + `<prefix>.owner`) and the
|
||||
create-records/remove-revokes ownership coupling are one-way: clients
|
||||
and the ownership store depend on the labels and the record/revoke
|
||||
timing. The label prefix (default `alknet`) is two-way-door config. The
|
||||
stale-entry policy (tolerate, no reaper) is two-way — a reaper
|
||||
subscription is an additive refinement.
|
||||
|
||||
## References
|
||||
|
||||
- [ADR-050](050-dynamic-resource-ownership-for-runtime-spawned-resources.md)
|
||||
— the model this ADR applies (specifics #4a, #4b, #2)
|
||||
- `docs/research/alknet-docker/poc-summary.md` §"What the POC Does NOT
|
||||
Validate" #6 (label namespace / ownership mapping)
|
||||
- `/workspace/@alkdev/dispatch/src/docker.rs` — the dispatch POC's
|
||||
`dispatch.managed=true` label (prior art this ADR generalizes)
|
||||
- [ADR-030](030-peerentry-and-identity-id-decoupling.md) — `Identity.id`
|
||||
as the stable peer id used in the `alknet.owner` label
|
||||
- [ADR-032](032-forwarded-for-identity.md) — `forwarded_for` for the
|
||||
proxy pattern's end-user identity
|
||||
- [ADR-015](015-privilege-model-and-authority-context.md) — the static
|
||||
`Identity.resources` path (the operator-role fallback)
|
||||
- `/workspace/system/dev1/docker.md` — the hosted-services use case
|
||||
(reverse-proxy, postgres, redis, gitea on dev1)
|
||||
- `/workspace/@alkdev/reverse-proxy/deploy/docker-compose.yml` — the
|
||||
reverse-proxy's docker setup (operator-created, not alknet-spawned)
|
||||
- Spec: [docker-operations.md](../crates/docker/docker-operations.md)
|
||||
§"Access Control" and §"Label Namespace"
|
||||
@@ -0,0 +1,272 @@
|
||||
# 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)
|
||||
@@ -0,0 +1,234 @@
|
||||
# ADR-062: Docker Client and OwnershipStore Injection via Closure Capture
|
||||
|
||||
## Status
|
||||
|
||||
Accepted
|
||||
|
||||
## Context
|
||||
|
||||
The docker operation handlers need access to two non-secret, shared
|
||||
runtime handles:
|
||||
|
||||
1. The bollard `Docker` client — a `Clone`-able handle (internally an
|
||||
`Arc`) to the docker daemon connection, constructed once at
|
||||
assembly-layer startup.
|
||||
2. The `OwnershipStore` — the ADR-050 write-side trait, used by
|
||||
`docker/container/create` (to `record`) and `docker/container/remove`
|
||||
(to `revoke`).
|
||||
|
||||
An earlier draft of the docker spec
|
||||
(`docker-operations.md`) showed handlers calling
|
||||
`ctx.docker_client()` and speculated the handle lived on "a
|
||||
`DockerOpsExt` extension trait on `OperationContext`, or a
|
||||
`DockerClient` capability in `ctx.capabilities`." This was an inline
|
||||
open question with a model conflict:
|
||||
|
||||
### Why `Capabilities` is the wrong channel
|
||||
|
||||
`Capabilities` (ADR-014, `operation-registry.md` §"Capability
|
||||
Injection") is the type for **outbound secret material** — decrypted
|
||||
API keys, signing keys, vault-derived credentials. Its contract
|
||||
(`core-types.md`): non-`Serialize`, zeroized on drop, populated from
|
||||
the vault at registration time. A bollard `Docker` handle to a local
|
||||
unix socket is not secret material; it has no zeroizing semantics; it
|
||||
must not be smuggled through the secret-injection channel. Putting a
|
||||
`Docker` handle in `Capabilities` would be a category error against
|
||||
the one-way `Capabilities` contract (ADR-014).
|
||||
|
||||
The same applies to the `OwnershipStore`: it's a shared state handle
|
||||
(shared `Arc<dyn OwnershipStore>`), not secret material.
|
||||
|
||||
### Why `OperationContext` extension is the wrong channel
|
||||
|
||||
`OperationContext` (`operation-registry.md:230`) is a concrete struct
|
||||
with a fixed field set, constructed by the dispatch path per call.
|
||||
Adding a `docker_client` field (or an extension trait that reads one)
|
||||
would either (a) make every non-docker handler pay for a field they
|
||||
don't use, or (b) require a typed-map / downcast pattern that
|
||||
violates the "context is concrete, not a bag" design. The
|
||||
established pattern for per-handler-set shared state is not a
|
||||
context field — it's closure capture.
|
||||
|
||||
### The established pattern: closure capture at registration
|
||||
|
||||
The `from_openapi` adapter (ADR-017, `client-and-adapters.md`,
|
||||
`operation-registry.md:819`) captures its `reqwest::Client` in each
|
||||
forwarding handler's closure at registration time:
|
||||
|
||||
```rust
|
||||
// operation-registry.md:819 (the established pattern)
|
||||
.with_local(vastai_listMachines_spec(), Arc::new(vastai_handler),
|
||||
CompositionAuthority::new("vastai", ["vastai:query"]),
|
||||
ScopedOperationEnv::new([...]),
|
||||
Capabilities::new().with_api_key("vastai", vastai_token))
|
||||
```
|
||||
|
||||
The `vastai_handler` closure captures its reqwest client (and its
|
||||
auth token via `Capabilities`, which *is* secret material). The
|
||||
non-secret shared handle (reqwest client) is captured in the closure;
|
||||
the secret (the API key) goes through `Capabilities`. This is the
|
||||
correct split: secret material through `Capabilities`, non-secret
|
||||
shared state through closure capture.
|
||||
|
||||
The docker ops follow the same split. The `Docker` client and the
|
||||
`OwnershipStore` are non-secret shared state — closure-captured. There
|
||||
are no secret capabilities (local bollard needs no API key; the
|
||||
docker daemon is local).
|
||||
|
||||
## Decision
|
||||
|
||||
### 1. `register_docker_ops` captures the `Docker` client and `OwnershipStore` in each handler closure
|
||||
|
||||
The `register_docker_ops` function (or the `DockerOps` builder)
|
||||
takes `Arc<Docker>` and `Arc<dyn OwnershipStore>` (plus the label
|
||||
config and the `CompositionAuthority`) as arguments, and constructs
|
||||
each `Handler` / `StreamingHandler` closure with those handles
|
||||
captured by reference (`Arc::clone` per handler):
|
||||
|
||||
```rust
|
||||
pub fn register_docker_ops(
|
||||
builder: &mut OperationRegistryBuilder,
|
||||
docker: Arc<bollard::Docker>,
|
||||
ownership_store: Arc<dyn OwnershipStore>,
|
||||
labels: &DockerLabels,
|
||||
authority: CompositionAuthority,
|
||||
) {
|
||||
let docker_clone = docker.clone();
|
||||
let ownership_clone = ownership_store.clone();
|
||||
let labels_clone = labels.clone();
|
||||
builder.with_local(
|
||||
container_inspect_spec(),
|
||||
Arc::new(move |input, ctx| {
|
||||
let docker = docker_clone.clone();
|
||||
Box::pin(async move {
|
||||
let container_id = input["containerId"].as_str()
|
||||
.ok_or_else(|| /* INVALID_INPUT */)?;
|
||||
match docker.inspect_container(container_id, None::<()>).await {
|
||||
Ok(info) => ResponseEnvelope::ok(to_json_value(info)),
|
||||
Err(e) => /* CONTAINER_NOT_FOUND / DOCKER_ERROR */,
|
||||
}
|
||||
})
|
||||
}),
|
||||
authority.clone(),
|
||||
ScopedOperationEnv::empty(),
|
||||
Capabilities::new(), // no secret caps — local bollard
|
||||
);
|
||||
// ... same pattern for each operation ...
|
||||
}
|
||||
```
|
||||
|
||||
Each handler closure captures the `Docker` client and (where needed)
|
||||
the `OwnershipStore` by cloning the `Arc`. The handler does not read
|
||||
these from `OperationContext`; they're baked into the closure at
|
||||
registration time. The `Capabilities` passed to `with_local` is empty
|
||||
(`Capabilities::new()`) — there are no secret capabilities for local
|
||||
bollard operations.
|
||||
|
||||
### 2. The `Docker` client is shared, `Clone`-able, and constructed once
|
||||
|
||||
bollard's `Docker` is `Clone` (it holds an internal `Arc` to the
|
||||
connection). The assembly layer constructs one
|
||||
(`Docker::connect_with_local_defaults()`, ADR-059 §2), wraps it in an
|
||||
`Arc`, and passes it to `register_docker_ops`. Each handler clones
|
||||
the `Arc` (cheap — a refcount bump) into its closure. There is no
|
||||
per-handler docker client; the one client is shared across all
|
||||
docker operations.
|
||||
|
||||
### 3. The `OwnershipStore` is shared the same way
|
||||
|
||||
`Arc<dyn OwnershipStore>` is cloned into the `create` and `remove`
|
||||
handler closures (the two operations that write to the store). The
|
||||
other operations (inspect, start, stop, logs, exec, list, images)
|
||||
don't capture the store — they don't write to it. The `create`
|
||||
closure captures it for `record`; the `remove` closure captures it
|
||||
for `revoke`.
|
||||
|
||||
### 4. No `docker_client()` accessor; no `OperationContext` extension
|
||||
|
||||
The handlers do not call `ctx.docker_client()` or any other accessor.
|
||||
The `Docker` client and `OwnershipStore` are not on `OperationContext`
|
||||
and are not accessible via an extension trait. They're closure-
|
||||
captured, full stop. A handler that needs the docker client gets it
|
||||
from its closure's captured `Arc<Docker>`, not from the context.
|
||||
|
||||
This means the handler signature is the standard
|
||||
`Fn(Value, OperationContext) -> Pin<Box<dyn Future<Output = ResponseEnvelope> + Send>>`
|
||||
(ADR-049) — no new parameter, no new context field. The docker-
|
||||
specific state is in the closure, not the context.
|
||||
|
||||
## Consequences
|
||||
|
||||
**Positive:**
|
||||
|
||||
- The injection model matches the established `from_openapi` pattern
|
||||
(non-secret shared state via closure capture, secret material via
|
||||
`Capabilities`). No new mechanism; no `OperationContext` change; no
|
||||
`Capabilities` conflict.
|
||||
- The `Capabilities` contract (ADR-014) stays clean — it holds secret
|
||||
material only. A local bollard handle is not smuggled through the
|
||||
secret channel.
|
||||
- The handlers are self-contained — the `Docker` client and store are
|
||||
baked in at registration, not looked up at call time. This is
|
||||
testable (construct a `Docker` client in a test, register the ops,
|
||||
invoke the handler directly) and composable (the same handler works
|
||||
whether the docker client points at a local socket or a test
|
||||
daemon).
|
||||
- No new trait, no extension mechanism, no downcast. The injection is
|
||||
plain Rust closure capture — the simplest thing that works.
|
||||
|
||||
**Negative:**
|
||||
|
||||
- Each handler closure captures the `Docker` and (for create/remove)
|
||||
the `OwnershipStore` by `Arc::clone`. This is a refcount bump per
|
||||
handler construction (at startup, once) — negligible. The per-call
|
||||
cost is zero (the closure already holds the `Arc`; calling the
|
||||
handler doesn't re-clone).
|
||||
- The `register_docker_ops` function signature is longer (takes the
|
||||
`Docker` + store + labels + authority). This is the assembly layer's
|
||||
wiring concern, not a handler concern — the handlers don't know
|
||||
where their `Docker` came from.
|
||||
- A test that wants to invoke a single docker handler must construct
|
||||
the `Docker` client and (if the handler uses the store) the
|
||||
`OwnershipStore` to register the op. This is the same as any
|
||||
handler test that needs its dependencies — not new.
|
||||
|
||||
## Door type
|
||||
|
||||
**One-way.** The decision to use closure capture (not a context field,
|
||||
not a `Capabilities` entry) is the injection model every docker
|
||||
handler is written against. Reversing to a context field would be a
|
||||
rewrite of every handler and an `OperationContext` change. The
|
||||
specific captures (`Docker` + `OwnershipStore`) are the non-secret
|
||||
state the handlers depend on; changing what's captured is a handler
|
||||
rewrite.
|
||||
|
||||
The choice of `Arc<Docker>` vs a non-`Arc` shared reference is a
|
||||
two-way-door implementation detail (bollard's `Docker` is already
|
||||
`Clone`-with-`Arc`-internally; the outer `Arc` is for the trait-object
|
||||
`OwnershipStore`'s sake, not the `Docker`'s). The capture pattern
|
||||
(closure capture) is the one-way commitment.
|
||||
|
||||
## References
|
||||
|
||||
- [ADR-014](014-secret-material-flow-and-capability-injection.md) —
|
||||
the `Capabilities` contract this ADR keeps clean (secret material
|
||||
only; the `Docker` handle is not secret)
|
||||
- [ADR-017](017-call-protocol-client-and-adapter-contract.md) — the
|
||||
`from_openapi`/`from_call` adapter pattern this ADR follows
|
||||
(non-secret shared state via closure capture)
|
||||
- [ADR-022](022-handler-registration-provenance-and-composition-authority.md)
|
||||
— `HandlerRegistration`, the bundle the `register_docker_ops`
|
||||
builder populates
|
||||
- [ADR-050](050-dynamic-resource-ownership-for-runtime-spawned-resources.md)
|
||||
— the `OwnershipStore` trait the `create`/`remove` handlers capture
|
||||
- [ADR-058](058-alknet-docker-on-alknet-call.md) — the operation
|
||||
surface this injection model serves
|
||||
- `crates/call/operation-registry.md` §"Capability Injection"
|
||||
(the `Capabilities` semantics this ADR respects) and
|
||||
§"OperationContext" (the struct this ADR does *not* extend)
|
||||
- `crates/call/client-and-adapters.md` §"from_openapi forwarding"
|
||||
(the prior art: reqwest client closure-captured, API key in
|
||||
`Capabilities`)
|
||||
- Spec: [docker-operations.md](../crates/docker/docker-operations.md)
|
||||
§"Handler injection" (the section this ADR adds, replacing the
|
||||
earlier `ctx.docker_client()` sketches)
|
||||
@@ -0,0 +1,185 @@
|
||||
# ADR-063: Exit Code on a Terminal `call.responded` for Non-Interactive Exec
|
||||
|
||||
## Status
|
||||
|
||||
Accepted
|
||||
|
||||
## Context
|
||||
|
||||
The alknet-docker POC (`docs/research/alknet-docker/poc-summary.md`
|
||||
§"POC Target 3") validated a completion shape for streaming exec:
|
||||
the exit code rides on a final `call.responded` frame before
|
||||
`call.completed`. This keeps `call.completed`'s payload empty
|
||||
(matching ADR-012's wire format — no core protocol change). The POC
|
||||
used `{ "exitCode": N }` on the final `call.responded`.
|
||||
|
||||
The docker spec (`docker-operations.md` §"docker/container/exec")
|
||||
carries this forward and adds a `"terminal": true` field to mark the
|
||||
exit-code `call.responded` as the final value before completion. The
|
||||
`terminal` flag is a docker-operation convention, not a call-protocol
|
||||
wire-format change — it's a field in the `call.responded` payload
|
||||
(the `Value`), not a new event type or a `call.completed` payload.
|
||||
|
||||
A reader asked: "why `terminal: true` and not a dedicated `call.exit`
|
||||
event or an exit code on `call.completed`?" This ADR records the
|
||||
decision.
|
||||
|
||||
### Why not an exit code on `call.completed`
|
||||
|
||||
ADR-012 defines `call.completed` with an empty payload (`{}`). The
|
||||
POC's completion-shape decision (POC §"The completion-shape decision
|
||||
this validates") chose to keep `call.completed` empty and put the
|
||||
exit code on the preceding `call.responded`. Changing `call.completed`
|
||||
to carry a payload would be a wire-format change (ADR-012 amendment,
|
||||
new ALPN per ADR-006) — a one-way door that affects every
|
||||
`call.completed` consumer, not just docker. The POC deliberately
|
||||
avoided this.
|
||||
|
||||
### Why not a dedicated `call.exit` event
|
||||
|
||||
A new event type (`call.exit`) would be a wire-format addition (new
|
||||
`EventEnvelope` variant, ADR-012 amendment). It would also duplicate
|
||||
the "terminal value before completion" semantics that
|
||||
`call.completed` already provides — `call.completed` is the
|
||||
"stream end" signal; an exit code is a "final value" that rides
|
||||
*before* the stream end. A `EventEnvelope` variant conflates these
|
||||
two: `call.exit` would be both the final value and the stream-end
|
||||
signal, but `call.completed` already handles stream end. Keeping
|
||||
the exit code as a `call.responded` (a normal value) and letting
|
||||
`call.completed` be the stream end preserves the
|
||||
one-value-per-`call.responded` invariant: the exit code is just the
|
||||
last value.
|
||||
|
||||
### Why `terminal: true`
|
||||
|
||||
A client consuming a `docker/container/exec` stream sees a sequence
|
||||
of `call.responded` frames (stdout/stderr lines) and then a final
|
||||
`call.responded` with `{ "exitCode": N, "terminal": true }`, then
|
||||
`call.completed`. The `terminal: true` flag tells the client "this
|
||||
is the last value; the next event is `call.completed`." Without it,
|
||||
the client would have to infer "this `call.responded` has an
|
||||
`exitCode` field, so it's the exit" — a content-sniffing heuristic
|
||||
that breaks if a future stdout line happens to carry an `exitCode`
|
||||
field.
|
||||
|
||||
The `terminal` flag is a docker-operation convention — a field in
|
||||
the `call.responded` payload, not a protocol-level marker. It's
|
||||
declared in the `docker/container/exec` `output_schema` (the exit
|
||||
frame is a documented part of the output stream). Other
|
||||
`Subscription` operations that produce a "terminal result before
|
||||
completion" can adopt the same convention (a `terminal: true` field
|
||||
on their final `call.responded`); it's not docker-specific, but
|
||||
docker is the first operation to need it.
|
||||
|
||||
## Decision
|
||||
|
||||
### 1. The exec exit code rides on a final `call.responded` with `terminal: true`
|
||||
|
||||
`docker/container/exec` (non-interactive, `tty: false`) emits:
|
||||
|
||||
1. Zero or more `call.responded` frames with stdout/stderr output
|
||||
(`{ "stream": "stdout"|"stderr", "text": "..." }`).
|
||||
2. A final `call.responded` with `{ "exitCode": N, "terminal": true }`.
|
||||
3. `call.completed` (empty payload, per ADR-012).
|
||||
|
||||
The `terminal: true` field marks the exit-code frame as the final
|
||||
value before completion. The `exitCode` is `i32` (matching
|
||||
`ExecInspectResponse.exit_code`; negative for signal-terminated,
|
||||
though docker exec exit codes are typically non-negative).
|
||||
|
||||
### 2. `call.completed` stays empty
|
||||
|
||||
No `call.completed` payload change. ADR-012's wire format is
|
||||
unchanged. The exit code is on the preceding `call.responded`, not
|
||||
on `call.completed`.
|
||||
|
||||
### 3. `terminal: true` is an operation-level convention, not a protocol field
|
||||
|
||||
The `terminal` flag is a field in the `call.responded` payload (the
|
||||
`Value`), declared in the operation's `output_schema`. It is not a
|
||||
new `EventEnvelope` field, not a new event type, and not a
|
||||
`call.completed` payload. The call protocol's wire format is
|
||||
unchanged. Other `Subscription` operations that produce a terminal
|
||||
result before completion may adopt the same convention; it's not
|
||||
docker-specific, but docker is the first to use it.
|
||||
|
||||
The `OperationSpec.output_schema` for `docker/container/exec`
|
||||
documents the two response shapes: the streaming output frames
|
||||
(`{ "stream": ..., "text": ... }`) and the terminal exit frame
|
||||
(`{ "exitCode": N, "terminal": true }`). A client reading the schema
|
||||
knows to expect the terminal frame.
|
||||
|
||||
### 4. The exit-code frame is the last `call.responded` before `call.completed`
|
||||
|
||||
The handler ensures the exit-code `call.responded` is the last one
|
||||
the stream produces. The `StreamingHandler` (ADR-049) pumps the
|
||||
output stream, then emits the exit-code frame, then the stream ends
|
||||
(the dispatcher writes `call.completed` on natural stream end). The
|
||||
ordering is the handler's responsibility (the handler controls the
|
||||
stream's item order); the dispatcher's `pump_stream` writes them in
|
||||
order. This mirrors the POC's validated pattern.
|
||||
|
||||
## Consequences
|
||||
|
||||
**Positive:**
|
||||
|
||||
- The exit code propagates through the streaming completion path
|
||||
without a wire-format change. `call.completed` stays empty
|
||||
(ADR-012 unchanged); the exit code is a normal `call.responded`
|
||||
value that happens to be the last one.
|
||||
- The `terminal: true` flag gives clients a deterministic "this is
|
||||
the exit" signal without content-sniffing the `exitCode` field. A
|
||||
client can stop reading output frames when it sees `terminal: true`
|
||||
and read the exit code.
|
||||
- The convention is reusable: any `Subscription` operation with a
|
||||
terminal result before completion can use `terminal: true` on its
|
||||
final `call.responded`. No protocol change; just an
|
||||
operation-level field.
|
||||
|
||||
**Negative:**
|
||||
|
||||
- The `terminal` flag is a convention, not enforced by the wire
|
||||
format. A `Subscription` operation that emits a terminal result
|
||||
*without* `terminal: true` would be non-conformant but not
|
||||
protocol-violating. The `output_schema` documents the convention;
|
||||
clients that read the schema know to expect it.
|
||||
- A client that doesn't check `terminal: true` and instead
|
||||
content-sniffs `exitCode` would work for docker exec but break on
|
||||
a hypothetical stdout line that carries an `exitCode` field. The
|
||||
flag is the robust path; content-sniffing is the fragile path. The
|
||||
spec recommends the flag; the schema documents it.
|
||||
|
||||
## Door type
|
||||
|
||||
**One-way.** The exit-code-on-`call.responded`-before-`call.completed`
|
||||
shape is the completion contract `docker/container/exec` commits to.
|
||||
Clients consuming the exec stream depend on this ordering; changing
|
||||
it (moving the exit code to `call.completed`, or adding a `call.exit`
|
||||
event) would be a break for those clients.
|
||||
|
||||
The `terminal: true` field name is two-way-door within the one-way
|
||||
shape — it could be renamed (e.g., `final: true`) without a wire-
|
||||
format change, since it's a payload field, not a protocol marker.
|
||||
But the *presence* of a terminal marker on the final `call.responded`
|
||||
is the one-way commitment: clients depend on "the last
|
||||
`call.responded` before `call.completed` is marked as terminal and
|
||||
carries the exit code."
|
||||
|
||||
## References
|
||||
|
||||
- `docs/research/alknet-docker/poc-summary.md` §"POC Target 3"
|
||||
(exec with exit code — the validated pattern this ADR formalizes)
|
||||
- `docs/research/alknet-docker/poc-summary.md` §"The completion-shape
|
||||
decision this validates" (the POC's reasoning for exit-on-
|
||||
`call.responded`, not on `call.completed`)
|
||||
- [ADR-012](012-call-protocol-stream-model.md) — the call protocol
|
||||
wire format this ADR keeps unchanged (`call.completed` stays empty)
|
||||
- [ADR-049](049-streaming-handler-for-subscriptions.md) —
|
||||
`StreamingHandler`, the dispatch path that pumps the
|
||||
`call.responded` stream and writes `call.completed` on stream end
|
||||
- [ADR-023](023-operation-error-schemas.md) — `output_schema`
|
||||
documents the terminal frame shape
|
||||
- [ADR-058](058-alknet-docker-on-alknet-call.md) — the operation
|
||||
taxonomy (exec is a `Subscription`)
|
||||
- Spec: [docker-operations.md](../crates/docker/docker-operations.md)
|
||||
§"docker/container/exec" (the operation this ADR's shape serves)
|
||||
Reference in new issue
Block a user