Skip to main content

boatramp_docker/
lib.rs

1//! The remote-Docker [`ComputeBackend`].
2//!
3//! Delegated: boatramp targets an **existing** Docker daemon via the Engine API
4//! ([`bollard`]) — it does not install or manage Docker. `materialize` pulls the
5//! image, `launch` creates + starts a container (entrypoint, env, cpu/mem limits,
6//! restart policy) and discovers its IP\:port, `stop` stops + removes it, and
7//! `health` inspects its running state. The daemon endpoint + TLS/SSH creds come
8//! from the environment (`DOCKER_HOST`, `DOCKER_TLS_VERIFY`, `DOCKER_CERT_PATH`),
9//! never from the spec — per the secrets rule.
10//!
11//! Cross-platform (it's an API client). The actual daemon round-trip is the
12//! live/integration seam (a self-skipping test against a local dockerd, like the
13//! S3/MinIO pattern); the orchestration here is what's compiled + linted.
14
15use async_trait::async_trait;
16use boatramp_core::compute::{
17    Artifact, BackendError, Capabilities, ComputeBackend, ComputeSpec, Endpoint, Health, Instance,
18    InstanceHandle, IsolationClass, LaunchRequest, RestartPolicy, RootSource, Scheme, VolumeRef,
19};
20use bollard::container::{
21    Config, CreateContainerOptions, RemoveContainerOptions, StopContainerOptions,
22};
23use bollard::image::CreateImageOptions;
24use bollard::models::{
25    HostConfig, Mount, MountTypeEnum, PortBinding, RestartPolicy as DockerRestartPolicy,
26    RestartPolicyNameEnum,
27};
28use bollard::Docker;
29use futures::StreamExt;
30use serde::{Deserialize, Serialize};
31use std::collections::HashMap;
32use std::path::{Path, PathBuf};
33
34/// How the remote-Docker backend reports a launched workload's reachable endpoint.
35///
36/// The default `Published` publishes the container port on the host loopback
37/// (`127.0.0.1:<ephemeral>`) and routes to that, so it works whenever `boatramp
38/// serve` runs on the host — including Docker Desktop / macOS, where the daemon runs
39/// in a VM and the container bridge IP is **not** host-routable. Binding to loopback
40/// (not `0.0.0.0`) keeps the workload port off the network, matching the hardened
41/// posture.
42///
43/// `Bridge` routes to the container's bridge IP directly (the pre-0.2.1 behavior). It
44/// is only reachable when `serve` shares the daemon's network — e.g. `serve` itself
45/// runs in a container on the same Docker bridge (docker-out-of-docker) — but avoids
46/// publishing a host port.
47#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
48#[serde(rename_all = "lowercase")]
49pub enum DockerEndpoint {
50    /// Publish the container port on `127.0.0.1:<ephemeral>` and route there (default).
51    #[default]
52    Published,
53    /// Route to the container's bridge IP directly (serve must share the network).
54    Bridge,
55}
56
57/// How the remote-Docker backend backs a workload's persistent [`VolumeRef`]s.
58///
59/// `Named` (the default) attaches a daemon-managed `docker volume` by name — it
60/// works with a **remote** daemon and Docker Desktop / macOS (where a client host
61/// path isn't the daemon's filesystem). `Bind` bind-mounts a host directory under
62/// `<data_dir>/compute/volumes/<name>` (matching the native-container convention),
63/// so it is **local-daemon only** but keeps the data on the node's own filesystem.
64/// Either way the volume is node-local and outside the blob-snapshot durability
65/// story (consistent with the docker backend's `scale_to_zero: false`).
66#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
67#[serde(rename_all = "lowercase")]
68pub enum DockerVolumeMode {
69    /// A daemon-managed named volume (`docker volume`), portable across daemons.
70    #[default]
71    Named,
72    /// A host bind mount under `<data_dir>/compute/volumes/<name>` (local daemon only).
73    Bind,
74}
75
76/// The docker named-volume for boatramp volume `name` — prefixed so it never
77/// clobbers an unrelated volume on a shared daemon.
78fn docker_volume_name(name: &str) -> String {
79    format!("boatramp-{name}")
80}
81
82/// The host backing directory for a `Bind`-mode volume `name`
83/// (`<data_dir>/compute/volumes/<name>`), matching the native-container layout.
84fn volume_dir(data_dir: &Path, name: &str) -> PathBuf {
85    data_dir.join("compute").join("volumes").join(name)
86}
87
88/// Parse a `ComputeSpec.user` (`"uid"` or `"uid:gid"`, numeric) into `(uid, gid)`,
89/// defaulting `gid` to `uid`. Returns `None` for a non-numeric value (the caller then
90/// leaves ownership untouched — the endpoint still passes the raw string to Docker,
91/// which resolves an image username itself).
92fn parse_uid_gid(user: &str) -> Option<(u32, u32)> {
93    match user.split_once(':') {
94        Some((u, g)) => Some((u.parse().ok()?, g.parse().ok()?)),
95        None => {
96            let uid = user.parse().ok()?;
97            Some((uid, uid))
98        }
99    }
100}
101
102/// `chown` a bind-volume host directory so a rootless image can own its data
103/// (e.g. Postgres' `PGDATA`). Non-recursive: only the mount root, so an already
104/// initialized volume's nested ownership is left alone. A no-op off unix.
105#[cfg(unix)]
106fn chown_dir(path: &Path, uid: u32, gid: u32) -> std::io::Result<()> {
107    std::os::unix::fs::chown(path, Some(uid), Some(gid))
108}
109#[cfg(not(unix))]
110fn chown_dir(_path: &Path, _uid: u32, _gid: u32) -> std::io::Result<()> {
111    Ok(())
112}
113
114/// Reject a volume whose `name` or `mount` could escape its sandboxed location
115/// (mirrors the native-container guard): `name` backs a docker volume / a
116/// `<data_dir>/compute/volumes/<name>` bind, so it must be a single normal path
117/// component; `mount` is the in-container target, so it must be absolute with no
118/// `..`/`.`.
119fn validate_volume(name: &str, mount: &str) -> Result<(), BackendError> {
120    use std::path::Component;
121    let name_ok = matches!(
122        Path::new(name).components().collect::<Vec<_>>().as_slice(),
123        [Component::Normal(_)]
124    );
125    if !name_ok {
126        return Err(BackendError::Launch(format!(
127            "invalid volume name {name:?}: must be a single path component"
128        )));
129    }
130    let m = Path::new(mount);
131    let mount_ok = m.is_absolute()
132        && m.components()
133            .all(|c| matches!(c, Component::RootDir | Component::Normal(_)));
134    if !mount_ok {
135        return Err(BackendError::Launch(format!(
136            "invalid volume mount {mount:?}: must be an absolute path with no `..`"
137        )));
138    }
139    Ok(())
140}
141
142/// Build the bollard [`Mount`] for one volume in the selected mode (a writable
143/// mount). Pure: `Bind` mode's host directory is created separately by
144/// [`DockerBackend::stage_volumes`] before the container is created.
145fn volume_mount(vol: &VolumeRef, mode: DockerVolumeMode, data_dir: &Path) -> Mount {
146    let (typ, source) = match mode {
147        DockerVolumeMode::Named => (MountTypeEnum::VOLUME, docker_volume_name(&vol.name)),
148        DockerVolumeMode::Bind => (
149            MountTypeEnum::BIND,
150            volume_dir(data_dir, &vol.name).display().to_string(),
151        ),
152    };
153    Mount {
154        target: Some(vol.mount.clone()),
155        source: Some(source),
156        typ: Some(typ),
157        read_only: Some(false),
158        ..Default::default()
159    }
160}
161
162/// The remote-Docker compute backend: a connected Engine API client.
163pub struct DockerBackend {
164    docker: Docker,
165    endpoint: DockerEndpoint,
166    /// How persistent volumes are backed (named daemon volume vs host bind).
167    volume_mode: DockerVolumeMode,
168    /// Node data directory, for `Bind`-mode volume host paths.
169    data_dir: PathBuf,
170    /// Whether a spec's `writable_root` is honored here. Set from the isolation
171    /// posture (single-tenant only); off under the multi-tenant guard, so a
172    /// writable-root spec is forced back to the hardened read-only root.
173    writable_root_allowed: bool,
174    /// Whether a spec's `cap_add` is honored here. Set from the isolation posture
175    /// (single-tenant only); off under the multi-tenant guard, so a cap-add spec is
176    /// forced back to the dropped-`ALL` default.
177    cap_add_allowed: bool,
178}
179
180impl DockerBackend {
181    /// Connect to the Docker daemon configured by the environment
182    /// (`DOCKER_HOST` + TLS/SSH vars, or the platform default socket).
183    pub fn connect() -> Result<Self, BackendError> {
184        let docker = Docker::connect_with_defaults()
185            .map_err(|e| BackendError::Other(format!("connect to docker: {e}")))?;
186        Ok(Self {
187            docker,
188            endpoint: DockerEndpoint::default(),
189            volume_mode: DockerVolumeMode::default(),
190            data_dir: PathBuf::from("."),
191            writable_root_allowed: false,
192            cap_add_allowed: false,
193        })
194    }
195
196    /// Wrap an already-connected client (for tests / custom transports).
197    pub fn with_client(docker: Docker) -> Self {
198        Self {
199            docker,
200            endpoint: DockerEndpoint::default(),
201            volume_mode: DockerVolumeMode::default(),
202            data_dir: PathBuf::from("."),
203            writable_root_allowed: false,
204            cap_add_allowed: false,
205        }
206    }
207
208    /// Select how a launched workload's endpoint is reported (see [`DockerEndpoint`]).
209    pub fn with_endpoint(mut self, endpoint: DockerEndpoint) -> Self {
210        self.endpoint = endpoint;
211        self
212    }
213
214    /// Allow a spec's `writable_root` to relax the read-only root here (single-tenant
215    /// posture). Off by default, so the multi-tenant guard keeps the hardened root.
216    pub fn with_writable_root_allowed(mut self, allowed: bool) -> Self {
217        self.writable_root_allowed = allowed;
218        self
219    }
220
221    /// Allow a spec's `cap_add` to add capabilities back on top of the dropped-`ALL`
222    /// default here (single-tenant posture). Off by default, so the multi-tenant guard
223    /// keeps every capability dropped.
224    pub fn with_cap_add_allowed(mut self, allowed: bool) -> Self {
225        self.cap_add_allowed = allowed;
226        self
227    }
228
229    /// Select how persistent volumes are backed (see [`DockerVolumeMode`]).
230    pub fn with_volume_mode(mut self, mode: DockerVolumeMode) -> Self {
231        self.volume_mode = mode;
232        self
233    }
234
235    /// Set the node data directory used for `Bind`-mode volume host paths.
236    pub fn with_data_dir(mut self, data_dir: impl Into<PathBuf>) -> Self {
237        self.data_dir = data_dir.into();
238        self
239    }
240
241    /// Whether the daemon answers a `ping` — used to decide whether to register
242    /// this backend (a connected client doesn't imply a reachable daemon).
243    pub async fn reachable(&self) -> bool {
244        self.docker.ping().await.is_ok()
245    }
246
247    /// Validate + stage the spec's persistent volumes into bollard [`Mount`]s: each
248    /// name/mount is checked for traversal, and a `Bind`-mode volume's host
249    /// directory is created (idempotent) so the daemon can bind it. A named volume
250    /// is auto-created by the daemon on container create. Returns the mounts to
251    /// attach (empty ⇒ no volumes).
252    async fn stage_volumes(&self, spec: &ComputeSpec) -> Result<Vec<Mount>, BackendError> {
253        // A rootless `user` (`uid[:gid]`) means the entrypoint runs unprivileged, so a
254        // bind volume it must write (a database's data dir) needs to be owned by that
255        // uid — the host owns the dir, so pre-chown it. Only meaningful for `Bind` mode.
256        let chown = spec.user.as_deref().and_then(parse_uid_gid);
257        let mut mounts = Vec::with_capacity(spec.volumes.len());
258        for vol in &spec.volumes {
259            validate_volume(&vol.name, &vol.mount)?;
260            if self.volume_mode == DockerVolumeMode::Bind {
261                let dir = volume_dir(&self.data_dir, &vol.name);
262                tokio::fs::create_dir_all(&dir).await.map_err(|e| {
263                    BackendError::Launch(format!("create volume {} dir: {e}", vol.name))
264                })?;
265                if let Some((uid, gid)) = chown {
266                    chown_dir(&dir, uid, gid).map_err(|e| {
267                        BackendError::Launch(format!(
268                            "chown volume {} to {uid}:{gid}: {e}",
269                            vol.name
270                        ))
271                    })?;
272                }
273            }
274            mounts.push(volume_mount(vol, self.volume_mode, &self.data_dir));
275        }
276        Ok(mounts)
277    }
278}
279
280/// Container name for a workload replica (`boatramp-<workload>-<replica>`).
281fn container_name(workload: &str, replica: u32) -> String {
282    format!("boatramp-{workload}-{replica}")
283}
284
285/// Encode `<name>@<ip>:<port>` into the handle ref so `stop`/`health` need no
286/// in-memory state (name → stop/inspect, ip\:port → health/route).
287fn encode_ref(name: &str, ip: &str, port: u16) -> String {
288    format!("{name}@{ip}:{port}")
289}
290
291/// Decode `<name>@<ip>:<port>`.
292fn decode_ref(s: &str) -> Option<(String, String, u16)> {
293    let (name, rest) = s.split_once('@')?;
294    let (ip, port) = rest.rsplit_once(':')?;
295    Some((name.to_string(), ip.to_string(), port.parse().ok()?))
296}
297
298/// Split an image reference into the `fromImage` name + `tag` the daemon's
299/// `create_image` wants, defaulting an untagged reference to `latest`. A tag is a
300/// `:` in the **last path component** (so a registry `host:port/repo` port isn't
301/// mistaken for one); a digest-pinned reference (`name@sha256:…`) is returned whole
302/// with an empty tag.
303fn image_pull_target(reference: &str) -> (String, String) {
304    if reference.contains('@') {
305        return (reference.to_string(), String::new()); // digest-pinned
306    }
307    let last = reference.rsplit('/').next().unwrap_or(reference);
308    if last.contains(':') {
309        // Tagged: the tag is after the final `:`.
310        let (name, tag) = reference.rsplit_once(':').expect("last has ':'");
311        (name.to_string(), tag.to_string())
312    } else {
313        (reference.to_string(), "latest".to_string())
314    }
315}
316
317/// Map a boatramp [`RestartPolicy`] to a Docker `HostConfig.restart_policy`.
318fn restart_policy(policy: RestartPolicy) -> DockerRestartPolicy {
319    let name = match policy {
320        RestartPolicy::Never => RestartPolicyNameEnum::NO,
321        RestartPolicy::OnFailure => RestartPolicyNameEnum::ON_FAILURE,
322        RestartPolicy::Always => RestartPolicyNameEnum::ALWAYS,
323    };
324    DockerRestartPolicy {
325        name: Some(name),
326        maximum_retry_count: None,
327    }
328}
329
330/// PID cap for a launched container — a fork-bomb guard. Generous
331/// for normal app workloads, bounded so a runaway can't exhaust host PIDs.
332const MAX_PIDS: i64 = 512;
333
334/// Build a **hardened** `HostConfig` for a launched workload. Beyond
335/// the mem/cpu/restart limits, a shared-kernel Docker workload runs least-
336/// privilege by default: no privilege escalation (`no-new-privileges`), **all**
337/// Linux capabilities dropped, a **read-only root filesystem** (with small
338/// `noexec`/`nosuid` tmpfs mounts for `/tmp` + `/run` so temp/runtime writes
339/// still work), and a **PID cap**. Running as a non-root *user* is left to the
340/// image — forcing a UID breaks images that expect their own user, and
341/// `no-new-privileges` already blocks setuid escalation.
342///
343/// The tmpfs mounts are `mode=1777` (world-writable + sticky, like a real `/tmp`
344/// and `/run`). Docker special-cases a bare `/run` tmpfs to `0755 root:root`,
345/// which a **non-root** image cannot write — so an entrypoint that creates its own
346/// runtime dir there (a stock Postgres `mkdir -p /var/run/postgresql` for its unix
347/// socket, MySQL's `/run/mysqld`, nginx's `/run/nginx`, …) silently fails and the
348/// service never comes up. `1777` restores exactly the pre-owned, writable runtime
349/// dir the image expects, generally and without special-casing any image, while
350/// `noexec`/`nosuid`/`size` keep the mount hardened.
351///
352/// `writable_root` relaxes only the read-only-root default (caller-gated to the
353/// single-tenant posture); every other hardening stays on. The idiomatic path for
354/// app writes is a persistent volume, not a writable root.
355///
356/// `cap_add` names capabilities (short form, no `CAP_` prefix) to grant back on top of
357/// the dropped-`ALL` default — also caller-gated to single-tenant — for an image whose
358/// entrypoint genuinely needs one (a stock database that `chown`s its data dir and
359/// drops privileges). `cap_drop: ALL` still applies, so it is an explicit allowlist,
360/// and `no-new-privileges` stays on. Empty ⇒ the strict dropped-`ALL` default.
361fn hardened_host_config(
362    mem_mib: u32,
363    vcpus: u32,
364    restart: RestartPolicy,
365    writable_root: bool,
366    cap_add: &[String],
367) -> HostConfig {
368    let tmpfs = std::collections::HashMap::from([
369        (
370            "/tmp".to_string(),
371            "rw,noexec,nosuid,size=64m,mode=1777".to_string(),
372        ),
373        (
374            "/run".to_string(),
375            "rw,noexec,nosuid,size=16m,mode=1777".to_string(),
376        ),
377    ]);
378    HostConfig {
379        memory: Some(i64::from(mem_mib) * 1024 * 1024),
380        nano_cpus: Some(i64::from(vcpus.max(1)) * 1_000_000_000),
381        restart_policy: Some(restart_policy(restart)),
382        // Hardening:
383        security_opt: Some(vec!["no-new-privileges:true".to_string()]),
384        cap_drop: Some(vec!["ALL".to_string()]),
385        // Add back only the explicitly-allowlisted capabilities (empty by default).
386        cap_add: (!cap_add.is_empty()).then(|| cap_add.to_vec()),
387        readonly_rootfs: Some(!writable_root),
388        tmpfs: Some(tmpfs),
389        pids_limit: Some(MAX_PIDS),
390        ..Default::default()
391    }
392}
393
394#[async_trait]
395impl ComputeBackend for DockerBackend {
396    fn id(&self) -> &'static str {
397        "docker"
398    }
399
400    fn capabilities(&self) -> Capabilities {
401        Capabilities {
402            isolation: IsolationClass::Container,
403            scale_to_zero: false,
404            persistent_volumes: true,
405            max_vcpus: None,
406            max_mem_mib: None,
407        }
408    }
409
410    async fn materialize(&self, spec: &ComputeSpec) -> Result<Artifact, BackendError> {
411        // The docker backend pulls an OCI **image reference** (registry/repo:tag or a
412        // digest); an ext4 rootfs is not runnable here.
413        let reference = match &spec.root {
414            RootSource::Image(reference) => reference.clone(),
415            RootSource::Tar(_) | RootSource::Rootfs(_) => {
416                return Err(BackendError::Materialize(
417                    "docker backend requires an image reference (RootSource::Image)".into(),
418                ))
419            }
420        };
421        // Split the reference into `from_image` + `tag`, defaulting an untagged
422        // reference to `:latest`. Without a tag the daemon's `fromImage=<name>` (no
423        // `tag`) pulls **every** tag of the repo -- which is slow and 501s on any
424        // repo that still has an ancient v1-manifest tag. A digest-pinned reference
425        // is passed whole (no tag).
426        let (from_image, tag) = image_pull_target(&reference);
427        let options = CreateImageOptions {
428            from_image: from_image.clone(),
429            tag: tag.clone(),
430            ..Default::default()
431        };
432        let mut pull = self.docker.create_image(Some(options), None, None);
433        while let Some(step) = pull.next().await {
434            step.map_err(|e| BackendError::Materialize(format!("pull {reference}: {e}")))?;
435        }
436        // Record the fully-qualified reference actually pulled, so `launch` runs the
437        // exact tag (not the bare, all-tags-ambiguous name).
438        let reference = if tag.is_empty() {
439            reference
440        } else {
441            format!("{from_image}:{tag}")
442        };
443        Ok(Artifact::Image { reference })
444    }
445
446    async fn launch(&self, req: &LaunchRequest) -> Result<Instance, BackendError> {
447        let reference = match &req.artifact {
448            Artifact::Image { reference } => reference.clone(),
449            _ => {
450                return Err(BackendError::Launch(
451                    "docker backend requires an Image artifact".into(),
452                ))
453            }
454        };
455        let name = container_name(&req.workload, req.replica);
456        let env: Vec<String> = req
457            .spec
458            .env
459            .iter()
460            .map(|(k, v)| format!("{k}={v}"))
461            .collect();
462        let port = req.spec.port;
463        let port_key = format!("{port}/tcp");
464        // Honor `writable_root` only where the posture allows it (single-tenant);
465        // otherwise the hardened read-only root stands.
466        let writable_root = req.spec.writable_root && self.writable_root_allowed;
467        // Same posture gate for `cap_add`: single-tenant may add capabilities back,
468        // the multi-tenant guard keeps the dropped-`ALL` default.
469        let cap_add: &[String] = if self.cap_add_allowed {
470            &req.spec.cap_add
471        } else {
472            &[]
473        };
474        let mut host_config = hardened_host_config(
475            req.spec.mem_mib,
476            req.spec.vcpus,
477            req.spec.restart,
478            writable_root,
479            cap_add,
480        );
481        // Attach the spec's persistent volumes (validated; bind dirs created).
482        let mounts = self.stage_volumes(&req.spec).await?;
483        if !mounts.is_empty() {
484            host_config.mounts = Some(mounts);
485        }
486        let mut config = Config {
487            image: Some(reference),
488            cmd: Some(req.spec.entrypoint.clone()),
489            env: Some(env),
490            // Run the entrypoint as this user (`uid[:gid]`) so a stock image runs
491            // rootless against its pre-chowned volume — no capabilities needed. Passed
492            // to Docker verbatim; `None` keeps the image's own user.
493            user: req.spec.user.clone(),
494            ..Default::default()
495        };
496        // In the default `Published` mode, publish the container port on the host
497        // loopback with an ephemeral host port (discovered after start), so a
498        // host-native `serve` can reach it even when the bridge IP is not host-routable
499        // (Docker Desktop / macOS). `Bridge` leaves the container unpublished.
500        if self.endpoint == DockerEndpoint::Published {
501            config.exposed_ports = Some(HashMap::from([(port_key.clone(), HashMap::new())]));
502            host_config.port_bindings = Some(HashMap::from([(
503                port_key.clone(),
504                Some(vec![PortBinding {
505                    host_ip: Some("127.0.0.1".to_string()),
506                    host_port: Some("0".to_string()),
507                }]),
508            )]));
509        }
510        config.host_config = Some(host_config);
511
512        // Best-effort clean of a stale container with the same name, then create.
513        let _ = self
514            .docker
515            .remove_container(
516                &name,
517                Some(RemoveContainerOptions {
518                    force: true,
519                    ..Default::default()
520                }),
521            )
522            .await;
523        let created = self
524            .docker
525            .create_container(
526                Some(CreateContainerOptions {
527                    name: name.clone(),
528                    platform: None,
529                }),
530                config,
531            )
532            .await
533            .map_err(|e| BackendError::Launch(format!("create {name}: {e}")))?;
534        self.docker
535            .start_container::<String>(&created.id, None)
536            .await
537            .map_err(|e| BackendError::Launch(format!("start {name}: {e}")))?;
538
539        // Reachable endpoint: the published host loopback port (default), or the
540        // container's bridge IP + in-container port (`Bridge`).
541        let (host, endpoint_port) = match self.endpoint {
542            DockerEndpoint::Published => (
543                "127.0.0.1".to_string(),
544                self.published_host_port(&created.id, &port_key).await?,
545            ),
546            DockerEndpoint::Bridge => (self.container_ip(&created.id).await?, port),
547        };
548        Ok(Instance {
549            handle: InstanceHandle {
550                workload: req.workload.clone(),
551                replica: req.replica,
552                backend_ref: encode_ref(&name, &host, endpoint_port),
553            },
554            endpoint: Endpoint {
555                scheme: Scheme::Http,
556                host,
557                port: endpoint_port,
558            },
559        })
560    }
561
562    async fn stop(&self, handle: &InstanceHandle) -> Result<(), BackendError> {
563        let name = decode_ref(&handle.backend_ref)
564            .map(|(n, _, _)| n)
565            .unwrap_or_else(|| container_name(&handle.workload, handle.replica));
566        // Stop (ignore "already stopped") then force-remove.
567        let _ = self
568            .docker
569            .stop_container(&name, None::<StopContainerOptions>)
570            .await;
571        self.docker
572            .remove_container(
573                &name,
574                Some(RemoveContainerOptions {
575                    force: true,
576                    ..Default::default()
577                }),
578            )
579            .await
580            .map_err(|e| BackendError::Stop(format!("remove {name}: {e}")))?;
581        Ok(())
582    }
583
584    async fn health(&self, handle: &InstanceHandle) -> Result<Health, BackendError> {
585        let name = match decode_ref(&handle.backend_ref) {
586            Some((n, _, _)) => n,
587            None => container_name(&handle.workload, handle.replica),
588        };
589        let info = match self.docker.inspect_container(&name, None).await {
590            Ok(info) => info,
591            Err(_) => return Ok(Health::Unhealthy),
592        };
593        let running = info.state.and_then(|s| s.running).unwrap_or(false);
594        Ok(if running {
595            Health::Healthy
596        } else {
597            Health::Unhealthy
598        })
599    }
600}
601
602impl DockerBackend {
603    /// The container's primary IPv4 address (the default bridge, or the first
604    /// network it's attached to).
605    async fn container_ip(&self, id: &str) -> Result<String, BackendError> {
606        let info = self
607            .docker
608            .inspect_container(id, None)
609            .await
610            .map_err(|e| BackendError::Launch(format!("inspect {id}: {e}")))?;
611        let networks = info
612            .network_settings
613            .ok_or_else(|| BackendError::Launch("container has no network settings".into()))?;
614        // Prefer the top-level address, else the first non-empty network IP.
615        if let Some(ip) = networks.ip_address.filter(|s| !s.is_empty()) {
616            return Ok(ip);
617        }
618        if let Some(nets) = networks.networks {
619            for net in nets.values() {
620                if let Some(ip) = net.ip_address.as_ref().filter(|s| !s.is_empty()) {
621                    return Ok(ip.clone());
622                }
623            }
624        }
625        Err(BackendError::Launch("container has no IP address".into()))
626    }
627
628    /// The host port Docker assigned to a published container port (`<port>/tcp`),
629    /// read back from the container's network settings after start.
630    async fn published_host_port(&self, id: &str, port_key: &str) -> Result<u16, BackendError> {
631        let info = self
632            .docker
633            .inspect_container(id, None)
634            .await
635            .map_err(|e| BackendError::Launch(format!("inspect {id}: {e}")))?;
636        info.network_settings
637            .and_then(|ns| ns.ports)
638            .and_then(|mut ports| ports.remove(port_key).flatten())
639            .and_then(|bindings| bindings.into_iter().next())
640            .and_then(|b| b.host_port)
641            .and_then(|hp| hp.parse::<u16>().ok())
642            .ok_or_else(|| {
643                BackendError::Launch(format!("no published host port for {port_key} on {id}"))
644            })
645    }
646}
647
648#[cfg(test)]
649mod tests {
650    use super::*;
651
652    #[test]
653    fn image_pull_target_defaults_untagged_to_latest() {
654        // Bare name -> latest (the papercut fix: no more all-tags pull).
655        assert_eq!(
656            image_pull_target("alpine"),
657            ("alpine".into(), "latest".into())
658        );
659        assert_eq!(
660            image_pull_target("ghcr.io/owner/app"),
661            ("ghcr.io/owner/app".into(), "latest".into())
662        );
663        // Explicit tag is preserved.
664        assert_eq!(
665            image_pull_target("nginx:1.27"),
666            ("nginx".into(), "1.27".into())
667        );
668        // A registry host:port is NOT mistaken for a tag.
669        assert_eq!(
670            image_pull_target("localhost:5000/app"),
671            ("localhost:5000/app".into(), "latest".into())
672        );
673        assert_eq!(
674            image_pull_target("localhost:5000/app:v2"),
675            ("localhost:5000/app".into(), "v2".into())
676        );
677        // A digest-pinned reference is passed whole, no tag.
678        let d = "alpine@sha256:abc123";
679        assert_eq!(image_pull_target(d), (d.into(), String::new()));
680    }
681
682    #[test]
683    fn docker_endpoint_defaults_to_published_and_parses_lowercase() {
684        // Default is the portable, host-reachable mode.
685        assert_eq!(DockerEndpoint::default(), DockerEndpoint::Published);
686        // Config deserializes the lowercase names.
687        assert_eq!(
688            serde_json::from_str::<DockerEndpoint>("\"published\"").unwrap(),
689            DockerEndpoint::Published
690        );
691        assert_eq!(
692            serde_json::from_str::<DockerEndpoint>("\"bridge\"").unwrap(),
693            DockerEndpoint::Bridge
694        );
695    }
696
697    #[test]
698    fn name_and_ref_round_trip() {
699        assert_eq!(container_name("web", 0), "boatramp-web-0");
700        let r = encode_ref("boatramp-web-0", "172.17.0.3", 8080);
701        assert_eq!(r, "boatramp-web-0@172.17.0.3:8080");
702        assert_eq!(
703            decode_ref(&r),
704            Some(("boatramp-web-0".to_string(), "172.17.0.3".to_string(), 8080))
705        );
706        assert_eq!(decode_ref("garbage"), None);
707    }
708
709    #[test]
710    fn host_config_is_hardened_by_default() {
711        let hc = hardened_host_config(256, 2, RestartPolicy::Never, false, &[]);
712        // Resource limits still applied.
713        assert_eq!(hc.memory, Some(256 * 1024 * 1024));
714        assert_eq!(hc.nano_cpus, Some(2_000_000_000));
715        assert_eq!(hc.pids_limit, Some(MAX_PIDS));
716        // Hardening: no escalation, no caps, read-only rootfs.
717        assert_eq!(
718            hc.security_opt.as_deref(),
719            Some(["no-new-privileges:true".to_string()].as_slice())
720        );
721        assert_eq!(hc.cap_drop.as_deref(), Some(["ALL".to_string()].as_slice()));
722        // No capabilities added back by default.
723        assert_eq!(hc.cap_add, None);
724        assert_eq!(hc.readonly_rootfs, Some(true));
725        // A read-only rootfs stays usable via small noexec/nosuid scratch mounts.
726        let tmpfs = hc.tmpfs.expect("tmpfs mounts for a read-only rootfs");
727        assert!(tmpfs.get("/tmp").is_some_and(|o| o.contains("noexec")));
728        // /run must be world-writable + sticky (mode=1777): Docker defaults a bare
729        // /run tmpfs to 0755 root:root, which a non-root image (e.g. a stock Postgres
730        // creating /var/run/postgresql for its socket) cannot write. Regression guard.
731        assert!(tmpfs.get("/run").is_some_and(|o| o.contains("mode=1777")));
732        assert!(tmpfs.get("/tmp").is_some_and(|o| o.contains("mode=1777")));
733        // At least one vCPU even when the spec asks for zero.
734        assert_eq!(
735            hardened_host_config(64, 0, RestartPolicy::Never, false, &[]).nano_cpus,
736            Some(1_000_000_000)
737        );
738    }
739
740    #[test]
741    fn writable_root_relaxes_only_the_read_only_root() {
742        let hc = hardened_host_config(256, 2, RestartPolicy::Never, true, &[]);
743        // The one relaxation.
744        assert_eq!(hc.readonly_rootfs, Some(false));
745        // Every other hardening still applies.
746        assert_eq!(
747            hc.security_opt.as_deref(),
748            Some(["no-new-privileges:true".to_string()].as_slice())
749        );
750        assert_eq!(hc.cap_drop.as_deref(), Some(["ALL".to_string()].as_slice()));
751        assert_eq!(hc.cap_add, None);
752        assert_eq!(hc.pids_limit, Some(MAX_PIDS));
753    }
754
755    #[test]
756    fn cap_add_adds_back_only_the_allowlist() {
757        let caps = ["CHOWN".to_string(), "SETUID".to_string()];
758        let hc = hardened_host_config(256, 2, RestartPolicy::Never, false, &caps);
759        // The allowlist is added back on top of the retained drop-ALL.
760        assert_eq!(hc.cap_drop.as_deref(), Some(["ALL".to_string()].as_slice()));
761        assert_eq!(hc.cap_add.as_deref(), Some(caps.as_slice()));
762        // Adding caps does not relax any other hardening.
763        assert_eq!(
764            hc.security_opt.as_deref(),
765            Some(["no-new-privileges:true".to_string()].as_slice())
766        );
767        assert_eq!(hc.readonly_rootfs, Some(true));
768    }
769
770    #[test]
771    fn writable_root_is_off_by_default_on_the_backend() {
772        // A backend built without the posture opt-in refuses to honor writable_root.
773        let docker = Docker::connect_with_defaults().unwrap();
774        let backend = DockerBackend::with_client(docker);
775        assert!(!backend.writable_root_allowed);
776        assert!(
777            backend
778                .with_writable_root_allowed(true)
779                .writable_root_allowed,
780            "the single-tenant posture opts in"
781        );
782    }
783
784    #[test]
785    fn parse_uid_gid_handles_uid_and_uid_gid() {
786        assert_eq!(parse_uid_gid("999"), Some((999, 999)));
787        assert_eq!(parse_uid_gid("1000:1001"), Some((1000, 1001)));
788        // A non-numeric user (an image username) is left to Docker to resolve.
789        assert_eq!(parse_uid_gid("postgres"), None);
790        assert_eq!(parse_uid_gid("999:abc"), None);
791    }
792
793    #[test]
794    fn cap_add_is_off_by_default_on_the_backend() {
795        // Without the posture opt-in the backend keeps every capability dropped.
796        let docker = Docker::connect_with_defaults().unwrap();
797        let backend = DockerBackend::with_client(docker);
798        assert!(!backend.cap_add_allowed);
799        assert!(
800            backend.with_cap_add_allowed(true).cap_add_allowed,
801            "the single-tenant posture opts in"
802        );
803    }
804
805    #[test]
806    fn named_volume_mode_builds_a_prefixed_daemon_volume_mount() {
807        let vol = VolumeRef {
808            name: "db".into(),
809            mount: "/data".into(),
810            size_mib: 64,
811        };
812        let m = volume_mount(&vol, DockerVolumeMode::Named, Path::new("/srv/data"));
813        assert_eq!(m.typ, Some(MountTypeEnum::VOLUME));
814        // Prefixed so it never clobbers an unrelated volume on a shared daemon.
815        assert_eq!(m.source.as_deref(), Some("boatramp-db"));
816        assert_eq!(m.target.as_deref(), Some("/data"));
817        assert_eq!(m.read_only, Some(false), "a persistent volume is writable");
818    }
819
820    #[test]
821    fn bind_volume_mode_builds_a_host_path_mount() {
822        let vol = VolumeRef {
823            name: "db".into(),
824            mount: "/data".into(),
825            size_mib: 64,
826        };
827        let m = volume_mount(&vol, DockerVolumeMode::Bind, Path::new("/srv/data"));
828        assert_eq!(m.typ, Some(MountTypeEnum::BIND));
829        assert_eq!(m.source.as_deref(), Some("/srv/data/compute/volumes/db"));
830        assert_eq!(m.target.as_deref(), Some("/data"));
831        assert_eq!(m.read_only, Some(false));
832    }
833
834    #[test]
835    fn validate_volume_rejects_traversal_in_name_and_mount() {
836        assert!(validate_volume("db", "/data").is_ok());
837        assert!(validate_volume("cache-1", "/var/lib/app").is_ok());
838        // A name must be a single path component.
839        assert!(validate_volume("../etc", "/data").is_err());
840        assert!(validate_volume("a/b", "/data").is_err());
841        // A mount must be absolute with no `..`.
842        assert!(validate_volume("db", "relative").is_err());
843        assert!(validate_volume("db", "/data/../etc").is_err());
844    }
845
846    #[test]
847    fn volume_mode_defaults_to_named_and_parses_lowercase() {
848        assert_eq!(DockerVolumeMode::default(), DockerVolumeMode::Named);
849        assert_eq!(
850            serde_json::from_str::<DockerVolumeMode>("\"named\"").unwrap(),
851            DockerVolumeMode::Named
852        );
853        assert_eq!(
854            serde_json::from_str::<DockerVolumeMode>("\"bind\"").unwrap(),
855            DockerVolumeMode::Bind
856        );
857    }
858
859    #[test]
860    fn restart_policy_maps_to_docker() {
861        assert_eq!(
862            restart_policy(RestartPolicy::Always).name,
863            Some(RestartPolicyNameEnum::ALWAYS)
864        );
865        assert_eq!(
866            restart_policy(RestartPolicy::OnFailure).name,
867            Some(RestartPolicyNameEnum::ON_FAILURE)
868        );
869        assert_eq!(
870            restart_policy(RestartPolicy::Never).name,
871            Some(RestartPolicyNameEnum::NO)
872        );
873    }
874}