1use 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
27const STOP_TIMEOUT_SECS: u64 = 10;
30
31const RUN_ID_LABEL_KEY: &str = "dev.rightsize.run_id";
36
37const REUSE_LABEL_KEY: &str = "dev.rightsize.reuse";
42
43struct 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
60pub struct DockerBackend {
62 client: DockerClient,
63 network_ids: Mutex<HashMap<String, String>>,
67}
68
69impl DockerBackend {
70 pub(crate) fn new(client: DockerClient) -> Self {
76 DockerBackend {
77 client,
78 network_ids: Mutex::new(HashMap::new()),
79 }
80 }
81
82 pub fn connecting_to_env() -> Self {
87 Self::new(DockerClient::from_env())
88 }
89
90 #[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 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 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 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 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
241fn 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
300fn 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
324fn 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
333fn encode_path_segment(s: &str) -> String {
337 s.to_string()
342}
343
344fn 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
360fn split_repo_tag(image: &str) -> (String, String) {
369 if image.contains('@') {
370 return (image.to_string(), String::new());
371 }
372 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 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(()); }
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; 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; 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 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, Err(_) => break, };
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; }
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 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; }
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(()); }
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 let _ = blocking_force_remove(self.client.socket_path(), container_id, STOP_TIMEOUT_SECS);
701 }
702
703 fn remove_by_name(&self, name: &str) {
704 let socket = self.client.socket_path();
712 let Some(id) = blocking_find_container_id_by_name(socket, name) else {
713 return; };
715 let _ = blocking_force_remove(socket, &id, STOP_TIMEOUT_SECS);
716 }
717
718 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 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
780fn 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
804fn 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
822fn 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
882fn 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 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 let mut crlf = [0u8; 2];
923 reader.read_exact(&mut crlf)?;
924 }
925 Ok(out)
926}
927
928fn 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
953fn 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 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 #[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); 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 #[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 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 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 use std::sync::Arc;
1288 use tokio::net::UnixListener;
1289
1290 mod tempdir_shim {
1296 use std::path::{Path, PathBuf};
1297
1298 pub(super) struct TempDir(PathBuf);
1299
1300 impl TempDir {
1301 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 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 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 fn container_list_response(id: &str) -> Vec<u8> {
1385 network_list_response(id)
1386 }
1387
1388 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 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 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 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 #[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}