Files
alknet/docs/architecture/crates/docker/docker-operations.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

27 KiB

status, last_updated
status last_updated
draft 2026-07-08

alknet-docker — Operations

The operation surface alknet-docker registers on the shared alknet/call OperationRegistry: lifecycle operations (Query/ Mutation), streaming operations (Subscription via StreamingHandler), access control (ADR-050/060 application), the label namespace, and teardown coupling. This document specifies what an implementer builds against. The ALPN decision (shared alknet/call, no separate alknet/docker) is in ADR-058.

What

alknet-docker exports a register_docker_ops() function (or a DockerOps registration bundle) that the assembly layer calls to add docker operations to the shared OperationRegistryBuilder. Each operation is a HandlerRegistration with:

  • An OperationSpec (name, namespace, op_type, schemas, access_control, resource_id_path per ADR-050).
  • A HandlerKind (Once for Query/Mutation, Stream for Subscription — ADR-049).
  • provenance: Local (assembly-registered, can compose).
  • A CompositionAuthority (the scopes the docker ops run under when composed — e.g., container:list, container:exec).
  • Empty Capabilities (local bollard, no outbound credentials).

The operations are dispatched by the existing CallAdapter through invoke() / invoke_streaming(). There is no docker-specific dispatch code in alknet-call.

Why

