1use std::collections::{BTreeMap, HashMap};
15use std::path::Path;
16use std::sync::Mutex;
17use std::time::Duration;
18
19use rightsize::backend::{Capabilities, FollowHandle, SandboxBackend, SandboxHandle};
20use rightsize::error::{Result, RightsizeError};
21use rightsize::model::{ContainerSpec, ExecResult};
22
23use crate::client::DockerClient;
24use crate::frames::{BodyReader, LineAssembler, StreamType, demux_into, read_frame};
25use crate::json::{
26 ConnectNetworkBody, CreateContainerBody, CreateExecBody, CreateNetworkBody, EmptyObject,
27 EndpointConfig, ExecInspect, HostConfig, IdResponse, LabelFilter, ListedEntry, NameFilter,
28 PortBinding as JsonPortBinding, StartExecBody,
29};
30
31const STOP_TIMEOUT_SECS: u64 = 10;
34
35const RUN_ID_LABEL_KEY: &str = "dev.rightsize.run_id";
40
41const REUSE_LABEL_KEY: &str = "dev.rightsize.reuse";
46
47struct Handle {
51 id: String,
52 spec: ContainerSpec,
53}
54
55impl SandboxHandle for Handle {
56 fn id(&self) -> &str {
57 &self.id
58 }
59 fn spec(&self) -> &ContainerSpec {
60 &self.spec
61 }
62}
63
64pub struct DockerBackend {
66 client: DockerClient,
67 network_ids: Mutex<HashMap<String, String>>,
71}
72
73impl DockerBackend {
74 pub(crate) fn new(client: DockerClient) -> Self {
80 DockerBackend {
81 client,
82 network_ids: Mutex::new(HashMap::new()),
83 }
84 }
85
86 pub fn connecting_to_env() -> Self {
91 Self::new(DockerClient::from_env())
92 }
93
94 #[cfg(test)]
98 pub(crate) fn socket_path_for_test(&self) -> std::path::PathBuf {
99 self.client.socket_path().to_path_buf()
100 }
101
102 async fn pull_if_missing(&self, image: &str) -> Result<()> {
109 let inspect_path = format!("/images/{}/json", encode_path_segment(image));
110 let inspect = self.client.request("GET", &inspect_path, None).await?;
111 if inspect.status == 200 {
112 return Ok(());
113 }
114 let (repo, tag) = split_repo_tag(image);
115 let pull_path = format!(
116 "/images/create?fromImage={}&tag={}",
117 encode_query_value(&repo),
118 encode_query_value(&tag)
119 );
120 let resp = self.client.request("POST", &pull_path, None).await?;
121 if resp.status >= 400 {
122 return Err(RightsizeError::Backend(format!(
123 "docker could not pull image '{image}' (HTTP {}): {}",
124 resp.status,
125 String::from_utf8_lossy(&resp.body)
126 )));
127 }
128 Ok(())
129 }
130
131 async fn connect_network(
134 &self,
135 container_id: &str,
136 network_id: &str,
137 aliases: &[String],
138 ) -> Result<()> {
139 let body = ConnectNetworkBody {
140 container: container_id.to_string(),
141 endpoint_config: EndpointConfig {
142 aliases: aliases.to_vec(),
143 },
144 };
145 let body = serde_json::to_string(&body)
146 .expect("ConnectNetworkBody has no non-serializable fields");
147 let path = format!("/networks/{network_id}/connect");
148 let resp = self.client.request("POST", &path, Some(&body)).await?;
149 if resp.status >= 400 {
150 return Err(RightsizeError::Backend(format!(
151 "docker could not connect container {container_id} to network {network_id} \
152 (HTTP {}): {}",
153 resp.status,
154 String::from_utf8_lossy(&resp.body)
155 )));
156 }
157 Ok(())
158 }
159
160 async fn ensure_network_get_id(&self, network_id: &str) -> Result<String> {
164 if let Some(id) = self
165 .network_ids
166 .lock()
167 .expect("network_ids mutex poisoned")
168 .get(network_id)
169 {
170 return Ok(id.clone());
171 }
172
173 let filters = NameFilter {
174 name: vec![network_id.to_string()],
175 };
176 let filters =
177 serde_json::to_string(&filters).expect("NameFilter has no non-serializable fields");
178 let list_path = format!("/networks?filters={}", encode_query_value(&filters));
179 let list = self.client.request("GET", &list_path, None).await?;
180 if list.status == 200 {
181 let entries: Vec<ListedEntry> = serde_json::from_slice(&list.body).unwrap_or_default();
182 if let Some(entry) = entries.into_iter().next() {
183 self.network_ids
184 .lock()
185 .expect("network_ids mutex poisoned")
186 .insert(network_id.to_string(), entry.id.clone());
187 return Ok(entry.id);
188 }
189 }
190
191 let create_body = CreateNetworkBody {
192 name: network_id.to_string(),
193 };
194 let create_body = serde_json::to_string(&create_body)
195 .expect("CreateNetworkBody has no non-serializable fields");
196 let created = self
197 .client
198 .request("POST", "/networks/create", Some(&create_body))
199 .await?;
200 if created.status >= 400 {
201 return Err(RightsizeError::Backend(format!(
202 "docker could not create network '{network_id}' (HTTP {}): {}",
203 created.status,
204 String::from_utf8_lossy(&created.body)
205 )));
206 }
207 let id = serde_json::from_slice::<IdResponse>(&created.body)
208 .map_err(|e| {
209 RightsizeError::Backend(format!(
210 "docker's network-create response for '{network_id}' had no Id field: {e} \
211 (body: {})",
212 String::from_utf8_lossy(&created.body)
213 ))
214 })?
215 .id;
216 self.network_ids
217 .lock()
218 .expect("network_ids mutex poisoned")
219 .insert(network_id.to_string(), id.clone());
220 Ok(id)
221 }
222
223 async fn lookup_network_id_by_name(&self, name: &str) -> Option<String> {
231 let filters = NameFilter {
232 name: vec![name.to_string()],
233 };
234 let filters = serde_json::to_string(&filters).ok()?;
235 let list_path = format!("/networks?filters={}", encode_query_value(&filters));
236 let list = self.client.request("GET", &list_path, None).await.ok()?;
237 if list.status != 200 {
238 return None;
239 }
240 let entries: Vec<ListedEntry> = serde_json::from_slice(&list.body).ok()?;
241 entries.into_iter().next().map(|e| e.id)
242 }
243}
244
245fn build_create_body(spec: &ContainerSpec) -> CreateContainerBody {
252 let env = spec.env.iter().map(|(k, v)| format!("{k}={v}")).collect();
253
254 let exposed_ports = spec
255 .ports
256 .iter()
257 .map(|p| (format!("{}/tcp", p.guest_port), EmptyObject {}))
258 .collect();
259
260 let port_bindings = spec
261 .ports
262 .iter()
263 .map(|p| {
264 (
265 format!("{}/tcp", p.guest_port),
266 vec![JsonPortBinding {
267 host_ip: "127.0.0.1".to_string(),
268 host_port: p.host_port.to_string(),
269 }],
270 )
271 })
272 .collect();
273
274 let binds = spec
275 .mounts
276 .iter()
277 .map(|m| {
278 let mode = if m.read_only { "ro" } else { "rw" };
279 format!("{}:{}:{}", m.host_path.display(), m.guest_path, mode)
280 })
281 .collect();
282
283 let labels = if spec.keep_alive {
284 BTreeMap::from([(REUSE_LABEL_KEY.to_string(), reuse_label_value(spec))])
285 } else {
286 BTreeMap::from([(RUN_ID_LABEL_KEY.to_string(), spec.run_id.clone())])
287 };
288
289 CreateContainerBody {
290 image: spec.image.clone(),
291 env,
292 cmd: spec.command.clone(),
293 exposed_ports,
294 labels,
295 host_config: HostConfig {
296 port_bindings,
297 binds,
298 extra_hosts: vec!["host.docker.internal:host-gateway".to_string()],
299 memory: spec.memory_limit_mb.map(|mb| mb * 1024 * 1024),
300 },
301 }
302}
303
304fn reuse_label_value(spec: &ContainerSpec) -> String {
315 if let Some(hex) = spec.name.strip_prefix("rz-reuse-") {
316 if hex.len() == 12 && hex.chars().all(|c| c.is_ascii_hexdigit()) {
317 return hex.to_string();
318 }
319 }
320 let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
321 for byte in spec.name.as_bytes() {
322 hash ^= *byte as u64;
323 hash = hash.wrapping_mul(0x0000_0100_0000_01B3);
324 }
325 format!("{:012x}", hash & 0xFFFF_FFFF_FFFF)
326}
327
328fn is_port_bind_conflict_message(message: &str) -> bool {
333 let m = message.to_lowercase();
334 m.contains("already in use") || m.contains("already allocated")
335}
336
337fn encode_path_segment(s: &str) -> String {
341 s.to_string()
346}
347
348fn encode_query_value(s: &str) -> String {
352 let mut out = String::with_capacity(s.len());
353 for b in s.bytes() {
354 match b {
355 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
356 out.push(b as char)
357 }
358 _ => out.push_str(&format!("%{b:02X}")),
359 }
360 }
361 out
362}
363
364fn split_repo_tag(image: &str) -> (String, String) {
373 if image.contains('@') {
374 return (image.to_string(), String::new());
375 }
376 let slash_idx = image.rfind('/').map(|i| i + 1).unwrap_or(0);
380 match image[slash_idx..].rfind(':') {
381 Some(rel_colon) => {
382 let colon = slash_idx + rel_colon;
383 (image[..colon].to_string(), image[colon + 1..].to_string())
384 }
385 None => (image.to_string(), "latest".to_string()),
386 }
387}
388
389#[async_trait::async_trait]
390impl SandboxBackend for DockerBackend {
391 fn name(&self) -> &str {
392 "docker"
393 }
394
395 fn supports_native_networks(&self) -> bool {
396 true
397 }
398
399 fn capabilities(&self) -> Capabilities {
400 Capabilities {
401 hardware_isolated: false,
403 checkpoint: true,
404 checkpoint_restarts_workload: false,
407 }
408 }
409
410 async fn create(&self, spec: ContainerSpec) -> Result<Box<dyn SandboxHandle>> {
411 self.pull_if_missing(&spec.image).await?;
412
413 let body = build_create_body(&spec);
414 let body = serde_json::to_string(&body)
415 .expect("CreateContainerBody has no non-serializable fields");
416 let path = format!("/containers/create?name={}", encode_query_value(&spec.name));
417 let resp = self.client.request("POST", &path, Some(&body)).await?;
418 if resp.status == 409 {
419 let message = String::from_utf8_lossy(&resp.body).into_owned();
420 return Err(RightsizeError::NameConflict {
421 message: format!(
422 "docker container name '{}' is already in use (HTTP 409): {message}",
423 spec.name
424 ),
425 source: None,
426 });
427 }
428 if resp.status >= 400 {
429 let message = String::from_utf8_lossy(&resp.body).into_owned();
430 return Err(RightsizeError::Backend(format!(
431 "docker could not create container '{}' (HTTP {}): {message}",
432 spec.name, resp.status
433 )));
434 }
435 let id = serde_json::from_slice::<IdResponse>(&resp.body)
436 .map_err(|e| {
437 RightsizeError::Backend(format!(
438 "docker's container-create response for '{}' had no Id field: {e} (body: {})",
439 spec.name,
440 String::from_utf8_lossy(&resp.body)
441 ))
442 })?
443 .id;
444
445 if let Some(network_id) = &spec.network_id {
446 let daemon_network_id = self.ensure_network_get_id(network_id).await?;
447 self.connect_network(&id, &daemon_network_id, &spec.aliases)
448 .await?;
449 }
450
451 Ok(Box::new(Handle { id, spec }))
452 }
453
454 async fn start(&self, handle: &dyn SandboxHandle) -> Result<()> {
455 let path = format!("/containers/{}/start", handle.id());
456 let resp = self.client.request("POST", &path, None).await?;
457 if resp.status == 204 || resp.status == 304 {
458 return Ok(()); }
460 let message = String::from_utf8_lossy(&resp.body).into_owned();
461 if resp.status == 500 && is_port_bind_conflict_message(&message) {
462 return Err(RightsizeError::PortBindConflict {
463 message: format!(
464 "docker could not bind a host port for {}: {message}",
465 handle.id()
466 ),
467 source: None,
468 });
469 }
470 Err(RightsizeError::Backend(format!(
471 "docker could not start container {} (HTTP {}): {message}",
472 handle.id(),
473 resp.status
474 )))
475 }
476
477 async fn stop(&self, handle: &dyn SandboxHandle) -> Result<()> {
478 let path = format!("/containers/{}/stop?t={STOP_TIMEOUT_SECS}", handle.id());
479 let _ = self.client.request("POST", &path, None).await; Ok(())
481 }
482
483 async fn remove(&self, handle: &dyn SandboxHandle) -> Result<()> {
484 let path = format!("/containers/{}?force=true", handle.id());
485 let _ = self.client.request("DELETE", &path, None).await; Ok(())
487 }
488
489 async fn exec(&self, handle: &dyn SandboxHandle, cmd: &[String]) -> Result<ExecResult> {
490 let create_body = CreateExecBody {
491 attach_stdout: true,
492 attach_stderr: true,
493 cmd: cmd.to_vec(),
494 };
495 let create_body = serde_json::to_string(&create_body)
496 .expect("CreateExecBody has no non-serializable fields");
497 let create_path = format!("/containers/{}/exec", handle.id());
498 let created = self
499 .client
500 .request("POST", &create_path, Some(&create_body))
501 .await?;
502 if created.status >= 400 {
503 return Err(RightsizeError::Backend(format!(
504 "docker could not create an exec for container {} (HTTP {}): {}",
505 handle.id(),
506 created.status,
507 String::from_utf8_lossy(&created.body)
508 )));
509 }
510 let exec_id = serde_json::from_slice::<IdResponse>(&created.body)
511 .map_err(|e| {
512 RightsizeError::Backend(format!(
513 "docker's exec-create response for container {} had no Id field: {e} \
514 (body: {})",
515 handle.id(),
516 String::from_utf8_lossy(&created.body)
517 ))
518 })?
519 .id;
520
521 let start_path = format!("/exec/{exec_id}/start");
522 let start_body = serde_json::to_string(&StartExecBody { detach: false })
523 .expect("StartExecBody has no non-serializable fields");
524 let (headers, mut stream) = self
525 .client
526 .request_stream("POST", &start_path, Some(&start_body))
527 .await?;
528 if headers.status >= 400 {
529 return Err(RightsizeError::Backend(format!(
530 "docker could not start exec {exec_id} for container {} (HTTP {})",
531 handle.id(),
532 headers.status
533 )));
534 }
535 let mut body = BodyReader::new(&mut stream, &headers);
536 let mut stdout = Vec::new();
537 let mut stderr = Vec::new();
538 demux_into(
539 &mut body,
540 |b| stdout.extend_from_slice(b),
541 |b| stderr.extend_from_slice(b),
542 )
543 .await?;
544
545 let inspect_path = format!("/exec/{exec_id}/json");
546 let inspected = self.client.request("GET", &inspect_path, None).await?;
547 let exit_code = serde_json::from_slice::<ExecInspect>(&inspected.body)
548 .ok()
549 .and_then(|r| r.exit_code)
550 .unwrap_or(-1);
551
552 Ok(ExecResult {
553 exit_code: exit_code as i32,
554 stdout: String::from_utf8_lossy(&stdout).into_owned(),
555 stderr: String::from_utf8_lossy(&stderr).into_owned(),
556 })
557 }
558
559 async fn logs(&self, handle: &dyn SandboxHandle) -> Result<String> {
560 let path = format!(
561 "/containers/{}/logs?stdout=1&stderr=1&tail=1000",
562 handle.id()
563 );
564 let (headers, mut stream) = self.client.request_stream("GET", &path, None).await?;
565 if headers.status >= 400 {
566 return Err(RightsizeError::Backend(format!(
567 "docker could not fetch logs for container {} (HTTP {})",
568 handle.id(),
569 headers.status
570 )));
571 }
572 let mut body = BodyReader::new(&mut stream, &headers);
573 let mut assembler = LineAssembler::new();
574 let mut out = String::new();
575 while let Some(frame) = read_frame(&mut body).await? {
576 if !matches!(frame.stream, StreamType::Stdout | StreamType::Stderr) {
577 continue;
578 }
579 let text = String::from_utf8_lossy(&frame.payload).into_owned();
580 for line in assembler.feed(&text) {
581 out.push_str(&line);
582 out.push('\n');
583 }
584 }
585 if let Some(tail) = assembler.flush() {
586 out.push_str(&tail);
587 out.push('\n');
588 }
589 Ok(out)
590 }
591
592 async fn follow_logs(
593 &self,
594 handle: &dyn SandboxHandle,
595 consumer: Box<dyn Fn(String) + Send + Sync>,
596 ) -> Result<FollowHandle> {
597 let path = format!(
598 "/containers/{}/logs?stdout=1&stderr=1&follow=1&tail=all",
599 handle.id()
600 );
601 let (headers, mut stream) = self.client.request_stream("GET", &path, None).await?;
602 if headers.status >= 400 {
603 return Err(RightsizeError::Backend(format!(
604 "docker could not follow logs for container {} (HTTP {})",
605 handle.id(),
606 headers.status
607 )));
608 }
609
610 let close_requested = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
611 let task_close = close_requested.clone();
612 let task = tokio::spawn(async move {
613 let mut body = BodyReader::new(&mut stream, &headers);
614 let mut assembler = LineAssembler::new();
615 loop {
616 if task_close.load(std::sync::atomic::Ordering::SeqCst) {
623 return;
624 }
625 let frame = match read_frame(&mut body).await {
626 Ok(Some(f)) => f,
627 Ok(None) => break, Err(_) => break, };
630 if !matches!(frame.stream, StreamType::Stdout | StreamType::Stderr) {
631 continue;
632 }
633 let text = String::from_utf8_lossy(&frame.payload).into_owned();
634 for line in assembler.feed(&text) {
635 if task_close.load(std::sync::atomic::Ordering::SeqCst) {
636 return;
637 }
638 consumer(line);
639 }
640 }
641 if task_close.load(std::sync::atomic::Ordering::SeqCst) {
642 return; }
644 if let Some(tail) = assembler.flush() {
645 consumer(tail);
646 }
647 });
648
649 Ok(FollowHandle::from_task(close_requested, task))
650 }
651
652 async fn ensure_network(&self, network_id: &str) -> Result<()> {
653 self.ensure_network_get_id(network_id).await?;
654 Ok(())
655 }
656
657 async fn remove_network(&self, network_id: &str) -> Result<()> {
658 let cached = self
659 .network_ids
660 .lock()
661 .expect("network_ids mutex poisoned")
662 .remove(network_id);
663 let daemon_id = match cached {
671 Some(id) => Some(id),
672 None => self.lookup_network_id_by_name(network_id).await,
673 };
674 if let Some(id) = daemon_id {
675 let path = format!("/networks/{id}");
676 let _ = self.client.request("DELETE", &path, None).await; }
678 Ok(())
679 }
680
681 async fn close(&self) -> Result<()> {
682 let run_id = rightsize::RunId::value();
683 let filters = LabelFilter {
684 label: vec![format!("{RUN_ID_LABEL_KEY}={run_id}")],
685 };
686 let filters =
687 serde_json::to_string(&filters).expect("LabelFilter has no non-serializable fields");
688 let path = format!(
689 "/containers/json?all=true&filters={}",
690 encode_query_value(&filters)
691 );
692 let listed = self.client.request("GET", &path, None).await?;
693 if listed.status != 200 {
694 return Ok(()); }
696 let entries: Vec<ListedEntry> = serde_json::from_slice(&listed.body).unwrap_or_default();
697 for entry in entries {
698 let remove_path = format!("/containers/{}?force=true", entry.id);
699 let _ = self.client.request("DELETE", &remove_path, None).await;
700 }
701 Ok(())
702 }
703
704 fn cleanup_sync(&self, container_id: &str) {
705 let _ = blocking_force_remove(self.client.socket_path(), container_id, STOP_TIMEOUT_SECS);
708 }
709
710 fn remove_by_name(&self, name: &str) {
711 let socket = self.client.socket_path();
719 let Some(id) = blocking_find_container_id_by_name(socket, name) else {
720 return; };
722 let _ = blocking_force_remove(socket, &id, STOP_TIMEOUT_SECS);
723 }
724
725 async fn find_running(&self, spec: &ContainerSpec) -> Result<Option<Box<dyn SandboxHandle>>> {
734 let filters = format!(r#"{{"name":["^/{}$"],"status":["running"]}}"#, spec.name);
735 let path = format!("/containers/json?filters={}", encode_query_value(&filters));
736 let resp = self.client.request("GET", &path, None).await?;
737 if resp.status != 200 {
738 return Ok(None);
739 }
740 let entries: Vec<ListedEntry> = serde_json::from_slice(&resp.body).unwrap_or_default();
741 Ok(entries.into_iter().next().map(|entry| {
742 Box::new(Handle {
743 id: entry.id,
744 spec: spec.clone(),
745 }) as Box<dyn SandboxHandle>
746 }))
747 }
748
749 async fn create_checkpoint(&self, handle: &dyn SandboxHandle, nonce: &str) -> Result<String> {
756 let checkpoint_ref = format!("rightsize/checkpoint:{nonce}");
757 let (repo, tag) = split_repo_tag(&checkpoint_ref);
758 let path = format!(
759 "/commit?container={}&repo={}&tag={}",
760 encode_query_value(handle.id()),
761 encode_query_value(&repo),
762 encode_query_value(&tag)
763 );
764 let resp = self.client.request("POST", &path, None).await?;
765 if resp.status >= 400 {
766 return Err(RightsizeError::Backend(format!(
767 "docker could not commit container {} to image '{checkpoint_ref}' (HTTP {}): {}",
768 handle.id(),
769 resp.status,
770 String::from_utf8_lossy(&resp.body)
771 )));
772 }
773 Ok(checkpoint_ref)
774 }
775
776 async fn remove_checkpoint(&self, checkpoint_ref: &str) -> Result<()> {
780 let path = format!("/images/{}?force=true", encode_path_segment(checkpoint_ref));
781 let resp = self.client.request("DELETE", &path, None).await?;
782 if resp.status >= 400 && resp.status != 404 {
783 return Err(RightsizeError::Backend(format!(
784 "docker could not remove checkpoint image '{checkpoint_ref}' (HTTP {}): {}",
785 resp.status,
786 String::from_utf8_lossy(&resp.body)
787 )));
788 }
789 Ok(())
790 }
791
792 async fn has_checkpoint(&self, checkpoint_ref: &str) -> Result<bool> {
801 let path = format!("/images/{}/json", encode_path_segment(checkpoint_ref));
802 let resp = self.client.request("GET", &path, None).await?;
803 match resp.status {
804 200 => Ok(true),
805 404 => Ok(false),
806 status => Err(RightsizeError::Backend(format!(
807 "docker could not inspect checkpoint image '{checkpoint_ref}' (HTTP {status}): {}",
808 String::from_utf8_lossy(&resp.body)
809 ))),
810 }
811 }
812
813 async fn copy_to_container(
822 &self,
823 handle: &dyn SandboxHandle,
824 host_path: &Path,
825 container_path: &str,
826 ) -> Result<()> {
827 let args = docker_cp_in_args(handle.id(), host_path, container_path);
828 let result = run_docker_cli(&args).await?;
829 if result.exit_code != 0 {
830 return Err(RightsizeError::Backend(format!(
831 "docker cp into container {} failed (exit {}): {}",
832 handle.id(),
833 result.exit_code,
834 result.stderr.trim()
835 )));
836 }
837 Ok(())
838 }
839
840 async fn copy_from_container(
843 &self,
844 handle: &dyn SandboxHandle,
845 container_path: &str,
846 host_path: &Path,
847 ) -> Result<()> {
848 let args = docker_cp_out_args(handle.id(), container_path, host_path);
849 let result = run_docker_cli(&args).await?;
850 if result.exit_code != 0 {
851 return Err(RightsizeError::Backend(format!(
852 "docker cp out of container {} failed (exit {}): {}",
853 handle.id(),
854 result.exit_code,
855 result.stderr.trim()
856 )));
857 }
858 Ok(())
859 }
860
861 fn watchdog_kill_command(&self) -> Vec<String> {
862 vec!["docker".to_string(), "rm".to_string(), "-f".to_string()]
863 }
864
865 fn watchdog_network_kill_command(&self) -> Vec<String> {
866 vec![
867 "docker".to_string(),
868 "network".to_string(),
869 "rm".to_string(),
870 ]
871 }
872}
873
874fn docker_cp_in_args(container_id: &str, host_path: &Path, container_path: &str) -> Vec<String> {
879 vec![
880 "cp".to_string(),
881 host_path.display().to_string(),
882 format!("{container_id}:{container_path}"),
883 ]
884}
885
886fn docker_cp_out_args(container_id: &str, container_path: &str, host_path: &Path) -> Vec<String> {
889 vec![
890 "cp".to_string(),
891 format!("{container_id}:{container_path}"),
892 host_path.display().to_string(),
893 ]
894}
895
896async fn run_docker_cli(args: &[String]) -> Result<ExecResult> {
902 let output = tokio::process::Command::new("docker")
903 .args(args)
904 .stdin(std::process::Stdio::null())
905 .output()
906 .await
907 .map_err(|e| {
908 RightsizeError::Backend(format!("failed to spawn docker {}: {e}", args.join(" ")))
909 })?;
910 Ok(ExecResult {
911 exit_code: output.status.code().unwrap_or(-1),
912 stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
913 stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
914 })
915}
916
917fn blocking_force_remove(
924 socket_path: &std::path::Path,
925 container_id: &str,
926 stop_timeout_secs: u64,
927) -> std::io::Result<()> {
928 let _ = blocking_request(
929 socket_path,
930 "POST",
931 &format!("/containers/{container_id}/stop?t={stop_timeout_secs}"),
932 );
933 blocking_request(
934 socket_path,
935 "DELETE",
936 &format!("/containers/{container_id}?force=true"),
937 )
938 .map(|_| ())
939}
940
941fn blocking_find_container_id_by_name(socket_path: &std::path::Path, name: &str) -> Option<String> {
949 let filters = format!(r#"{{"name":["^/{name}$"]}}"#);
950 let path = format!(
951 "/containers/json?all=true&filters={}",
952 encode_query_value(&filters)
953 );
954 let body = blocking_get_body(socket_path, &path).ok()?;
955 let entries: Vec<ListedEntry> = serde_json::from_slice(&body).ok()?;
956 entries.into_iter().next().map(|e| e.id)
957}
958
959fn blocking_get_body(socket_path: &std::path::Path, path: &str) -> std::io::Result<Vec<u8>> {
971 use std::io::{BufRead, BufReader, Read, Write};
972 use std::os::unix::net::UnixStream;
973
974 let mut stream = UnixStream::connect(socket_path)?;
975 stream.set_read_timeout(Some(Duration::from_secs(stop_timeout_budget())))?;
976 let request = format!("GET {path} HTTP/1.1\r\nHost: docker\r\nConnection: close\r\n\r\n");
977 stream.write_all(request.as_bytes())?;
978
979 let mut reader = BufReader::new(stream);
980 let mut status_line = String::new();
981 reader.read_line(&mut status_line)?;
982
983 let mut content_length: Option<usize> = None;
984 let mut chunked = false;
985 loop {
986 let mut line = String::new();
987 let n = reader.read_line(&mut line)?;
988 if n == 0 || line == "\r\n" || line == "\n" {
989 break;
990 }
991 if let Some((name, value)) = line.split_once(':') {
992 let name = name.trim();
993 let value = value.trim();
994 if name.eq_ignore_ascii_case("content-length") {
995 content_length = value.parse::<usize>().ok();
996 } else if name.eq_ignore_ascii_case("transfer-encoding") {
997 chunked = value.to_ascii_lowercase().contains("chunked");
998 }
999 }
1000 }
1001
1002 if chunked {
1003 return blocking_read_chunked_body(&mut reader);
1004 }
1005
1006 let mut body = Vec::new();
1007 match content_length {
1008 Some(len) => {
1009 body.resize(len, 0);
1010 reader.read_exact(&mut body)?;
1011 }
1012 None => {
1013 reader.read_to_end(&mut body)?;
1014 }
1015 }
1016 Ok(body)
1017}
1018
1019fn blocking_read_chunked_body(
1026 reader: &mut std::io::BufReader<std::os::unix::net::UnixStream>,
1027) -> std::io::Result<Vec<u8>> {
1028 use std::io::{BufRead, Read};
1029
1030 let mut out = Vec::new();
1031 loop {
1032 let mut size_line = String::new();
1033 reader.read_line(&mut size_line)?;
1034 let size_str = size_line.trim().split(';').next().unwrap_or("").trim();
1035 let size = usize::from_str_radix(size_str, 16).map_err(|_| {
1036 std::io::Error::new(
1037 std::io::ErrorKind::InvalidData,
1038 format!(
1039 "could not parse a chunk size from the Docker daemon's response: \
1040 {size_line:?}"
1041 ),
1042 )
1043 })?;
1044 if size == 0 {
1045 loop {
1047 let mut trailer = String::new();
1048 let n = reader.read_line(&mut trailer)?;
1049 if n == 0 || trailer == "\r\n" || trailer == "\n" {
1050 break;
1051 }
1052 }
1053 break;
1054 }
1055 let mut chunk = vec![0u8; size];
1056 reader.read_exact(&mut chunk)?;
1057 out.extend_from_slice(&chunk);
1058 let mut crlf = [0u8; 2];
1060 reader.read_exact(&mut crlf)?;
1061 }
1062 Ok(out)
1063}
1064
1065fn blocking_request(
1074 socket_path: &std::path::Path,
1075 method: &str,
1076 path: &str,
1077) -> std::io::Result<()> {
1078 use std::io::{Read, Write};
1079 use std::os::unix::net::UnixStream;
1080
1081 let mut stream = UnixStream::connect(socket_path)?;
1082 stream.set_read_timeout(Some(Duration::from_secs(stop_timeout_budget())))?;
1083 let request = format!("{method} {path} HTTP/1.1\r\nHost: docker\r\nConnection: close\r\n\r\n");
1084 stream.write_all(request.as_bytes())?;
1085 let mut discard = Vec::new();
1086 let _ = stream.read_to_end(&mut discard);
1087 Ok(())
1088}
1089
1090fn stop_timeout_budget() -> u64 {
1095 STOP_TIMEOUT_SECS + 20
1096}
1097
1098#[cfg(test)]
1099mod tests {
1100 use super::*;
1101
1102 #[test]
1103 fn capabilities_report_no_hardware_isolation_but_checkpoint_support() {
1104 let backend = DockerBackend::connecting_to_env();
1105 let caps = backend.capabilities();
1106 assert!(!caps.hardware_isolated, "containers share the host kernel");
1107 assert!(caps.checkpoint);
1108 assert!(
1109 !caps.checkpoint_restarts_workload,
1110 "an image commit leaves the running container undisturbed"
1111 );
1112 }
1113
1114 mod blocking_tempdir_shim {
1118 use std::path::{Path, PathBuf};
1119
1120 pub(super) struct TempDir(PathBuf);
1121
1122 impl TempDir {
1123 pub(super) fn new() -> Self {
1124 static COUNTER: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0);
1125 let seq = COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1126 let unique = format!("rzdbb-{:x}-{:x}", std::process::id() as u16, seq);
1127 let path = Path::new("/tmp").join(unique);
1128 std::fs::create_dir_all(&path).expect("create temp dir");
1129 TempDir(path)
1130 }
1131
1132 pub(super) fn path(&self) -> &Path {
1133 &self.0
1134 }
1135 }
1136
1137 impl Drop for TempDir {
1138 fn drop(&mut self) {
1139 let _ = std::fs::remove_dir_all(&self.0);
1140 }
1141 }
1142 }
1143
1144 #[test]
1151 fn blocking_get_body_dechunks_a_transfer_encoding_chunked_response() {
1152 use std::io::{Read, Write};
1153 use std::os::unix::net::UnixListener;
1154
1155 let dir = blocking_tempdir_shim::TempDir::new();
1156 let sock_path = dir.path().join("docker.sock");
1157 let listener = UnixListener::bind(&sock_path).expect("bind fixture socket");
1158
1159 let server = std::thread::spawn(move || {
1160 let (mut stream, _) = listener.accept().expect("accept fixture connection");
1161 let mut buf = [0u8; 4096];
1162 let _ = stream.read(&mut buf); stream
1164 .write_all(
1165 b"HTTP/1.1 200 OK\r\n\
1166 Content-Type: application/json\r\n\
1167 Transfer-Encoding: chunked\r\n\
1168 \r\n\
1169 5\r\nhello\r\n\
1170 6\r\n world\r\n\
1171 0\r\n\r\n",
1172 )
1173 .expect("write fixture response");
1174 });
1175
1176 let body =
1177 blocking_get_body(&sock_path, "/containers/json").expect("dechunking must succeed");
1178 assert_eq!(body, b"hello world");
1179 server.join().expect("fixture server thread must not panic");
1180 }
1181
1182 #[test]
1186 fn blocking_get_body_honors_content_length_when_present() {
1187 use std::io::{Read, Write};
1188 use std::os::unix::net::UnixListener;
1189
1190 let dir = blocking_tempdir_shim::TempDir::new();
1191 let sock_path = dir.path().join("docker.sock");
1192 let listener = UnixListener::bind(&sock_path).expect("bind fixture socket");
1193
1194 let server = std::thread::spawn(move || {
1195 let (mut stream, _) = listener.accept().expect("accept fixture connection");
1196 let mut buf = [0u8; 4096];
1197 let _ = stream.read(&mut buf);
1198 stream
1199 .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 13\r\n\r\nhello world!!")
1200 .expect("write fixture response");
1201 });
1202
1203 let body = blocking_get_body(&sock_path, "/containers/json")
1204 .expect("Content-Length read must succeed");
1205 assert_eq!(body, b"hello world!!");
1206 server.join().expect("fixture server thread must not panic");
1207 }
1208
1209 #[test]
1210 fn is_port_bind_conflict_message_matches_known_phrasings() {
1211 assert!(is_port_bind_conflict_message(
1212 "driver failed programming external connectivity: address already in use"
1213 ));
1214 assert!(is_port_bind_conflict_message(
1215 "Bind for 0.0.0.0:6379 failed: port is already allocated"
1216 ));
1217 assert!(is_port_bind_conflict_message(
1218 "ALREADY ALLOCATED (case-insensitive)"
1219 ));
1220 }
1221
1222 #[test]
1223 fn is_port_bind_conflict_message_negative_cases_do_not_match() {
1224 assert!(!is_port_bind_conflict_message("no such image"));
1225 assert!(!is_port_bind_conflict_message("container already stopped"));
1226 assert!(!is_port_bind_conflict_message(""));
1227 }
1228
1229 #[test]
1230 fn split_repo_tag_defaults_to_latest_when_untagged() {
1231 assert_eq!(
1232 split_repo_tag("redis"),
1233 ("redis".to_string(), "latest".to_string())
1234 );
1235 assert_eq!(
1236 split_repo_tag("redis:8.6-alpine"),
1237 ("redis".to_string(), "8.6-alpine".to_string())
1238 );
1239 }
1240
1241 #[test]
1242 fn split_repo_tag_handles_a_registry_host_with_a_port() {
1243 assert_eq!(
1244 split_repo_tag("localhost:5000/redis"),
1245 ("localhost:5000/redis".to_string(), "latest".to_string())
1246 );
1247 assert_eq!(
1248 split_repo_tag("localhost:5000/redis:8.6-alpine"),
1249 ("localhost:5000/redis".to_string(), "8.6-alpine".to_string())
1250 );
1251 }
1252
1253 #[test]
1254 fn split_repo_tag_passes_through_a_digest_reference_with_no_tag() {
1255 assert_eq!(
1256 split_repo_tag("redis@sha256:abcdef"),
1257 ("redis@sha256:abcdef".to_string(), String::new())
1258 );
1259 }
1260
1261 fn serialize(spec: &ContainerSpec) -> String {
1267 serde_json::to_string(&build_create_body(spec)).unwrap()
1268 }
1269
1270 #[test]
1271 fn build_create_body_includes_port_bindings_on_loopback() {
1272 let mut spec = ContainerSpec::new("rz-x-0", "redis:8.6-alpine", "deadbeef");
1273 spec.ports.push(rightsize::model::PortBinding {
1274 host_port: 32768,
1275 guest_port: 6379,
1276 });
1277 let body = serialize(&spec);
1278 assert!(body.contains("\"HostIp\":\"127.0.0.1\""));
1279 assert!(body.contains("\"HostPort\":\"32768\""));
1280 assert!(body.contains("\"6379/tcp\""));
1281 }
1282
1283 #[test]
1284 fn build_create_body_includes_the_run_id_label_and_extra_host() {
1285 let spec = ContainerSpec::new("rz-x-0", "redis:8.6-alpine", "deadbeef");
1286 let body = serialize(&spec);
1287 assert!(body.contains("\"dev.rightsize.run_id\":\"deadbeef\""));
1288 assert!(body.contains("host.docker.internal:host-gateway"));
1289 }
1290
1291 #[test]
1292 fn build_create_body_honors_mount_read_only_flag() {
1293 let mut spec = ContainerSpec::new("rz-x-0", "redis:8.6-alpine", "deadbeef");
1294 spec.mounts
1295 .push(rightsize::model::FileMount::new("/host/a", "/guest/a"));
1296 spec.mounts
1297 .push(rightsize::model::FileMount::new("/host/b", "/guest/b").read_write());
1298 let body = serialize(&spec);
1299 assert!(body.contains("/host/a:/guest/a:ro"));
1300 assert!(body.contains("/host/b:/guest/b:rw"));
1301 }
1302
1303 #[test]
1304 fn build_create_body_omits_memory_when_unset_and_includes_it_when_set() {
1305 let spec = ContainerSpec::new("rz-x-0", "redis:8.6-alpine", "deadbeef");
1306 assert!(!serialize(&spec).contains("Memory"));
1307
1308 let mut spec = ContainerSpec::new("rz-x-0", "redis:8.6-alpine", "deadbeef");
1309 spec.memory_limit_mb = Some(512);
1310 assert!(serialize(&spec).contains("\"Memory\":536870912"));
1311 }
1312
1313 #[test]
1314 fn build_create_body_omits_cmd_when_command_is_none_and_includes_it_when_some() {
1315 let spec = ContainerSpec::new("rz-x-0", "redis:8.6-alpine", "deadbeef");
1316 assert!(!serialize(&spec).contains("\"Cmd\""));
1317
1318 let mut spec = ContainerSpec::new("rz-x-0", "redis:8.6-alpine", "deadbeef");
1319 spec.command = Some(vec![
1320 "redis-server".to_string(),
1321 "--port".to_string(),
1322 "6379".to_string(),
1323 ]);
1324 let body = serialize(&spec);
1325 assert!(body.contains("\"Cmd\":[\"redis-server\",\"--port\",\"6379\"]"));
1326 }
1327
1328 #[test]
1329 fn docker_backend_dials_a_unix_socket_never_a_tcp_host() {
1330 let backend = DockerBackend::new(DockerClient::from_docker_host(None));
1335 let path = backend.socket_path_for_test();
1336 assert!(
1337 path.is_absolute(),
1338 "expected an absolute unix socket path, got {path:?}"
1339 );
1340 let path_str = path.to_string_lossy();
1341 assert!(
1342 !path_str.contains(':'),
1343 "a unix socket path must not look like a host:port TCP address — got {path_str}"
1344 );
1345
1346 let backend_env =
1347 DockerBackend::new(DockerClient::from_docker_host(Some("tcp://127.0.0.1:2375")));
1348 let fallback_path = backend_env.socket_path_for_test();
1349 assert!(
1350 !fallback_path.to_string_lossy().contains("2375"),
1351 "a tcp:// DOCKER_HOST must not leak a TCP port into this backend's transport — \
1352 got {fallback_path:?}"
1353 );
1354 }
1355
1356 #[test]
1357 fn keep_alive_spec_gets_the_reuse_label_instead_of_the_run_id_label() {
1358 let mut spec = ContainerSpec::new("rz-x-0", "redis:8.6-alpine", "deadbeef");
1359 spec.keep_alive = true;
1360 let body = serialize(&spec);
1361 assert!(body.contains("\"dev.rightsize.reuse\""), "{body}");
1362 assert!(!body.contains("\"dev.rightsize.run_id\""), "{body}");
1363 }
1364
1365 #[test]
1366 fn non_keep_alive_spec_still_gets_the_run_id_label_only() {
1367 let spec = ContainerSpec::new("rz-x-0", "redis:8.6-alpine", "deadbeef");
1368 let body = serialize(&spec);
1369 assert!(
1370 body.contains("\"dev.rightsize.run_id\":\"deadbeef\""),
1371 "{body}"
1372 );
1373 assert!(!body.contains("\"dev.rightsize.reuse\""), "{body}");
1374 }
1375
1376 #[test]
1377 fn watchdog_kill_command_is_a_plain_docker_rm_dash_f() {
1378 let backend = DockerBackend::new(DockerClient::from_docker_host(None));
1379 assert_eq!(
1380 backend.watchdog_kill_command(),
1381 vec!["docker".to_string(), "rm".to_string(), "-f".to_string()]
1382 );
1383 }
1384
1385 #[test]
1386 fn watchdog_network_kill_command_is_docker_network_rm() {
1387 let backend = DockerBackend::new(DockerClient::from_docker_host(None));
1388 assert_eq!(
1389 backend.watchdog_network_kill_command(),
1390 vec![
1391 "docker".to_string(),
1392 "network".to_string(),
1393 "rm".to_string()
1394 ]
1395 );
1396 }
1397
1398 #[test]
1399 fn reuse_label_value_extracts_the_hash_straight_from_an_rz_reuse_name() {
1400 let spec = ContainerSpec::new("rz-reuse-799aad5a3338", "redis:7-alpine", "deadbeef");
1401 assert_eq!(reuse_label_value(&spec), "799aad5a3338");
1402 }
1403
1404 #[test]
1405 fn reuse_label_value_is_twelve_hex_chars_and_deterministic() {
1406 let spec_a = ContainerSpec::new("rz-x-0", "redis:8.6-alpine", "deadbeef");
1407 let spec_b = ContainerSpec::new("rz-x-0", "redis:8.6-alpine", "deadbeef");
1408 let spec_c = ContainerSpec::new("rz-x-1", "redis:8.6-alpine", "deadbeef");
1409 let a = reuse_label_value(&spec_a);
1410 let b = reuse_label_value(&spec_b);
1411 let c = reuse_label_value(&spec_c);
1412 assert_eq!(a.len(), 12);
1413 assert!(a.chars().all(|ch| ch.is_ascii_hexdigit()));
1414 assert_eq!(a, b, "same spec.name must hash the same every time");
1415 assert_ne!(a, c, "a different spec.name must hash differently");
1416 }
1417
1418 use std::sync::Arc;
1429 use tokio::net::UnixListener;
1430
1431 mod tempdir_shim {
1437 use std::path::{Path, PathBuf};
1438
1439 pub(super) struct TempDir(PathBuf);
1440
1441 impl TempDir {
1442 pub(super) fn new() -> Self {
1445 static COUNTER: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0);
1446 let seq = COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1447 let unique = format!("rzdb-{:x}-{:x}", std::process::id() as u16, seq);
1448 let path = Path::new("/tmp").join(unique);
1449 std::fs::create_dir_all(&path).expect("create temp dir");
1450 TempDir(path)
1451 }
1452
1453 pub(super) fn path(&self) -> &Path {
1454 &self.0
1455 }
1456 }
1457
1458 impl Drop for TempDir {
1459 fn drop(&mut self) {
1460 let _ = std::fs::remove_dir_all(&self.0);
1461 }
1462 }
1463 }
1464
1465 async fn multi_request_fixture(
1474 responses: Vec<Vec<u8>>,
1475 ) -> (DockerClient, tempdir_shim::TempDir, Arc<Mutex<Vec<String>>>) {
1476 let dir = tempdir_shim::TempDir::new();
1477 let sock_path = dir.path().join("docker.sock");
1478 let listener = UnixListener::bind(&sock_path).expect("bind fixture socket");
1479 let received: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
1480 let received_clone = received.clone();
1481 tokio::spawn(async move {
1482 for response in responses {
1483 if let Ok((mut conn, _)) = listener.accept().await {
1484 use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
1485 let mut buf = vec![0u8; 4096];
1486 let n = tokio::time::timeout(
1487 std::time::Duration::from_millis(200),
1488 conn.read(&mut buf),
1489 )
1490 .await
1491 .ok()
1492 .and_then(|r| r.ok())
1493 .unwrap_or(0);
1494 let request_line = String::from_utf8_lossy(&buf[..n])
1495 .lines()
1496 .next()
1497 .unwrap_or_default()
1498 .to_string();
1499 received_clone.lock().unwrap().push(request_line);
1500 let _ = conn.write_all(&response).await;
1501 let _ = conn.shutdown().await;
1502 }
1503 }
1504 });
1505 (DockerClient::at_socket(sock_path), dir, received)
1506 }
1507
1508 fn network_list_response(id: &str) -> Vec<u8> {
1511 let payload = format!(r#"[{{"Id":"{id}"}}]"#);
1512 format!(
1513 "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
1514 payload.len(),
1515 payload
1516 )
1517 .into_bytes()
1518 }
1519
1520 fn container_list_response(id: &str) -> Vec<u8> {
1526 network_list_response(id)
1527 }
1528
1529 fn empty_list_response() -> Vec<u8> {
1531 let payload = "[]";
1532 format!(
1533 "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
1534 payload.len(),
1535 payload
1536 )
1537 .into_bytes()
1538 }
1539
1540 #[tokio::test]
1541 async fn find_running_returns_a_handle_when_the_daemon_lists_a_running_match() {
1542 let (client, _dir, received) =
1543 multi_request_fixture(vec![container_list_response("daemon-c-1")]).await;
1544 let backend = DockerBackend::new(client);
1545 let spec = ContainerSpec::new("rz-reuse-799aad5a3338", "redis:7-alpine", "deadbeef");
1546
1547 let found = backend
1548 .find_running(&spec)
1549 .await
1550 .unwrap()
1551 .expect("must find the running container");
1552 assert_eq!(found.id(), "daemon-c-1");
1553
1554 let requests = received.lock().unwrap().clone();
1555 assert_eq!(requests.len(), 1);
1556 assert!(
1557 requests[0].starts_with("GET /containers/json?filters="),
1558 "{requests:?}"
1559 );
1560 }
1561
1562 #[tokio::test]
1563 async fn find_running_returns_none_when_nothing_matches() {
1564 let (client, _dir, _received) = multi_request_fixture(vec![empty_list_response()]).await;
1565 let backend = DockerBackend::new(client);
1566 let spec = ContainerSpec::new("rz-reuse-799aad5a3338", "redis:7-alpine", "deadbeef");
1567
1568 assert!(backend.find_running(&spec).await.unwrap().is_none());
1569 }
1570
1571 #[tokio::test]
1572 async fn create_maps_a_409_conflict_to_the_typed_name_conflict_error() {
1573 let (client, _dir, _received) = multi_request_fixture(vec![
1578 b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\n{}".to_vec(),
1579 b"HTTP/1.1 409 Conflict\r\nContent-Length: 44\r\n\r\n{\"message\":\"Conflict. Name already in use.\"}".to_vec(),
1580 ])
1581 .await;
1582 let backend = DockerBackend::new(client);
1583 let spec = ContainerSpec::new("rz-reuse-799aad5a3338", "redis:7-alpine", "deadbeef");
1584
1585 let err = match backend.create(spec).await {
1586 Ok(_) => panic!("a 409 must surface as an error"),
1587 Err(e) => e,
1588 };
1589 assert!(
1590 matches!(err, rightsize::error::RightsizeError::NameConflict { .. }),
1591 "{err}"
1592 );
1593 }
1594
1595 #[tokio::test]
1596 async fn remove_network_falls_back_to_a_live_lookup_when_the_in_memory_cache_never_saw_it() {
1597 let (client, _dir, received) = multi_request_fixture(vec![
1598 network_list_response("daemon-net-9"),
1599 b"HTTP/1.1 204 No Content\r\n\r\n".to_vec(),
1600 ])
1601 .await;
1602 let backend = DockerBackend::new(client);
1603
1604 backend.remove_network("rz-net-deadrun").await.unwrap();
1608
1609 let requests = received.lock().unwrap().clone();
1610 assert_eq!(
1611 requests.len(),
1612 2,
1613 "a cache miss must fall back to a live GET lookup, then DELETE the id it \
1614 finds — got: {requests:?}"
1615 );
1616 assert!(
1617 requests[0].starts_with("GET /networks?filters="),
1618 "{requests:?}"
1619 );
1620 assert!(
1621 requests[1].starts_with("DELETE /networks/daemon-net-9 "),
1622 "the DELETE must target the id the live lookup resolved, not the \
1623 rightsize-side network name: {requests:?}"
1624 );
1625 }
1626
1627 #[tokio::test]
1628 async fn remove_network_uses_the_cached_id_without_a_lookup_when_this_instance_created_it() {
1629 let (client, _dir, received) =
1634 multi_request_fixture(vec![b"HTTP/1.1 204 No Content\r\n\r\n".to_vec()]).await;
1635 let backend = DockerBackend::new(client);
1636 backend
1637 .network_ids
1638 .lock()
1639 .unwrap()
1640 .insert("rz-net-ownrun".to_string(), "daemon-net-owned".to_string());
1641
1642 backend.remove_network("rz-net-ownrun").await.unwrap();
1643
1644 let requests = received.lock().unwrap().clone();
1645 assert_eq!(requests.len(), 1, "{requests:?}");
1646 assert!(
1647 requests[0].starts_with("DELETE /networks/daemon-net-owned "),
1648 "{requests:?}"
1649 );
1650 }
1651
1652 #[tokio::test]
1655 async fn create_checkpoint_posts_commit_with_the_split_repo_and_tag_and_returns_the_ref() {
1656 let (client, _dir, received) = multi_request_fixture(vec![
1657 b"HTTP/1.1 201 Created\r\nContent-Length: 2\r\n\r\n{}".to_vec(),
1658 ])
1659 .await;
1660 let backend = DockerBackend::new(client);
1661 let handle = Handle {
1662 id: "daemon-c-checkpoint".to_string(),
1663 spec: ContainerSpec::new("rz-x-0", "redis:8.6-alpine", "deadbeef"),
1664 };
1665
1666 let checkpoint_ref = backend
1667 .create_checkpoint(&handle, "abc123def456")
1668 .await
1669 .expect("commit must succeed on a 201");
1670 assert_eq!(checkpoint_ref, "rightsize/checkpoint:abc123def456");
1671
1672 let requests = received.lock().unwrap().clone();
1673 assert_eq!(requests.len(), 1, "{requests:?}");
1674 assert!(
1675 requests[0].starts_with("POST /commit?container=daemon-c-checkpoint"),
1676 "{requests:?}"
1677 );
1678 assert!(
1679 requests[0].contains("repo=rightsize%2Fcheckpoint"),
1680 "{requests:?}"
1681 );
1682 assert!(requests[0].contains("tag=abc123def456"), "{requests:?}");
1683 }
1684
1685 #[tokio::test]
1686 async fn create_checkpoint_surfaces_a_daemon_error_as_a_backend_error() {
1687 let (client, _dir, _received) = multi_request_fixture(vec![
1688 b"HTTP/1.1 404 Not Found\r\nContent-Length: 23\r\n\r\n{\"message\":\"no such\"}"
1689 .to_vec(),
1690 ])
1691 .await;
1692 let backend = DockerBackend::new(client);
1693 let handle = Handle {
1694 id: "missing-container".to_string(),
1695 spec: ContainerSpec::new("rz-x-0", "redis:8.6-alpine", "deadbeef"),
1696 };
1697
1698 let err = backend
1699 .create_checkpoint(&handle, "abc123def456")
1700 .await
1701 .expect_err("a 404 must surface as an error");
1702 assert!(
1703 matches!(err, rightsize::error::RightsizeError::Backend(_)),
1704 "{err}"
1705 );
1706 }
1707
1708 #[tokio::test]
1709 async fn remove_checkpoint_deletes_the_image_and_treats_not_found_as_success() {
1710 let (client, _dir, received) = multi_request_fixture(vec![
1711 b"HTTP/1.1 404 Not Found\r\nContent-Length: 2\r\n\r\n{}".to_vec(),
1712 ])
1713 .await;
1714 let backend = DockerBackend::new(client);
1715
1716 backend
1717 .remove_checkpoint("rightsize/checkpoint:abc123def456")
1718 .await
1719 .expect("a 404 (already gone) must be treated as success");
1720
1721 let requests = received.lock().unwrap().clone();
1722 assert_eq!(requests.len(), 1, "{requests:?}");
1723 assert!(
1724 requests[0].starts_with("DELETE /images/rightsize/checkpoint:abc123def456"),
1725 "{requests:?}"
1726 );
1727 }
1728
1729 #[tokio::test]
1732 async fn has_checkpoint_returns_true_on_a_200() {
1733 let (client, _dir, received) = multi_request_fixture(vec![
1734 b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\n{}".to_vec(),
1735 ])
1736 .await;
1737 let backend = DockerBackend::new(client);
1738
1739 let exists = backend
1740 .has_checkpoint("rightsize/checkpoint:abc123def456")
1741 .await
1742 .expect("a 200 must resolve to Ok(true)");
1743 assert!(exists);
1744
1745 let requests = received.lock().unwrap().clone();
1746 assert_eq!(requests.len(), 1, "{requests:?}");
1747 assert!(
1748 requests[0].starts_with("GET /images/rightsize/checkpoint:abc123def456/json"),
1749 "{requests:?}"
1750 );
1751 }
1752
1753 #[tokio::test]
1754 async fn has_checkpoint_returns_false_on_a_404() {
1755 let (client, _dir, _received) = multi_request_fixture(vec![
1756 b"HTTP/1.1 404 Not Found\r\nContent-Length: 2\r\n\r\n{}".to_vec(),
1757 ])
1758 .await;
1759 let backend = DockerBackend::new(client);
1760
1761 let exists = backend
1762 .has_checkpoint("rightsize/checkpoint:gone")
1763 .await
1764 .expect("a 404 must resolve to Ok(false), not an error");
1765 assert!(!exists);
1766 }
1767
1768 #[tokio::test]
1769 async fn has_checkpoint_surfaces_any_other_status_as_an_error() {
1770 let (client, _dir, _received) = multi_request_fixture(vec![
1771 b"HTTP/1.1 500 Internal Server Error\r\nContent-Length: 15\r\n\r\n{\"message\":\"x\"}"
1772 .to_vec(),
1773 ])
1774 .await;
1775 let backend = DockerBackend::new(client);
1776
1777 let err = backend
1778 .has_checkpoint("rightsize/checkpoint:abc123def456")
1779 .await
1780 .expect_err("a probe failure must surface, never resolve to Ok(false)");
1781 assert!(
1782 matches!(err, rightsize::error::RightsizeError::Backend(_)),
1783 "{err}"
1784 );
1785 }
1786
1787 #[test]
1790 fn docker_cp_in_args_puts_the_host_path_first_and_container_id_colon_path_second() {
1791 assert_eq!(
1792 docker_cp_in_args("daemon-id-1", Path::new("/host/src.txt"), "/guest/dst.txt"),
1793 vec!["cp", "/host/src.txt", "daemon-id-1:/guest/dst.txt"]
1794 );
1795 }
1796
1797 #[test]
1798 fn docker_cp_out_args_puts_the_container_id_colon_path_first_and_host_path_second() {
1799 assert_eq!(
1800 docker_cp_out_args("daemon-id-1", "/guest/src.txt", Path::new("/host/dst.txt")),
1801 vec!["cp", "daemon-id-1:/guest/src.txt", "/host/dst.txt"]
1802 );
1803 }
1804}