Files
alknet/docs/architecture/crates/docker/overview.md
T
glm-5.2 ab963d0e46 docs(arch): resolve OQ-050 — include docker/system/events in v1
Resolve the deferred docker system events subscription question.
docker/system/events is now a v1 Subscription operation using the
same StreamingHandler pattern already wired for logs, exec, and
image/pull. The internal ownership-store subscription for stale-entry
cleanup on destroy events is a follow-up refinement.

Scrub hedging language from ADR-060 and docker specs:
- Remove 'marginal gain' / 'future feature is additive' framing
- Replace 'no reaper' / 'not promptly cleaned up' with clean
  statement that events subscription provides the prompt-cleanup path
- Remove stale-entry policy from ADR-060's two-way door classification
2026-07-10 07:31:55 +00:00

17 KiB

status, last_updated
status last_updated
draft 2026-07-08

alknet-docker — Overview

The docker operations crate: a thin, single-host bollard wrapper that exposes docker container and image operations as call-protocol operations on alknet/call, plus (behind a tty feature) a DockerTtyBackend implementing alknet-tty's TtyBackend trait. This document covers the crate's purpose, the two-role design, its dependency edges, the ALPN decision, the label namespace, the feature gates, and the assembly-layer wiring. Component details are in the sibling documents.

What