The POC (docs/research/alknet-docker/poc-summary.md) validated the hard parts (interactive attach, logs subscription, exec with exit code) and noted the remaining lifecycle operations are "mechanical bollard wrapping, no feasibility risk... Query/Mutation operations with single call.responded responses, the boring case" (POC §"What the POC Does NOT Validate" #4). This document specifies the boring case + the validated streaming cases as a concrete operation surface.

The operations declare AccessControl against the ADR-050 container-as-resource model, applied to bollard's API via ADR-060 (label namespace, list filter, hosted-services fallback, teardown coupling). No new auth model is invented; the existing model is applied.

Architecture

Operation Surface (v1 scope)

Operation Type bollard method resource_id_path Notes
docker/container/list Query list_containers (none — list case) owned_only input flag (ADR-060 §2)
docker/container/inspect Query inspect_container $.containerId
docker/container/create Mutation create_container (none — creates the resource) Records ownership (ADR-060 §5)
docker/container/start Mutation start_container $.containerId
docker/container/stop Mutation stop_container $.containerId
docker/container/remove Mutation remove_container $.containerId Revokes ownership (ADR-060 §4)
docker/container/restart Mutation restart_container $.containerId
docker/container/logs Subscription logs $.containerId StreamingHandler; follow input flag
docker/container/exec Subscription create_exec + start_exec + inspect_exec $.containerId StreamingHandler; tty:false only; exit code on final call.responded
docker/image/list Query list_images (none)
docker/image/pull Subscription create_image (none) StreamingHandler; progress events
docker/image/inspect Query inspect_image (none)
docker/system/events Subscription events (none) StreamingHandler; daemon events (container start/stop/die/destroy, image pull/tag/delete, etc.)

Out of scope for v1 (deferred):

  • Network operations (docker/network/*) — OQ-048.
  • Volume operations (docker/volume/*) — OQ-048.
  • Image build (docker/image/build with buildkit) — OQ-049.
  • Full CreateContainerOptions surface (mounts, port bindings, networks) — OQ-051. v1 create accepts the image, command, env, labels, and name; the full options surface is a v1 implementation refinement.
  • Interactive exec / attach (tty: true) — not a call operation; DockerTtyBackend on alknet/tty (ADR-061).

Lifecycle Operations (Query / Mutation)

The lifecycle operations are the "boring case": a single bollard async method call, a single call.responded with the JSON result (or call.error on failure). The handler is a Handler (not StreamingHandler), registered as HandlerKind::Once.

The start, stop, and restart operations share the inspect shape (a Mutation with resource_id_path: "$.containerId", container:<action> scope, a single bollard method call, a single call.responded). The representative example below is inspect; the others differ only in the bollard method and the declared scope.

// docker/container/inspect — representative lifecycle op.
// The handler closure captures the bollard Docker client by
// Arc::clone at registration time (ADR-062); it does not read the
// client from OperationContext.
let docker_clone = docker.clone();  // Arc<bollard::Docker>
let handler: Handler = 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(bollard::Error::DockerResponseServerError { status_code: 404, .. }) =>
                ResponseEnvelope::error(CallError {
                    code: "CONTAINER_NOT_FOUND".into(),
                    message: format!("container {} not found", container_id),
                    retryable: false, details: None,
                }),
            Err(e) => ResponseEnvelope::error(CallError {
                code: "DOCKER_ERROR".into(),
                message: e.to_string(),
                retryable: false, details: None,
            }),
        }
    })
});

The handler extracts the container ID from the input, calls the bollard method, and maps the result to a ResponseEnvelope. The bollard error is mapped to a declared domain error code (CONTAINER_NOT_FOUND, DOCKER_ERROR) per ADR-023. The OperationSpec.error_schemas declare these codes so clients get typed error enums.

Handler Injection (ADR-062)

The Docker client and OwnershipStore are closure-captured at registration time, not read from OperationContext or smuggled through Capabilities. This matches the established from_openapi pattern (ADR-017): non-secret shared state via closure capture, secret material via Capabilities. A bollard Docker handle to a local unix socket is not secret material — it must not go through Capabilities (ADR-014's contract: Capabilities is for outbound secret material only). The register_docker_ops function takes Arc<Docker> + Arc<dyn OwnershipStore> + the label config + the CompositionAuthority, and constructs each handler closure with the handles it needs captured by Arc::clone. The Capabilities passed to each registration is empty (Capabilities::new()) — local bollard needs no API key. See ADR-062 for the full decision and the rationale for why Capabilities and OperationContext extension are the wrong channels.

docker/container/create — ownership recording

create is the spawn event (ADR-060 §5). The handler:

  1. Parses the input (image, command, env, labels, name, and v1-limited CreateContainerOptions — OQ-051 for the full surface).
  2. Injects the alknet labels: alknet.managed=true, alknet.owner=<caller_peer_id>. The caller's peer ID comes from ctx.identity (Identity.id, ADR-030). For a composed call (the coordinator composing create), this is the coordinator's identity, not the end user's — the proxy pattern (ADR-050 §3).
  3. Calls bollard::create_container().
  4. On success, calls OwnershipStore::record(identity, "container", container_id) (ADR-050 §1, ADR-060 §5).
  5. Returns the ContainerCreateResponse (with the container ID) as call.responded.

The label injection is the handler's job, not bollard's — bollard's CreateContainerOptions.labels accepts a map; the handler merges the alknet labels with any caller-provided labels (caller labels win on conflict for non-alknet.* keys; alknet.* keys are reserved and overwritten by the handler to prevent a caller spoofing ownership).

docker/container/remove — ownership revocation

remove is the teardown event (ADR-060 §4). The handler:

  1. Calls bollard::remove_container().
  2. On success, calls OwnershipStore::revoke("container", container_id).
  3. Returns call.responded with an empty/ok result.

Autonomous container death (a --rm exit, external docker rm, daemon restart) is tolerated — stale ownership entries are inert (a reused container ID gets a fresh record on its next create). The docker/system/events subscription provides the prompt-cleanup path (internal ownership-store subscription on destroy events). See ADR-060 §4.

docker/container/list — scope-gate + optional result-filter

list is the ADR-050 #4a "list case": resource_type: "container", no resource_id_path. The input accepts an owned_only: bool flag (default false):

  • owned_only: false — bollard::list_containers() with no label filter; returns all containers the daemon sees. The scope check gates the call (caller needs container:list). This is the hosted-services case (operator lists all).
  • owned_only: true — bollard::list_containers() with a label filter (label: alknet.owner=<caller_peer_id>); returns only the caller's containers. This is the disposable-dev-container case (coordinator lists its own).

The result-filter is bollard-side (the label filter is in the ListContainersOptions), not handler-side — bollard filters at the daemon. The handler doesn't call OwnershipProvider::owned_resources and filter in Rust; it pushes the filter to bollard via the label. This is more efficient (the daemon filters before sending) and correct (the label and the ownership store agree for alknet-spawned containers; the cross-check is ADR-060 §1's secondary signal).

Streaming Operations (Subscription)

The streaming operations (logs, exec, image pull) are Subscription operations using StreamingHandler (ADR-049). The handler returns a Stream<ResponseEnvelope>; the dispatcher's pump_stream writes each Ok(value) as call.responded, an Err as call.error (terminal), and natural stream end as call.completed.

docker/container/logs

Maps bollard::container::logs() (returns Stream<Item = Result<LogOutput, Error>>) to a stream of call.responded frames. The POC validated this path (POC target 2: docker_logs_subscription_pumps_frames_and_completes).

// The StreamingHandler closure captures the bollard Docker client
// by Arc::clone at registration time (ADR-062). ResponseStream is
// the type alias Pin<Box<dyn Stream<Item = ResponseEnvelope> + Send>>
// (ADR-049, operation-registry.md §"Handler").
let docker_clone = docker.clone();
let handler: StreamingHandler = Arc::new(move |input, ctx| {
    let docker = docker_clone.clone();
    Box::pin(async_stream::stream! {
        let container_id = input["containerId"].as_str().unwrap();
        let follow = input["follow"].as_bool().unwrap_or(false);
        let options = LogsOptionsBuilder::default()
            .follow(follow)
            .stdout(true).stderr(true)
            .timestamps(true)  // ADR-059 §4: time feature
            .build();
        let mut stream = docker.logs(container_id, Some(options));
        while let Some(result) = stream.next().await {
            match result {
                Ok(LogOutput::StdOut { message }) |
                Ok(LogOutput::Console { message }) => yield ResponseEnvelope::ok(json!({
                    "stream": "stdout",
                    "timestamp": /* from LogOutput */,
                    "text": /* message bytes as UTF-8 */
                })),
                Ok(LogOutput::StdErr { message }) => yield ResponseEnvelope::ok(json!({
                    "stream": "stderr",
                    "timestamp": /* ... */,
                    "text": /* ... */
                })),
                Ok(_) => yield ResponseEnvelope::ok(json!({})),  // other variants
                Err(e) => yield ResponseEnvelope::error(CallError {
                    code: "DOCKER_ERROR".into(),
                    message: e.to_string(),
                    retryable: false, details: None,
                }),
            }
        }
        // stream end → call.completed (the dispatcher's pump_stream
        // writes call.completed on natural stream end — ADR-049)
    }) as ResponseStream
});

