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
334 lines
17 KiB
Markdown
334 lines
17 KiB
Markdown
---
|
|
status: draft
|
|
last_updated: 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
|
|
`OperationSpec`s, `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](../../decisions/058-alknet-docker-on-alknet-call.md).
|
|
|
|
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](../tty/tty-backend.md) §"Backend implementations").
|
|
Decided in [ADR-061](../../decisions/061-docker-tty-backend-in-alknet-docker.md).
|
|
|
|
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](docker-operations.md).
|
|
|
|
### Role 2: `DockerTtyBackend` on `alknet/tty`
|
|
|
|
```rust
|
|
// 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](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](../../decisions/059-bollard-021-dependency-and-features.md).
|
|
|
|
## 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](docker-operations.md) §"Label
|
|
Namespace" and
|
|
[ADR-060](../../decisions/060-container-resource-model-and-label-namespace.md).
|
|
|
|
## Feature Gates
|
|
|
|
```toml
|
|
# 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](../../decisions/061-docker-tty-backend-in-alknet-docker.md).
|
|
|
|
## 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:
|
|
|
|
```rust
|
|
// 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](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](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](../../decisions/058-alknet-docker-on-alknet-call.md) | Call-protocol operations; raw-carriage dissolved by alknet-tty; no `carriage` field |
|
|
| bollard 0.21 + feature selection | [ADR-059](../../decisions/059-bollard-021-dependency-and-features.md) | Version verified current; `http`+`pipe`+`time`; no `ssl`/`ssh`/`websocket`/`buildkit` |
|
|
| Container resource model + label namespace | [ADR-060](../../decisions/060-container-resource-model-and-label-namespace.md) | ADR-050 application; `alknet.managed`/`alknet.owner` labels; `list` owned_only; hosted-services static fallback |
|
|
| DockerTtyBackend in alknet-docker | [ADR-061](../../decisions/061-docker-tty-backend-in-alknet-docker.md) | Behind `tty` feature; attach/exec mode; POC `drive_attach_raw` as reference |
|
|
| Crate decomposition | [ADR-003](../../decisions/003-crate-decomposition.md) Am. 1 | alknet-docker depends on alknet-call (protocol-foundation exception) + alknet-tty (tty feature) |
|
|
| Call protocol stream model | [ADR-012](../../decisions/012-call-protocol-stream-model.md) | `EventEnvelope` framing, no `carriage` extension |
|
|
| Streaming handler for subscriptions | [ADR-049](../../decisions/049-streaming-handler-for-subscriptions.md) | `StreamingHandler` for logs/exec/pull; exit code on final `call.responded` |
|
|
| Dynamic resource ownership | [ADR-050](../../decisions/050-dynamic-resource-ownership-for-runtime-spawned-resources.md) | Containers as `AccessControl` resources; the model ADR-060 applies |
|
|
| Peer-graph routing | [ADR-029](../../decisions/029-peer-graph-routing-model.md) | Head→worker docker management via `PeerRef` / `invoke_peer` |
|
|
| Forwarded-for identity | [ADR-032](../../decisions/032-forwarded-for-identity.md) | End-user identity as metadata when proxying |
|
|
| TtyBackend trait | [ADR-053](../../decisions/053-ttybackend-trait-and-ttyhandle.md) | The trait `DockerTtyBackend` implements |
|
|
| Exit code on control chunk | [ADR-055](../../decisions/055-exit-code-on-control-chunk.md) | The exit-chunk ordering `DockerTtyBackend`'s `exit_code` feeds into |
|
|
| Backend cleanup on cancel | [ADR-056](../../decisions/056-backend-cleanup-on-session-cancel.md) | `DockerTtyBackend`'s `exit_code` `Drop` kills the container/exec |
|
|
| Docker client + OwnershipStore injection | [ADR-062](../../decisions/062-docker-client-injection-via-closure-capture.md) | Closure capture at registration; `Capabilities` for secrets only |
|
|
| Exit code on terminal `call.responded` | [ADR-063](../../decisions/063-exit-code-on-terminal-call-responded.md) | `{ "exitCode": N, "terminal": true }` on final `call.responded` for non-interactive exec |
|
|
|
|
## Open Questions
|
|
|
|
See [open-questions.md](../../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 |