Files
alknet/docs/architecture/crates/docker/docker-tty-backend.md
T
glm-5.2 1ce11717d8 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.
2026-07-08 09:29:22 +00:00

22 KiB

status, last_updated
status last_updated
draft 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.

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.

// 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 §"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).

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"):

#[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 §3 — not the websocket path).

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:

async fn allocate_exec(&self, params: &TtyParams, container: &str) -> Result<TtyHandle, TtyError> {
    let cmd = &params.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

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

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:

// 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

// 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 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 Behind tty feature; attach/exec mode; POC drive_attach_raw as reference
TtyBackend trait and TtyHandle ADR-053 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 The chunk codec + control channel the adapter pumps to/from these handles
Exit code on a control chunk ADR-055 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 exit_code future's Drop kills the container (attach) or container's main process (exec, best-effort)
Container resource model ADR-060 resource_id() delegation; "tty" action distinct from call op's "exec"
bollard 0.21 + features ADR-059 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 — the crate placement and attach/exec mode decision
  • ADR-053 — the trait this backend implements
  • ADR-052 — the wire format the adapter pumps
  • ADR-055 — the exit-chunk ordering the exit_code future feeds into
  • ADR-056 — the cancel-cleanup contract (exit_code future's Drop kills)
  • ADR-060 — 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 §"Backend implementations" (the row this spec fills)