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::{Capabilities, 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 non-`keep_alive` container this backend creates carries, so
32/// `close()`/the reaper can find exactly this run's containers without touching
33/// anyone else's. This is a cross-process wire format, not Rust API surface, so it
34/// doesn't need to match this crate's own naming conventions.
35const RUN_ID_LABEL_KEY: &str = "dev.rightsize.run_id";
36
37/// The label a `keep_alive` (reuse) container carries INSTEAD of
38/// [`RUN_ID_LABEL_KEY`] — deliberately a different key, so `close()`'s run-id label
39/// filter (and any run-id-scoped listing) structurally never matches a reuse
40/// container. See `ContainerSpec::keep_alive`'s doc.
41const REUSE_LABEL_KEY: &str = "dev.rightsize.reuse";
42
43/// A Docker container reference: the daemon-assigned id plus the spec that created
44/// it. No mutable per-container state (see the module docs) — every operation is a
45/// stateless call keyed by `id`.
46struct Handle {
47    id: String,
48    spec: ContainerSpec,
49}
50
51impl SandboxHandle for Handle {
52    fn id(&self) -> &str {
53        &self.id
54    }
55    fn spec(&self) -> &ContainerSpec {
56        &self.spec
57    }
58}
59
60/// Drives the Docker daemon over `DockerClient`. See the module docs for the shape.
61pub struct DockerBackend {
62    client: DockerClient,
63    /// Caches network-id lookups so `ensure_network`/`install`/`remove_network` agree
64    /// on which daemon-side network a given `rightsize` network id maps to, without a
65    /// list-by-name round trip on every call.
66    network_ids: Mutex<HashMap<String, String>>,
67}
68
69impl DockerBackend {
70    /// Builds a backend talking to the daemon through `client`. Crate-internal:
71    /// [`crate::DockerBackendProvider`] (via `rightsize::backends::resolve`) is the
72    /// usual way to get one; [`Self::connecting_to_env`] is the public escape hatch
73    /// for callers (integration tests, mainly) that want a `DockerBackend` directly
74    /// without going through backend resolution.
75    pub(crate) fn new(client: DockerClient) -> Self {
76        DockerBackend {
77            client,
78            network_ids: Mutex::new(HashMap::new()),
79        }
80    }
81
82    /// Builds a backend pointed at the daemon `DOCKER_HOST` (or the default socket)
83    /// names — the same resolution `crate::DockerBackendProvider`'s `create` uses,
84    /// exposed directly for callers that want a concrete `DockerBackend` without
85    /// going through `rightsize::backends::resolve` (integration tests, mainly).
86    pub fn connecting_to_env() -> Self {
87        Self::new(DockerClient::from_env())
88    }
89
90    /// Exposed for the transport regression test: the socket path this
91    /// backend's client actually dials, so a test can assert it's a unix path and
92    /// never `localhost:2375` or similar.
93    #[cfg(test)]
94    pub(crate) fn socket_path_for_test(&self) -> std::path::PathBuf {
95        self.client.socket_path().to_path_buf()
96    }
97
98    /// `GET /images/{name}/json` to check presence, `POST /images/create` to pull if
99    /// a 404 says it's missing. The pull response is a stream of progress JSON lines
100    /// this backend doesn't parse — it drains the body to completion (the daemon
101    /// itself decides when the pull is done; a stream that closes IS "pull finished
102    /// or failed") and doesn't fail loudly on inspect trouble, only reacts to
103    /// affirmatively-absent.
104    async fn pull_if_missing(&self, image: &str) -> Result<()> {
105        let inspect_path = format!("/images/{}/json", encode_path_segment(image));
106        let inspect = self.client.request("GET", &inspect_path, None).await?;
107        if inspect.status == 200 {
108            return Ok(());
109        }
110        let (repo, tag) = split_repo_tag(image);
111        let pull_path = format!(
112            "/images/create?fromImage={}&tag={}",
113            encode_query_value(&repo),
114            encode_query_value(&tag)
115        );
116        let resp = self.client.request("POST", &pull_path, None).await?;
117        if resp.status >= 400 {
118            return Err(RightsizeError::Backend(format!(
119                "docker could not pull image '{image}' (HTTP {}): {}",
120                resp.status,
121                String::from_utf8_lossy(&resp.body)
122            )));
123        }
124        Ok(())
125    }
126
127    /// `POST /networks/{id}/connect` with the aliases this container should be
128    /// reachable as.
129    async fn connect_network(
130        &self,
131        container_id: &str,
132        network_id: &str,
133        aliases: &[String],
134    ) -> Result<()> {
135        let body = ConnectNetworkBody {
136            container: container_id.to_string(),
137            endpoint_config: EndpointConfig {
138                aliases: aliases.to_vec(),
139            },
140        };
141        let body = serde_json::to_string(&body)
142            .expect("ConnectNetworkBody has no non-serializable fields");
143        let path = format!("/networks/{network_id}/connect");
144        let resp = self.client.request("POST", &path, Some(&body)).await?;
145        if resp.status >= 400 {
146            return Err(RightsizeError::Backend(format!(
147                "docker could not connect container {container_id} to network {network_id} \
148                 (HTTP {}): {}",
149                resp.status,
150                String::from_utf8_lossy(&resp.body)
151            )));
152        }
153        Ok(())
154    }
155
156    /// `GET /networks?filters=...` then `POST /networks/create` — cached by
157    /// `network_id` so repeat calls (e.g. a second container joining the same
158    /// `rightsize` network) don't re-list/re-create.
159    async fn ensure_network_get_id(&self, network_id: &str) -> Result<String> {
160        if let Some(id) = self
161            .network_ids
162            .lock()
163            .expect("network_ids mutex poisoned")
164            .get(network_id)
165        {
166            return Ok(id.clone());
167        }
168
169        let filters = NameFilter {
170            name: vec![network_id.to_string()],
171        };
172        let filters =
173            serde_json::to_string(&filters).expect("NameFilter has no non-serializable fields");
174        let list_path = format!("/networks?filters={}", encode_query_value(&filters));
175        let list = self.client.request("GET", &list_path, None).await?;
176        if list.status == 200 {
177            let entries: Vec<ListedEntry> = serde_json::from_slice(&list.body).unwrap_or_default();
178            if let Some(entry) = entries.into_iter().next() {
179                self.network_ids
180                    .lock()
181                    .expect("network_ids mutex poisoned")
182                    .insert(network_id.to_string(), entry.id.clone());
183                return Ok(entry.id);
184            }
185        }
186
187        let create_body = CreateNetworkBody {
188            name: network_id.to_string(),
189        };
190        let create_body = serde_json::to_string(&create_body)
191            .expect("CreateNetworkBody has no non-serializable fields");
192        let created = self
193            .client
194            .request("POST", "/networks/create", Some(&create_body))
195            .await?;
196        if created.status >= 400 {
197            return Err(RightsizeError::Backend(format!(
198                "docker could not create network '{network_id}' (HTTP {}): {}",
199                created.status,
200                String::from_utf8_lossy(&created.body)
201            )));
202        }
203        let id = serde_json::from_slice::<IdResponse>(&created.body)
204            .map_err(|e| {
205                RightsizeError::Backend(format!(
206                    "docker's network-create response for '{network_id}' had no Id field: {e} \
207                     (body: {})",
208                    String::from_utf8_lossy(&created.body)
209                ))
210            })?
211            .id;
212        self.network_ids
213            .lock()
214            .expect("network_ids mutex poisoned")
215            .insert(network_id.to_string(), id.clone());
216        Ok(id)
217    }
218
219    /// A live `GET /networks?filters={"name":[name]}` lookup, independent of the
220    /// in-memory `network_ids` cache — [`Self::remove_network`]'s cache-miss
221    /// fallback, so removing a network created by a DIFFERENT process (the reaping
222    /// sweep's whole purpose) doesn't silently no-op just because THIS backend
223    /// instance never itself called [`Self::ensure_network_get_id`] for it.
224    /// Best-effort: `None` on any failure (request error, non-200, no match,
225    /// unparseable body), matching `remove_network`'s own best-effort contract.
226    async fn lookup_network_id_by_name(&self, name: &str) -> Option<String> {
227        let filters = NameFilter {
228            name: vec![name.to_string()],
229        };
230        let filters = serde_json::to_string(&filters).ok()?;
231        let list_path = format!("/networks?filters={}", encode_query_value(&filters));
232        let list = self.client.request("GET", &list_path, None).await.ok()?;
233        if list.status != 200 {
234            return None;
235        }
236        let entries: Vec<ListedEntry> = serde_json::from_slice(&list.body).ok()?;
237        entries.into_iter().next().map(|e| e.id)
238    }
239}
240
241/// Builds the `POST /containers/create` JSON body: `Image`, `Env[]`, `Cmd[]?`,
242/// `ExposedPorts{}`, `Labels`, and a `HostConfig` carrying port bindings (bound to
243/// `127.0.0.1` — never the wildcard address), read-only/read-write binds, the
244/// `host.docker.internal:host-gateway` extra host (lets a container reach services
245/// running on the host, e.g. a test's own HTTP server, the same way it would under a
246/// real Docker Desktop install), and an optional memory cap.
247fn build_create_body(spec: &ContainerSpec) -> CreateContainerBody {
248    let env = spec.env.iter().map(|(k, v)| format!("{k}={v}")).collect();
249
250    let exposed_ports = spec
251        .ports
252        .iter()
253        .map(|p| (format!("{}/tcp", p.guest_port), EmptyObject {}))
254        .collect();
255
256    let port_bindings = spec
257        .ports
258        .iter()
259        .map(|p| {
260            (
261                format!("{}/tcp", p.guest_port),
262                vec![JsonPortBinding {
263                    host_ip: "127.0.0.1".to_string(),
264                    host_port: p.host_port.to_string(),
265                }],
266            )
267        })
268        .collect();
269
270    let binds = spec
271        .mounts
272        .iter()
273        .map(|m| {
274            let mode = if m.read_only { "ro" } else { "rw" };
275            format!("{}:{}:{}", m.host_path.display(), m.guest_path, mode)
276        })
277        .collect();
278
279    let labels = if spec.keep_alive {
280        BTreeMap::from([(REUSE_LABEL_KEY.to_string(), reuse_label_value(spec))])
281    } else {
282        BTreeMap::from([(RUN_ID_LABEL_KEY.to_string(), spec.run_id.clone())])
283    };
284
285    CreateContainerBody {
286        image: spec.image.clone(),
287        env,
288        cmd: spec.command.clone(),
289        exposed_ports,
290        labels,
291        host_config: HostConfig {
292            port_bindings,
293            binds,
294            extra_hosts: vec!["host.docker.internal:host-gateway".to_string()],
295            memory: spec.memory_limit_mb.map(|mb| mb * 1024 * 1024),
296        },
297    }
298}
299
300/// The 12-hex-char value a `keep_alive` container's [`REUSE_LABEL_KEY`] label
301/// carries. Every real reuse container `rightsize::container`'s reuse flow builds
302/// always carries the exact `rz-reuse-<12hex>` name that hash is derived from (see
303/// `rightsize::reuse::compute_identity`) — this extracts that suffix directly,
304/// rather than re-deriving the hash itself, so the label always agrees with the
305/// name regardless of which language built the spec. Falls back to a cheap,
306/// dependency-free FNV-1a-style hash of `spec.name` for a `keep_alive` spec built
307/// directly with some OTHER name shape (only ever a unit test constructing
308/// `ContainerSpec` by hand) — deterministic, just not meaningfully tied to any
309/// cross-language identity.
310fn reuse_label_value(spec: &ContainerSpec) -> String {
311    if let Some(hex) = spec.name.strip_prefix("rz-reuse-") {
312        if hex.len() == 12 && hex.chars().all(|c| c.is_ascii_hexdigit()) {
313            return hex.to_string();
314        }
315    }
316    let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
317    for byte in spec.name.as_bytes() {
318        hash ^= *byte as u64;
319        hash = hash.wrapping_mul(0x0000_0100_0000_01B3);
320    }
321    format!("{:012x}", hash & 0xFFFF_FFFF_FFFF)
322}
323
324/// True if `message` (a daemon error body/exception message) names a host-port bind
325/// conflict — the daemon has no distinct exception type for this, only free-text like
326/// "driver failed programming external connectivity ... address already in use" or
327/// "Bind for 0.0.0.0:PORT failed: port is already allocated".
328fn is_port_bind_conflict_message(message: &str) -> bool {
329    let m = message.to_lowercase();
330    m.contains("already in use") || m.contains("already allocated")
331}
332
333/// Percent-encodes a path segment (just enough for image references, which can
334/// contain `/` that must stay literal in this position but nothing else exotic in
335/// practice) — used for `GET /images/{name}/json`.
336fn encode_path_segment(s: &str) -> String {
337    // Image names/tags are `[a-zA-Z0-9_.\-/:@]`-shaped in practice; none of those need
338    // percent-encoding in a URL path segment, so this is intentionally a pass-through
339    // rather than a full RFC 3986 encoder — there is nothing in this crate's own
340    // input space that would need escaping here.
341    s.to_string()
342}
343
344/// Percent-encodes a query string value (`fromImage`, `tag`, the JSON `filters` blob)
345/// — covers exactly the characters that show up in image references and the small
346/// hand-built JSON filter strings this backend sends, not a general URL encoder.
347fn encode_query_value(s: &str) -> String {
348    let mut out = String::with_capacity(s.len());
349    for b in s.bytes() {
350        match b {
351            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
352                out.push(b as char)
353            }
354            _ => out.push_str(&format!("%{b:02X}")),
355        }
356    }
357    out
358}
359
360/// Splits `image` into `(repository, tag)` for `POST /images/create?fromImage=&tag=`.
361/// A tag-less reference (`"redis"`) defaults to `"latest"`, matching Docker's own
362/// convention; a digest reference (`repo@sha256:...`) is passed through as the
363/// "repository" half with an empty tag, since `fromImage` accepts the whole
364/// `repo@digest` form and an empty `tag` is simply omitted from the query by the
365/// caller not being asked to add one — see the call site, which always sends both
366/// params, so this returns `""` rather than `"latest"` for a digest reference,
367/// avoiding a nonsensical `tag=latest` alongside an explicit digest.
368fn split_repo_tag(image: &str) -> (String, String) {
369    if image.contains('@') {
370        return (image.to_string(), String::new());
371    }
372    // A tag separator is the LAST colon that appears after the last slash (so a
373    // registry host:port prefix like `localhost:5000/redis` isn't mistaken for a
374    // tag separator).
375    let slash_idx = image.rfind('/').map(|i| i + 1).unwrap_or(0);
376    match image[slash_idx..].rfind(':') {
377        Some(rel_colon) => {
378            let colon = slash_idx + rel_colon;
379            (image[..colon].to_string(), image[colon + 1..].to_string())
380        }
381        None => (image.to_string(), "latest".to_string()),
382    }
383}
384
385#[async_trait::async_trait]
386impl SandboxBackend for DockerBackend {
387    fn name(&self) -> &str {
388        "docker"
389    }
390
391    fn supports_native_networks(&self) -> bool {
392        true
393    }
394
395    fn capabilities(&self) -> Capabilities {
396        Capabilities {
397            // Containers share the host kernel — no per-sandbox microVM boundary.
398            hardware_isolated: false,
399            checkpoint: true,
400        }
401    }
402
403    async fn create(&self, spec: ContainerSpec) -> Result<Box<dyn SandboxHandle>> {
404        self.pull_if_missing(&spec.image).await?;
405
406        let body = build_create_body(&spec);
407        let body = serde_json::to_string(&body)
408            .expect("CreateContainerBody has no non-serializable fields");
409        let path = format!("/containers/create?name={}", encode_query_value(&spec.name));
410        let resp = self.client.request("POST", &path, Some(&body)).await?;
411        if resp.status == 409 {
412            let message = String::from_utf8_lossy(&resp.body).into_owned();
413            return Err(RightsizeError::NameConflict {
414                message: format!(
415                    "docker container name '{}' is already in use (HTTP 409): {message}",
416                    spec.name
417                ),
418                source: None,
419            });
420        }
421        if resp.status >= 400 {
422            let message = String::from_utf8_lossy(&resp.body).into_owned();
423            return Err(RightsizeError::Backend(format!(
424                "docker could not create container '{}' (HTTP {}): {message}",
425                spec.name, resp.status
426            )));
427        }
428        let id = serde_json::from_slice::<IdResponse>(&resp.body)
429            .map_err(|e| {
430                RightsizeError::Backend(format!(
431                    "docker's container-create response for '{}' had no Id field: {e} (body: {})",
432                    spec.name,
433                    String::from_utf8_lossy(&resp.body)
434                ))
435            })?
436            .id;
437
438        if let Some(network_id) = &spec.network_id {
439            let daemon_network_id = self.ensure_network_get_id(network_id).await?;
440            self.connect_network(&id, &daemon_network_id, &spec.aliases)
441                .await?;
442        }
443
444        Ok(Box::new(Handle { id, spec }))
445    }
446
447    async fn start(&self, handle: &dyn SandboxHandle) -> Result<()> {
448        let path = format!("/containers/{}/start", handle.id());
449        let resp = self.client.request("POST", &path, None).await?;
450        if resp.status == 204 || resp.status == 304 {
451            return Ok(()); // 304 = already started, treated as success like the daemon intends.
452        }
453        let message = String::from_utf8_lossy(&resp.body).into_owned();
454        if resp.status == 500 && is_port_bind_conflict_message(&message) {
455            return Err(RightsizeError::PortBindConflict {
456                message: format!(
457                    "docker could not bind a host port for {}: {message}",
458                    handle.id()
459                ),
460                source: None,
461            });
462        }
463        Err(RightsizeError::Backend(format!(
464            "docker could not start container {} (HTTP {}): {message}",
465            handle.id(),
466            resp.status
467        )))
468    }
469
470    async fn stop(&self, handle: &dyn SandboxHandle) -> Result<()> {
471        let path = format!("/containers/{}/stop?t={STOP_TIMEOUT_SECS}", handle.id());
472        let _ = self.client.request("POST", &path, None).await; // best-effort
473        Ok(())
474    }
475
476    async fn remove(&self, handle: &dyn SandboxHandle) -> Result<()> {
477        let path = format!("/containers/{}?force=true", handle.id());
478        let _ = self.client.request("DELETE", &path, None).await; // best-effort
479        Ok(())
480    }
481
482    async fn exec(&self, handle: &dyn SandboxHandle, cmd: &[String]) -> Result<ExecResult> {
483        let create_body = CreateExecBody {
484            attach_stdout: true,
485            attach_stderr: true,
486            cmd: cmd.to_vec(),
487        };
488        let create_body = serde_json::to_string(&create_body)
489            .expect("CreateExecBody has no non-serializable fields");
490        let create_path = format!("/containers/{}/exec", handle.id());
491        let created = self
492            .client
493            .request("POST", &create_path, Some(&create_body))
494            .await?;
495        if created.status >= 400 {
496            return Err(RightsizeError::Backend(format!(
497                "docker could not create an exec for container {} (HTTP {}): {}",
498                handle.id(),
499                created.status,
500                String::from_utf8_lossy(&created.body)
501            )));
502        }
503        let exec_id = serde_json::from_slice::<IdResponse>(&created.body)
504            .map_err(|e| {
505                RightsizeError::Backend(format!(
506                    "docker's exec-create response for container {} had no Id field: {e} \
507                     (body: {})",
508                    handle.id(),
509                    String::from_utf8_lossy(&created.body)
510                ))
511            })?
512            .id;
513
514        let start_path = format!("/exec/{exec_id}/start");
515        let start_body = serde_json::to_string(&StartExecBody { detach: false })
516            .expect("StartExecBody has no non-serializable fields");
517        let (headers, mut stream) = self
518            .client
519            .request_stream("POST", &start_path, Some(&start_body))
520            .await?;
521        if headers.status >= 400 {
522            return Err(RightsizeError::Backend(format!(
523                "docker could not start exec {exec_id} for container {} (HTTP {})",
524                handle.id(),
525                headers.status
526            )));
527        }
528        let mut body = BodyReader::new(&mut stream, &headers);
529        let mut stdout = Vec::new();
530        let mut stderr = Vec::new();
531        demux_into(
532            &mut body,
533            |b| stdout.extend_from_slice(b),
534            |b| stderr.extend_from_slice(b),
535        )
536        .await?;
537
538        let inspect_path = format!("/exec/{exec_id}/json");
539        let inspected = self.client.request("GET", &inspect_path, None).await?;
540        let exit_code = serde_json::from_slice::<ExecInspect>(&inspected.body)
541            .ok()
542            .and_then(|r| r.exit_code)
543            .unwrap_or(-1);
544
545        Ok(ExecResult {
546            exit_code: exit_code as i32,
547            stdout: String::from_utf8_lossy(&stdout).into_owned(),
548            stderr: String::from_utf8_lossy(&stderr).into_owned(),
549        })
550    }
551
552    async fn logs(&self, handle: &dyn SandboxHandle) -> Result<String> {
553        let path = format!(
554            "/containers/{}/logs?stdout=1&stderr=1&tail=1000",
555            handle.id()
556        );
557        let (headers, mut stream) = self.client.request_stream("GET", &path, None).await?;
558        if headers.status >= 400 {
559            return Err(RightsizeError::Backend(format!(
560                "docker could not fetch logs for container {} (HTTP {})",
561                handle.id(),
562                headers.status
563            )));
564        }
565        let mut body = BodyReader::new(&mut stream, &headers);
566        let mut assembler = LineAssembler::new();
567        let mut out = String::new();
568        while let Some(frame) = read_frame(&mut body).await? {
569            if !matches!(frame.stream, StreamType::Stdout | StreamType::Stderr) {
570                continue;
571            }
572            let text = String::from_utf8_lossy(&frame.payload).into_owned();
573            for line in assembler.feed(&text) {
574                out.push_str(&line);
575                out.push('\n');
576            }
577        }
578        if let Some(tail) = assembler.flush() {
579            out.push_str(&tail);
580            out.push('\n');
581        }
582        Ok(out)
583    }
584
585    async fn follow_logs(
586        &self,
587        handle: &dyn SandboxHandle,
588        consumer: Box<dyn Fn(String) + Send + Sync>,
589    ) -> Result<FollowHandle> {
590        let path = format!(
591            "/containers/{}/logs?stdout=1&stderr=1&follow=1&tail=all",
592            handle.id()
593        );
594        let (headers, mut stream) = self.client.request_stream("GET", &path, None).await?;
595        if headers.status >= 400 {
596            return Err(RightsizeError::Backend(format!(
597                "docker could not follow logs for container {} (HTTP {})",
598                handle.id(),
599                headers.status
600            )));
601        }
602
603        let close_requested = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
604        let task_close = close_requested.clone();
605        let task = tokio::spawn(async move {
606            let mut body = BodyReader::new(&mut stream, &headers);
607            let mut assembler = LineAssembler::new();
608            loop {
609                // Checked BEFORE each delivery (not just once per loop) — the
610                // resolved P1 forward flag: `abort()` alone can't guarantee no
611                // post-close callback, since it only cancels at the next `.await`
612                // point. Checking this flag right before every consumer call means
613                // the flag and the abort jointly rule that out — see
614                // `rightsize::backend::FollowHandle`'s doc for the full contract.
615                if task_close.load(std::sync::atomic::Ordering::SeqCst) {
616                    return;
617                }
618                let frame = match read_frame(&mut body).await {
619                    Ok(Some(f)) => f,
620                    Ok(None) => break, // clean stream end — the normal way this loop exits.
621                    Err(_) => break,   // best-effort: a stream error just ends delivery.
622                };
623                if !matches!(frame.stream, StreamType::Stdout | StreamType::Stderr) {
624                    continue;
625                }
626                let text = String::from_utf8_lossy(&frame.payload).into_owned();
627                for line in assembler.feed(&text) {
628                    if task_close.load(std::sync::atomic::Ordering::SeqCst) {
629                        return;
630                    }
631                    consumer(line);
632                }
633            }
634            if task_close.load(std::sync::atomic::Ordering::SeqCst) {
635                return; // stop delivery, never flush — an explicit close beat the stream to the end.
636            }
637            if let Some(tail) = assembler.flush() {
638                consumer(tail);
639            }
640        });
641
642        Ok(FollowHandle::from_task(close_requested, task))
643    }
644
645    async fn ensure_network(&self, network_id: &str) -> Result<()> {
646        self.ensure_network_get_id(network_id).await?;
647        Ok(())
648    }
649
650    async fn remove_network(&self, network_id: &str) -> Result<()> {
651        let cached = self
652            .network_ids
653            .lock()
654            .expect("network_ids mutex poisoned")
655            .remove(network_id);
656        // Cache miss: this backend instance never itself called `ensure_network` for
657        // `network_id` — the reaping sweep's whole point is removing resources a
658        // DIFFERENT (dead) process created, so `network_ids` (populated only by THIS
659        // instance's own `ensure_network_get_id` calls) is empty for it. Fall back to
660        // a live daemon lookup by name — the same "must not depend on in-process
661        // state" requirement `remove_by_name`'s container-level lookup already
662        // satisfies (`blocking_find_container_id_by_name`).
663        let daemon_id = match cached {
664            Some(id) => Some(id),
665            None => self.lookup_network_id_by_name(network_id).await,
666        };
667        if let Some(id) = daemon_id {
668            let path = format!("/networks/{id}");
669            let _ = self.client.request("DELETE", &path, None).await; // best-effort
670        }
671        Ok(())
672    }
673
674    async fn close(&self) -> Result<()> {
675        let run_id = rightsize::RunId::value();
676        let filters = LabelFilter {
677            label: vec![format!("{RUN_ID_LABEL_KEY}={run_id}")],
678        };
679        let filters =
680            serde_json::to_string(&filters).expect("LabelFilter has no non-serializable fields");
681        let path = format!(
682            "/containers/json?all=true&filters={}",
683            encode_query_value(&filters)
684        );
685        let listed = self.client.request("GET", &path, None).await?;
686        if listed.status != 200 {
687            return Ok(()); // best-effort: nothing more to do if even listing fails.
688        }
689        let entries: Vec<ListedEntry> = serde_json::from_slice(&listed.body).unwrap_or_default();
690        for entry in entries {
691            let remove_path = format!("/containers/{}?force=true", entry.id);
692            let _ = self.client.request("DELETE", &remove_path, None).await;
693        }
694        Ok(())
695    }
696
697    fn cleanup_sync(&self, container_id: &str) {
698        // Blocking std I/O only, no Tokio — this runs on the dedicated cleanup thread
699        // (see rightsize::cleanup), never in async context (decision 1).
700        let _ = blocking_force_remove(self.client.socket_path(), container_id, STOP_TIMEOUT_SECS);
701    }
702
703    fn remove_by_name(&self, name: &str) {
704        // The reaping ledger persists NAMES, but every other call on this backend is
705        // keyed by the daemon-assigned id already carried on a `Handle` — so this
706        // resolves name -> id first (a list-by-name-filter GET), then force-removes
707        // that id. SYNCHRONOUS, blocking std I/O only (see
708        // `SandboxBackend::remove_by_name`'s doc for why) — the same transport shape
709        // `cleanup_sync`/`blocking_force_remove` already use, extended with a body
710        // read since this call needs the list response, not just a status code.
711        let socket = self.client.socket_path();
712        let Some(id) = blocking_find_container_id_by_name(socket, name) else {
713            return; // not found (or the lookup itself failed): silently fine.
714        };
715        let _ = blocking_force_remove(socket, &id, STOP_TIMEOUT_SECS);
716    }
717
718    /// `GET /containers/json?filters={"name":["^/<name>$"],"status":["running"]}` —
719    /// the reuse adopt path's own query (`rightsize::reuse`). The `name` filter is
720    /// anchored the same way `blocking_find_container_id_by_name` anchors it
721    /// (Docker's `name` filter is substring-by-default); `status` is explicit
722    /// rather than relying on the daemon's own "no `all=true`" default meaning
723    /// running-only, so the intent reads directly off the request. `Ok(None)` for
724    /// no match OR a non-200 response — matching this backend's other best-effort
725    /// list-then-not-found reads (e.g. `lookup_network_id_by_name`).
726    async fn find_running(&self, spec: &ContainerSpec) -> Result<Option<Box<dyn SandboxHandle>>> {
727        let filters = format!(r#"{{"name":["^/{}$"],"status":["running"]}}"#, spec.name);
728        let path = format!("/containers/json?filters={}", encode_query_value(&filters));
729        let resp = self.client.request("GET", &path, None).await?;
730        if resp.status != 200 {
731            return Ok(None);
732        }
733        let entries: Vec<ListedEntry> = serde_json::from_slice(&resp.body).unwrap_or_default();
734        Ok(entries.into_iter().next().map(|entry| {
735            Box::new(Handle {
736                id: entry.id,
737                spec: spec.clone(),
738            }) as Box<dyn SandboxHandle>
739        }))
740    }
741
742    /// `POST /commit?container=&repo=&tag=` — the checkpoint feature's own backend
743    /// primitive (`rightsize::ContainerGuard::checkpoint`). `image_ref` is already
744    /// the full `rightsize/checkpoint:<12hex>` reference the core allocator chose;
745    /// this only needs to split it into the `repo`/`tag` pair the endpoint wants
746    /// (the same split `split_repo_tag` already does for image pulls).
747    async fn commit_to_image(&self, handle: &dyn SandboxHandle, image_ref: &str) -> Result<()> {
748        let (repo, tag) = split_repo_tag(image_ref);
749        let path = format!(
750            "/commit?container={}&repo={}&tag={}",
751            encode_query_value(handle.id()),
752            encode_query_value(&repo),
753            encode_query_value(&tag)
754        );
755        let resp = self.client.request("POST", &path, None).await?;
756        if resp.status >= 400 {
757            return Err(RightsizeError::Backend(format!(
758                "docker could not commit container {} to image '{image_ref}' (HTTP {}): {}",
759                handle.id(),
760                resp.status,
761                String::from_utf8_lossy(&resp.body)
762            )));
763        }
764        Ok(())
765    }
766
767    fn watchdog_kill_command(&self) -> Vec<String> {
768        vec!["docker".to_string(), "rm".to_string(), "-f".to_string()]
769    }
770
771    fn watchdog_network_kill_command(&self) -> Vec<String> {
772        vec![
773            "docker".to_string(),
774            "network".to_string(),
775            "rm".to_string(),
776        ]
777    }
778}
779
780/// The `cleanup_sync` path's blocking counterpart to `stop`+`remove`: issues a plain
781/// `POST /containers/{id}/stop?t=` then `DELETE /containers/{id}?force=true` over a
782/// blocking `std::os::unix::net::UnixStream` — no Tokio, since this runs on the
783/// dedicated `Drop`-path cleanup thread with no async runtime in context. Best-effort:
784/// errors are swallowed by the caller, matching this backend's own async `stop`/
785/// `remove`.
786fn blocking_force_remove(
787    socket_path: &std::path::Path,
788    container_id: &str,
789    stop_timeout_secs: u64,
790) -> std::io::Result<()> {
791    let _ = blocking_request(
792        socket_path,
793        "POST",
794        &format!("/containers/{container_id}/stop?t={stop_timeout_secs}"),
795    );
796    blocking_request(
797        socket_path,
798        "DELETE",
799        &format!("/containers/{container_id}?force=true"),
800    )
801    .map(|_| ())
802}
803
804/// Resolves `name` to a daemon-assigned container id via a blocking
805/// `GET /containers/json?all=true&filters={"name":["^/<name>$"]}` — the `^/...$`
806/// anchoring matches Docker's own convention for an exact-name filter (Docker's
807/// `name` filter is substring-by-default; anchoring is what makes it exact). Returns
808/// `None` on any failure (lookup error, no match, unparseable body) — the caller
809/// treats that as "nothing to remove", matching `remove_by_name`'s best-effort,
810/// not-found-is-fine contract.
811fn blocking_find_container_id_by_name(socket_path: &std::path::Path, name: &str) -> Option<String> {
812    let filters = format!(r#"{{"name":["^/{name}$"]}}"#);
813    let path = format!(
814        "/containers/json?all=true&filters={}",
815        encode_query_value(&filters)
816    );
817    let body = blocking_get_body(socket_path, &path).ok()?;
818    let entries: Vec<ListedEntry> = serde_json::from_slice(&body).ok()?;
819    entries.into_iter().next().map(|e| e.id)
820}
821
822/// A minimal blocking HTTP GET over a blocking unix socket, returning the response
823/// body — the read-the-body counterpart to [`blocking_request`] (which discards it),
824/// needed by [`blocking_find_container_id_by_name`]. Honors `Content-Length` when
825/// present, dechunks a `Transfer-Encoding: chunked` response (`GET
826/// /containers/json` — this call's only caller — comes back chunked on every
827/// dockerd/Docker Desktop build observed; empirically NOT the "Content-Length in
828/// practice" case an earlier version of this function assumed, which made
829/// `blocking_find_container_id_by_name` silently find nothing on every real
830/// daemon), and otherwise falls back to reading until the peer closes (this client
831/// always sends `Connection: close`, matching [`blocking_request`]'s own
832/// assumption).
833fn blocking_get_body(socket_path: &std::path::Path, path: &str) -> std::io::Result<Vec<u8>> {
834    use std::io::{BufRead, BufReader, Read, Write};
835    use std::os::unix::net::UnixStream;
836
837    let mut stream = UnixStream::connect(socket_path)?;
838    stream.set_read_timeout(Some(Duration::from_secs(stop_timeout_budget())))?;
839    let request = format!("GET {path} HTTP/1.1\r\nHost: docker\r\nConnection: close\r\n\r\n");
840    stream.write_all(request.as_bytes())?;
841
842    let mut reader = BufReader::new(stream);
843    let mut status_line = String::new();
844    reader.read_line(&mut status_line)?;
845
846    let mut content_length: Option<usize> = None;
847    let mut chunked = false;
848    loop {
849        let mut line = String::new();
850        let n = reader.read_line(&mut line)?;
851        if n == 0 || line == "\r\n" || line == "\n" {
852            break;
853        }
854        if let Some((name, value)) = line.split_once(':') {
855            let name = name.trim();
856            let value = value.trim();
857            if name.eq_ignore_ascii_case("content-length") {
858                content_length = value.parse::<usize>().ok();
859            } else if name.eq_ignore_ascii_case("transfer-encoding") {
860                chunked = value.to_ascii_lowercase().contains("chunked");
861            }
862        }
863    }
864
865    if chunked {
866        return blocking_read_chunked_body(&mut reader);
867    }
868
869    let mut body = Vec::new();
870    match content_length {
871        Some(len) => {
872            body.resize(len, 0);
873            reader.read_exact(&mut body)?;
874        }
875        None => {
876            reader.read_to_end(&mut body)?;
877        }
878    }
879    Ok(body)
880}
881
882/// Dechunks an HTTP `Transfer-Encoding: chunked` body from `reader` — the blocking
883/// counterpart to `client::read_chunked_body`, needed because [`blocking_get_body`]
884/// runs on a plain OS thread with no Tokio runtime available. Same framing: a hex
885/// chunk-size line, that many bytes, a trailing `\r\n`, repeated until a zero-size
886/// chunk (optionally followed by trailer headers, drained and discarded) ends the
887/// body.
888fn blocking_read_chunked_body(
889    reader: &mut std::io::BufReader<std::os::unix::net::UnixStream>,
890) -> std::io::Result<Vec<u8>> {
891    use std::io::{BufRead, Read};
892
893    let mut out = Vec::new();
894    loop {
895        let mut size_line = String::new();
896        reader.read_line(&mut size_line)?;
897        let size_str = size_line.trim().split(';').next().unwrap_or("").trim();
898        let size = usize::from_str_radix(size_str, 16).map_err(|_| {
899            std::io::Error::new(
900                std::io::ErrorKind::InvalidData,
901                format!(
902                    "could not parse a chunk size from the Docker daemon's response: \
903                     {size_line:?}"
904                ),
905            )
906        })?;
907        if size == 0 {
908            // Drain trailer headers up to the final blank line, then stop.
909            loop {
910                let mut trailer = String::new();
911                let n = reader.read_line(&mut trailer)?;
912                if n == 0 || trailer == "\r\n" || trailer == "\n" {
913                    break;
914                }
915            }
916            break;
917        }
918        let mut chunk = vec![0u8; size];
919        reader.read_exact(&mut chunk)?;
920        out.extend_from_slice(&chunk);
921        // Each chunk is followed by a trailing \r\n that isn't part of the payload.
922        let mut crlf = [0u8; 2];
923        reader.read_exact(&mut crlf)?;
924    }
925    Ok(out)
926}
927
928/// A minimal blocking HTTP/1.1 request over a blocking unix socket — the Drop-path
929/// analogue of [`DockerClient::request`], deliberately NOT reusing any of that async
930/// client's code (which is all `tokio::net::UnixStream`-shaped): this runs on a plain
931/// OS thread with no Tokio runtime available, so it needs its own blocking transport.
932/// Doesn't need to handle chunked responses — the
933/// only calls made here (`stop`, `force=true remove`) return small `Content-Length`
934/// (or empty) bodies in practice — so this reads until the peer closes and returns
935/// whatever arrived, without trying to interpret framing at all.
936fn blocking_request(
937    socket_path: &std::path::Path,
938    method: &str,
939    path: &str,
940) -> std::io::Result<()> {
941    use std::io::{Read, Write};
942    use std::os::unix::net::UnixStream;
943
944    let mut stream = UnixStream::connect(socket_path)?;
945    stream.set_read_timeout(Some(Duration::from_secs(stop_timeout_budget())))?;
946    let request = format!("{method} {path} HTTP/1.1\r\nHost: docker\r\nConnection: close\r\n\r\n");
947    stream.write_all(request.as_bytes())?;
948    let mut discard = Vec::new();
949    let _ = stream.read_to_end(&mut discard);
950    Ok(())
951}
952
953/// A generous fixed budget for the blocking cleanup-path request's read timeout —
954/// this path has no caller to propagate a timeout error to (it's best-effort, fired
955/// from `Drop`), so it just needs to not hang the cleanup thread forever if the
956/// daemon never responds.
957fn stop_timeout_budget() -> u64 {
958    STOP_TIMEOUT_SECS + 20
959}
960
961#[cfg(test)]
962mod tests {
963    use super::*;
964
965    #[test]
966    fn capabilities_report_no_hardware_isolation_but_checkpoint_support() {
967        let backend = DockerBackend::connecting_to_env();
968        let caps = backend.capabilities();
969        assert!(!caps.hardware_isolated, "containers share the host kernel");
970        assert!(caps.checkpoint);
971    }
972
973    /// A minimal, dependency-free temp-directory helper for the blocking-socket
974    /// tests below — see the async test module's own `tempdir_shim` for why this
975    /// crate hand-rolls one per test module rather than sharing it.
976    mod blocking_tempdir_shim {
977        use std::path::{Path, PathBuf};
978
979        pub(super) struct TempDir(PathBuf);
980
981        impl TempDir {
982            pub(super) fn new() -> Self {
983                static COUNTER: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0);
984                let seq = COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
985                let unique = format!("rzdbb-{:x}-{:x}", std::process::id() as u16, seq);
986                let path = Path::new("/tmp").join(unique);
987                std::fs::create_dir_all(&path).expect("create temp dir");
988                TempDir(path)
989            }
990
991            pub(super) fn path(&self) -> &Path {
992                &self.0
993            }
994        }
995
996        impl Drop for TempDir {
997            fn drop(&mut self) {
998                let _ = std::fs::remove_dir_all(&self.0);
999            }
1000        }
1001    }
1002
1003    /// `blocking_get_body` must dechunk a `Transfer-Encoding: chunked` response —
1004    /// this is the real shape `GET /containers/json` comes back in on every
1005    /// dockerd/Docker Desktop build observed, not the `Content-Length` shape an
1006    /// earlier version of this function assumed, which made
1007    /// `blocking_find_container_id_by_name` (and therefore `remove_by_name`) find
1008    /// nothing on every real daemon.
1009    #[test]
1010    fn blocking_get_body_dechunks_a_transfer_encoding_chunked_response() {
1011        use std::io::{Read, Write};
1012        use std::os::unix::net::UnixListener;
1013
1014        let dir = blocking_tempdir_shim::TempDir::new();
1015        let sock_path = dir.path().join("docker.sock");
1016        let listener = UnixListener::bind(&sock_path).expect("bind fixture socket");
1017
1018        let server = std::thread::spawn(move || {
1019            let (mut stream, _) = listener.accept().expect("accept fixture connection");
1020            let mut buf = [0u8; 4096];
1021            let _ = stream.read(&mut buf); // drain the request line/headers.
1022            stream
1023                .write_all(
1024                    b"HTTP/1.1 200 OK\r\n\
1025                      Content-Type: application/json\r\n\
1026                      Transfer-Encoding: chunked\r\n\
1027                      \r\n\
1028                      5\r\nhello\r\n\
1029                      6\r\n world\r\n\
1030                      0\r\n\r\n",
1031                )
1032                .expect("write fixture response");
1033        });
1034
1035        let body =
1036            blocking_get_body(&sock_path, "/containers/json").expect("dechunking must succeed");
1037        assert_eq!(body, b"hello world");
1038        server.join().expect("fixture server thread must not panic");
1039    }
1040
1041    /// The `Content-Length`-framed shape must keep working too — the dechunking
1042    /// addition must be additive, not a regression on the framing that already
1043    /// worked.
1044    #[test]
1045    fn blocking_get_body_honors_content_length_when_present() {
1046        use std::io::{Read, Write};
1047        use std::os::unix::net::UnixListener;
1048
1049        let dir = blocking_tempdir_shim::TempDir::new();
1050        let sock_path = dir.path().join("docker.sock");
1051        let listener = UnixListener::bind(&sock_path).expect("bind fixture socket");
1052
1053        let server = std::thread::spawn(move || {
1054            let (mut stream, _) = listener.accept().expect("accept fixture connection");
1055            let mut buf = [0u8; 4096];
1056            let _ = stream.read(&mut buf);
1057            stream
1058                .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 13\r\n\r\nhello world!!")
1059                .expect("write fixture response");
1060        });
1061
1062        let body = blocking_get_body(&sock_path, "/containers/json")
1063            .expect("Content-Length read must succeed");
1064        assert_eq!(body, b"hello world!!");
1065        server.join().expect("fixture server thread must not panic");
1066    }
1067
1068    #[test]
1069    fn is_port_bind_conflict_message_matches_known_phrasings() {
1070        assert!(is_port_bind_conflict_message(
1071            "driver failed programming external connectivity: address already in use"
1072        ));
1073        assert!(is_port_bind_conflict_message(
1074            "Bind for 0.0.0.0:6379 failed: port is already allocated"
1075        ));
1076        assert!(is_port_bind_conflict_message(
1077            "ALREADY ALLOCATED (case-insensitive)"
1078        ));
1079    }
1080
1081    #[test]
1082    fn is_port_bind_conflict_message_negative_cases_do_not_match() {
1083        assert!(!is_port_bind_conflict_message("no such image"));
1084        assert!(!is_port_bind_conflict_message("container already stopped"));
1085        assert!(!is_port_bind_conflict_message(""));
1086    }
1087
1088    #[test]
1089    fn split_repo_tag_defaults_to_latest_when_untagged() {
1090        assert_eq!(
1091            split_repo_tag("redis"),
1092            ("redis".to_string(), "latest".to_string())
1093        );
1094        assert_eq!(
1095            split_repo_tag("redis:8.6-alpine"),
1096            ("redis".to_string(), "8.6-alpine".to_string())
1097        );
1098    }
1099
1100    #[test]
1101    fn split_repo_tag_handles_a_registry_host_with_a_port() {
1102        assert_eq!(
1103            split_repo_tag("localhost:5000/redis"),
1104            ("localhost:5000/redis".to_string(), "latest".to_string())
1105        );
1106        assert_eq!(
1107            split_repo_tag("localhost:5000/redis:8.6-alpine"),
1108            ("localhost:5000/redis".to_string(), "8.6-alpine".to_string())
1109        );
1110    }
1111
1112    #[test]
1113    fn split_repo_tag_passes_through_a_digest_reference_with_no_tag() {
1114        assert_eq!(
1115            split_repo_tag("redis@sha256:abcdef"),
1116            ("redis@sha256:abcdef".to_string(), String::new())
1117        );
1118    }
1119
1120    /// `build_create_body` returns a struct now (see `crate::json::CreateContainerBody`);
1121    /// these tests still assert on the actual bytes sent over the wire, serializing it
1122    /// exactly like `create()` does, so a field-rename typo or a `skip_serializing_if`
1123    /// slip is still caught here rather than only in `crate::json`'s own struct-shape
1124    /// tests.
1125    fn serialize(spec: &ContainerSpec) -> String {
1126        serde_json::to_string(&build_create_body(spec)).unwrap()
1127    }
1128
1129    #[test]
1130    fn build_create_body_includes_port_bindings_on_loopback() {
1131        let mut spec = ContainerSpec::new("rz-x-0", "redis:8.6-alpine", "deadbeef");
1132        spec.ports.push(rightsize::model::PortBinding {
1133            host_port: 32768,
1134            guest_port: 6379,
1135        });
1136        let body = serialize(&spec);
1137        assert!(body.contains("\"HostIp\":\"127.0.0.1\""));
1138        assert!(body.contains("\"HostPort\":\"32768\""));
1139        assert!(body.contains("\"6379/tcp\""));
1140    }
1141
1142    #[test]
1143    fn build_create_body_includes_the_run_id_label_and_extra_host() {
1144        let spec = ContainerSpec::new("rz-x-0", "redis:8.6-alpine", "deadbeef");
1145        let body = serialize(&spec);
1146        assert!(body.contains("\"dev.rightsize.run_id\":\"deadbeef\""));
1147        assert!(body.contains("host.docker.internal:host-gateway"));
1148    }
1149
1150    #[test]
1151    fn build_create_body_honors_mount_read_only_flag() {
1152        let mut spec = ContainerSpec::new("rz-x-0", "redis:8.6-alpine", "deadbeef");
1153        spec.mounts
1154            .push(rightsize::model::FileMount::new("/host/a", "/guest/a"));
1155        spec.mounts
1156            .push(rightsize::model::FileMount::new("/host/b", "/guest/b").read_write());
1157        let body = serialize(&spec);
1158        assert!(body.contains("/host/a:/guest/a:ro"));
1159        assert!(body.contains("/host/b:/guest/b:rw"));
1160    }
1161
1162    #[test]
1163    fn build_create_body_omits_memory_when_unset_and_includes_it_when_set() {
1164        let spec = ContainerSpec::new("rz-x-0", "redis:8.6-alpine", "deadbeef");
1165        assert!(!serialize(&spec).contains("Memory"));
1166
1167        let mut spec = ContainerSpec::new("rz-x-0", "redis:8.6-alpine", "deadbeef");
1168        spec.memory_limit_mb = Some(512);
1169        assert!(serialize(&spec).contains("\"Memory\":536870912"));
1170    }
1171
1172    #[test]
1173    fn build_create_body_omits_cmd_when_command_is_none_and_includes_it_when_some() {
1174        let spec = ContainerSpec::new("rz-x-0", "redis:8.6-alpine", "deadbeef");
1175        assert!(!serialize(&spec).contains("\"Cmd\""));
1176
1177        let mut spec = ContainerSpec::new("rz-x-0", "redis:8.6-alpine", "deadbeef");
1178        spec.command = Some(vec![
1179            "redis-server".to_string(),
1180            "--port".to_string(),
1181            "6379".to_string(),
1182        ]);
1183        let body = serialize(&spec);
1184        assert!(body.contains("\"Cmd\":[\"redis-server\",\"--port\",\"6379\"]"));
1185    }
1186
1187    #[test]
1188    fn docker_backend_dials_a_unix_socket_never_a_tcp_host() {
1189        // Standing regression guard: this backend's transport must always be
1190        // a unix socket path, never a `host:port` TCP endpoint — the exact failure
1191        // mode a shared/bumped HTTP stack can cause if a dependency update
1192        // silently changes the default transport.
1193        let backend = DockerBackend::new(DockerClient::from_docker_host(None));
1194        let path = backend.socket_path_for_test();
1195        assert!(
1196            path.is_absolute(),
1197            "expected an absolute unix socket path, got {path:?}"
1198        );
1199        let path_str = path.to_string_lossy();
1200        assert!(
1201            !path_str.contains(':'),
1202            "a unix socket path must not look like a host:port TCP address — got {path_str}"
1203        );
1204
1205        let backend_env =
1206            DockerBackend::new(DockerClient::from_docker_host(Some("tcp://127.0.0.1:2375")));
1207        let fallback_path = backend_env.socket_path_for_test();
1208        assert!(
1209            !fallback_path.to_string_lossy().contains("2375"),
1210            "a tcp:// DOCKER_HOST must not leak a TCP port into this backend's transport — \
1211             got {fallback_path:?}"
1212        );
1213    }
1214
1215    #[test]
1216    fn keep_alive_spec_gets_the_reuse_label_instead_of_the_run_id_label() {
1217        let mut spec = ContainerSpec::new("rz-x-0", "redis:8.6-alpine", "deadbeef");
1218        spec.keep_alive = true;
1219        let body = serialize(&spec);
1220        assert!(body.contains("\"dev.rightsize.reuse\""), "{body}");
1221        assert!(!body.contains("\"dev.rightsize.run_id\""), "{body}");
1222    }
1223
1224    #[test]
1225    fn non_keep_alive_spec_still_gets_the_run_id_label_only() {
1226        let spec = ContainerSpec::new("rz-x-0", "redis:8.6-alpine", "deadbeef");
1227        let body = serialize(&spec);
1228        assert!(
1229            body.contains("\"dev.rightsize.run_id\":\"deadbeef\""),
1230            "{body}"
1231        );
1232        assert!(!body.contains("\"dev.rightsize.reuse\""), "{body}");
1233    }
1234
1235    #[test]
1236    fn watchdog_kill_command_is_a_plain_docker_rm_dash_f() {
1237        let backend = DockerBackend::new(DockerClient::from_docker_host(None));
1238        assert_eq!(
1239            backend.watchdog_kill_command(),
1240            vec!["docker".to_string(), "rm".to_string(), "-f".to_string()]
1241        );
1242    }
1243
1244    #[test]
1245    fn watchdog_network_kill_command_is_docker_network_rm() {
1246        let backend = DockerBackend::new(DockerClient::from_docker_host(None));
1247        assert_eq!(
1248            backend.watchdog_network_kill_command(),
1249            vec![
1250                "docker".to_string(),
1251                "network".to_string(),
1252                "rm".to_string()
1253            ]
1254        );
1255    }
1256
1257    #[test]
1258    fn reuse_label_value_extracts_the_hash_straight_from_an_rz_reuse_name() {
1259        let spec = ContainerSpec::new("rz-reuse-799aad5a3338", "redis:7-alpine", "deadbeef");
1260        assert_eq!(reuse_label_value(&spec), "799aad5a3338");
1261    }
1262
1263    #[test]
1264    fn reuse_label_value_is_twelve_hex_chars_and_deterministic() {
1265        let spec_a = ContainerSpec::new("rz-x-0", "redis:8.6-alpine", "deadbeef");
1266        let spec_b = ContainerSpec::new("rz-x-0", "redis:8.6-alpine", "deadbeef");
1267        let spec_c = ContainerSpec::new("rz-x-1", "redis:8.6-alpine", "deadbeef");
1268        let a = reuse_label_value(&spec_a);
1269        let b = reuse_label_value(&spec_b);
1270        let c = reuse_label_value(&spec_c);
1271        assert_eq!(a.len(), 12);
1272        assert!(a.chars().all(|ch| ch.is_ascii_hexdigit()));
1273        assert_eq!(a, b, "same spec.name must hash the same every time");
1274        assert_ne!(a, c, "a different spec.name must hash differently");
1275    }
1276
1277    // --- remove_network's cache-miss fallback -------------------------------------
1278    //
1279    // The reaping sweep's whole point is removing resources a DIFFERENT (dead)
1280    // process created — a network this backend INSTANCE never itself called
1281    // `ensure_network` for, so `network_ids` (populated only by THIS instance's own
1282    // `ensure_network_get_id` calls) has nothing cached for it. These tests drive
1283    // `DockerBackend` against a real (fixture) unix socket rather than a fake
1284    // `SandboxBackend`, since the bug lives in the daemon-call sequence itself, not
1285    // in anything a fake could stand in for.
1286
1287    use std::sync::Arc;
1288    use tokio::net::UnixListener;
1289
1290    /// A minimal, dependency-free temp-directory helper — duplicated from
1291    /// `crate::client`'s own test-only `tempdir_shim` rather than shared, since that
1292    /// one is nested inside a private `#[cfg(test)] mod tests` in a different file
1293    /// and this crate's own convention is small hand-rolled scaffolding per test
1294    /// module over an extra dependency or cross-module test plumbing.
1295    mod tempdir_shim {
1296        use std::path::{Path, PathBuf};
1297
1298        pub(super) struct TempDir(PathBuf);
1299
1300        impl TempDir {
1301            /// Rooted at `/tmp`, not `std::env::temp_dir()` — see
1302            /// `crate::client`'s `tempdir_shim` for why (`AF_UNIX`'s `SUN_LEN` cap).
1303            pub(super) fn new() -> Self {
1304                static COUNTER: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0);
1305                let seq = COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1306                let unique = format!("rzdb-{:x}-{:x}", std::process::id() as u16, seq);
1307                let path = Path::new("/tmp").join(unique);
1308                std::fs::create_dir_all(&path).expect("create temp dir");
1309                TempDir(path)
1310            }
1311
1312            pub(super) fn path(&self) -> &Path {
1313                &self.0
1314            }
1315        }
1316
1317        impl Drop for TempDir {
1318            fn drop(&mut self) {
1319                let _ = std::fs::remove_dir_all(&self.0);
1320            }
1321        }
1322    }
1323
1324    /// Spawns a fixture unix-socket "server" that accepts connections IN SEQUENCE —
1325    /// one per entry in `responses` — replying with the given canned bytes and
1326    /// recording each request's status line, so a test can drive (and assert on) a
1327    /// `DockerBackend` method that makes more than one sequential HTTP call. Unlike
1328    /// `crate::client`'s own single-request fixture, this one is needed because
1329    /// `remove_network`'s cache-miss fallback issues a `GET` lookup THEN a `DELETE`
1330    /// — two separate connections, since this client never keeps one alive across
1331    /// calls (see `crate::client`'s module doc).
1332    async fn multi_request_fixture(
1333        responses: Vec<Vec<u8>>,
1334    ) -> (DockerClient, tempdir_shim::TempDir, Arc<Mutex<Vec<String>>>) {
1335        let dir = tempdir_shim::TempDir::new();
1336        let sock_path = dir.path().join("docker.sock");
1337        let listener = UnixListener::bind(&sock_path).expect("bind fixture socket");
1338        let received: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
1339        let received_clone = received.clone();
1340        tokio::spawn(async move {
1341            for response in responses {
1342                if let Ok((mut conn, _)) = listener.accept().await {
1343                    use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
1344                    let mut buf = vec![0u8; 4096];
1345                    let n = tokio::time::timeout(
1346                        std::time::Duration::from_millis(200),
1347                        conn.read(&mut buf),
1348                    )
1349                    .await
1350                    .ok()
1351                    .and_then(|r| r.ok())
1352                    .unwrap_or(0);
1353                    let request_line = String::from_utf8_lossy(&buf[..n])
1354                        .lines()
1355                        .next()
1356                        .unwrap_or_default()
1357                        .to_string();
1358                    received_clone.lock().unwrap().push(request_line);
1359                    let _ = conn.write_all(&response).await;
1360                    let _ = conn.shutdown().await;
1361                }
1362            }
1363        });
1364        (DockerClient::at_socket(sock_path), dir, received)
1365    }
1366
1367    /// A canned `200 OK` response listing exactly one network whose daemon id is
1368    /// `id` — the shape `GET /networks?filters=...` returns.
1369    fn network_list_response(id: &str) -> Vec<u8> {
1370        let payload = format!(r#"[{{"Id":"{id}"}}]"#);
1371        format!(
1372            "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
1373            payload.len(),
1374            payload
1375        )
1376        .into_bytes()
1377    }
1378
1379    /// A canned `200 OK` response listing exactly one container whose daemon id is
1380    /// `id` — the shape `GET /containers/json?filters=...` returns (same `[{"Id":
1381    /// "..."}]` shape as [`network_list_response`], reused as-is by
1382    /// [`ListedEntry`], but named separately since it stands for a different
1383    /// daemon resource at each call site).
1384    fn container_list_response(id: &str) -> Vec<u8> {
1385        network_list_response(id)
1386    }
1387
1388    /// A canned `200 OK` empty-array response — "nothing matched this filter."
1389    fn empty_list_response() -> Vec<u8> {
1390        let payload = "[]";
1391        format!(
1392            "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
1393            payload.len(),
1394            payload
1395        )
1396        .into_bytes()
1397    }
1398
1399    #[tokio::test]
1400    async fn find_running_returns_a_handle_when_the_daemon_lists_a_running_match() {
1401        let (client, _dir, received) =
1402            multi_request_fixture(vec![container_list_response("daemon-c-1")]).await;
1403        let backend = DockerBackend::new(client);
1404        let spec = ContainerSpec::new("rz-reuse-799aad5a3338", "redis:7-alpine", "deadbeef");
1405
1406        let found = backend
1407            .find_running(&spec)
1408            .await
1409            .unwrap()
1410            .expect("must find the running container");
1411        assert_eq!(found.id(), "daemon-c-1");
1412
1413        let requests = received.lock().unwrap().clone();
1414        assert_eq!(requests.len(), 1);
1415        assert!(
1416            requests[0].starts_with("GET /containers/json?filters="),
1417            "{requests:?}"
1418        );
1419    }
1420
1421    #[tokio::test]
1422    async fn find_running_returns_none_when_nothing_matches() {
1423        let (client, _dir, _received) = multi_request_fixture(vec![empty_list_response()]).await;
1424        let backend = DockerBackend::new(client);
1425        let spec = ContainerSpec::new("rz-reuse-799aad5a3338", "redis:7-alpine", "deadbeef");
1426
1427        assert!(backend.find_running(&spec).await.unwrap().is_none());
1428    }
1429
1430    #[tokio::test]
1431    async fn create_maps_a_409_conflict_to_the_typed_name_conflict_error() {
1432        // Two canned responses: `create()` first checks the image is present
1433        // (`pull_if_missing`'s `GET /images/{name}/json`, answered `200 OK` so it
1434        // never attempts a pull), THEN issues the actual `POST /containers/create`
1435        // that this test cares about, answered with the 409 conflict.
1436        let (client, _dir, _received) = multi_request_fixture(vec![
1437            b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\n{}".to_vec(),
1438            b"HTTP/1.1 409 Conflict\r\nContent-Length: 44\r\n\r\n{\"message\":\"Conflict. Name already in use.\"}".to_vec(),
1439        ])
1440        .await;
1441        let backend = DockerBackend::new(client);
1442        let spec = ContainerSpec::new("rz-reuse-799aad5a3338", "redis:7-alpine", "deadbeef");
1443
1444        let err = match backend.create(spec).await {
1445            Ok(_) => panic!("a 409 must surface as an error"),
1446            Err(e) => e,
1447        };
1448        assert!(
1449            matches!(err, rightsize::error::RightsizeError::NameConflict { .. }),
1450            "{err}"
1451        );
1452    }
1453
1454    #[tokio::test]
1455    async fn remove_network_falls_back_to_a_live_lookup_when_the_in_memory_cache_never_saw_it() {
1456        let (client, _dir, received) = multi_request_fixture(vec![
1457            network_list_response("daemon-net-9"),
1458            b"HTTP/1.1 204 No Content\r\n\r\n".to_vec(),
1459        ])
1460        .await;
1461        let backend = DockerBackend::new(client);
1462
1463        // No prior `ensure_network` call on THIS instance — `network_ids` starts
1464        // empty, exactly as it would for a process reaping a DIFFERENT (dead)
1465        // process's leftover network.
1466        backend.remove_network("rz-net-deadrun").await.unwrap();
1467
1468        let requests = received.lock().unwrap().clone();
1469        assert_eq!(
1470            requests.len(),
1471            2,
1472            "a cache miss must fall back to a live GET lookup, then DELETE the id it \
1473             finds — got: {requests:?}"
1474        );
1475        assert!(
1476            requests[0].starts_with("GET /networks?filters="),
1477            "{requests:?}"
1478        );
1479        assert!(
1480            requests[1].starts_with("DELETE /networks/daemon-net-9 "),
1481            "the DELETE must target the id the live lookup resolved, not the \
1482             rightsize-side network name: {requests:?}"
1483        );
1484    }
1485
1486    #[tokio::test]
1487    async fn remove_network_uses_the_cached_id_without_a_lookup_when_this_instance_created_it() {
1488        // Only ONE canned response: if this test needed a second connection (a GET
1489        // lookup it shouldn't be making), the fixture would have nothing left to
1490        // accept and the DELETE call would hang until the client's own timeout —
1491        // a real failure signal, not a false pass.
1492        let (client, _dir, received) =
1493            multi_request_fixture(vec![b"HTTP/1.1 204 No Content\r\n\r\n".to_vec()]).await;
1494        let backend = DockerBackend::new(client);
1495        backend
1496            .network_ids
1497            .lock()
1498            .unwrap()
1499            .insert("rz-net-ownrun".to_string(), "daemon-net-owned".to_string());
1500
1501        backend.remove_network("rz-net-ownrun").await.unwrap();
1502
1503        let requests = received.lock().unwrap().clone();
1504        assert_eq!(requests.len(), 1, "{requests:?}");
1505        assert!(
1506            requests[0].starts_with("DELETE /networks/daemon-net-owned "),
1507            "{requests:?}"
1508        );
1509    }
1510
1511    // --- commit_to_image (checkpoint) -----------------------------------------
1512
1513    #[tokio::test]
1514    async fn commit_to_image_posts_commit_with_the_split_repo_and_tag() {
1515        let (client, _dir, received) = multi_request_fixture(vec![
1516            b"HTTP/1.1 201 Created\r\nContent-Length: 2\r\n\r\n{}".to_vec(),
1517        ])
1518        .await;
1519        let backend = DockerBackend::new(client);
1520        let handle = Handle {
1521            id: "daemon-c-checkpoint".to_string(),
1522            spec: ContainerSpec::new("rz-x-0", "redis:8.6-alpine", "deadbeef"),
1523        };
1524
1525        backend
1526            .commit_to_image(&handle, "rightsize/checkpoint:abc123def456")
1527            .await
1528            .expect("commit must succeed on a 201");
1529
1530        let requests = received.lock().unwrap().clone();
1531        assert_eq!(requests.len(), 1, "{requests:?}");
1532        assert!(
1533            requests[0].starts_with("POST /commit?container=daemon-c-checkpoint"),
1534            "{requests:?}"
1535        );
1536        assert!(
1537            requests[0].contains("repo=rightsize%2Fcheckpoint"),
1538            "{requests:?}"
1539        );
1540        assert!(requests[0].contains("tag=abc123def456"), "{requests:?}");
1541    }
1542
1543    #[tokio::test]
1544    async fn commit_to_image_surfaces_a_daemon_error_as_a_backend_error() {
1545        let (client, _dir, _received) = multi_request_fixture(vec![
1546            b"HTTP/1.1 404 Not Found\r\nContent-Length: 23\r\n\r\n{\"message\":\"no such\"}"
1547                .to_vec(),
1548        ])
1549        .await;
1550        let backend = DockerBackend::new(client);
1551        let handle = Handle {
1552            id: "missing-container".to_string(),
1553            spec: ContainerSpec::new("rz-x-0", "redis:8.6-alpine", "deadbeef"),
1554        };
1555
1556        let err = backend
1557            .commit_to_image(&handle, "rightsize/checkpoint:abc123def456")
1558            .await
1559            .expect_err("a 404 must surface as an error");
1560        assert!(
1561            matches!(err, rightsize::error::RightsizeError::Backend(_)),
1562            "{err}"
1563        );
1564    }
1565}