alknet-docker is the docker integration point for the ALPN-as-service architecture. It does two things:

  1. Registers docker operations on the shared alknet/call OperationRegistry. The operations — docker/container/* and docker/image/* — are ordinary call-protocol operations with OperationSpecs, AccessControl, and service discovery. The existing CallAdapter dispatches them through invoke() / invoke_streaming(). There is no alknet/docker ALPN and no DockerProtocolHandler. Decided in ADR-058.

  2. Provides DockerTtyBackend (behind the tty feature), an impl TtyBackend (ADR-053) for interactive terminal sessions into containers over alknet/tty. This is the backend the alknet-tty spec listed as "future, out of scope here" (tty-backend.md §"Backend implementations"). Decided in ADR-061.

The two roles share the bollard Docker client, the container identity model, and the label/ownership reasoning (ADR-060), but diverge at the wire format: call operations use EventEnvelope on alknet/call; interactive sessions use the raw chunk codec on alknet/tty (ADR-052).

Why

The overwhelming common use case (per the user's brief) is a hub that runs as a call-protocol client, connects to worker nodes, and exposes docker operations on those workers. alknet-docker is what runs on the worker: it wraps the local docker daemon and registers the operations. The hub composes them via from_call (ADR-017) + peer routing (ADR-029) — the proxy pattern (ADR-050 §3). For interactive terminals into the containers (an agent needing a shell), the hub opens an alknet/tty session that the worker's DockerTtyBackend serves.

The two container use cases:

  • Disposable dev containers (common by volume) — a coordinator spawns a container for an implementation agent or an isolated env. docker/container/create records ownership; the coordinator owns the container; docker/container/remove revokes. Interactive sessions go through DockerTtyBackend. The container is short-lived; "burn it and start over" is the recovery model (ADR-050 §4b).

  • Long-running hosted services (less common, important) — the production server (/workspace/system/dev1) hosts rarely-changing services (reverse-proxy, postgres, redis, gitea) in docker. These are created by an operator via docker compose, not via alknet. alknet-docker's operations manage them (start/stop/inspect/logs); the operator role reaches them via the static-resource fallback (ADR-060 §3) — no ownership-store entry needed for pre-existing containers an operator is statically authorized to manage.

Both use cases work through one AccessControl model (ADR-050) and one operation surface. No special-casing, no "is this a managed container?" branch.

The Two Roles in Brief

Role 1: Call operations on alknet/call

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
docker/system/events Subscription events JSON (StreamingHandler)

Interactive exec (tty: true) and interactive attach are not call operations — they are alknet/tty sessions via DockerTtyBackend. A docker/container/exec call with tty: true in the input returns INVALID_INPUT directing the caller to alknet/tty. The full surface, access control, and the streaming shapes are in docker-operations.md.

Role 2: DockerTtyBackend on alknet/tty

// behind the `tty` feature
pub struct DockerTtyBackend {
    docker: Docker,
    label_prefix: String,  // "alknet" by default; configurable
}

#[async_trait]
impl TtyBackend for DockerTtyBackend {
    async fn allocate(&self, params: &TtyParams) -> Result<TtyHandle, TtyError>;
    fn resource_id(&self, params: &TtyParams) -> Option<(&'static str, String)>;
}

The backend wraps bollard::attach_container() (attach mode) or bollard::exec::start_exec with tty: true (exec mode), producing a TtyHandle the TtyAdapter pumps. The wire format, the session lifecycle, and the exit-chunk ordering live in alknet-tty; the backend produces bollard-backed handles. See docker-tty-backend.md.

Dependencies

alknet-docker
├── alknet-core    (Identity, AccessControl, OwnershipProvider/Store — ADR-050)
├── alknet-call     (OperationSpec, Handler, StreamingHandler, OperationRegistryBuilder)
├── bollard 0.21    (http + pipe + time features; the docker API — ADR-059)
├── alknet-tty      (TtyBackend trait — only behind `tty` feature; ADR-061)
├── tokio           (async runtime — bollard is tokio-based)
├── serde / serde_json (operation input/output, label values)
└── thiserror       (DockerError enum)

The alknet-call edge is the protocol-foundation exception (ADR-003 Am. 1): alknet-docker consumes OperationSpec, Handler, StreamingHandler, and OperationRegistryBuilder from alknet-call to register its operations. This is the same edge alknet-http has (HTTP uses the call protocol types). alknet-docker does not depend on alknet-http, alknet-tty-local, or any other handler crate — the no-handler-depends-on-another-handler rule (ADR-003) is preserved.

The alknet-tty edge is only behind the tty feature. A deployment using alknet-docker for call operations only (the common case) does not pull in alknet-tty. See Feature Gates below.

bollard version and features

bollard 0.21, verified as the latest published version on crates.io (as of 2026-07-08). The POC's local checkout (0.21.0) matches the published version — identical API surface. Features: http + pipe (default, local daemon connect) + time (log timestamp typing). No ssl (no remote daemon over TLS — fleet is call-protocol), no ssh (the SSH-tunnel fleet model is what alknet replaces), no websocket (reliable attach only, per the POC), no buildkit (deferred, OQ-049). See ADR-059.

ALPN

ALPN Role Handler Transport
alknet/call Call operations CallAdapter (shared, not docker-specific) QUIC bidi stream, EventEnvelope framing
alknet/tty Interactive terminal sessions TtyAdapter (shared, not docker-specific) QUIC bidi stream, raw chunk codec (ADR-052)

alknet-docker registers no ALPN of its own. The call operations are dispatched by the shared CallAdapter on alknet/call; the interactive sessions are dispatched by the shared TtyAdapter on alknet/tty, using DockerTtyBackend as one of potentially several registered backends. This is the ADR-058 decision: docker ops are call-protocol operations, and the one operation that needed raw carriage moved to alknet-tty.

Label Namespace

alknet-docker applies two labels to containers it creates (ADR-060):

Label Value Purpose
alknet.managed "true" Marks the container as alknet-managed; the list owned_only filter and the ownership cross-check key on this.
alknet.owner <peer_id> The Identity.id of the spawner (the coordinator's identity, not the end user's — proxy pattern).

The prefix (alknet) is configurable at assembly-layer wiring (two-way-door). The label schema (two labels, <prefix>.managed + <prefix>.owner) is the one-way commitment. Containers created by docker compose or docker run (the hosted-services case) have no alknet labels and no ownership-store entry; they're reached via the static-resource operator fallback.

Full details: docker-operations.md §"Label Namespace" and ADR-060.

Feature Gates

# alknet-docker Cargo.toml
[features]
default = ["ops"]
ops = []                  # docker/container/* and docker/image/* call operations
tty = ["dep:alknet-tty"]  # DockerTtyBackend (impl TtyBackend)
  • default = ["ops"] — the call operations. A hub or coordinator wiring docker management over the call protocol uses the default features. Pulls in bollard + alknet-call + alknet-core; no alknet-tty.
  • tty — adds DockerTtyBackend, pulling in alknet-tty for the TtyBackend trait. A deployment that wants interactive terminal sessions into containers enables this and registers DockerTtyBackend with the TtyAdapter. A call-operations-only deployment leaves this off and doesn't pull in alknet-tty.

The tty feature mirrors alknet-tty's local feature pattern (ADR-054): the heavy backend edge is opt-in, so a deployment that doesn't want it doesn't pay for it. See ADR-061.

Assembly Layer Wiring

The assembly layer (the CLI binary or a hub/worker binary) constructs the bollard client, the ownership store, and the label config, then registers the docker operations and (optionally) the tty backend:

// 1. Construct the bollard client (local daemon, ADR-059 §2)
let docker = bollard::Docker::connect_with_local_defaults()?;

// 2. Construct the ownership store (in-memory default, ADR-050 §1)
let ownership_store = Arc::new(InMemoryOwnershipStore::new());

// 3. Register docker call operations on the shared registry
let mut builder = OperationRegistryBuilder::new();
// ... other operations (services/list, agent ops, etc.) ...
register_docker_ops(
    &mut builder,
    docker.clone(),
    ownership_store.clone(),
    &DockerLabels { prefix: "alknet".into() },
    CompositionAuthority::new("docker-ops", ["container:list", "container:exec", /* ... */]),
);

