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