Each LogOutput becomes a call.responded with stream (stdout/stderr), timestamp (from the time feature, ADR-059 §4), and text (the log line, as UTF-8 — the POC's single-text-field refinement separates timestamp and text). Stream end (the container exits for follow: true, or historical logs drain for follow: false) → call.completed (the dispatcher's pump_stream handles this on natural stream end).

docker/container/exec (non-interactive, tty: false)

Maps bollard::create_exec + start_exec + inspect_exec to a stream of call.responded frames, with the exit code on a final call.responded before call.completed. The POC validated this path (POC target 3: docker_exec_streams_output_and_exit_code).

The tty field in the input MUST be false (or absent). If tty: true, the handler returns a single ResponseEnvelope::error with code INVALID_INPUT and a message directing the caller to alknet/tty for interactive exec (ADR-058 §3). This is the one-operation-per-carriage invariant: the call operation is captured output; the tty session is interactive.

The handler:

  1. create_exec(container_id, CreateExecOptions { cmd, env, tty: false, ... }) → exec ID.
  2. start_exec(exec_id, None) → StartExecResults::Attached { output, input }.
  3. Pump output (a Stream<LogOutput>) as call.responded frames (stdout/stderr separated, since tty: false).
  4. After the output stream ends, inspect_exec(exec_id) → ExecInspectResponse { exit_code, ... }.
  5. Emit a final call.responded with { "exitCode": N, "terminal": true }.
  6. Stream end → call.completed (the exit-code call.responded is the last one before completed).

The exit code rides on a call.responded, not on call.completed. The terminal: true flag marks this call.responded as the final value before call.completed. The full shape decision (why terminal: true on call.responded and not an exit code on call.completed or a call.exit event) is in ADR-063.

docker/image/pull

Maps bollard::image::create_image() (returns Stream<Item = Result<CreateImageInfo, Error>>) to a stream of call.responded frames with progress events. Same shape as logs: each progress event → call.responded, stream end → call.completed. No exit code (image pull has no exit code; success is stream end without error).

docker/system/events

Maps bollard::system::events() (returns Stream<Item = Result<SystemEventsMessage, Error>>) to a stream of call.responded frames. Same shape as logs: each daemon event (container start/stop/die/destroy, image pull/tag/delete, network create/connect, volume create/mount, etc.) → call.responded, stream end on client disconnect or daemon connection close → call.completed.

