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.
234 lines
10 KiB
Markdown
234 lines
10 KiB
Markdown
# 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) |