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.
10 KiB
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:
- The bollard
Dockerclient — aClone-able handle (internally anArc) to the docker daemon connection, constructed once at assembly-layer startup. - The
OwnershipStore— the ADR-050 write-side trait, used bydocker/container/create(torecord) anddocker/container/remove(torevoke).
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:
// 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):
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_openapipattern (non-secret shared state via closure capture, secret material viaCapabilities). No new mechanism; noOperationContextchange; noCapabilitiesconflict. - The
Capabilitiescontract (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
Dockerclient and store are baked in at registration, not looked up at call time. This is testable (construct aDockerclient 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
Dockerand (for create/remove) theOwnershipStorebyArc::clone. This is a refcount bump per handler construction (at startup, once) — negligible. The per-call cost is zero (the closure already holds theArc; calling the handler doesn't re-clone). - The
register_docker_opsfunction signature is longer (takes theDocker+ store + labels + authority). This is the assembly layer's wiring concern, not a handler concern — the handlers don't know where theirDockercame from. - A test that wants to invoke a single docker handler must construct
the
Dockerclient and (if the handler uses the store) theOwnershipStoreto 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 —
the
Capabilitiescontract this ADR keeps clean (secret material only; theDockerhandle is not secret) - ADR-017 — the
from_openapi/from_calladapter pattern this ADR follows (non-secret shared state via closure capture) - ADR-022
—
HandlerRegistration, the bundle theregister_docker_opsbuilder populates - ADR-050
— the
OwnershipStoretrait thecreate/removehandlers capture - ADR-058 — the operation surface this injection model serves
crates/call/operation-registry.md§"Capability Injection" (theCapabilitiessemantics 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 inCapabilities)- Spec: docker-operations.md
§"Handler injection" (the section this ADR adds, replacing the
earlier
ctx.docker_client()sketches)