docs(arch): draft alknet-docker architecture specs (ADRs 058-063, OQs 048-051)
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.
This commit is contained in:
1 parent
5873fa22d6
commit
1ce11717d8
17 files changed
+3196
-9
No files matched your search
@@ -0,0 +1,166 @@
|
||||
---
|
||||
status: draft
|
||||
last_updated: 2026-07-08
|
||||
---
|
||||
|
||||
# alknet-docker
|
||||
|
||||
The docker operations crate for the ALPN-as-service architecture: a
|
||||
thin, single-host bollard wrapper that exposes docker container and
|
||||
image operations as call-protocol operations on the shared
|
||||
`alknet/call` ALPN, plus (behind a `tty` feature) a `DockerTtyBackend`
|
||||
implementing `alknet-tty`'s `TtyBackend` trait for interactive terminal
|
||||
sessions into containers.
|
||||
|
||||
## What
|
||||
|
||||
alknet-docker does two things:
|
||||
|
||||
1. **Call operations.** A set of `OperationSpec`-registered operations
|
||||
(`docker/container/*`, `docker/image/*`) on the shared
|
||||
`alknet/call` ALPN, mapping bollard's docker API to the call
|
||||
protocol's `Query` / `Mutation` / `Subscription` dispatch paths.
|
||||
Lifecycle (create/start/stop/remove/list/inspect) is `Query`/
|
||||
`Mutation`; logs, non-interactive exec, and image pull are
|
||||
`Subscription` (streaming via `StreamingHandler`, ADR-049). The
|
||||
operations declare `AccessControl` against the ADR-050 container-
|
||||
as-resource model. Decided in
|
||||
[ADR-058](../../decisions/058-alknet-docker-on-alknet-call.md).
|
||||
|
||||
2. **`DockerTtyBackend`** (behind the `tty` feature). An
|
||||
`impl TtyBackend` (ADR-053) wrapping `bollard::attach_container()`
|
||||
and `bollard::exec::start_exec` with `tty: true`, for interactive
|
||||
terminal sessions into containers over `alknet/tty`. This is the
|
||||
`TtyBackend` row the alknet-tty spec left open ("future, out of
|
||||
scope here"). Decided in
|
||||
[ADR-061](../../decisions/061-docker-tty-backend-in-alknet-docker.md).
|
||||
|
||||
The two use cases the crate serves (per the user's brief and the system
|
||||
docs):
|
||||
|
||||
- **Disposable dev containers** (the common case by volume) — a
|
||||
coordinator spawns a container for an implementation agent or an
|
||||
isolated env. alknet-docker's `create` records ownership
|
||||
(`OwnershipStore::record`); the coordinator owns the container for
|
||||
its lifetime; `remove` revokes. Interactive sessions into the
|
||||
container go through `DockerTtyBackend` on `alknet/tty`.
|
||||
- **Long-running hosted services** (the production server case) —
|
||||
rarely-changing services (reverse-proxy, postgres, redis, gitea on
|
||||
dev1, per `/workspace/system/dev1/docker.md`) created by an operator
|
||||
via `docker compose`, not via alknet. alknet-docker's operations
|
||||
(start/stop/inspect/logs) manage them; the operator role reaches them
|
||||
via the static-resource fallback (ADR-060 §3).
|
||||
|
||||
## Documents
|
||||
|
||||
| Document | Status | Description |
|
||||
|----------|--------|-------------|
|
||||
| [overview.md](overview.md) | draft | Crate purpose, two-role design (call ops + DockerTtyBackend), dependencies, ALPN, label namespace, feature gates, assembly-layer wiring |
|
||||
| [docker-operations.md](docker-operations.md) | draft | The operation surface: lifecycle (Query/Mutation), logs/exec/pull (Subscription via StreamingHandler), access control, label namespace, teardown coupling |
|
||||
| [docker-tty-backend.md](docker-tty-backend.md) | draft | `DockerTtyBackend` (impl `TtyBackend`): attach vs exec mode, `TtyHandle` field mapping, `TtyControl` → bollard resize/signal, `exit_code` Drop-kill (ADR-056) |
|
||||
|
||||
## Applicable ADRs
|
||||
|
||||
| ADR | Title | Relevance |
|
||||
|-----|-------|-----------|
|
||||
| [058](../../decisions/058-alknet-docker-on-alknet-call.md) | alknet-docker Registers on `alknet/call` | Docker ops are call-protocol operations, not a separate ALPN; raw-carriage dissolved by alknet-tty extraction |
|
||||
| [059](../../decisions/059-bollard-021-dependency-and-features.md) | bollard 0.21 Dependency and Feature Selection | Version pin (0.21, verified current); features `http`+`pipe`+`time`, no `ssl`/`ssh`/`websocket`/`buildkit` |
|
||||
| [060](../../decisions/060-container-resource-model-and-label-namespace.md) | Container Resource Model and Label Namespace | ADR-050 application: `alknet.managed`/`alknet.owner` labels; `list` owned_only flag; hosted-services static-resource fallback; handler-driven revoke + autonomous-death tolerance |
|
||||
| [061](../../decisions/061-docker-tty-backend-in-alknet-docker.md) | DockerTtyBackend in alknet-docker | Backend in alknet-docker behind `tty` feature; attach/exec mode split; POC `drive_attach_raw` as reference |
|
||||
| [003](../../decisions/003-crate-decomposition.md) | Crate Decomposition | alknet-docker depends on alknet-core + alknet-call (ops) and alknet-tty (tty feature); no handler-depends-on-handler violation |
|
||||
| [012](../../decisions/012-call-protocol-stream-model.md) | Call Protocol Stream Model | The wire format docker ops use (`EventEnvelope`, no `carriage` field) |
|
||||
| [017](../../decisions/017-call-protocol-client-and-adapter-contract.md) | Call Protocol Client and Adapter Contract | `from_call` re-export — the proxy pattern's mechanism for hub→worker docker management |
|
||||
| [023](../../decisions/023-operation-error-schemas.md) | Operation Error Schemas | Docker ops declare domain error codes (CONTAINER_NOT_FOUND, IMAGE_NOT_FOUND, etc.) |
|
||||
| [024](../../decisions/024-operation-registry-layering.md) | Operation Registry Layering | Docker ops register in the curated Layer 0 at startup |
|
||||
| [029](../../decisions/029-peer-graph-routing-model.md) | Peer-Graph Routing Model | Head→worker docker management via `PeerRef` / `invoke_peer` |
|
||||
| [032](../../decisions/032-forwarded-for-identity.md) | Forwarded-For Identity | End-user identity as metadata when a coordinator proxies docker ops |
|
||||
| [049](../../decisions/049-streaming-handler-for-subscriptions.md) | Streaming Handler for Subscriptions | `StreamingHandler` for logs/exec/pull; exit code on final `call.responded` |
|
||||
| [050](../../decisions/050-dynamic-resource-ownership-for-runtime-spawned-resources.md) | Dynamic Resource Ownership | Containers as `AccessControl` resources; the model ADR-060 applies |
|
||||
| [052](../../decisions/052-alknet-tty-wire-format-and-two-carriage.md) | alknet-tty Wire Format | The `alknet/tty` wire format `DockerTtyBackend` sessions use |
|
||||
| [053](../../decisions/053-ttybackend-trait-and-ttyhandle.md) | TtyBackend Trait and TtyHandle | The trait `DockerTtyBackend` implements |
|
||||
| [055](../../decisions/055-exit-code-on-control-chunk.md) | Exit Code on a Control Chunk | The exit-chunk ordering `DockerTtyBackend`'s `exit_code` feeds into |
|
||||
| [056](../../decisions/056-backend-cleanup-on-session-cancel.md) | Backend Cleanup on Session Cancel | `DockerTtyBackend`'s `exit_code` future `Drop` kills the container/exec |
|
||||
| [014](../../decisions/014-secret-material-flow-and-capability-injection.md) | Secret Material Flow and Capability Injection | `Capabilities` is for secret material only — the `Docker` handle is not secret (ADR-062) |
|
||||
| [062](../../decisions/062-docker-client-injection-via-closure-capture.md) | Docker Client and OwnershipStore Injection via Closure Capture | Closure capture at registration; `Capabilities` for secrets only; matches `from_openapi` pattern |
|
||||
| [063](../../decisions/063-exit-code-on-terminal-call-responded.md) | Exit Code on a Terminal `call.responded` for Non-Interactive Exec | `{ "exitCode": N, "terminal": true }` on final `call.responded`; `call.completed` stays empty |
|
||||
|
||||
## Relevant Open Questions
|
||||
|
||||
| OQ | Title | Status | Relevance |
|
||||
|----|-------|--------|-----------|
|
||||
| OQ-048 | Network and volume operation surface | deferred(scope) | Network/volume CRUD deferred; v1 is containers + images |
|
||||
| OQ-049 | Image build (buildkit) scope | deferred(scope) | `buildkit` feature deferred; v1 has `image/pull` + `image/list` + `image/inspect` |
|
||||
| OQ-050 | Docker system events subscription | deferred(scope) | `docker/system/events` subscription for stale-ownership cleanup deferred |
|
||||
| OQ-051 | Container create options surface | deferred(scope) | Full `CreateContainerOptions` (mounts, port bindings, networks) surface deferred to v1 implementation |
|
||||
|
||||
## Key Design Principles
|
||||
|
||||
1. **Single-host, bollard-specific.** alknet-docker talks to one local
|
||||
docker daemon over `/var/run/docker.sock`. The fleet case (multiple
|
||||
daemons on different machines) is a call-protocol concern — a
|
||||
`CallClient` per remote daemon, each running alknet-docker locally
|
||||
— not a bollard-feature concern. No `ssl`/`ssh` features, no remote
|
||||
daemon over TLS. See [overview.md](overview.md) and
|
||||
[ADR-058](../../decisions/058-alknet-docker-on-alknet-call.md).
|
||||
|
||||
2. **Call operations on `alknet/call`, not a separate ALPN.** Docker
|
||||
ops are ordinary call-protocol operations with `OperationSpec`s,
|
||||
`AccessControl`, and service discovery. They inherit `from_call`
|
||||
re-export and peer routing. The one operation that didn't fit
|
||||
(interactive attach) moved to `alknet/tty` via `DockerTtyBackend`.
|
||||
No `carriage` field on `call.requested`, no parallel dispatch
|
||||
surface. See [ADR-058](../../decisions/058-alknet-docker-on-alknet-call.md).
|
||||
|
||||
3. **Containers are runtime-spawned resources (ADR-050).** `create`
|
||||
records ownership; `remove` revokes; `exec`/`stop`/`inspect` check
|
||||
ownership via `OperationSpec.resource_id_path`. The
|
||||
hosted-services case (operator-managed, pre-existing containers)
|
||||
works through the static-resource fallback. Two labels
|
||||
(`alknet.managed`, `alknet.owner`) mark alknet-spawned containers
|
||||
for the `list` filter and the cross-check. See
|
||||
[docker-operations.md](docker-operations.md) and
|
||||
[ADR-060](../../decisions/060-container-resource-model-and-label-namespace.md).
|
||||
|
||||
4. **Interactive terminal is a tty concern, not a docker concern.**
|
||||
`DockerTtyBackend` implements `alknet-tty`'s `TtyBackend` trait,
|
||||
behind a `tty` feature. The wire format, the session lifecycle, and
|
||||
the exit-chunk ordering live in alknet-tty; the backend produces
|
||||
bollard-backed handles. This is the same inversion as
|
||||
`LocalTtyBackend` (local process) and `SshTtyBackend` (SSH). See
|
||||
[docker-tty-backend.md](docker-tty-backend.md) and
|
||||
[ADR-061](../../decisions/061-docker-tty-backend-in-alknet-docker.md).
|
||||
|
||||
5. **bollard 0.21, verified current.** The POC used a local 0.21
|
||||
checkout; the crate depends on published 0.21 from crates.io
|
||||
(verified as latest). Features: `http` + `pipe` (default, local
|
||||
daemon) + `time` (log timestamps). No `websocket` (reliable attach
|
||||
only), no `ssl`/`ssh` (fleet is call-protocol), no `buildkit`
|
||||
(deferred). See [ADR-059](../../decisions/059-bollard-021-dependency-and-features.md).
|
||||
|
||||
6. **Mechanical mapping, no feasibility risk.** The POC validated the
|
||||
hard parts (raw carriage attach, logs subscription, exec with exit
|
||||
code). The remaining lifecycle operations (create/start/stop/remove/
|
||||
list/inspect) are mechanical bollard wrapping — `Query`/`Mutation`
|
||||
with single `call.responded` responses, "the boring case" (POC §"What
|
||||
the POC Does NOT Validate" #4). See
|
||||
[docker-operations.md](docker-operations.md).
|
||||
|
||||
## References
|
||||
|
||||
- `docs/research/alknet-docker/poc-summary.md` — the POC that validated
|
||||
the hard parts (interactive attach, logs subscription, exec with exit
|
||||
code) and surfaced the open unknowns this spec set resolves
|
||||
- `/workspace/alknet-docker-poc/` — the POC source (`src/ops.rs`
|
||||
`DockerOps`, `src/raw.rs` chunk codec, `src/frame.rs` EventEnvelope
|
||||
mirror, `tests/integration.rs` 6 tests against a live daemon)
|
||||
- `/workspace/bollard/` — bollard 0.21.0 source (the local checkout the
|
||||
POC used; identical API surface to the published 0.21)
|
||||
- `/workspace/@alkdev/dispatch/` — the dispatch POC (bollard 0.18,
|
||||
`dispatch.managed=true` labels, SSH-tunnel fleet model — the prior
|
||||
art this crate generalizes and the friction it removes)
|
||||
- `/workspace/system/dev1/docker.md` — the production hosted-services
|
||||
use case (reverse-proxy, postgres, redis, gitea on dev1)
|
||||
- `/workspace/@alkdev/reverse-proxy/deploy/docker-compose.yml` — the
|
||||
reverse-proxy's docker setup (operator-created, not alknet-spawned)
|
||||
- `docs/architecture/crates/tty/` — the alknet-tty spec
|
||||
(`DockerTtyBackend` is the row `tty-backend.md` left open)
|
||||
@@ -0,0 +1,537 @@
|
||||
---
|
||||
status: draft
|
||||
last_updated: 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](../../decisions/058-alknet-docker-on-alknet-call.md).
|
||||
|
||||
## 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) | |
|
||||
|
||||
**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.
|
||||
- System events (`docker/system/events`) — OQ-050.
|
||||
- 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.
|
||||
|
||||
```rust
|
||||
// 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](../../decisions/062-docker-client-injection-via-closure-capture.md)
|
||||
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 not
|
||||
promptly cleaned up (no reaper); they're inert (a reused container ID
|
||||
gets a fresh `record` on its next `create`). 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`).
|
||||
|
||||
```rust
|
||||
// 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](../../decisions/063-exit-code-on-terminal-call-responded.md).
|
||||
|
||||
#### `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).
|
||||
|
||||
### 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`)
|
||||
|
||||
```rust
|
||||
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`)
|
||||
|
||||
```rust
|
||||
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`)
|
||||
|
||||
```rust
|
||||
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`)
|
||||
|
||||
```rust
|
||||
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.
|
||||
|
||||
### 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; no reaper cleans it promptly. The
|
||||
entry is inert (a reused container ID gets a fresh `record`). A
|
||||
`docker/system/events` subscription for prompt cleanup is deferred
|
||||
(OQ-050).
|
||||
- **`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](../../decisions/058-alknet-docker-on-alknet-call.md) | No separate ALPN; raw-carriage dissolved; `tty:true` exec rejected |
|
||||
| Container resource model + labels | [ADR-060](../../decisions/060-container-resource-model-and-label-namespace.md) | `alknet.managed`/`alknet.owner` labels; `list` `owned_only`; hosted-services fallback; teardown coupling |
|
||||
| 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; `resource_id_path` |
|
||||
| Operation error schemas | [ADR-023](../../decisions/023-operation-error-schemas.md) | `CONTAINER_NOT_FOUND`, `IMAGE_NOT_FOUND`, `DOCKER_ERROR`, `INVALID_INPUT` |
|
||||
| Peer-graph routing | [ADR-029](../../decisions/029-peer-graph-routing-model.md) | Head→worker docker management |
|
||||
| Forwarded-for identity | [ADR-032](../../decisions/032-forwarded-for-identity.md) | End-user identity as metadata in the proxy pattern |
|
||||
| bollard 0.21 + features | [ADR-059](../../decisions/059-bollard-021-dependency-and-features.md) | `time` feature for log timestamps |
|
||||
| Docker client + OwnershipStore injection | [ADR-062](../../decisions/062-docker-client-injection-via-closure-capture.md) | Closure capture at registration (not `Capabilities`, not `OperationContext`); matches `from_openapi` pattern |
|
||||
| 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`; `call.completed` stays empty |
|
||||
|
||||
## 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-050** (deferred(scope)): Docker system events subscription.
|
||||
- **OQ-051** (deferred(scope)): Container create options surface.
|
||||
|
||||
## References
|
||||
|
||||
- [ADR-058](../../decisions/058-alknet-docker-on-alknet-call.md) — the
|
||||
ALPN decision (shared `alknet/call`, no `carriage` field)
|
||||
- [ADR-060](../../decisions/060-container-resource-model-and-label-namespace.md)
|
||||
— the resource model and label namespace this surface applies
|
||||
- [ADR-049](../../decisions/049-streaming-handler-for-subscriptions.md)
|
||||
— `StreamingHandler` for logs/exec/pull
|
||||
- [ADR-050](../../decisions/050-dynamic-resource-ownership-for-runtime-spawned-resources.md)
|
||||
— the ownership model
|
||||
- [ADR-023](../../decisions/023-operation-error-schemas.md) — declared
|
||||
domain error codes
|
||||
- [ADR-062](../../decisions/062-docker-client-injection-via-closure-capture.md)
|
||||
— the Docker client + OwnershipStore injection model (closure
|
||||
capture, not `Capabilities`)
|
||||
- [ADR-063](../../decisions/063-exit-code-on-terminal-call-responded.md)
|
||||
— 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)
|
||||
@@ -0,0 +1,482 @@
|
||||
---
|
||||
status: draft
|
||||
last_updated: 2026-07-08
|
||||
---
|
||||
|
||||
# alknet-docker — DockerTtyBackend
|
||||
|
||||
The `DockerTtyBackend` is alknet-docker's `impl TtyBackend` (ADR-053)
|
||||
for interactive terminal sessions into docker containers over
|
||||
`alknet/tty`. It wraps `bollard::attach_container()` (attach mode) and
|
||||
`bollard::exec::start_exec` with `tty: true` (exec mode), producing a
|
||||
`TtyHandle` the `TtyAdapter` pumps. This document specifies the
|
||||
backend's allocation, control, and cancel-cleanup. The crate
|
||||
placement is decided in
|
||||
[ADR-061](../../decisions/061-docker-tty-backend-in-alknet-docker.md).
|
||||
|
||||
## What
|
||||
|
||||
`DockerTtyBackend` is the `TtyBackend` implementation the
|
||||
`TtyAdapter` dispatches to when a client opens an `alknet/tty` session
|
||||
with `backend: "docker"` in the negotiation frame. The adapter selects
|
||||
the backend by the `backend` field, calls `allocate()`, and pumps the
|
||||
resulting `TtyHandle`'s fields bidirectionally using the alknet-tty
|
||||
wire format (ADR-052). The backend produces handles; the adapter owns
|
||||
the wire format.
|
||||
|
||||
```rust
|
||||
// behind the `tty` feature in alknet-docker
|
||||
pub struct DockerTtyBackend {
|
||||
docker: bollard::Docker,
|
||||
label_prefix: String, // "alknet" by default; for the ownership cross-check
|
||||
}
|
||||
|
||||
#[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 is constructed by the assembly layer with the bollard
|
||||
client (shared with the call operations) and the label prefix (shared
|
||||
with ADR-060's label namespace), then registered in the
|
||||
`TtyAdapter`'s backend map under the `"docker"` key.
|
||||
|
||||
## Why
|
||||
|
||||
The alknet-tty spec ([tty-backend.md](../tty/tty-backend.md) §"Backend
|
||||
implementations") listed the `DockerTtyBackend` location as "future,
|
||||
out of scope here." This spec fills that row: the backend lives in
|
||||
alknet-docker (ADR-061), behind the `tty` feature, and wraps the
|
||||
bollard attach/exec API the POC validated.
|
||||
|
||||
The POC's `drive_attach_raw` (`/workspace/alknet-docker-poc/src/ops.rs`)
|
||||
is the seed of `allocate()`. The POC proved the mechanism (raw chunks
|
||||
on a bidi stream after a JSON request); the `TtyBackend` trait
|
||||
extracts the bollard interaction into a backend that produces handles,
|
||||
while the `TtyAdapter` (in alknet-tty) owns the wire format and
|
||||
session lifecycle. The backend is the inversion point
|
||||
([ADR-053](../../decisions/053-ttybackend-trait-and-ttyhandle.md)).
|
||||
|
||||
## Architecture
|
||||
|
||||
### Backend Params
|
||||
|
||||
The `DockerTtyBackend` deserializes its params from
|
||||
`TtyParams.backend_params` (the opaque `serde_json::Map` the adapter
|
||||
passes through verbatim, per ADR-053 §"Backend params are opaque"):
|
||||
|
||||
```rust
|
||||
#[derive(Deserialize)]
|
||||
struct DockerBackendParams {
|
||||
/// The container to attach to or exec in.
|
||||
container: String,
|
||||
/// "attach" (attach to the running primary process) or "exec"
|
||||
/// (start a new process with a PTY). Default: "exec".
|
||||
#[serde(default = "default_mode")]
|
||||
mode: String,
|
||||
}
|
||||
|
||||
fn default_mode() -> String { "exec".into() }
|
||||
```
|
||||
|
||||
- `container` — the container ID or name. Required. The backend
|
||||
validates it exists (the bollard call will fail if not; the error
|
||||
maps to `TtyError::Backend`).
|
||||
- `mode` — `"attach"` or `"exec"`. `"attach"` attaches to the
|
||||
container's primary process (the running process, pid 1 or the
|
||||
`ENTRYPOINT`); `"exec"` starts a new process in the container with a
|
||||
PTY. Default `"exec"` (the common case — a new shell). For `exec`
|
||||
mode, `TtyParams.cmd` is the command vector; for `attach` mode,
|
||||
`cmd` is ignored (the primary process is already running).
|
||||
|
||||
The adapter does not interpret these fields — it passes the
|
||||
negotiation frame's backend-specific JSON through as
|
||||
`backend_params`, and the backend deserializes its own
|
||||
strongly-typed struct (ADR-053).
|
||||
|
||||
### `allocate()` — attach mode
|
||||
|
||||
Wraps `bollard::container::attach_container()` (the reliable
|
||||
HTTP-upgrade-to-TCP path, per the POC and
|
||||
[ADR-059](../../decisions/059-bollard-021-dependency-and-features.md)
|
||||
§3 — not the websocket path).
|
||||
|
||||
```rust
|
||||
async fn allocate_attach(&self, params: &TtyParams, container: &str) -> Result<TtyHandle, TtyError> {
|
||||
let options = AttachContainerOptionsBuilder::default()
|
||||
.stdout(true).stderr(true).stdin(true)
|
||||
.stream(true).logs(false)
|
||||
.build();
|
||||
let AttachContainerResults { output, input } = self.docker
|
||||
.attach_container(container, Some(options))
|
||||
.await
|
||||
.map_err(|e| TtyError::Backend { message: e.to_string() })?;
|
||||
|
||||
// output: Stream<LogOutput> → TtyHandle.stdout (Stream<Bytes>)
|
||||
// PTY mode (tty: true on the container) merges stdout/stderr;
|
||||
// bollard's LogOutput on a TTY returns StdOut only, so stderr
|
||||
// is None. (tty-backend.md §DockerTtyBackend notes this.)
|
||||
let stdout = Box::pin(output.map(|r| match r {
|
||||
Ok(LogOutput::StdOut { message }) | Ok(LogOutput::Console { message }) =>
|
||||
message, // Bytes
|
||||
Ok(_) => bytes::Bytes::new(),
|
||||
Err(_) => bytes::Bytes::new(),
|
||||
}));
|
||||
|
||||
// input: AsyncWrite → TtyHandle.stdin (Box<dyn AsyncWrite + Send + Unpin>)
|
||||
let stdin: Box<dyn tokio::io::AsyncWrite + Send + Unpin> = Box::new(input);
|
||||
|
||||
// exit_code: wait for the container's process to exit, then read
|
||||
// the exit status from inspect_container. This future resolves
|
||||
// independently of the output stream — the adapter (ADR-055)
|
||||
// awaits BOTH the stdout pump's EOF AND this future's resolve
|
||||
// before sending the exit chunk, so the backend does not need to
|
||||
// drain stdout itself. For a container that exits with a non-zero
|
||||
// status, the State.exit_code carries it. The Drop of this future
|
||||
// (cancel) kills the container (ADR-056) — see "Cancel-Cleanup".
|
||||
let docker = self.docker.clone();
|
||||
let container_for_exit = container.to_string();
|
||||
let exit_code: BoxFuture<'static, Result<i32, TtyError>> = Box::pin(async move {
|
||||
// Poll the container until it exits. wait_container returns a
|
||||
// stream of WaitContainerResponse; the first (and usually
|
||||
// only) one carries the exit code. Alternatively, poll
|
||||
// inspect_container until State.running is false. The
|
||||
// adapter's stdout pump drains the output stream in
|
||||
// parallel; this future resolves when the process exits.
|
||||
let mut wait_stream = docker.wait_container(&container_for_exit,
|
||||
None::<WaitContainerOptions<String>>);
|
||||
if let Some(result) = wait_stream.next().await {
|
||||
match result {
|
||||
Ok(resp) => Ok(resp.status_code.unwrap_or(-1) as i32),
|
||||
Err(e) => Err(TtyError::WaitFailed { message: e.to_string() }),
|
||||
}
|
||||
} else {
|
||||
Ok(-1)
|
||||
}
|
||||
});
|
||||
|
||||
Ok(TtyHandle {
|
||||
stdin,
|
||||
stdout,
|
||||
stderr: None, // PTY mode merges
|
||||
exit_code,
|
||||
control: Some(TtyControlHandle::new(Arc::new(DockerControl {
|
||||
docker: self.docker.clone(),
|
||||
container: container.to_string(),
|
||||
mode: "attach".into(),
|
||||
}))),
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
The output stream maps `LogOutput` → `Bytes` (the adapter pumps these
|
||||
as stdout chunks, stream_type 1). PTY mode (`tty: true` on the
|
||||
container) merges stdout/stderr into `StdOut`, so `TtyHandle.stderr` is
|
||||
`None` — the adapter pumps only stdout chunks. This matches the
|
||||
`tty-backend.md` sketch: "stdout/stderr are merged when `tty: true`
|
||||
(bollard's `LogOutput` on a TTY exec returns `StdOut` only), so
|
||||
`TtyHandle.stderr` is `None` for the PTY case."
|
||||
|
||||
### `allocate()` — exec mode
|
||||
|
||||
Wraps `bollard::exec::create_exec` + `start_exec` with `tty: true`:
|
||||
|
||||
```rust
|
||||
async fn allocate_exec(&self, params: &TtyParams, container: &str) -> Result<TtyHandle, TtyError> {
|
||||
let cmd = ¶ms.cmd; // argv[0] + args
|
||||
let config = CreateExecOptions {
|
||||
cmd: Some(cmd.clone()),
|
||||
env: Some(params.env.clone()),
|
||||
attach_stdout: Some(true),
|
||||
attach_stderr: Some(true),
|
||||
attach_stdin: Some(true),
|
||||
tty: Some(true), // PTY mode
|
||||
..Default::default()
|
||||
};
|
||||
let exec = self.docker.create_exec(container, config).await
|
||||
.map_err(|e| TtyError::Backend { message: e.to_string() })?;
|
||||
let exec_id = exec.id;
|
||||
|
||||
let StartExecResults::Attached { output, input } = self.docker
|
||||
.start_exec(&exec_id, None).await
|
||||
.map_err(|e| TtyError::Backend { message: e.to_string() })?;
|
||||
|
||||
// Same mapping as attach mode: output → stdout, input → stdin,
|
||||
// stderr None (PTY mode merges).
|
||||
let stdout = /* output → Stream<Bytes> */;
|
||||
let stdin: Box<dyn AsyncWrite + Send + Unpin> = Box::new(input);
|
||||
|
||||
// exit_code: after output stream ends, inspect_exec for the exit code
|
||||
let docker = self.docker.clone();
|
||||
let exec_id = exec_id.clone();
|
||||
let exit_code: BoxFuture<'static, Result<i32, TtyError>> = Box::pin(async move {
|
||||
// Wait for output stream end (the future resolves after the
|
||||
// pump drains output — the adapter's exit-chunk ordering
|
||||
// awaits both stdout EOF and exit_code resolve, ADR-055).
|
||||
// Then inspect_exec:
|
||||
let inspect = docker.inspect_exec(&exec_id).await
|
||||
.map_err(|e| TtyError::WaitFailed { message: e.to_string() })?;
|
||||
Ok(inspect.exit_code.unwrap_or(-1))
|
||||
});
|
||||
|
||||
Ok(TtyHandle { stdin, stdout, stderr: None, exit_code,
|
||||
control: Some(TtyControlHandle::new(Arc::new(DockerControl {
|
||||
docker: self.docker.clone(),
|
||||
container: container.to_string(),
|
||||
mode: "exec".into(),
|
||||
exec_id: Some(exec_id),
|
||||
}))) })
|
||||
}
|
||||
```
|
||||
|
||||
The exit-code future for exec mode follows the POC's pattern (POC
|
||||
target 3): pump the output stream, then `inspect_exec` for the exit
|
||||
code. The adapter's exit-chunk ordering (ADR-055) awaits both the
|
||||
stdout pump's EOF and the `exit_code` future's resolve before sending
|
||||
the exit chunk — so the `exit_code` future must not resolve until the
|
||||
output stream has ended. For exec mode, the output stream end is the
|
||||
signal that the exec process exited; `inspect_exec` after that returns
|
||||
the exit code.
|
||||
|
||||
### `resource_id()` — ownership check delegation
|
||||
|
||||
```rust
|
||||
fn resource_id(&self, params: &TtyParams) -> Option<(&'static str, String)> {
|
||||
let p: DockerBackendParams = serde_json::from_value(
|
||||
serde_json::Value::Object(params.backend_params.clone())
|
||||
).ok()?;
|
||||
Some(("container", p.container))
|
||||
}
|
||||
```
|
||||
|
||||
The adapter calls this at negotiation and checks
|
||||
`OwnershipProvider::owns(identity, "container", container_id, "tty")`
|
||||
if an ownership provider is wired (ADR-050, ADR-060). The backend owns
|
||||
the extraction; the adapter doesn't parse docker-specific JSON. The
|
||||
`"tty"` action is a distinct resource action from `"exec"` (the call
|
||||
operation's action) — a caller authorized to `docker/container/exec`
|
||||
(captured, non-interactive) is not automatically authorized for an
|
||||
interactive tty session; the `tty` action is a separate scope. This
|
||||
matches the "interactive terminal ≠ captured command" boundary
|
||||
(ADR-058 §3).
|
||||
|
||||
### `DockerControl` — `TtyControl` impl
|
||||
|
||||
```rust
|
||||
struct DockerControl {
|
||||
docker: bollard::Docker,
|
||||
container: String,
|
||||
mode: String, // "attach" or "exec"
|
||||
exec_id: Option<String>, // Some for exec mode (for resize_exec)
|
||||
}
|
||||
|
||||
impl TtyControl for DockerControl {
|
||||
fn resize(&self, cols: u16, rows: u16, pixel_width: u16, pixel_height: u16) {
|
||||
let docker = self.docker.clone();
|
||||
let container = self.container.clone();
|
||||
let exec_id = self.exec_id.clone();
|
||||
let mode = self.mode.clone();
|
||||
tokio::spawn(async move {
|
||||
let _ = match mode.as_str() {
|
||||
"attach" => docker.resize_container_tty(&container,
|
||||
ResizeContainerTtyOptions { width: cols, height: rows, .. }).await,
|
||||
"exec" if exec_id.is_some() => docker.resize_exec(
|
||||
exec_id.as_ref().unwrap(),
|
||||
ResizeExecOptions { height: rows, width: cols, .. }).await,
|
||||
_ => Ok(()),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
fn signal(&self, name: &str) {
|
||||
// docker has no per-exec signal API in bollard's stable surface.
|
||||
// Best-effort: kill_container with the mapped signal.
|
||||
// Unknown signals fall back to SIGKILL (TtyControl::signal contract).
|
||||
let docker = self.docker.clone();
|
||||
let container = self.container.clone();
|
||||
let sig = signal_name_to_bollard(name); // SIGINT, SIGTERM, etc.
|
||||
tokio::spawn(async move {
|
||||
let _ = docker.kill_container(&container,
|
||||
Some(KillContainerOptions { signal: sig })).await;
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- `resize()` — `bollard::container::resize_container_tty()` (attach
|
||||
mode) or `bollard::exec::resize_exec()` (exec mode with an exec ID).
|
||||
Fire-and-forget (the `TtyControl` trait methods are sync, not async —
|
||||
ADR-053; the bollard call is spawned).
|
||||
- `signal()` — docker has no per-exec signal API in bollard's stable
|
||||
surface. The best-effort path is `bollard::kill_container()` with
|
||||
the mapped signal (SIGINT, SIGTERM, etc.); unknown signals fall back
|
||||
to SIGKILL. For exec mode, the signal goes to the container's main
|
||||
process, not just the exec's process — docker's exec isolation
|
||||
limits per-exec signal targeting. This is within the
|
||||
`TtyControl::signal` contract ("best-effort delivery to the
|
||||
foreground process group") but is a docker-semantics limitation: a
|
||||
user expecting `Ctrl-C` to kill only the exec process may see the
|
||||
container killed. This is documented in the spec, not hidden.
|
||||
|
||||
The signal-name → bollard-signal mapping (`signal_name_to_bollard`)
|
||||
covers the standard set (`HUP`, `INT`, `QUIT`, `TERM`, `KILL`, `USR1`,
|
||||
`USR2`, `TSTP`, `CONT`) — the same set alknet-tty's control channel
|
||||
supports (ADR-052 §Control Channel). Unknown names fall back to the
|
||||
backend's default kill (`SIGKILL`).
|
||||
|
||||
### Cancel-Cleanup (ADR-056)
|
||||
|
||||
The `TtyHandle.exit_code` future's `Drop`-on-cancel (the adapter drops
|
||||
the `TtyHandle` on connection drop, stream reset, or pump-task panic —
|
||||
ADR-056) MUST kill the session target. The docker backend's
|
||||
`exit_code` future wraps the kill in its `Drop`:
|
||||
|
||||
```rust
|
||||
// The exit_code future holds a guard that kills on Drop.
|
||||
struct DockerExitGuard {
|
||||
docker: bollard::Docker,
|
||||
container: String,
|
||||
mode: String,
|
||||
killed: bool,
|
||||
}
|
||||
|
||||
impl Drop for DockerExitGuard {
|
||||
fn drop(&mut self) {
|
||||
if !self.killed {
|
||||
let docker = self.docker.clone();
|
||||
let container = self.container.clone();
|
||||
tokio::spawn(async move {
|
||||
let _ = docker.kill_container(&container,
|
||||
Some(KillContainerOptions { signal: "SIGKILL".into() })).await;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- **Attach mode** — `kill_container` with `SIGKILL`. The container is
|
||||
not removed (attach doesn't own the container's lifecycle — the
|
||||
container may be a hosted service the operator wants to keep); the
|
||||
kill terminates the process the terminal session was attached to.
|
||||
- **Exec mode** — `kill_container` with `SIGKILL`, targeting the
|
||||
container's main process (best-effort, per docker's exec isolation).
|
||||
The exec instance's process is not separately killable in docker's
|
||||
model; the container-level kill is the available mechanism.
|
||||
|
||||
The guard's `killed` flag prevents a double-kill if the exit_code
|
||||
future resolved normally (the process exited) before the Drop. The
|
||||
spawn-on-Drop pattern (tokio::spawn in the Drop impl) is the standard
|
||||
way to run async cleanup from a sync `Drop`; the spawned task outlives
|
||||
the dropping context.
|
||||
|
||||
This satisfies ADR-056's contract: dropping `exit_code` (cancel) kills
|
||||
the session target. The "session target" for docker is the container
|
||||
(attach) or the exec's process (exec, best-effort via the container).
|
||||
A backend that returned an `exit_code` future without a kill-on-Drop
|
||||
guard would violate the contract and could leave orphaned containers.
|
||||
|
||||
### Backend Registration
|
||||
|
||||
```rust
|
||||
// assembly layer (behind `tty` feature)
|
||||
let docker_backend = Arc::new(DockerTtyBackend::new(
|
||||
docker.clone(),
|
||||
"alknet".into(), // label prefix (ADR-060)
|
||||
)) as Arc<dyn TtyBackend>;
|
||||
|
||||
let mut backends = HashMap::new();
|
||||
backends.insert("docker".into(), docker_backend);
|
||||
// (other backends: "local" from alknet-tty-local, "ssh" from alknet-ssh, etc.)
|
||||
let tty_adapter = TtyAdapter::new(Arc::new(backends), ownership_provider);
|
||||
```
|
||||
|
||||
The backend is registered under the `"docker"` key — the
|
||||
`backend: "docker"` field in the negotiation frame selects it. A
|
||||
deployment that doesn't want docker terminals doesn't register it; a
|
||||
deployment that wants both docker and local terminals registers both.
|
||||
|
||||
## Constraints
|
||||
|
||||
- **The backend produces handles; the adapter owns the wire format.**
|
||||
The backend does not write chunks to the bidi stream — the
|
||||
`TtyAdapter` does (ADR-052). The backend's `allocate()` returns a
|
||||
`TtyHandle`; the adapter pumps `stdin`, `stdout`, `exit_code`, and
|
||||
dispatches `control` chunks. A backend that wrote to the wire
|
||||
directly would break the wire-format invariants (the exit-chunk
|
||||
ordering, ADR-055).
|
||||
- **PTY mode merges stdout/stderr.** `TtyHandle.stderr` is `None` for
|
||||
both attach and exec modes (both set `tty: true`). The adapter pumps
|
||||
only stdout chunks (stream_type 1). This matches the PTY property
|
||||
(one output stream from the slave) and the
|
||||
[tty-backend.md](../tty/tty-backend.md) sketch.
|
||||
- **`exit_code` future's `Drop` kills (ADR-056).** The `DockerExitGuard`
|
||||
in the `exit_code` future issues `kill_container` on `Drop`-without-
|
||||
resolve. The guard's `killed` flag prevents double-kill. The
|
||||
spawned-on-Drop pattern runs the async kill from the sync `Drop`.
|
||||
- **Signal delivery is best-effort.** docker has no per-exec signal
|
||||
API in bollard's stable surface. `TtyControl::signal()` maps to
|
||||
`kill_container` with the container's main process as the target.
|
||||
Unknown signals fall back to `SIGKILL`. This is within the
|
||||
`TtyControl::signal` contract (ADR-053) but is a docker-semantics
|
||||
limitation, documented not hidden.
|
||||
- **Attach doesn't own the container lifecycle.** The cancel-cleanup
|
||||
kills the container (terminates the attached process) but does not
|
||||
remove it. A container that was a hosted service (operator-created)
|
||||
stays after the terminal session ends — the operator may want it
|
||||
running. The kill (not remove) is the correct teardown for attach.
|
||||
- **`resource_id()` delegates to the backend.** The adapter doesn't
|
||||
parse docker JSON to extract the container ID; the backend's
|
||||
`resource_id()` does. The adapter checks
|
||||
`OwnershipProvider::owns(identity, "container", id, "tty")`. The
|
||||
`"tty"` action is distinct from the call operation's `"exec"`
|
||||
action — interactive terminal authorization is a separate scope.
|
||||
|
||||
## Design Decisions
|
||||
|
||||
| Decision | ADR | Summary |
|
||||
|----------|-----|---------|
|
||||
| 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 |
|
||||
| TtyBackend trait and TtyHandle | [ADR-053](../../decisions/053-ttybackend-trait-and-ttyhandle.md) | The trait this backend implements; backend params opaque; REQ-TTY-01 (backends need not be natively async — bollard is async, so no bridge needed) |
|
||||
| Wire format | [ADR-052](../../decisions/052-alknet-tty-wire-format-and-two-carriage.md) | The chunk codec + control channel the adapter pumps to/from these handles |
|
||||
| Exit code on a control chunk | [ADR-055](../../decisions/055-exit-code-on-control-chunk.md) | The adapter awaits `exit_code`, sends the exit chunk; the backend's `exit_code` resolves after output EOF + `inspect_exec` |
|
||||
| Backend cleanup on session cancel | [ADR-056](../../decisions/056-backend-cleanup-on-session-cancel.md) | `exit_code` future's `Drop` kills the container (attach) or container's main process (exec, best-effort) |
|
||||
| Container resource model | [ADR-060](../../decisions/060-container-resource-model-and-label-namespace.md) | `resource_id()` delegation; `"tty"` action distinct from call op's `"exec"` |
|
||||
| bollard 0.21 + features | [ADR-059](../../decisions/059-bollard-021-dependency-and-features.md) | No `websocket` feature — reliable `attach_container()` only |
|
||||
|
||||
## Open Questions
|
||||
|
||||
None. The backend's design is decided in ADR-061. The
|
||||
signal-delivery path's precision in exec mode is a docker-semantics
|
||||
limitation (docker has no per-exec signal API in bollard's stable
|
||||
surface; `kill_container` targets the container's main process,
|
||||
best-effort per the `TtyControl::signal` contract), not an open
|
||||
question — the limitation is documented in §"DockerControl" and
|
||||
acknowledged in ADR-061 §3.
|
||||
|
||||
## References
|
||||
|
||||
- [ADR-061](../../decisions/061-docker-tty-backend-in-alknet-docker.md)
|
||||
— the crate placement and attach/exec mode decision
|
||||
- [ADR-053](../../decisions/053-ttybackend-trait-and-ttyhandle.md) — the
|
||||
trait this backend implements
|
||||
- [ADR-052](../../decisions/052-alknet-tty-wire-format-and-two-carriage.md)
|
||||
— the wire format the adapter pumps
|
||||
- [ADR-055](../../decisions/055-exit-code-on-control-chunk.md) — the
|
||||
exit-chunk ordering the `exit_code` future feeds into
|
||||
- [ADR-056](../../decisions/056-backend-cleanup-on-session-cancel.md)
|
||||
— the cancel-cleanup contract (`exit_code` future's `Drop` kills)
|
||||
- [ADR-060](../../decisions/060-container-resource-model-and-label-namespace.md)
|
||||
— the `resource_id()` delegation and the `"tty"` action
|
||||
- `docs/research/alknet-docker/poc-summary.md` §"POC Target 1"
|
||||
(interactive attach — the seed of this backend)
|
||||
- `/workspace/alknet-docker-poc/src/ops.rs` — `drive_attach_raw` (the
|
||||
reference for `allocate()`)
|
||||
- `/workspace/bollard/src/container.rs` (`attach_container` :540,
|
||||
`AttachContainerResults` :80, `LogOutput` :96,
|
||||
`resize_container_tty` :687, `kill_container` :1059)
|
||||
- `/workspace/bollard/src/exec.rs` (`create_exec` :172, `start_exec`
|
||||
:225, `inspect_exec` :315, `resize_exec` :362, `StartExecResults` :99)
|
||||
- [tty-backend.md](../tty/tty-backend.md) §"Backend implementations"
|
||||
(the row this spec fills)
|
||||
@@ -0,0 +1,334 @@
|
||||
---
|
||||
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 |
|
||||
|
||||
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-050** (deferred(scope)): Docker system events subscription.
|
||||
- **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
|
||||
@@ -313,7 +313,7 @@ let mut backends = HashMap::new();
|
||||
backends.insert("local".into(),
|
||||
Arc::new(LocalTtyBackend::new()) as Arc<dyn TtyBackend>);
|
||||
backends.insert("docker".into(),
|
||||
Arc::new(DockerTtyBackend::new(docker_client)) as Arc<dyn TtyBackend>);
|
||||
Arc::new(DockerTtyBackend::new(docker_client, "alknet".into())) as Arc<dyn TtyBackend>);
|
||||
backends.insert("ssh".into(),
|
||||
Arc::new(SshTtyBackend::new(ssh_session)) as Arc<dyn TtyBackend>);
|
||||
let tty_adapter = TtyAdapter::new(Arc::new(backends));
|
||||
@@ -329,12 +329,13 @@ layer chooses what's available.
|
||||
| Backend | Crate | Status | Notes |
|
||||
|---------|-------|--------|-------|
|
||||
| `LocalTtyBackend` | `alknet-tty-local` (sibling, behind `local` feature) | in scope ([tty-local.md](tty-local.md)) | `portable_pty` (PTY) + `std::process` (pipe); the runner pattern |
|
||||
| `DockerTtyBackend` | `alknet-docker` or sibling adapter | future, out of scope here | wraps `bollard::attach_container` / `exec` with `tty: true` |
|
||||
| `DockerTtyBackend` | `alknet-docker` (behind `tty` feature) | specced ([docker-tty-backend.md](../docker/docker-tty-backend.md), [ADR-061](../../decisions/061-docker-tty-backend-in-alknet-docker.md)) | wraps `bollard::attach_container` / `exec` with `tty: true`; attach vs exec mode |
|
||||
| `SshTtyBackend` | `alknet-ssh` | future, out of scope here | wraps russh `pty_request` + `shell_request`/`exec_request`; dissolves alknet-ssh DP-5 PTY hedge |
|
||||
|
||||
The docker and SSH backend crates are future work; this spec commits the
|
||||
trait shape they implement so they can be built against it without
|
||||
re-spec'ing the seam. The `DockerTtyBackend` is the natural extension of
|
||||
The SSH backend crate is future work; this spec commits the
|
||||
trait shape it implements so it can be built against it without
|
||||
re-spec'ing the seam. The `DockerTtyBackend` is now specced in
|
||||
[alknet-docker](../docker/docker-tty-backend.md) (ADR-061) — the natural extension of
|
||||
the alknet-docker POC's `drive_attach_raw` (`/workspace/alknet-docker-poc/src/ops.rs`)
|
||||
— with the trait, it becomes `impl TtyBackend for DockerTtyBackend`. The
|
||||
`SshTtyBackend` dissolves the alknet-ssh research's PTY hedge (DP-5):
|
||||
|
||||
Reference in new issue
Block a user