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
595 lines
27 KiB
Markdown
595 lines
27 KiB
Markdown
---
|
|
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) | |
|
|
| `docker/system/events` | Subscription | `events` | (none) | StreamingHandler; daemon events (container start/stop/die/destroy, image pull/tag/delete, etc.) |
|
|
|
|
**Out of scope for v1 (deferred):**
|
|
|
|
- Network operations (`docker/network/*`) — OQ-048.
|
|
- Volume operations (`docker/volume/*`) — OQ-048.
|
|
- Image build (`docker/image/build` with buildkit) — OQ-049.
|
|
- Full `CreateContainerOptions` surface (mounts, port bindings,
|
|
networks) — OQ-051. v1 `create` accepts the image, command, env,
|
|
labels, and name; the full options surface is a v1 implementation
|
|
refinement.
|
|
- Interactive exec / attach (`tty: true`) — not a call operation;
|
|
`DockerTtyBackend` on `alknet/tty` (ADR-061).
|
|
|
|
### Lifecycle Operations (Query / Mutation)
|
|
|
|
The lifecycle operations are the "boring case": a single bollard
|
|
async method call, a single `call.responded` with the JSON result
|
|
(or `call.error` on failure). The handler is a `Handler` (not
|
|
`StreamingHandler`), registered as `HandlerKind::Once`.
|
|
|
|
The `start`, `stop`, and `restart` operations share the `inspect`
|
|
shape (a `Mutation` with `resource_id_path: "$.containerId"`,
|
|
`container:<action>` scope, a single bollard method call, a single
|
|
`call.responded`). The representative example below is `inspect`;
|
|
the others differ only in the bollard method and the declared
|
|
scope.
|
|
|
|
```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 inert
|
|
(a reused container ID gets a fresh `record` on its next `create`).
|
|
The `docker/system/events` subscription provides the prompt-cleanup
|
|
path (internal ownership-store subscription on `destroy` events).
|
|
See ADR-060 §4.
|
|
|
|
### `docker/container/list` — scope-gate + optional result-filter
|
|
|
|
`list` is the ADR-050 #4a "list case": `resource_type: "container"`,
|
|
no `resource_id_path`. The input accepts an `owned_only: bool` flag
|
|
(default `false`):
|
|
|
|
- `owned_only: false` — `bollard::list_containers()` with no label
|
|
filter; returns all containers the daemon sees. The scope check
|
|
gates the call (caller needs `container:list`). This is the
|
|
hosted-services case (operator lists all).
|
|
- `owned_only: true` — `bollard::list_containers()` with a label
|
|
filter (`label: alknet.owner=<caller_peer_id>`); returns only the
|
|
caller's containers. This is the disposable-dev-container case
|
|
(coordinator lists its own).
|
|
|
|
The result-filter is bollard-side (the label filter is in the
|
|
`ListContainersOptions`), not handler-side — bollard filters at the
|
|
daemon. The handler doesn't call `OwnershipProvider::owned_resources`
|
|
and filter in Rust; it pushes the filter to bollard via the label.
|
|
This is more efficient (the daemon filters before sending) and
|
|
correct (the label and the ownership store agree for alknet-spawned
|
|
containers; the cross-check is ADR-060 §1's secondary signal).
|
|
|
|
### Streaming Operations (Subscription)
|
|
|
|
The streaming operations (logs, exec, image pull) are `Subscription`
|
|
operations using `StreamingHandler` (ADR-049). The handler returns a
|
|
`Stream<ResponseEnvelope>`; the dispatcher's `pump_stream` writes
|
|
each `Ok(value)` as `call.responded`, an `Err` as `call.error`
|
|
(terminal), and natural stream end as `call.completed`.
|
|
|
|
#### `docker/container/logs`
|
|
|
|
Maps `bollard::container::logs()` (returns
|
|
`Stream<Item = Result<LogOutput, Error>>`) to a stream of
|
|
`call.responded` frames. The POC validated this path (POC target 2:
|
|
`docker_logs_subscription_pumps_frames_and_completes`).
|
|
|
|
```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).
|
|
|
|
#### `docker/system/events`
|
|
|
|
Maps `bollard::system::events()` (returns
|
|
`Stream<Item = Result<SystemEventsMessage, Error>>`) to a stream of
|
|
`call.responded` frames. Same shape as logs: each daemon event
|
|
(container start/stop/die/destroy, image pull/tag/delete, network
|
|
create/connect, volume create/mount, etc.) → `call.responded`, stream
|
|
end on client disconnect or daemon connection close →
|
|
`call.completed`.
|
|
|
|
```rust
|
|
let docker_clone = docker.clone();
|
|
let handler: StreamingHandler = Arc::new(move |_input, _ctx| {
|
|
let docker = docker_clone.clone();
|
|
Box::pin(async_stream::stream! {
|
|
let mut stream = docker.events(None::<EventsOptions<String>>);
|
|
while let Some(result) = stream.next().await {
|
|
match result {
|
|
Ok(event) => yield ResponseEnvelope::ok(serde_json::to_value(event)
|
|
.unwrap_or_default()),
|
|
Err(e) => yield ResponseEnvelope::error(CallError {
|
|
code: "DOCKER_ERROR".into(),
|
|
message: e.to_string(),
|
|
retryable: false, details: None,
|
|
}),
|
|
}
|
|
}
|
|
}) as ResponseStream
|
|
});
|
|
```
|
|
|
|
The operation has no `resource_id_path` and no `resource_type` — it
|
|
surfaces daemon-wide events, not per-container events. The scope
|
|
check (`system:events`) gates the call. The internal ownership-store
|
|
subscription for stale-entry cleanup on `destroy` events is a
|
|
follow-up refinement — the operation itself is the architecture
|
|
decision.
|
|
|
|
### Access Control
|
|
|
|
The operations declare `AccessControl` against the ADR-050 model,
|
|
applied via ADR-060. Three patterns:
|
|
|
|
#### Specific-container operations (`exec`, `inspect`, `start`, `stop`, `remove`, `restart`, `logs`)
|
|
|
|
```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.
|
|
|
|
#### System events (`system/events`)
|
|
|
|
```rust
|
|
OperationSpec {
|
|
name: "docker/system/events",
|
|
access_control: AccessControl {
|
|
required_scopes: vec!["system:events".into()],
|
|
// no resource_type — daemon-wide events, not per-resource
|
|
..
|
|
},
|
|
// no resource_id_path
|
|
..
|
|
}
|
|
```
|
|
|
|
Scope-gate only. The operation surfaces daemon-wide events; there is
|
|
no per-resource ownership to check.
|
|
|
|
### Error Schemas (ADR-023)
|
|
|
|
Each operation declares its domain error codes in
|
|
`OperationSpec.error_schemas`:
|
|
|
|
| Code | Operations | HTTP status (for to_openapi projection) | Description |
|
|
|------|-----------|----------------------------------------|-------------|
|
|
| `CONTAINER_NOT_FOUND` | inspect, start, stop, remove, restart, logs, exec | 404 | The container ID doesn't exist on the daemon |
|
|
| `IMAGE_NOT_FOUND` | image/inspect | 404 | The image doesn't exist locally |
|
|
| `IMAGE_PULL_FAILED` | image/pull | 502 | The pull failed (registry error, network) |
|
|
| `DOCKER_ERROR` | all | 500 | A bollard error not covered by a specific code |
|
|
| `INVALID_INPUT` | exec (tty:true) | 400 | `tty: true` on `docker/container/exec` — use `alknet/tty` |
|
|
|
|
The `INVALID_INPUT` for `tty: true` on exec carries a message
|
|
directing the caller to `alknet/tty` for interactive exec. This is
|
|
the one-operation-per-carriage invariant's user-visible surface
|
|
(ADR-058 §3).
|
|
|
|
### Composition and Peer Routing
|
|
|
|
Docker operations compose through the standard call-protocol
|
|
mechanisms:
|
|
|
|
- **`from_call` re-export (ADR-017).** A hub that wants to manage
|
|
docker on a worker imports the worker's `docker/container/*`
|
|
operations via `from_call` and re-registers them locally. The hub's
|
|
clients call the re-exported operations; the hub is the direct
|
|
caller to the worker; the worker's ownership store sees the hub as
|
|
the owner (the proxy pattern, ADR-050 §3). The end user's identity
|
|
rides as `forwarded_for` (ADR-032).
|
|
- **Peer routing (ADR-029).** A hub with multiple worker connections
|
|
composes `docker/container/exec` via `invoke_peer(PeerRef::Specific(
|
|
worker_peer_id), ...)`. The `PeerCompositeEnv` routes to the named
|
|
worker's sub-overlay. This is the head→worker fan-out primitive.
|
|
- **Local composition.** A coordinator handler that spawns a
|
|
container and then execs into it composes `docker/container/create`
|
|
then `docker/container/exec` through `OperationEnv::invoke()`. The
|
|
coordinator's `CompositionAuthority` has `container:create` +
|
|
`container:exec` scopes (static, ADR-022); the ownership check
|
|
passes because `create` recorded the coordinator as the owner.
|
|
|
|
The `docker/container/exec` with `tty: true` rejection is a
|
|
composition boundary: a coordinator composing exec must use `tty:
|
|
false` (captured output); interactive exec requires an `alknet/tty`
|
|
session, which is a different protocol (the coordinator opens a tty
|
|
connection, not a call operation). This is the same boundary as
|
|
"SSH exec" (call op) vs "SSH PTY session" (alknet-tty).
|
|
|
|
## Constraints
|
|
|
|
- **No `carriage` field on `call.requested`.** The call protocol's
|
|
wire format (ADR-012) is `EventEnvelope`-only on `alknet/call`.
|
|
The one operation that needed raw carriage (interactive attach)
|
|
moved to `alknet/tty` via `DockerTtyBackend` (ADR-058 §2, ADR-061).
|
|
A `docker/container/exec` call with `tty: true` is rejected with
|
|
`INVALID_INPUT`.
|
|
- **`resource_id_path` is required for specific-container ops.** The
|
|
dispatcher extracts the container ID from the input via the JSON
|
|
pointer; `AccessControl::check` receives it. Operations without
|
|
`resource_id_path` (`list`, `create`, image ops) don't reference a
|
|
specific container and don't consult the ownership provider for a
|
|
specific resource.
|
|
- **`create` records ownership; `remove` revokes; lifecycle ops do
|
|
neither.** Only the spawn (`create`) and the teardown (`remove`)
|
|
touch the ownership store. `start`/`stop`/`restart` act on an
|
|
existing container whose ownership was recorded at create time
|
|
(or which pre-exists and is reached via the static fallback).
|
|
- **Stale ownership entries are tolerated.** Autonomous container
|
|
death leaves a stale entry; the `docker/system/events` subscription
|
|
provides the prompt-cleanup path (internal ownership-store
|
|
subscription on `destroy` events). Until that internal subscription
|
|
is wired, stale entries are inert (a reused container ID gets a
|
|
fresh `record`).
|
|
- **`owned_only` on `list` is the caller's choice.** Default
|
|
`false` (returns all); `true` filters to the caller's containers.
|
|
The default and the two cases are decided in ADR-060 §2; the scope
|
|
check gates regardless of the flag.
|
|
|
|
## Design Decisions
|
|
|
|
| Decision | ADR | Summary |
|
|
|----------|-----|---------|
|
|
| Docker ops on `alknet/call` | [ADR-058](../../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-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) |