// 4. (optional, behind `tty` feature) Register DockerTtyBackend
#[cfg(feature = "tty")]
{
    let docker_backend = Arc::new(DockerTtyBackend::new(
        docker.clone(),
        "alknet".into(),
    )) as Arc<dyn TtyBackend>;
    // Insert into the TtyAdapter's backend map under "docker"
    backends.insert("docker".into(), docker_backend);
}

// 5. Build the registry and start the endpoint
let registry = builder.build();
let call_adapter = CallAdapter::new(Arc::new(registry), identity_provider);
// ... register handlers on the endpoint, start ...

The register_docker_ops function (or a DockerOps struct the builder consumes) adds each docker/container/* and docker/image/* operation as a HandlerRegistration with the right HandlerKind (Once for Query/Mutation, Stream for Subscription — ADR-049) and AccessControl (per ADR-060). The assembly layer provides the CompositionAuthority for the docker-ops handler (the scopes the docker ops run under when composed) and the Capabilities (empty — Capabilities::new() for local bollard; no outbound credentials needed). The Docker client and OwnershipStore are closure-captured into each handler at registration time (ADR-062) — not read from OperationContext and not put in Capabilities (Capabilities is for secret material only, per ADR-014).

The OwnershipStore is shared between the docker ops (which record on create and revoke on remove) and the AccessControl::check path (which consults the OwnershipProvider read side). The in-memory default is sufficient for the single-host case; a persistence adapter (ADR-035 shape) is built when a hub wants fleet ownership to survive restarts.

Architecture (component pointers)

  • docker-operations.md — the operation surface: lifecycle (Query/Mutation), logs/exec/pull (Subscription via StreamingHandler), access control (ADR-050/050 application), label namespace, teardown coupling, the exit-code-on-final- call.responded pattern for exec.
  • docker-tty-backend.md — the DockerTtyBackend: attach vs exec mode, TtyHandle field mapping (PTY mode merges stderr), TtyControl → bollard resize/signal, exit_code future Drop-kill (ADR-056), resource_id() delegation.

Design Decisions

Decision ADR Summary
Docker ops on alknet/call (no separate ALPN) ADR-058 Call-protocol operations; raw-carriage dissolved by alknet-tty; no carriage field
bollard 0.21 + feature selection ADR-059 Version verified current; http+pipe+time; no ssl/ssh/websocket/buildkit
Container resource model + label namespace ADR-060 ADR-050 application; alknet.managed/alknet.owner labels; list owned_only; hosted-services static fallback
DockerTtyBackend in alknet-docker ADR-061 Behind tty feature; attach/exec mode; POC drive_attach_raw as reference
Crate decomposition ADR-003 Am. 1 alknet-docker depends on alknet-call (protocol-foundation exception) + alknet-tty (tty feature)
Call protocol stream model ADR-012 EventEnvelope framing, no carriage extension
Streaming handler for subscriptions ADR-049 StreamingHandler for logs/exec/pull; exit code on final call.responded
Dynamic resource ownership ADR-050 Containers as AccessControl resources; the model ADR-060 applies
Peer-graph routing ADR-029 Head→worker docker management via PeerRef / invoke_peer
Forwarded-for identity ADR-032 End-user identity as metadata when proxying
TtyBackend trait ADR-053 The trait DockerTtyBackend implements
Exit code on control chunk ADR-055 The exit-chunk ordering DockerTtyBackend's exit_code feeds into
Backend cleanup on cancel ADR-056 DockerTtyBackend's exit_code Drop kills the container/exec
Docker client + OwnershipStore injection ADR-062 Closure capture at registration; Capabilities for secrets only
Exit code on terminal call.responded ADR-063 { "exitCode": N, "terminal": true } on final call.responded for non-interactive exec

Open Questions

See open-questions.md for full details.

  • OQ-048 (deferred(scope)): Network and volume operation surface.
  • OQ-049 (deferred(scope)): Image build (buildkit) scope.
  • OQ-051 (deferred(scope)): Container create options surface.

References

  • docs/research/alknet-docker/poc-summary.md — the POC summary this spec set builds on (validated targets, open unknowns)
  • /workspace/alknet-docker-poc/ — the POC source
  • /workspace/bollard/ — bollard 0.21.0 source (verified current)
  • /workspace/@alkdev/dispatch/ — the dispatch POC (prior art: dispatch.managed labels, SSH-tunnel fleet model)
  • /workspace/system/dev1/docker.md — the hosted-services use case
  • /workspace/@alkdev/reverse-proxy/deploy/docker-compose.yml — operator-created container example