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 export_checkpoint(&self, checkpoint_ref: &str, dest: &Path) -> Result<()> {
819 let args = docker_save_args(checkpoint_ref, dest);
820 let result = run_docker_cli(&args).await?;
821 if result.exit_code != 0 {
822 return Err(RightsizeError::Backend(format!(
823 "docker save -o {} {checkpoint_ref} failed (exit {}): {}",
824 dest.display(),
825 result.exit_code,
826 result.stderr.trim()
827 )));
828 }
829 Ok(())
830 }
831
832 async fn import_checkpoint(&self, src_file: &Path, ref_hint: &str) -> Result<String> {
839 let args = docker_load_args(src_file);
840 let result = run_docker_cli(&args).await?;
841 if result.exit_code != 0 {
842 return Err(RightsizeError::Backend(format!(
843 "docker load -i {} failed (exit {}): {}",
844 src_file.display(),
845 result.exit_code,
846 result.stderr.trim()
847 )));
848 }
849 Ok(ref_hint.to_string())
850 }
851
852 async fn copy_to_container(
861 &self,
862 handle: &dyn SandboxHandle,
863 host_path: &Path,
864 container_path: &str,
865 ) -> Result<()> {
866 let args = docker_cp_in_args(handle.id(), host_path, container_path);
867 let result = run_docker_cli(&args).await?;
868 if result.exit_code != 0 {
869 return Err(RightsizeError::Backend(format!(
870 "docker cp into container {} failed (exit {}): {}",
871 handle.id(),
872 result.exit_code,
873 result.stderr.trim()
874 )));
875 }
876 Ok(())
877 }
878
879 async fn copy_from_container(
882 &self,
883 handle: &dyn SandboxHandle,
884 container_path: &str,
885 host_path: &Path,
886 ) -> Result<()> {
887 let args = docker_cp_out_args(handle.id(), container_path, host_path);
888 let result = run_docker_cli(&args).await?;
889 if result.exit_code != 0 {
890 return Err(RightsizeError::Backend(format!(
891 "docker cp out of container {} failed (exit {}): {}",
892 handle.id(),
893 result.exit_code,
894 result.stderr.trim()
895 )));
896 }
897 Ok(())
898 }
899
900 fn watchdog_kill_command(&self) -> Vec<String> {
901 vec!["docker".to_string(), "rm".to_string(), "-f".to_string()]
902 }
903
904 fn watchdog_network_kill_command(&self) -> Vec<String> {
905 vec![
906 "docker".to_string(),
907 "network".to_string(),
908 "rm".to_string(),
909 ]
910 }
911}
912
913fn docker_cp_in_args(container_id: &str, host_path: &Path, container_path: &str) -> Vec<String> {
918 vec![
919 "cp".to_string(),
920 host_path.display().to_string(),
921 format!("{container_id}:{container_path}"),
922 ]
923}
924
925fn docker_cp_out_args(container_id: &str, container_path: &str, host_path: &Path) -> Vec<String> {
928 vec![
929 "cp".to_string(),
930 format!("{container_id}:{container_path}"),
931 host_path.display().to_string(),
932 ]
933}
934
935fn docker_save_args(checkpoint_ref: &str, dest: &Path) -> Vec<String> {
939 vec![
940 "save".to_string(),
941 "-o".to_string(),
942 dest.display().to_string(),
943 checkpoint_ref.to_string(),
944 ]
945}
946
947fn docker_load_args(src_file: &Path) -> Vec<String> {
950 vec![
951 "load".to_string(),
952 "-i".to_string(),
953 src_file.display().to_string(),
954 ]
955}
956
957async fn run_docker_cli(args: &[String]) -> Result<ExecResult> {
963 let output = tokio::process::Command::new("docker")
964 .args(args)
965 .stdin(std::process::Stdio::null())
966 .output()
967 .await
968 .map_err(|e| {
969 RightsizeError::Backend(format!("failed to spawn docker {}: {e}", args.join(" ")))
970 })?;
971 Ok(ExecResult {
972 exit_code: output.status.code().unwrap_or(-1),
973 stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
974 stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
975 })
976}
977
978fn blocking_force_remove(
985 socket_path: &std::path::Path,
986 container_id: &str,
987 stop_timeout_secs: u64,
988) -> std::io::Result<()> {
989 let _ = blocking_request(
990 socket_path,
991 "POST",
992 &format!("/containers/{container_id}/stop?t={stop_timeout_secs}"),
993 );
994 blocking_request(
995 socket_path,
996 "DELETE",
997 &format!("/containers/{container_id}?force=true"),
998 )
999 .map(|_| ())
1000}
1001
1002fn blocking_find_container_id_by_name(socket_path: &std::path::Path, name: &str) -> Option<String> {
1010 let filters = format!(r#"{{"name":["^/{name}$"]}}"#);
1011 let path = format!(
1012 "/containers/json?all=true&filters={}",
1013 encode_query_value(&filters)
1014 );
1015 let body = blocking_get_body(socket_path, &path).ok()?;
1016 let entries: Vec<ListedEntry> = serde_json::from_slice(&body).ok()?;
1017 entries.into_iter().next().map(|e| e.id)
1018}
1019
1020fn blocking_get_body(socket_path: &std::path::Path, path: &str) -> std::io::Result<Vec<u8>> {
1032 use std::io::{BufRead, BufReader, Read, Write};
1033 use std::os::unix::net::UnixStream;
1034
1035 let mut stream = UnixStream::connect(socket_path)?;
1036 stream.set_read_timeout(Some(Duration::from_secs(stop_timeout_budget())))?;
1037 let request = format!("GET {path} HTTP/1.1\r\nHost: docker\r\nConnection: close\r\n\r\n");
1038 stream.write_all(request.as_bytes())?;
1039
1040 let mut reader = BufReader::new(stream);
1041 let mut status_line = String::new();
1042 reader.read_line(&mut status_line)?;
1043
1044 let mut content_length: Option<usize> = None;
1045 let mut chunked = false;
1046 loop {
1047 let mut line = String::new();
1048 let n = reader.read_line(&mut line)?;
1049 if n == 0 || line == "\r\n" || line == "\n" {
1050 break;
1051 }
1052 if let Some((name, value)) = line.split_once(':') {
1053 let name = name.trim();
1054 let value = value.trim();
1055 if name.eq_ignore_ascii_case("content-length") {
1056 content_length = value.parse::<usize>().ok();
1057 } else if name.eq_ignore_ascii_case("transfer-encoding") {
1058 chunked = value.to_ascii_lowercase().contains("chunked");
1059 }
1060 }
1061 }
1062
1063 if chunked {
1064 return blocking_read_chunked_body(&mut reader);
1065 }
1066
1067 let mut body = Vec::new();
1068 match content_length {
1069 Some(len) => {
1070 body.resize(len, 0);
1071 reader.read_exact(&mut body)?;
1072 }
1073 None => {
1074 reader.read_to_end(&mut body)?;
1075 }
1076 }
1077 Ok(body)
1078}
1079
1080fn blocking_read_chunked_body(
1087 reader: &mut std::io::BufReader<std::os::unix::net::UnixStream>,
1088) -> std::io::Result<Vec<u8>> {
1089 use std::io::{BufRead, Read};
1090
1091 let mut out = Vec::new();
1092 loop {
1093 let mut size_line = String::new();
1094 reader.read_line(&mut size_line)?;
1095 let size_str = size_line.trim().split(';').next().unwrap_or("").trim();
1096 let size = usize::from_str_radix(size_str, 16).map_err(|_| {
1097 std::io::Error::new(
1098 std::io::ErrorKind::InvalidData,
1099 format!(
1100 "could not parse a chunk size from the Docker daemon's response: \
1101 {size_line:?}"
1102 ),
1103 )
1104 })?;
1105 if size == 0 {
1106 loop {
1108 let mut trailer = String::new();
1109 let n = reader.read_line(&mut trailer)?;
1110 if n == 0 || trailer == "\r\n" || trailer == "\n" {
1111 break;
1112 }
1113 }
1114 break;
1115 }
1116 let mut chunk = vec![0u8; size];
1117 reader.read_exact(&mut chunk)?;
1118 out.extend_from_slice(&chunk);
1119 let mut crlf = [0u8; 2];
1121 reader.read_exact(&mut crlf)?;
1122 }
1123 Ok(out)
1124}
1125
1126fn blocking_request(
1135 socket_path: &std::path::Path,
1136 method: &str,
1137 path: &str,
1138) -> std::io::Result<()> {
1139 use std::io::{Read, Write};
1140 use std::os::unix::net::UnixStream;
1141
1142 let mut stream = UnixStream::connect(socket_path)?;
1143 stream.set_read_timeout(Some(Duration::from_secs(stop_timeout_budget())))?;
1144 let request = format!("{method} {path} HTTP/1.1\r\nHost: docker\r\nConnection: close\r\n\r\n");
1145 stream.write_all(request.as_bytes())?;
1146 let mut discard = Vec::new();
1147 let _ = stream.read_to_end(&mut discard);
1148 Ok(())
1149}
1150
1151fn stop_timeout_budget() -> u64 {
1156 STOP_TIMEOUT_SECS + 20
1157}
1158
1159#[cfg(test)]
1160mod tests {
1161 use super::*;
1162
1163 #[test]
1164 fn capabilities_report_no_hardware_isolation_but_checkpoint_support() {
1165 let backend = DockerBackend::connecting_to_env();
1166 let caps = backend.capabilities();
1167 assert!(!caps.hardware_isolated, "containers share the host kernel");
1168 assert!(caps.checkpoint);
1169 assert!(
1170 !caps.checkpoint_restarts_workload,
1171 "an image commit leaves the running container undisturbed"
1172 );
1173 }
1174
1175 mod blocking_tempdir_shim {
1179 use std::path::{Path, PathBuf};
1180
1181 pub(super) struct TempDir(PathBuf);
1182
1183 impl TempDir {
1184 pub(super) fn new() -> Self {
1185 static COUNTER: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0);
1186 let seq = COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1187 let unique = format!("rzdbb-{:x}-{:x}", std::process::id() as u16, seq);
1188 let path = Path::new("/tmp").join(unique);
1189 std::fs::create_dir_all(&path).expect("create temp dir");
1190 TempDir(path)
1191 }
1192
1193 pub(super) fn path(&self) -> &Path {
1194 &self.0
1195 }
1196 }
1197
1198 impl Drop for TempDir {
1199 fn drop(&mut self) {
1200 let _ = std::fs::remove_dir_all(&self.0);
1201 }
1202 }
1203 }
1204
1205 #[test]
1212 fn blocking_get_body_dechunks_a_transfer_encoding_chunked_response() {
1213 use std::io::{Read, Write};
1214 use std::os::unix::net::UnixListener;
1215
1216 let dir = blocking_tempdir_shim::TempDir::new();
1217 let sock_path = dir.path().join("docker.sock");
1218 let listener = UnixListener::bind(&sock_path).expect("bind fixture socket");
1219
1220 let server = std::thread::spawn(move || {
1221 let (mut stream, _) = listener.accept().expect("accept fixture connection");
1222 let mut buf = [0u8; 4096];
1223 let _ = stream.read(&mut buf); stream
1225 .write_all(
1226 b"HTTP/1.1 200 OK\r\n\
1227 Content-Type: application/json\r\n\
1228 Transfer-Encoding: chunked\r\n\
1229 \r\n\
1230 5\r\nhello\r\n\
1231 6\r\n world\r\n\
1232 0\r\n\r\n",
1233 )
1234 .expect("write fixture response");
1235 });
1236
1237 let body =
1238 blocking_get_body(&sock_path, "/containers/json").expect("dechunking must succeed");
1239 assert_eq!(body, b"hello world");
1240 server.join().expect("fixture server thread must not panic");
1241 }
1242
1243 #[test]
1247 fn blocking_get_body_honors_content_length_when_present() {
1248 use std::io::{Read, Write};
1249 use std::os::unix::net::UnixListener;
1250
1251 let dir = blocking_tempdir_shim::TempDir::new();
1252 let sock_path = dir.path().join("docker.sock");
1253 let listener = UnixListener::bind(&sock_path).expect("bind fixture socket");
1254
1255 let server = std::thread::spawn(move || {
1256 let (mut stream, _) = listener.accept().expect("accept fixture connection");
1257 let mut buf = [0u8; 4096];
1258 let _ = stream.read(&mut buf);
1259 stream
1260 .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 13\r\n\r\nhello world!!")
1261 .expect("write fixture response");
1262 });
1263
1264 let body = blocking_get_body(&sock_path, "/containers/json")
1265 .expect("Content-Length read must succeed");
1266 assert_eq!(body, b"hello world!!");
1267 server.join().expect("fixture server thread must not panic");
1268 }
1269
1270 #[test]
1271 fn is_port_bind_conflict_message_matches_known_phrasings() {
1272 assert!(is_port_bind_conflict_message(
1273 "driver failed programming external connectivity: address already in use"
1274 ));
1275 assert!(is_port_bind_conflict_message(
1276 "Bind for 0.0.0.0:6379 failed: port is already allocated"
1277 ));
1278 assert!(is_port_bind_conflict_message(
1279 "ALREADY ALLOCATED (case-insensitive)"
1280 ));
1281 }
1282
1283 #[test]
1284 fn is_port_bind_conflict_message_negative_cases_do_not_match() {
1285 assert!(!is_port_bind_conflict_message("no such image"));
1286 assert!(!is_port_bind_conflict_message("container already stopped"));
1287 assert!(!is_port_bind_conflict_message(""));
1288 }
1289
1290 #[test]
1291 fn split_repo_tag_defaults_to_latest_when_untagged() {
1292 assert_eq!(
1293 split_repo_tag("redis"),
1294 ("redis".to_string(), "latest".to_string())
1295 );
1296 assert_eq!(
1297 split_repo_tag("redis:8.6-alpine"),
1298 ("redis".to_string(), "8.6-alpine".to_string())
1299 );
1300 }
1301
1302 #[test]
1303 fn split_repo_tag_handles_a_registry_host_with_a_port() {
1304 assert_eq!(
1305 split_repo_tag("localhost:5000/redis"),
1306 ("localhost:5000/redis".to_string(), "latest".to_string())
1307 );
1308 assert_eq!(
1309 split_repo_tag("localhost:5000/redis:8.6-alpine"),
1310 ("localhost:5000/redis".to_string(), "8.6-alpine".to_string())
1311 );
1312 }
1313
1314 #[test]
1315 fn split_repo_tag_passes_through_a_digest_reference_with_no_tag() {
1316 assert_eq!(
1317 split_repo_tag("redis@sha256:abcdef"),
1318 ("redis@sha256:abcdef".to_string(), String::new())
1319 );
1320 }
1321
1322 fn serialize(spec: &ContainerSpec) -> String {
1328 serde_json::to_string(&build_create_body(spec)).unwrap()
1329 }
1330
1331 #[test]
1332 fn build_create_body_includes_port_bindings_on_loopback() {
1333 let mut spec = ContainerSpec::new("rz-x-0", "redis:8.6-alpine", "deadbeef");
1334 spec.ports.push(rightsize::model::PortBinding {
1335 host_port: 32768,
1336 guest_port: 6379,
1337 });
1338 let body = serialize(&spec);
1339 assert!(body.contains("\"HostIp\":\"127.0.0.1\""));
1340 assert!(body.contains("\"HostPort\":\"32768\""));
1341 assert!(body.contains("\"6379/tcp\""));
1342 }
1343
1344 #[test]
1345 fn build_create_body_includes_the_run_id_label_and_extra_host() {
1346 let spec = ContainerSpec::new("rz-x-0", "redis:8.6-alpine", "deadbeef");
1347 let body = serialize(&spec);
1348 assert!(body.contains("\"dev.rightsize.run_id\":\"deadbeef\""));
1349 assert!(body.contains("host.docker.internal:host-gateway"));
1350 }
1351
1352 #[test]
1353 fn build_create_body_honors_mount_read_only_flag() {
1354 let mut spec = ContainerSpec::new("rz-x-0", "redis:8.6-alpine", "deadbeef");
1355 spec.mounts
1356 .push(rightsize::model::FileMount::new("/host/a", "/guest/a"));
1357 spec.mounts
1358 .push(rightsize::model::FileMount::new("/host/b", "/guest/b").read_write());
1359 let body = serialize(&spec);
1360 assert!(body.contains("/host/a:/guest/a:ro"));
1361 assert!(body.contains("/host/b:/guest/b:rw"));
1362 }
1363
1364 #[test]
1365 fn build_create_body_omits_memory_when_unset_and_includes_it_when_set() {
1366 let spec = ContainerSpec::new("rz-x-0", "redis:8.6-alpine", "deadbeef");
1367 assert!(!serialize(&spec).contains("Memory"));
1368
1369 let mut spec = ContainerSpec::new("rz-x-0", "redis:8.6-alpine", "deadbeef");
1370 spec.memory_limit_mb = Some(512);
1371 assert!(serialize(&spec).contains("\"Memory\":536870912"));
1372 }
1373
1374 #[test]
1375 fn build_create_body_omits_cmd_when_command_is_none_and_includes_it_when_some() {
1376 let spec = ContainerSpec::new("rz-x-0", "redis:8.6-alpine", "deadbeef");
1377 assert!(!serialize(&spec).contains("\"Cmd\""));
1378
1379 let mut spec = ContainerSpec::new("rz-x-0", "redis:8.6-alpine", "deadbeef");
1380 spec.command = Some(vec![
1381 "redis-server".to_string(),
1382 "--port".to_string(),
1383 "6379".to_string(),
1384 ]);
1385 let body = serialize(&spec);
1386 assert!(body.contains("\"Cmd\":[\"redis-server\",\"--port\",\"6379\"]"));
1387 }
1388
1389 #[test]
1390 fn docker_backend_dials_a_unix_socket_never_a_tcp_host() {
1391 let backend = DockerBackend::new(DockerClient::from_docker_host(None));
1396 let path = backend.socket_path_for_test();
1397 assert!(
1398 path.is_absolute(),
1399 "expected an absolute unix socket path, got {path:?}"
1400 );
1401 let path_str = path.to_string_lossy();
1402 assert!(
1403 !path_str.contains(':'),
1404 "a unix socket path must not look like a host:port TCP address — got {path_str}"
1405 );
1406
1407 let backend_env =
1408 DockerBackend::new(DockerClient::from_docker_host(Some("tcp://127.0.0.1:2375")));
1409 let fallback_path = backend_env.socket_path_for_test();
1410 assert!(
1411 !fallback_path.to_string_lossy().contains("2375"),
1412 "a tcp:// DOCKER_HOST must not leak a TCP port into this backend's transport — \
1413 got {fallback_path:?}"
1414 );
1415 }
1416
1417 #[test]
1418 fn keep_alive_spec_gets_the_reuse_label_instead_of_the_run_id_label() {
1419 let mut spec = ContainerSpec::new("rz-x-0", "redis:8.6-alpine", "deadbeef");
1420 spec.keep_alive = true;
1421 let body = serialize(&spec);
1422 assert!(body.contains("\"dev.rightsize.reuse\""), "{body}");
1423 assert!(!body.contains("\"dev.rightsize.run_id\""), "{body}");
1424 }
1425
1426 #[test]
1427 fn non_keep_alive_spec_still_gets_the_run_id_label_only() {
1428 let spec = ContainerSpec::new("rz-x-0", "redis:8.6-alpine", "deadbeef");
1429 let body = serialize(&spec);
1430 assert!(
1431 body.contains("\"dev.rightsize.run_id\":\"deadbeef\""),
1432 "{body}"
1433 );
1434 assert!(!body.contains("\"dev.rightsize.reuse\""), "{body}");
1435 }
1436
1437 #[test]
1438 fn watchdog_kill_command_is_a_plain_docker_rm_dash_f() {
1439 let backend = DockerBackend::new(DockerClient::from_docker_host(None));
1440 assert_eq!(
1441 backend.watchdog_kill_command(),
1442 vec!["docker".to_string(), "rm".to_string(), "-f".to_string()]
1443 );
1444 }
1445
1446 #[test]
1447 fn watchdog_network_kill_command_is_docker_network_rm() {
1448 let backend = DockerBackend::new(DockerClient::from_docker_host(None));
1449 assert_eq!(
1450 backend.watchdog_network_kill_command(),
1451 vec![
1452 "docker".to_string(),
1453 "network".to_string(),
1454 "rm".to_string()
1455 ]
1456 );
1457 }
1458
1459 #[test]
1460 fn reuse_label_value_extracts_the_hash_straight_from_an_rz_reuse_name() {
1461 let spec = ContainerSpec::new("rz-reuse-799aad5a3338", "redis:7-alpine", "deadbeef");
1462 assert_eq!(reuse_label_value(&spec), "799aad5a3338");
1463 }
1464
1465 #[test]
1466 fn reuse_label_value_is_twelve_hex_chars_and_deterministic() {
1467 let spec_a = ContainerSpec::new("rz-x-0", "redis:8.6-alpine", "deadbeef");
1468 let spec_b = ContainerSpec::new("rz-x-0", "redis:8.6-alpine", "deadbeef");
1469 let spec_c = ContainerSpec::new("rz-x-1", "redis:8.6-alpine", "deadbeef");
1470 let a = reuse_label_value(&spec_a);
1471 let b = reuse_label_value(&spec_b);
1472 let c = reuse_label_value(&spec_c);
1473 assert_eq!(a.len(), 12);
1474 assert!(a.chars().all(|ch| ch.is_ascii_hexdigit()));
1475 assert_eq!(a, b, "same spec.name must hash the same every time");
1476 assert_ne!(a, c, "a different spec.name must hash differently");
1477 }
1478
1479 use std::sync::Arc;
1490 use tokio::net::UnixListener;
1491
1492 mod tempdir_shim {
1498 use std::path::{Path, PathBuf};
1499
1500 pub(super) struct TempDir(PathBuf);
1501
1502 impl TempDir {
1503 pub(super) fn new() -> Self {
1506 static COUNTER: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0);
1507 let seq = COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1508 let unique = format!("rzdb-{:x}-{:x}", std::process::id() as u16, seq);
1509 let path = Path::new("/tmp").join(unique);
1510 std::fs::create_dir_all(&path).expect("create temp dir");
1511 TempDir(path)
1512 }
1513
1514 pub(super) fn path(&self) -> &Path {
1515 &self.0
1516 }
1517 }
1518
1519 impl Drop for TempDir {
1520 fn drop(&mut self) {
1521 let _ = std::fs::remove_dir_all(&self.0);
1522 }
1523 }
1524 }
1525
1526 async fn multi_request_fixture(
1535 responses: Vec<Vec<u8>>,
1536 ) -> (DockerClient, tempdir_shim::TempDir, Arc<Mutex<Vec<String>>>) {
1537 let dir = tempdir_shim::TempDir::new();
1538 let sock_path = dir.path().join("docker.sock");
1539 let listener = UnixListener::bind(&sock_path).expect("bind fixture socket");
1540 let received: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
1541 let received_clone = received.clone();
1542 tokio::spawn(async move {
1543 for response in responses {
1544 if let Ok((mut conn, _)) = listener.accept().await {
1545 use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
1546 let mut buf = vec![0u8; 4096];
1547 let n = tokio::time::timeout(
1548 std::time::Duration::from_millis(200),
1549 conn.read(&mut buf),
1550 )
1551 .await
1552 .ok()
1553 .and_then(|r| r.ok())
1554 .unwrap_or(0);
1555 let request_line = String::from_utf8_lossy(&buf[..n])
1556 .lines()
1557 .next()
1558 .unwrap_or_default()
1559 .to_string();
1560 received_clone.lock().unwrap().push(request_line);
1561 let _ = conn.write_all(&response).await;
1562 let _ = conn.shutdown().await;
1563 }
1564 }
1565 });
1566 (DockerClient::at_socket(sock_path), dir, received)
1567 }
1568
1569 fn network_list_response(id: &str) -> Vec<u8> {
1572 let payload = format!(r#"[{{"Id":"{id}"}}]"#);
1573 format!(
1574 "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
1575 payload.len(),
1576 payload
1577 )
1578 .into_bytes()
1579 }
1580
1581 fn container_list_response(id: &str) -> Vec<u8> {
1587 network_list_response(id)
1588 }
1589
1590 fn empty_list_response() -> Vec<u8> {
1592 let payload = "[]";
1593 format!(
1594 "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
1595 payload.len(),
1596 payload
1597 )
1598 .into_bytes()
1599 }
1600
1601 #[tokio::test]
1602 async fn find_running_returns_a_handle_when_the_daemon_lists_a_running_match() {
1603 let (client, _dir, received) =
1604 multi_request_fixture(vec![container_list_response("daemon-c-1")]).await;
1605 let backend = DockerBackend::new(client);
1606 let spec = ContainerSpec::new("rz-reuse-799aad5a3338", "redis:7-alpine", "deadbeef");
1607
1608 let found = backend
1609 .find_running(&spec)
1610 .await
1611 .unwrap()
1612 .expect("must find the running container");
1613 assert_eq!(found.id(), "daemon-c-1");
1614
1615 let requests = received.lock().unwrap().clone();
1616 assert_eq!(requests.len(), 1);
1617 assert!(
1618 requests[0].starts_with("GET /containers/json?filters="),
1619 "{requests:?}"
1620 );
1621 }
1622
1623 #[tokio::test]
1624 async fn find_running_returns_none_when_nothing_matches() {
1625 let (client, _dir, _received) = multi_request_fixture(vec![empty_list_response()]).await;
1626 let backend = DockerBackend::new(client);
1627 let spec = ContainerSpec::new("rz-reuse-799aad5a3338", "redis:7-alpine", "deadbeef");
1628
1629 assert!(backend.find_running(&spec).await.unwrap().is_none());
1630 }
1631
1632 #[tokio::test]
1633 async fn create_maps_a_409_conflict_to_the_typed_name_conflict_error() {
1634 let (client, _dir, _received) = multi_request_fixture(vec![
1639 b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\n{}".to_vec(),
1640 b"HTTP/1.1 409 Conflict\r\nContent-Length: 44\r\n\r\n{\"message\":\"Conflict. Name already in use.\"}".to_vec(),
1641 ])
1642 .await;
1643 let backend = DockerBackend::new(client);
1644 let spec = ContainerSpec::new("rz-reuse-799aad5a3338", "redis:7-alpine", "deadbeef");
1645
1646 let err = match backend.create(spec).await {
1647 Ok(_) => panic!("a 409 must surface as an error"),
1648 Err(e) => e,
1649 };
1650 assert!(
1651 matches!(err, rightsize::error::RightsizeError::NameConflict { .. }),
1652 "{err}"
1653 );
1654 }
1655
1656 #[tokio::test]
1657 async fn remove_network_falls_back_to_a_live_lookup_when_the_in_memory_cache_never_saw_it() {
1658 let (client, _dir, received) = multi_request_fixture(vec![
1659 network_list_response("daemon-net-9"),
1660 b"HTTP/1.1 204 No Content\r\n\r\n".to_vec(),
1661 ])
1662 .await;
1663 let backend = DockerBackend::new(client);
1664
1665 backend.remove_network("rz-net-deadrun").await.unwrap();
1669
1670 let requests = received.lock().unwrap().clone();
1671 assert_eq!(
1672 requests.len(),
1673 2,
1674 "a cache miss must fall back to a live GET lookup, then DELETE the id it \
1675 finds — got: {requests:?}"
1676 );
1677 assert!(
1678 requests[0].starts_with("GET /networks?filters="),
1679 "{requests:?}"
1680 );
1681 assert!(
1682 requests[1].starts_with("DELETE /networks/daemon-net-9 "),
1683 "the DELETE must target the id the live lookup resolved, not the \
1684 rightsize-side network name: {requests:?}"
1685 );
1686 }
1687
1688 #[tokio::test]
1689 async fn remove_network_uses_the_cached_id_without_a_lookup_when_this_instance_created_it() {
1690 let (client, _dir, received) =
1695 multi_request_fixture(vec![b"HTTP/1.1 204 No Content\r\n\r\n".to_vec()]).await;
1696 let backend = DockerBackend::new(client);
1697 backend
1698 .network_ids
1699 .lock()
1700 .unwrap()
1701 .insert("rz-net-ownrun".to_string(), "daemon-net-owned".to_string());
1702
1703 backend.remove_network("rz-net-ownrun").await.unwrap();
1704
1705 let requests = received.lock().unwrap().clone();
1706 assert_eq!(requests.len(), 1, "{requests:?}");
1707 assert!(
1708 requests[0].starts_with("DELETE /networks/daemon-net-owned "),
1709 "{requests:?}"
1710 );
1711 }
1712
1713 #[tokio::test]
1716 async fn create_checkpoint_posts_commit_with_the_split_repo_and_tag_and_returns_the_ref() {
1717 let (client, _dir, received) = multi_request_fixture(vec![
1718 b"HTTP/1.1 201 Created\r\nContent-Length: 2\r\n\r\n{}".to_vec(),
1719 ])
1720 .await;
1721 let backend = DockerBackend::new(client);
1722 let handle = Handle {
1723 id: "daemon-c-checkpoint".to_string(),
1724 spec: ContainerSpec::new("rz-x-0", "redis:8.6-alpine", "deadbeef"),
1725 };
1726
1727 let checkpoint_ref = backend
1728 .create_checkpoint(&handle, "abc123def456")
1729 .await
1730 .expect("commit must succeed on a 201");
1731 assert_eq!(checkpoint_ref, "rightsize/checkpoint:abc123def456");
1732
1733 let requests = received.lock().unwrap().clone();
1734 assert_eq!(requests.len(), 1, "{requests:?}");
1735 assert!(
1736 requests[0].starts_with("POST /commit?container=daemon-c-checkpoint"),
1737 "{requests:?}"
1738 );
1739 assert!(
1740 requests[0].contains("repo=rightsize%2Fcheckpoint"),
1741 "{requests:?}"
1742 );
1743 assert!(requests[0].contains("tag=abc123def456"), "{requests:?}");
1744 }
1745
1746 #[tokio::test]
1747 async fn create_checkpoint_surfaces_a_daemon_error_as_a_backend_error() {
1748 let (client, _dir, _received) = multi_request_fixture(vec![
1749 b"HTTP/1.1 404 Not Found\r\nContent-Length: 23\r\n\r\n{\"message\":\"no such\"}"
1750 .to_vec(),
1751 ])
1752 .await;
1753 let backend = DockerBackend::new(client);
1754 let handle = Handle {
1755 id: "missing-container".to_string(),
1756 spec: ContainerSpec::new("rz-x-0", "redis:8.6-alpine", "deadbeef"),
1757 };
1758
1759 let err = backend
1760 .create_checkpoint(&handle, "abc123def456")
1761 .await
1762 .expect_err("a 404 must surface as an error");
1763 assert!(
1764 matches!(err, rightsize::error::RightsizeError::Backend(_)),
1765 "{err}"
1766 );
1767 }
1768
1769 #[tokio::test]
1770 async fn remove_checkpoint_deletes_the_image_and_treats_not_found_as_success() {
1771 let (client, _dir, received) = multi_request_fixture(vec![
1772 b"HTTP/1.1 404 Not Found\r\nContent-Length: 2\r\n\r\n{}".to_vec(),
1773 ])
1774 .await;
1775 let backend = DockerBackend::new(client);
1776
1777 backend
1778 .remove_checkpoint("rightsize/checkpoint:abc123def456")
1779 .await
1780 .expect("a 404 (already gone) must be treated as success");
1781
1782 let requests = received.lock().unwrap().clone();
1783 assert_eq!(requests.len(), 1, "{requests:?}");
1784 assert!(
1785 requests[0].starts_with("DELETE /images/rightsize/checkpoint:abc123def456"),
1786 "{requests:?}"
1787 );
1788 }
1789
1790 #[tokio::test]
1793 async fn has_checkpoint_returns_true_on_a_200() {
1794 let (client, _dir, received) = multi_request_fixture(vec![
1795 b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\n{}".to_vec(),
1796 ])
1797 .await;
1798 let backend = DockerBackend::new(client);
1799
1800 let exists = backend
1801 .has_checkpoint("rightsize/checkpoint:abc123def456")
1802 .await
1803 .expect("a 200 must resolve to Ok(true)");
1804 assert!(exists);
1805
1806 let requests = received.lock().unwrap().clone();
1807 assert_eq!(requests.len(), 1, "{requests:?}");
1808 assert!(
1809 requests[0].starts_with("GET /images/rightsize/checkpoint:abc123def456/json"),
1810 "{requests:?}"
1811 );
1812 }
1813
1814 #[tokio::test]
1815 async fn has_checkpoint_returns_false_on_a_404() {
1816 let (client, _dir, _received) = multi_request_fixture(vec![
1817 b"HTTP/1.1 404 Not Found\r\nContent-Length: 2\r\n\r\n{}".to_vec(),
1818 ])
1819 .await;
1820 let backend = DockerBackend::new(client);
1821
1822 let exists = backend
1823 .has_checkpoint("rightsize/checkpoint:gone")
1824 .await
1825 .expect("a 404 must resolve to Ok(false), not an error");
1826 assert!(!exists);
1827 }
1828
1829 #[tokio::test]
1830 async fn has_checkpoint_surfaces_any_other_status_as_an_error() {
1831 let (client, _dir, _received) = multi_request_fixture(vec![
1832 b"HTTP/1.1 500 Internal Server Error\r\nContent-Length: 15\r\n\r\n{\"message\":\"x\"}"
1833 .to_vec(),
1834 ])
1835 .await;
1836 let backend = DockerBackend::new(client);
1837
1838 let err = backend
1839 .has_checkpoint("rightsize/checkpoint:abc123def456")
1840 .await
1841 .expect_err("a probe failure must surface, never resolve to Ok(false)");
1842 assert!(
1843 matches!(err, rightsize::error::RightsizeError::Backend(_)),
1844 "{err}"
1845 );
1846 }
1847
1848 #[test]
1851 fn docker_cp_in_args_puts_the_host_path_first_and_container_id_colon_path_second() {
1852 assert_eq!(
1853 docker_cp_in_args("daemon-id-1", Path::new("/host/src.txt"), "/guest/dst.txt"),
1854 vec!["cp", "/host/src.txt", "daemon-id-1:/guest/dst.txt"]
1855 );
1856 }
1857
1858 #[test]
1859 fn docker_cp_out_args_puts_the_container_id_colon_path_first_and_host_path_second() {
1860 assert_eq!(
1861 docker_cp_out_args("daemon-id-1", "/guest/src.txt", Path::new("/host/dst.txt")),
1862 vec!["cp", "daemon-id-1:/guest/src.txt", "/host/dst.txt"]
1863 );
1864 }
1865
1866 #[test]
1869 fn docker_save_args_spelling() {
1870 assert_eq!(
1871 docker_save_args(
1872 "rightsize/checkpoint:abc123def456",
1873 Path::new("/tmp/cp.archive-payload")
1874 ),
1875 vec![
1876 "save",
1877 "-o",
1878 "/tmp/cp.archive-payload",
1879 "rightsize/checkpoint:abc123def456"
1880 ]
1881 );
1882 }
1883
1884 #[test]
1885 fn docker_load_args_spelling() {
1886 assert_eq!(
1887 docker_load_args(Path::new("/tmp/cp.archive-payload")),
1888 vec!["load", "-i", "/tmp/cp.archive-payload"]
1889 );
1890 }
1891}