let docker_clone = docker.clone();
let handler: StreamingHandler = Arc::new(move |_input, _ctx| {
    let docker = docker_clone.clone();
    Box::pin(async_stream::stream! {
        let mut stream = docker.events(None::<EventsOptions<String>>);
        while let Some(result) = stream.next().await {
            match result {
                Ok(event) => yield ResponseEnvelope::ok(serde_json::to_value(event)
                    .unwrap_or_default()),
                Err(e) => yield ResponseEnvelope::error(CallError {
                    code: "DOCKER_ERROR".into(),
                    message: e.to_string(),
                    retryable: false, details: None,
                }),
            }
        }
    }) as ResponseStream
});

The operation has no resource_id_path and no resource_type — it surfaces daemon-wide events, not per-container events. The scope check (system:events) gates the call. The internal ownership-store subscription for stale-entry cleanup on destroy events is a follow-up refinement — the operation itself is the architecture decision.

Access Control

The operations declare AccessControl against the ADR-050 model, applied via ADR-060. Three patterns:

Specific-container operations (exec, inspect, start, stop, remove, restart, logs)

OperationSpec {
    name: "docker/container/exec",
    access_control: AccessControl {
        required_scopes: vec!["container:exec".into()],
        resource_type: Some("container".into()),
        resource_action: Some("exec".into()),
        ..
    },
    resource_id_path: Some("$.containerId".into()),
    ..
}

The dispatcher extracts containerId from the input via resource_id_path and passes it to AccessControl::check, which consults the OwnershipProvider (ADR-050 §2). The check passes if:

  • the caller owns the container (the ownership store says so — the create path recorded it), OR
  • the caller's static Identity.resources["container"] includes an action that subsumes the required action (the operator-role fallback — ADR-060 §3, e.g., container:manage ⊇ container:exec).

The list case (container/list)

OperationSpec {
    name: "docker/container/list",
    access_control: AccessControl {
        required_scopes: vec!["container:list".into()],
        resource_type: Some("container".into()),
        // no resource_action — the list case
        ..
    },
    // no resource_id_path — the list case (ADR-050 #4a)
    ..
}

The scope check gates the call. The owned_only input flag selects the result-filter (label-based, bollard-side). See ADR-060 §2.

The create case (container/create)

OperationSpec {
    name: "docker/container/create",
    access_control: AccessControl {
        required_scopes: vec!["container:create".into()],
        // no resource_type — create spawns the resource; the
        // ownership is recorded after the bollard call succeeds.
        // The scope check gates; the ownership is a side effect.
        ..
    },
    // no resource_id_path — the container doesn't exist yet
    ..
}

