Skip to main content

rightsize_docker/
backend.rs

1//! `DockerBackend`: maps `SandboxBackend` onto exactly the daemon HTTP endpoints this
2//! backend needs, over the hand-rolled unix-socket client and the frame demuxer. This
3//! is also the correctness oracle other backends are checked against, since Docker
4//! enforces semantics (read-only mounts, native networks) microsandbox only emulates.
5//!
6//! **`Handle` has no per-container mutable state** (contrast microsandbox's
7//! `HandleState` map): every operation here is a stateless HTTP call keyed by the
8//! daemon-assigned container id already carried on the handle, so there's nothing to
9//! look up in a side table.
10
11use std::collections::{BTreeMap, HashMap};
12use std::sync::Mutex;
13use std::time::Duration;
14
15use rightsize::backend::{FollowHandle, SandboxBackend, SandboxHandle};
16use rightsize::error::{Result, RightsizeError};
17use rightsize::model::{ContainerSpec, ExecResult};
18
19use crate::client::DockerClient;
20use crate::frames::{BodyReader, LineAssembler, StreamType, demux_into, read_frame};
21use crate::json::{
22    ConnectNetworkBody, CreateContainerBody, CreateExecBody, CreateNetworkBody, EmptyObject,
23    EndpointConfig, ExecInspect, HostConfig, IdResponse, LabelFilter, ListedEntry, NameFilter,
24    PortBinding as JsonPortBinding, StartExecBody,
25};
26
27/// How long `POST /containers/{id}/stop` gives the entrypoint to exit before the
28/// daemon SIGKILLs it.
29const STOP_TIMEOUT_SECS: u64 = 10;
30
31/// The label every container this backend creates carries, so `close()`/the reaper
32/// can find exactly this run's containers without touching anyone else's. This is
33/// a cross-process wire format, not Rust API surface, so it doesn't need to match
34/// this crate's own naming conventions.
35const RUN_ID_LABEL_KEY: &str = "dev.rightsize.run_id";
36
37/// A Docker container reference: the daemon-assigned id plus the spec that created
38/// it. No mutable per-container state (see the module docs) — every operation is a
39/// stateless call keyed by `id`.
40struct Handle {
41    id: String,
42    spec: ContainerSpec,
43}
44
45impl SandboxHandle for Handle {
46    fn id(&self) -> &str {
47        &self.id
48    }
49    fn spec(&self) -> &ContainerSpec {
50        &self.spec
51    }
52}
53
54/// Drives the Docker daemon over `DockerClient`. See the module docs for the shape.
55pub struct DockerBackend {
56    client: DockerClient,
57    /// Caches network-id lookups so `ensure_network`/`install`/`remove_network` agree
58    /// on which daemon-side network a given `rightsize` network id maps to, without a
59    /// list-by-name round trip on every call.
60    network_ids: Mutex<HashMap<String, String>>,
61}
62
63impl DockerBackend {
64    /// Builds a backend talking to the daemon through `client`. Crate-internal:
65    /// [`crate::DockerBackendProvider`] (via `rightsize::backends::resolve`) is the
66    /// usual way to get one; [`Self::connecting_to_env`] is the public escape hatch
67    /// for callers (integration tests, mainly) that want a `DockerBackend` directly
68    /// without going through backend resolution.
69    pub(crate) fn new(client: DockerClient) -> Self {
70        DockerBackend {
71            client,
72            network_ids: Mutex::new(HashMap::new()),
73        }
74    }
75
76    /// Builds a backend pointed at the daemon `DOCKER_HOST` (or the default socket)
77    /// names — the same resolution `crate::DockerBackendProvider`'s `create` uses,
78    /// exposed directly for callers that want a concrete `DockerBackend` without
79    /// going through `rightsize::backends::resolve` (integration tests, mainly).
80    pub fn connecting_to_env() -> Self {
81        Self::new(DockerClient::from_env())
82    }
83
84    /// Exposed for the transport regression test: the socket path this
85    /// backend's client actually dials, so a test can assert it's a unix path and
86    /// never `localhost:2375` or similar.
87    #[cfg(test)]
88    pub(crate) fn socket_path_for_test(&self) -> std::path::PathBuf {
89        self.client.socket_path().to_path_buf()
90    }
91
92    /// `GET /images/{name}/json` to check presence, `POST /images/create` to pull if
93    /// a 404 says it's missing. The pull response is a stream of progress JSON lines
94    /// this backend doesn't parse — it drains the body to completion (the daemon
95    /// itself decides when the pull is done; a stream that closes IS "pull finished
96    /// or failed") and doesn't fail loudly on inspect trouble, only reacts to
97    /// affirmatively-absent.
98    async fn pull_if_missing(&self, image: &str) -> Result<()> {
99        let inspect_path = format!("/images/{}/json", encode_path_segment(image));
100        let inspect = self.client.request("GET", &inspect_path, None).await?;
101        if inspect.status == 200 {
102            return Ok(());
103        }
104        let (repo, tag) = split_repo_tag(image);
105        let pull_path = format!(
106            "/images/create?fromImage={}&tag={}",
107            encode_query_value(&repo),
108            encode_query_value(&tag)
109        );
110        let resp = self.client.request("POST", &pull_path, None).await?;
111        if resp.status >= 400 {
112            return Err(RightsizeError::Backend(format!(
113                "docker could not pull image '{image}' (HTTP {}): {}",
114                resp.status,
115                String::from_utf8_lossy(&resp.body)
116            )));
117        }
118        Ok(())
119    }
120
121    /// `POST /networks/{id}/connect` with the aliases this container should be
122    /// reachable as.
123    async fn connect_network(
124        &self,
125        container_id: &str,
126        network_id: &str,
127        aliases: &[String],
128    ) -> Result<()> {
129        let body = ConnectNetworkBody {
130            container: container_id.to_string(),
131            endpoint_config: EndpointConfig {
132                aliases: aliases.to_vec(),
133            },
134        };
135        let body = serde_json::to_string(&body)
136            .expect("ConnectNetworkBody has no non-serializable fields");
137        let path = format!("/networks/{network_id}/connect");
138        let resp = self.client.request("POST", &path, Some(&body)).await?;
139        if resp.status >= 400 {
140            return Err(RightsizeError::Backend(format!(
141                "docker could not connect container {container_id} to network {network_id} \
142                 (HTTP {}): {}",
143                resp.status,
144                String::from_utf8_lossy(&resp.body)
145            )));
146        }
147        Ok(())
148    }
149
150    /// `GET /networks?filters=...` then `POST /networks/create` — cached by
151    /// `network_id` so repeat calls (e.g. a second container joining the same
152    /// `rightsize` network) don't re-list/re-create.
153    async fn ensure_network_get_id(&self, network_id: &str) -> Result<String> {
154        if let Some(id) = self
155            .network_ids
156            .lock()
157            .expect("network_ids mutex poisoned")
158            .get(network_id)
159        {
160            return Ok(id.clone());
161        }
162
163        let filters = NameFilter {
164            name: vec![network_id.to_string()],
165        };
166        let filters =
167            serde_json::to_string(&filters).expect("NameFilter has no non-serializable fields");
168        let list_path = format!("/networks?filters={}", encode_query_value(&filters));
169        let list = self.client.request("GET", &list_path, None).await?;
170        if list.status == 200 {
171            let entries: Vec<ListedEntry> = serde_json::from_slice(&list.body).unwrap_or_default();
172            if let Some(entry) = entries.into_iter().next() {
173                self.network_ids
174                    .lock()
175                    .expect("network_ids mutex poisoned")
176                    .insert(network_id.to_string(), entry.id.clone());
177                return Ok(entry.id);
178            }
179        }
180
181        let create_body = CreateNetworkBody {
182            name: network_id.to_string(),
183        };
184        let create_body = serde_json::to_string(&create_body)
185            .expect("CreateNetworkBody has no non-serializable fields");
186        let created = self
187            .client
188            .request("POST", "/networks/create", Some(&create_body))
189            .await?;
190        if created.status >= 400 {
191            return Err(RightsizeError::Backend(format!(
192                "docker could not create network '{network_id}' (HTTP {}): {}",
193                created.status,
194                String::from_utf8_lossy(&created.body)
195            )));
196        }
197        let id = serde_json::from_slice::<IdResponse>(&created.body)
198            .map_err(|e| {
199                RightsizeError::Backend(format!(
200                    "docker's network-create response for '{network_id}' had no Id field: {e} \
201                     (body: {})",
202                    String::from_utf8_lossy(&created.body)
203                ))
204            })?
205            .id;
206        self.network_ids
207            .lock()
208            .expect("network_ids mutex poisoned")
209            .insert(network_id.to_string(), id.clone());
210        Ok(id)
211    }
212}
213
214/// Builds the `POST /containers/create` JSON body: `Image`, `Env[]`, `Cmd[]?`,
215/// `ExposedPorts{}`, `Labels`, and a `HostConfig` carrying port bindings (bound to
216/// `127.0.0.1` — never the wildcard address), read-only/read-write binds, the
217/// `host.docker.internal:host-gateway` extra host (lets a container reach services
218/// running on the host, e.g. a test's own HTTP server, the same way it would under a
219/// real Docker Desktop install), and an optional memory cap.
220fn build_create_body(spec: &ContainerSpec) -> CreateContainerBody {
221    let env = spec.env.iter().map(|(k, v)| format!("{k}={v}")).collect();
222
223    let exposed_ports = spec
224        .ports
225        .iter()
226        .map(|p| (format!("{}/tcp", p.guest_port), EmptyObject {}))
227        .collect();
228
229    let port_bindings = spec
230        .ports
231        .iter()
232        .map(|p| {
233            (
234                format!("{}/tcp", p.guest_port),
235                vec![JsonPortBinding {
236                    host_ip: "127.0.0.1".to_string(),
237                    host_port: p.host_port.to_string(),
238                }],
239            )
240        })
241        .collect();
242
243    let binds = spec
244        .mounts
245        .iter()
246        .map(|m| {
247            let mode = if m.read_only { "ro" } else { "rw" };
248            format!("{}:{}:{}", m.host_path.display(), m.guest_path, mode)
249        })
250        .collect();
251
252    CreateContainerBody {
253        image: spec.image.clone(),
254        env,
255        cmd: spec.command.clone(),
256        exposed_ports,
257        labels: BTreeMap::from([(RUN_ID_LABEL_KEY.to_string(), spec.run_id.clone())]),
258        host_config: HostConfig {
259            port_bindings,
260            binds,
261            extra_hosts: vec!["host.docker.internal:host-gateway".to_string()],
262            memory: spec.memory_limit_mb.map(|mb| mb * 1024 * 1024),
263        },
264    }
265}
266
267/// True if `message` (a daemon error body/exception message) names a host-port bind
268/// conflict — the daemon has no distinct exception type for this, only free-text like
269/// "driver failed programming external connectivity ... address already in use" or
270/// "Bind for 0.0.0.0:PORT failed: port is already allocated".
271fn is_port_bind_conflict_message(message: &str) -> bool {
272    let m = message.to_lowercase();
273    m.contains("already in use") || m.contains("already allocated")
274}
275
276/// Percent-encodes a path segment (just enough for image references, which can
277/// contain `/` that must stay literal in this position but nothing else exotic in
278/// practice) — used for `GET /images/{name}/json`.
279fn encode_path_segment(s: &str) -> String {
280    // Image names/tags are `[a-zA-Z0-9_.\-/:@]`-shaped in practice; none of those need
281    // percent-encoding in a URL path segment, so this is intentionally a pass-through
282    // rather than a full RFC 3986 encoder — there is nothing in this crate's own
283    // input space that would need escaping here.
284    s.to_string()
285}
286
287/// Percent-encodes a query string value (`fromImage`, `tag`, the JSON `filters` blob)
288/// — covers exactly the characters that show up in image references and the small
289/// hand-built JSON filter strings this backend sends, not a general URL encoder.
290fn encode_query_value(s: &str) -> String {
291    let mut out = String::with_capacity(s.len());
292    for b in s.bytes() {
293        match b {
294            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
295                out.push(b as char)
296            }
297            _ => out.push_str(&format!("%{b:02X}")),
298        }
299    }
300    out
301}
302
303/// Splits `image` into `(repository, tag)` for `POST /images/create?fromImage=&tag=`.
304/// A tag-less reference (`"redis"`) defaults to `"latest"`, matching Docker's own
305/// convention; a digest reference (`repo@sha256:...`) is passed through as the
306/// "repository" half with an empty tag, since `fromImage` accepts the whole
307/// `repo@digest` form and an empty `tag` is simply omitted from the query by the
308/// caller not being asked to add one — see the call site, which always sends both
309/// params, so this returns `""` rather than `"latest"` for a digest reference,
310/// avoiding a nonsensical `tag=latest` alongside an explicit digest.
311fn split_repo_tag(image: &str) -> (String, String) {
312    if image.contains('@') {
313        return (image.to_string(), String::new());
314    }
315    // A tag separator is the LAST colon that appears after the last slash (so a
316    // registry host:port prefix like `localhost:5000/redis` isn't mistaken for a
317    // tag separator).
318    let slash_idx = image.rfind('/').map(|i| i + 1).unwrap_or(0);
319    match image[slash_idx..].rfind(':') {
320        Some(rel_colon) => {
321            let colon = slash_idx + rel_colon;
322            (image[..colon].to_string(), image[colon + 1..].to_string())
323        }
324        None => (image.to_string(), "latest".to_string()),
325    }
326}
327
328#[async_trait::async_trait]
329impl SandboxBackend for DockerBackend {
330    fn name(&self) -> &str {
331        "docker"
332    }
333
334    fn supports_native_networks(&self) -> bool {
335        true
336    }
337
338    async fn create(&self, spec: ContainerSpec) -> Result<Box<dyn SandboxHandle>> {
339        self.pull_if_missing(&spec.image).await?;
340
341        let body = build_create_body(&spec);
342        let body = serde_json::to_string(&body)
343            .expect("CreateContainerBody has no non-serializable fields");
344        let path = format!("/containers/create?name={}", encode_query_value(&spec.name));
345        let resp = self.client.request("POST", &path, Some(&body)).await?;
346        if resp.status >= 400 {
347            let message = String::from_utf8_lossy(&resp.body).into_owned();
348            return Err(RightsizeError::Backend(format!(
349                "docker could not create container '{}' (HTTP {}): {message}",
350                spec.name, resp.status
351            )));
352        }
353        let id = serde_json::from_slice::<IdResponse>(&resp.body)
354            .map_err(|e| {
355                RightsizeError::Backend(format!(
356                    "docker's container-create response for '{}' had no Id field: {e} (body: {})",
357                    spec.name,
358                    String::from_utf8_lossy(&resp.body)
359                ))
360            })?
361            .id;
362
363        if let Some(network_id) = &spec.network_id {
364            let daemon_network_id = self.ensure_network_get_id(network_id).await?;
365            self.connect_network(&id, &daemon_network_id, &spec.aliases)
366                .await?;
367        }
368
369        Ok(Box::new(Handle { id, spec }))
370    }
371
372    async fn start(&self, handle: &dyn SandboxHandle) -> Result<()> {
373        let path = format!("/containers/{}/start", handle.id());
374        let resp = self.client.request("POST", &path, None).await?;
375        if resp.status == 204 || resp.status == 304 {
376            return Ok(()); // 304 = already started, treated as success like the daemon intends.
377        }
378        let message = String::from_utf8_lossy(&resp.body).into_owned();
379        if resp.status == 500 && is_port_bind_conflict_message(&message) {
380            return Err(RightsizeError::PortBindConflict {
381                message: format!(
382                    "docker could not bind a host port for {}: {message}",
383                    handle.id()
384                ),
385                source: None,
386            });
387        }
388        Err(RightsizeError::Backend(format!(
389            "docker could not start container {} (HTTP {}): {message}",
390            handle.id(),
391            resp.status
392        )))
393    }
394
395    async fn stop(&self, handle: &dyn SandboxHandle) -> Result<()> {
396        let path = format!("/containers/{}/stop?t={STOP_TIMEOUT_SECS}", handle.id());
397        let _ = self.client.request("POST", &path, None).await; // best-effort
398        Ok(())
399    }
400
401    async fn remove(&self, handle: &dyn SandboxHandle) -> Result<()> {
402        let path = format!("/containers/{}?force=true", handle.id());
403        let _ = self.client.request("DELETE", &path, None).await; // best-effort
404        Ok(())
405    }
406
407    async fn exec(&self, handle: &dyn SandboxHandle, cmd: &[String]) -> Result<ExecResult> {
408        let create_body = CreateExecBody {
409            attach_stdout: true,
410            attach_stderr: true,
411            cmd: cmd.to_vec(),
412        };
413        let create_body = serde_json::to_string(&create_body)
414            .expect("CreateExecBody has no non-serializable fields");
415        let create_path = format!("/containers/{}/exec", handle.id());
416        let created = self
417            .client
418            .request("POST", &create_path, Some(&create_body))
419            .await?;
420        if created.status >= 400 {
421            return Err(RightsizeError::Backend(format!(
422                "docker could not create an exec for container {} (HTTP {}): {}",
423                handle.id(),
424                created.status,
425                String::from_utf8_lossy(&created.body)
426            )));
427        }
428        let exec_id = serde_json::from_slice::<IdResponse>(&created.body)
429            .map_err(|e| {
430                RightsizeError::Backend(format!(
431                    "docker's exec-create response for container {} had no Id field: {e} \
432                     (body: {})",
433                    handle.id(),
434                    String::from_utf8_lossy(&created.body)
435                ))
436            })?
437            .id;
438
439        let start_path = format!("/exec/{exec_id}/start");
440        let start_body = serde_json::to_string(&StartExecBody { detach: false })
441            .expect("StartExecBody has no non-serializable fields");
442        let (headers, mut stream) = self
443            .client
444            .request_stream("POST", &start_path, Some(&start_body))
445            .await?;
446        if headers.status >= 400 {
447            return Err(RightsizeError::Backend(format!(
448                "docker could not start exec {exec_id} for container {} (HTTP {})",
449                handle.id(),
450                headers.status
451            )));
452        }
453        let mut body = BodyReader::new(&mut stream, &headers);
454        let mut stdout = Vec::new();
455        let mut stderr = Vec::new();
456        demux_into(
457            &mut body,
458            |b| stdout.extend_from_slice(b),
459            |b| stderr.extend_from_slice(b),
460        )
461        .await?;
462
463        let inspect_path = format!("/exec/{exec_id}/json");
464        let inspected = self.client.request("GET", &inspect_path, None).await?;
465        let exit_code = serde_json::from_slice::<ExecInspect>(&inspected.body)
466            .ok()
467            .and_then(|r| r.exit_code)
468            .unwrap_or(-1);
469
470        Ok(ExecResult {
471            exit_code: exit_code as i32,
472            stdout: String::from_utf8_lossy(&stdout).into_owned(),
473            stderr: String::from_utf8_lossy(&stderr).into_owned(),
474        })
475    }
476
477    async fn logs(&self, handle: &dyn SandboxHandle) -> Result<String> {
478        let path = format!(
479            "/containers/{}/logs?stdout=1&stderr=1&tail=1000",
480            handle.id()
481        );
482        let (headers, mut stream) = self.client.request_stream("GET", &path, None).await?;
483        if headers.status >= 400 {
484            return Err(RightsizeError::Backend(format!(
485                "docker could not fetch logs for container {} (HTTP {})",
486                handle.id(),
487                headers.status
488            )));
489        }
490        let mut body = BodyReader::new(&mut stream, &headers);
491        let mut assembler = LineAssembler::new();
492        let mut out = String::new();
493        while let Some(frame) = read_frame(&mut body).await? {
494            if !matches!(frame.stream, StreamType::Stdout | StreamType::Stderr) {
495                continue;
496            }
497            let text = String::from_utf8_lossy(&frame.payload).into_owned();
498            for line in assembler.feed(&text) {
499                out.push_str(&line);
500                out.push('\n');
501            }
502        }
503        if let Some(tail) = assembler.flush() {
504            out.push_str(&tail);
505            out.push('\n');
506        }
507        Ok(out)
508    }
509
510    async fn follow_logs(
511        &self,
512        handle: &dyn SandboxHandle,
513        consumer: Box<dyn Fn(String) + Send + Sync>,
514    ) -> Result<FollowHandle> {
515        let path = format!(
516            "/containers/{}/logs?stdout=1&stderr=1&follow=1&tail=all",
517            handle.id()
518        );
519        let (headers, mut stream) = self.client.request_stream("GET", &path, None).await?;
520        if headers.status >= 400 {
521            return Err(RightsizeError::Backend(format!(
522                "docker could not follow logs for container {} (HTTP {})",
523                handle.id(),
524                headers.status
525            )));
526        }
527
528        let close_requested = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
529        let task_close = close_requested.clone();
530        let task = tokio::spawn(async move {
531            let mut body = BodyReader::new(&mut stream, &headers);
532            let mut assembler = LineAssembler::new();
533            loop {
534                // Checked BEFORE each delivery (not just once per loop) — the
535                // resolved P1 forward flag: `abort()` alone can't guarantee no
536                // post-close callback, since it only cancels at the next `.await`
537                // point. Checking this flag right before every consumer call means
538                // the flag and the abort jointly rule that out — see
539                // `rightsize::backend::FollowHandle`'s doc for the full contract.
540                if task_close.load(std::sync::atomic::Ordering::SeqCst) {
541                    return;
542                }
543                let frame = match read_frame(&mut body).await {
544                    Ok(Some(f)) => f,
545                    Ok(None) => break, // clean stream end — the normal way this loop exits.
546                    Err(_) => break,   // best-effort: a stream error just ends delivery.
547                };
548                if !matches!(frame.stream, StreamType::Stdout | StreamType::Stderr) {
549                    continue;
550                }
551                let text = String::from_utf8_lossy(&frame.payload).into_owned();
552                for line in assembler.feed(&text) {
553                    if task_close.load(std::sync::atomic::Ordering::SeqCst) {
554                        return;
555                    }
556                    consumer(line);
557                }
558            }
559            if task_close.load(std::sync::atomic::Ordering::SeqCst) {
560                return; // stop delivery, never flush — an explicit close beat the stream to the end.
561            }
562            if let Some(tail) = assembler.flush() {
563                consumer(tail);
564            }
565        });
566
567        Ok(FollowHandle::from_task(close_requested, task))
568    }
569
570    async fn ensure_network(&self, network_id: &str) -> Result<()> {
571        self.ensure_network_get_id(network_id).await?;
572        Ok(())
573    }
574
575    async fn remove_network(&self, network_id: &str) -> Result<()> {
576        let daemon_id = self
577            .network_ids
578            .lock()
579            .expect("network_ids mutex poisoned")
580            .remove(network_id);
581        if let Some(id) = daemon_id {
582            let path = format!("/networks/{id}");
583            let _ = self.client.request("DELETE", &path, None).await; // best-effort
584        }
585        Ok(())
586    }
587
588    async fn close(&self) -> Result<()> {
589        let run_id = rightsize::RunId::value();
590        let filters = LabelFilter {
591            label: vec![format!("{RUN_ID_LABEL_KEY}={run_id}")],
592        };
593        let filters =
594            serde_json::to_string(&filters).expect("LabelFilter has no non-serializable fields");
595        let path = format!(
596            "/containers/json?all=true&filters={}",
597            encode_query_value(&filters)
598        );
599        let listed = self.client.request("GET", &path, None).await?;
600        if listed.status != 200 {
601            return Ok(()); // best-effort: nothing more to do if even listing fails.
602        }
603        let entries: Vec<ListedEntry> = serde_json::from_slice(&listed.body).unwrap_or_default();
604        for entry in entries {
605            let remove_path = format!("/containers/{}?force=true", entry.id);
606            let _ = self.client.request("DELETE", &remove_path, None).await;
607        }
608        Ok(())
609    }
610
611    fn cleanup_sync(&self, container_id: &str) {
612        // Blocking std I/O only, no Tokio — this runs on the dedicated cleanup thread
613        // (see rightsize::cleanup), never in async context (decision 1).
614        let _ = blocking_force_remove(self.client.socket_path(), container_id, STOP_TIMEOUT_SECS);
615    }
616}
617
618/// The `cleanup_sync` path's blocking counterpart to `stop`+`remove`: issues a plain
619/// `POST /containers/{id}/stop?t=` then `DELETE /containers/{id}?force=true` over a
620/// blocking `std::os::unix::net::UnixStream` — no Tokio, since this runs on the
621/// dedicated `Drop`-path cleanup thread with no async runtime in context. Best-effort:
622/// errors are swallowed by the caller, matching this backend's own async `stop`/
623/// `remove`.
624fn blocking_force_remove(
625    socket_path: &std::path::Path,
626    container_id: &str,
627    stop_timeout_secs: u64,
628) -> std::io::Result<()> {
629    let _ = blocking_request(
630        socket_path,
631        "POST",
632        &format!("/containers/{container_id}/stop?t={stop_timeout_secs}"),
633    );
634    blocking_request(
635        socket_path,
636        "DELETE",
637        &format!("/containers/{container_id}?force=true"),
638    )
639    .map(|_| ())
640}
641
642/// A minimal blocking HTTP/1.1 request over a blocking unix socket — the Drop-path
643/// analogue of [`DockerClient::request`], deliberately NOT reusing any of that async
644/// client's code (which is all `tokio::net::UnixStream`-shaped): this runs on a plain
645/// OS thread with no Tokio runtime available, so it needs its own blocking transport.
646/// Doesn't need to handle chunked responses — the
647/// only calls made here (`stop`, `force=true remove`) return small `Content-Length`
648/// (or empty) bodies in practice — so this reads until the peer closes and returns
649/// whatever arrived, without trying to interpret framing at all.
650fn blocking_request(
651    socket_path: &std::path::Path,
652    method: &str,
653    path: &str,
654) -> std::io::Result<()> {
655    use std::io::{Read, Write};
656    use std::os::unix::net::UnixStream;
657
658    let mut stream = UnixStream::connect(socket_path)?;
659    stream.set_read_timeout(Some(Duration::from_secs(stop_timeout_budget())))?;
660    let request = format!("{method} {path} HTTP/1.1\r\nHost: docker\r\nConnection: close\r\n\r\n");
661    stream.write_all(request.as_bytes())?;
662    let mut discard = Vec::new();
663    let _ = stream.read_to_end(&mut discard);
664    Ok(())
665}
666
667/// A generous fixed budget for the blocking cleanup-path request's read timeout —
668/// this path has no caller to propagate a timeout error to (it's best-effort, fired
669/// from `Drop`), so it just needs to not hang the cleanup thread forever if the
670/// daemon never responds.
671fn stop_timeout_budget() -> u64 {
672    STOP_TIMEOUT_SECS + 20
673}
674
675#[cfg(test)]
676mod tests {
677    use super::*;
678
679    #[test]
680    fn is_port_bind_conflict_message_matches_known_phrasings() {
681        assert!(is_port_bind_conflict_message(
682            "driver failed programming external connectivity: address already in use"
683        ));
684        assert!(is_port_bind_conflict_message(
685            "Bind for 0.0.0.0:6379 failed: port is already allocated"
686        ));
687        assert!(is_port_bind_conflict_message(
688            "ALREADY ALLOCATED (case-insensitive)"
689        ));
690    }
691
692    #[test]
693    fn is_port_bind_conflict_message_negative_cases_do_not_match() {
694        assert!(!is_port_bind_conflict_message("no such image"));
695        assert!(!is_port_bind_conflict_message("container already stopped"));
696        assert!(!is_port_bind_conflict_message(""));
697    }
698
699    #[test]
700    fn split_repo_tag_defaults_to_latest_when_untagged() {
701        assert_eq!(
702            split_repo_tag("redis"),
703            ("redis".to_string(), "latest".to_string())
704        );
705        assert_eq!(
706            split_repo_tag("redis:8.6-alpine"),
707            ("redis".to_string(), "8.6-alpine".to_string())
708        );
709    }
710
711    #[test]
712    fn split_repo_tag_handles_a_registry_host_with_a_port() {
713        assert_eq!(
714            split_repo_tag("localhost:5000/redis"),
715            ("localhost:5000/redis".to_string(), "latest".to_string())
716        );
717        assert_eq!(
718            split_repo_tag("localhost:5000/redis:8.6-alpine"),
719            ("localhost:5000/redis".to_string(), "8.6-alpine".to_string())
720        );
721    }
722
723    #[test]
724    fn split_repo_tag_passes_through_a_digest_reference_with_no_tag() {
725        assert_eq!(
726            split_repo_tag("redis@sha256:abcdef"),
727            ("redis@sha256:abcdef".to_string(), String::new())
728        );
729    }
730
731    /// `build_create_body` returns a struct now (see `crate::json::CreateContainerBody`);
732    /// these tests still assert on the actual bytes sent over the wire, serializing it
733    /// exactly like `create()` does, so a field-rename typo or a `skip_serializing_if`
734    /// slip is still caught here rather than only in `crate::json`'s own struct-shape
735    /// tests.
736    fn serialize(spec: &ContainerSpec) -> String {
737        serde_json::to_string(&build_create_body(spec)).unwrap()
738    }
739
740    #[test]
741    fn build_create_body_includes_port_bindings_on_loopback() {
742        let mut spec = ContainerSpec::new("rz-x-0", "redis:8.6-alpine", "deadbeef");
743        spec.ports.push(rightsize::model::PortBinding {
744            host_port: 32768,
745            guest_port: 6379,
746        });
747        let body = serialize(&spec);
748        assert!(body.contains("\"HostIp\":\"127.0.0.1\""));
749        assert!(body.contains("\"HostPort\":\"32768\""));
750        assert!(body.contains("\"6379/tcp\""));
751    }
752
753    #[test]
754    fn build_create_body_includes_the_run_id_label_and_extra_host() {
755        let spec = ContainerSpec::new("rz-x-0", "redis:8.6-alpine", "deadbeef");
756        let body = serialize(&spec);
757        assert!(body.contains("\"dev.rightsize.run_id\":\"deadbeef\""));
758        assert!(body.contains("host.docker.internal:host-gateway"));
759    }
760
761    #[test]
762    fn build_create_body_honors_mount_read_only_flag() {
763        let mut spec = ContainerSpec::new("rz-x-0", "redis:8.6-alpine", "deadbeef");
764        spec.mounts
765            .push(rightsize::model::FileMount::new("/host/a", "/guest/a"));
766        spec.mounts
767            .push(rightsize::model::FileMount::new("/host/b", "/guest/b").read_write());
768        let body = serialize(&spec);
769        assert!(body.contains("/host/a:/guest/a:ro"));
770        assert!(body.contains("/host/b:/guest/b:rw"));
771    }
772
773    #[test]
774    fn build_create_body_omits_memory_when_unset_and_includes_it_when_set() {
775        let spec = ContainerSpec::new("rz-x-0", "redis:8.6-alpine", "deadbeef");
776        assert!(!serialize(&spec).contains("Memory"));
777
778        let mut spec = ContainerSpec::new("rz-x-0", "redis:8.6-alpine", "deadbeef");
779        spec.memory_limit_mb = Some(512);
780        assert!(serialize(&spec).contains("\"Memory\":536870912"));
781    }
782
783    #[test]
784    fn build_create_body_omits_cmd_when_command_is_none_and_includes_it_when_some() {
785        let spec = ContainerSpec::new("rz-x-0", "redis:8.6-alpine", "deadbeef");
786        assert!(!serialize(&spec).contains("\"Cmd\""));
787
788        let mut spec = ContainerSpec::new("rz-x-0", "redis:8.6-alpine", "deadbeef");
789        spec.command = Some(vec![
790            "redis-server".to_string(),
791            "--port".to_string(),
792            "6379".to_string(),
793        ]);
794        let body = serialize(&spec);
795        assert!(body.contains("\"Cmd\":[\"redis-server\",\"--port\",\"6379\"]"));
796    }
797
798    #[test]
799    fn docker_backend_dials_a_unix_socket_never_a_tcp_host() {
800        // Standing regression guard: this backend's transport must always be
801        // a unix socket path, never a `host:port` TCP endpoint — the exact failure
802        // mode a shared/bumped HTTP stack can cause if a dependency update
803        // silently changes the default transport.
804        let backend = DockerBackend::new(DockerClient::from_docker_host(None));
805        let path = backend.socket_path_for_test();
806        assert!(
807            path.is_absolute(),
808            "expected an absolute unix socket path, got {path:?}"
809        );
810        let path_str = path.to_string_lossy();
811        assert!(
812            !path_str.contains(':'),
813            "a unix socket path must not look like a host:port TCP address — got {path_str}"
814        );
815
816        let backend_env =
817            DockerBackend::new(DockerClient::from_docker_host(Some("tcp://127.0.0.1:2375")));
818        let fallback_path = backend_env.socket_path_for_test();
819        assert!(
820            !fallback_path.to_string_lossy().contains("2375"),
821            "a tcp:// DOCKER_HOST must not leak a TCP port into this backend's transport — \
822             got {fallback_path:?}"
823        );
824    }
825}