create has no resource_type (the resource doesn't exist yet); the scope check gates. The ownership recording is a handler side effect (ADR-060 §5), not an ACL check.

Image operations (image/list, image/pull, image/inspect)

OperationSpec {
    name: "docker/image/pull",
    access_control: AccessControl {
        required_scopes: vec!["image:pull".into()],
        // no resource_type — images are not runtime-spawned resources
        // in the ADR-050 sense (they're pulled, not spawned with
        // per-caller ownership). Scope-gate only.
        ..
    },
    ..
}

Images are not runtime-spawned resources with per-caller ownership — they're shared daemon state. The scope check gates; no ownership provider consultation.

System events (system/events)

OperationSpec {
    name: "docker/system/events",
    access_control: AccessControl {
        required_scopes: vec!["system:events".into()],
        // no resource_type — daemon-wide events, not per-resource
        ..
    },
    // no resource_id_path
    ..
}

Scope-gate only. The operation surfaces daemon-wide events; there is no per-resource ownership to check.

Error Schemas (ADR-023)

Each operation declares its domain error codes in OperationSpec.error_schemas:

Code Operations HTTP status (for to_openapi projection) Description
CONTAINER_NOT_FOUND inspect, start, stop, remove, restart, logs, exec 404 The container ID doesn't exist on the daemon
IMAGE_NOT_FOUND image/inspect 404 The image doesn't exist locally
IMAGE_PULL_FAILED image/pull 502 The pull failed (registry error, network)
DOCKER_ERROR all 500 A bollard error not covered by a specific code
INVALID_INPUT exec (tty:true) 400 tty: true on docker/container/exec — use alknet/tty

The INVALID_INPUT for tty: true on exec carries a message directing the caller to alknet/tty for interactive exec. This is the one-operation-per-carriage invariant's user-visible surface (ADR-058 §3).

Composition and Peer Routing

Docker operations compose through the standard call-protocol mechanisms:

  • from_call re-export (ADR-017). A hub that wants to manage docker on a worker imports the worker's docker/container/* operations via from_call and re-registers them locally. The hub's clients call the re-exported operations; the hub is the direct caller to the worker; the worker's ownership store sees the hub as the owner (the proxy pattern, ADR-050 §3). The end user's identity rides as forwarded_for (ADR-032).
  • Peer routing (ADR-029). A hub with multiple worker connections composes docker/container/exec via invoke_peer(PeerRef::Specific( worker_peer_id), ...). The PeerCompositeEnv routes to the named worker's sub-overlay. This is the head→worker fan-out primitive.
  • Local composition. A coordinator handler that spawns a container and then execs into it composes docker/container/create then docker/container/exec through OperationEnv::invoke(). The coordinator's CompositionAuthority has container:create + container:exec scopes (static, ADR-022); the ownership check passes because create recorded the coordinator as the owner.

The docker/container/exec with tty: true rejection is a composition boundary: a coordinator composing exec must use tty: false (captured output); interactive exec requires an alknet/tty session, which is a different protocol (the coordinator opens a tty connection, not a call operation). This is the same boundary as "SSH exec" (call op) vs "SSH PTY session" (alknet-tty).

Constraints

  • No carriage field on call.requested. The call protocol's wire format (ADR-012) is EventEnvelope-only on alknet/call. The one operation that needed raw carriage (interactive attach) moved to alknet/tty via DockerTtyBackend (ADR-058 §2, ADR-061). A docker/container/exec call with tty: true is rejected with INVALID_INPUT.
  • resource_id_path is required for specific-container ops. The dispatcher extracts the container ID from the input via the JSON pointer; AccessControl::check receives it. Operations without resource_id_path (list, create, image ops) don't reference a specific container and don't consult the ownership provider for a specific resource.
  • create records ownership; remove revokes; lifecycle ops do neither. Only the spawn (create) and the teardown (remove) touch the ownership store. start/stop/restart act on an existing container whose ownership was recorded at create time (or which pre-exists and is reached via the static fallback).
  • Stale ownership entries are tolerated. Autonomous container death leaves a stale entry; the docker/system/events subscription provides the prompt-cleanup path (internal ownership-store subscription on destroy events). Until that internal subscription is wired, stale entries are inert (a reused container ID gets a fresh record).
  • owned_only on list is the caller's choice. Default false (returns all); true filters to the caller's containers. The default and the two cases are decided in ADR-060 §2; the scope check gates regardless of the flag.

Design Decisions

Decision ADR Summary
Docker ops on alknet/call ADR-058 No separate ALPN; raw-carriage dissolved; tty:true exec rejected
Container resource model + labels ADR-060 alknet.managed/alknet.owner labels; list owned_only; hosted-services fallback; teardown coupling
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; resource_id_path
Operation error schemas ADR-023 CONTAINER_NOT_FOUND, IMAGE_NOT_FOUND, DOCKER_ERROR, INVALID_INPUT
Peer-graph routing ADR-029 Head→worker docker management
Forwarded-for identity ADR-032 End-user identity as metadata in the proxy pattern
bollard 0.21 + features ADR-059 time feature for log timestamps
Docker client + OwnershipStore injection ADR-062 Closure capture at registration (not Capabilities, not OperationContext); matches from_openapi pattern
Exit code on terminal call.responded ADR-063 { "exitCode": N, "terminal": true } on final call.responded; call.completed stays empty

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

  • ADR-058 — the ALPN decision (shared alknet/call, no carriage field)
  • ADR-060 — the resource model and label namespace this surface applies
  • ADR-049 — StreamingHandler for logs/exec/pull
  • ADR-050 — the ownership model
  • ADR-023 — declared domain error codes
  • ADR-062 — the Docker client + OwnershipStore injection model (closure capture, not Capabilities)
  • ADR-063 — the exec exit-code frame shape (terminal: true)
  • docs/research/alknet-docker/poc-summary.md — the POC (validated logs subscription, exec with exit code; lifecycle ops are "the boring case")
  • /workspace/alknet-docker-poc/src/ops.rs — DockerOps: drive_logs, drive_exec (the streaming-handler reference)
  • /workspace/bollard/src/container.rs (list_containers :245, create_container :296, inspect_container :777, logs :928), /workspace/bollard/src/exec.rs (create_exec :172, start_exec :225, inspect_exec :315), /workspace/bollard/src/image.rs (create_image :120, list_images :66, inspect_image :177)