1use crate::error::{MigrationError, Result};
4use crate::helper_image::HELPER_OBJECT_PREFIX;
5use crate::model::{
6 ContainerMount, ContainerPlan, ContainerSpec, MigrationPlan, NetworkModeSpec, PortPublish,
7 SourceConfig,
8};
9use crate::progress::{MigrationProgress, MigrationStage};
10use crate::runner::{CreateNetworkOptions, DockerCliRunner};
11use std::collections::{BTreeSet, HashMap};
12
13#[derive(Debug, Clone, Copy, Default)]
15pub struct MigrationExecutorOptions {
16 pub confirm_replace: bool,
18 pub confirm_stop_source_containers: bool,
20 pub start_containers: bool,
23}
24
25#[derive(Debug, Clone, Default, PartialEq, Eq)]
27pub struct MigrationOutcome {
28 pub warnings: Vec<String>,
30}
31
32#[derive(Debug, Clone)]
34pub struct MigrationExecutor {
35 target: DockerCliRunner,
36}
37
38impl MigrationExecutor {
39 #[must_use]
41 pub fn new(target: DockerCliRunner) -> Self {
42 Self { target }
43 }
44
45 pub async fn execute<F>(
56 &self,
57 source: SourceConfig,
58 plan: &MigrationPlan,
59 options: MigrationExecutorOptions,
60 mut progress: F,
61 ) -> Result<MigrationOutcome>
62 where
63 F: FnMut(MigrationProgress),
64 {
65 if !plan.unsupported_resources.is_empty() {
66 return Err(MigrationError::Blocked(format!(
67 "unsupported resources: {}",
68 plan.unsupported_resources.join(", ")
69 )));
70 }
71 if !plan.replacements.is_empty() && !options.confirm_replace {
72 return Err(MigrationError::Blocked(
73 "replace confirmation is required".to_string(),
74 ));
75 }
76 if !plan.blockers.is_empty() && !options.confirm_stop_source_containers {
77 return Err(MigrationError::Blocked(
78 "stopping source containers is required".to_string(),
79 ));
80 }
81
82 let source_runner = DockerCliRunner::new(source.socket_path)?;
83
84 stop_source_blockers(&source_runner, plan, &mut progress).await?;
85 remove_target_conflicts(&self.target, plan, &mut progress).await?;
86
87 source_runner.ensure_helper_image().await?;
88 self.target.ensure_helper_image().await?;
89
90 let image_rewrites =
91 import_images(&source_runner, &self.target, plan, &mut progress).await?;
92 import_volumes(&source_runner, &self.target, plan, &mut progress).await?;
93 recreate_networks(&self.target, plan, &mut progress).await?;
94 let (created, mut warnings) =
95 recreate_containers(&self.target, plan, &image_rewrites, &mut progress).await;
96 if options.start_containers {
97 warnings.extend(start_containers(&self.target, plan, &created, &mut progress).await);
98 }
99
100 Ok(MigrationOutcome { warnings })
105 }
106}
107
108async fn stop_source_blockers<F>(
109 source: &DockerCliRunner,
110 plan: &MigrationPlan,
111 progress: &mut F,
112) -> Result<()>
113where
114 F: FnMut(MigrationProgress),
115{
116 let mut containers = plan
117 .blockers
118 .iter()
119 .flat_map(|blocker| blocker.containers.clone())
120 .collect::<Vec<_>>();
121 containers.sort();
122 containers.dedup();
123
124 let total = u32::try_from(containers.len()).unwrap_or(0);
125 for (index, container) in containers.into_iter().enumerate() {
126 progress(MigrationProgress {
127 stage: MigrationStage::StopSourceContainers,
128 detail: format!("stopping source container '{container}'"),
129 resource_type: Some("container".to_string()),
130 resource_name: Some(container.clone()),
131 current: Some(u32::try_from(index + 1).unwrap_or(total)),
132 total: Some(total),
133 });
134 source.stop_container(&container).await?;
135 }
136 Ok(())
137}
138
139async fn remove_target_conflicts<F>(
140 target: &DockerCliRunner,
141 plan: &MigrationPlan,
142 progress: &mut F,
143) -> Result<()>
144where
145 F: FnMut(MigrationProgress),
146{
147 for container in &plan.replacements.containers {
148 progress(MigrationProgress {
149 stage: MigrationStage::Cleanup,
150 detail: format!("removing target container '{container}'"),
151 resource_type: Some("container".to_string()),
152 resource_name: Some(container.clone()),
153 current: None,
154 total: None,
155 });
156 target.remove_container(container).await?;
157 }
158 for network in &plan.replacements.networks {
159 progress(MigrationProgress {
160 stage: MigrationStage::Cleanup,
161 detail: format!("removing target network '{network}'"),
162 resource_type: Some("network".to_string()),
163 resource_name: Some(network.clone()),
164 current: None,
165 total: None,
166 });
167 target.remove_network(network).await?;
168 }
169 for volume in &plan.replacements.volumes {
170 progress(MigrationProgress {
171 stage: MigrationStage::Cleanup,
172 detail: format!("removing target volume '{volume}'"),
173 resource_type: Some("volume".to_string()),
174 resource_name: Some(volume.clone()),
175 current: None,
176 total: None,
177 });
178 target.remove_volume(volume).await?;
179 }
180 Ok(())
181}
182
183async fn import_images<F>(
192 source: &DockerCliRunner,
193 target: &DockerCliRunner,
194 plan: &MigrationPlan,
195 progress: &mut F,
196) -> Result<HashMap<String, String>>
197where
198 F: FnMut(MigrationProgress),
199{
200 let mut rewrites = HashMap::new();
201 let total = u32::try_from(plan.images.len()).unwrap_or(0);
202 for (index, image) in plan.images.iter().enumerate() {
203 progress(MigrationProgress {
204 stage: MigrationStage::ImportImages,
205 detail: format!("importing image '{}'", image.primary_reference()),
206 resource_type: Some("image".to_string()),
207 resource_name: Some(image.primary_reference().to_string()),
208 current: Some(u32::try_from(index + 1).unwrap_or(total)),
209 total: Some(total),
210 });
211 let transfer = source
212 .pipe_save_into(target, &image.export_references)
213 .await?;
214
215 if !image.repo_tags.is_empty() {
216 continue;
217 }
218 let assigned = transfer.loaded_image_id.ok_or_else(|| {
221 MigrationError::Docker(format!(
222 "docker load reported no image ID for untagged image '{}'",
223 image.image_id
224 ))
225 })?;
226 rewrites.insert(image.primary_reference().to_string(), assigned);
227 }
228 Ok(rewrites)
229}
230
231async fn import_volumes<F>(
232 source: &DockerCliRunner,
233 target: &DockerCliRunner,
234 plan: &MigrationPlan,
235 progress: &mut F,
236) -> Result<()>
237where
238 F: FnMut(MigrationProgress),
239{
240 let total = u32::try_from(plan.volumes.len()).unwrap_or(0);
241 for (index, volume) in plan.volumes.iter().enumerate() {
242 progress(MigrationProgress {
243 stage: MigrationStage::ImportVolumes,
244 detail: format!("importing volume '{}'", volume.name),
245 resource_type: Some("volume".to_string()),
246 resource_name: Some(volume.name.clone()),
247 current: Some(u32::try_from(index + 1).unwrap_or(total)),
248 total: Some(total),
249 });
250
251 target
252 .create_volume(
253 &volume.name,
254 &volume
255 .labels
256 .iter()
257 .map(|(key, value)| (key.clone(), value.clone()))
258 .collect::<Vec<_>>(),
259 &volume
260 .options
261 .iter()
262 .map(|(key, value)| (key.clone(), value.clone()))
263 .collect::<Vec<_>>(),
264 )
265 .await?;
266
267 let source_helper_name =
270 format!("{HELPER_OBJECT_PREFIX}src-{}", sanitize_name(&volume.name));
271 let target_helper_name =
272 format!("{HELPER_OBJECT_PREFIX}dst-{}", sanitize_name(&volume.name));
273
274 source.remove_stale_helper(&source_helper_name).await?;
277 target.remove_stale_helper(&target_helper_name).await?;
278
279 let source_helper = source
280 .create_helper_container(&source_helper_name, &volume.name)
281 .await?;
282 let target_helper = target
283 .create_helper_container(&target_helper_name, &volume.name)
284 .await?;
285
286 let archive_result = source.copy_from_container(&source_helper, "/volume").await;
287 let copy_result = match archive_result {
288 Ok(archive) => {
289 target
290 .copy_to_container(archive.path(), &target_helper, "/")
291 .await
292 }
293 Err(err) => Err(err),
294 };
295
296 let cleanup_source = source.remove_container(&source_helper).await;
297 let cleanup_target = target.remove_container(&target_helper).await;
298
299 copy_result?;
300 cleanup_source?;
301 cleanup_target?;
302 }
303 Ok(())
304}
305
306async fn recreate_networks<F>(
307 target: &DockerCliRunner,
308 plan: &MigrationPlan,
309 progress: &mut F,
310) -> Result<()>
311where
312 F: FnMut(MigrationProgress),
313{
314 let total = u32::try_from(plan.networks.len()).unwrap_or(0);
315 for (index, network) in plan.networks.iter().enumerate() {
316 progress(MigrationProgress {
317 stage: MigrationStage::RecreateNetworks,
318 detail: format!("recreating network '{}'", network.name),
319 resource_type: Some("network".to_string()),
320 resource_name: Some(network.name.clone()),
321 current: Some(u32::try_from(index + 1).unwrap_or(total)),
322 total: Some(total),
323 });
324 let ipam = network
325 .ipam
326 .iter()
327 .map(|entry| {
328 (
329 entry.subnet.clone(),
330 entry.gateway.clone(),
331 entry.ip_range.clone(),
332 )
333 })
334 .collect::<Vec<_>>();
335 let create_options = CreateNetworkOptions {
336 internal: network.internal,
337 enable_ipv6: network.enable_ipv6,
338 attachable: network.attachable,
339 labels: network
340 .labels
341 .iter()
342 .map(|(key, value)| (key.clone(), value.clone()))
343 .collect(),
344 options: network
345 .options
346 .iter()
347 .map(|(key, value)| (key.clone(), value.clone()))
348 .collect(),
349 ipam,
350 };
351 target
352 .create_network(&network.name, &create_options)
353 .await?;
354 }
355 Ok(())
356}
357
358async fn recreate_containers<F>(
375 target: &DockerCliRunner,
376 plan: &MigrationPlan,
377 image_rewrites: &HashMap<String, String>,
378 progress: &mut F,
379) -> (BTreeSet<String>, Vec<String>)
380where
381 F: FnMut(MigrationProgress),
382{
383 let mut created = BTreeSet::new();
384 let mut warnings = Vec::new();
385 let total = u32::try_from(plan.containers.len()).unwrap_or(0);
386 for (index, container) in plan.containers.iter().enumerate() {
387 let current = Some(u32::try_from(index + 1).unwrap_or(total));
388 let create_args = build_create_args(container, resolve_image(container, image_rewrites));
389 let detail = match target.create_container(create_args).await {
390 Ok(container_id) => {
391 created.insert(container.name.clone());
392 match attach_extra_networks(target, container, &container_id).await {
393 Ok(()) => format!("recreated container '{}'", container.name),
394 Err(error) => {
395 let warning = format!(
398 "container '{}' was migrated but could not join every network: {error}",
399 container.name
400 );
401 warnings.push(warning.clone());
402 warning
403 }
404 }
405 }
406 Err(error) => {
407 let warning = format!(
408 "container '{}' could not be recreated: {error}",
409 container.name
410 );
411 warnings.push(warning.clone());
412 warning
413 }
414 };
415 progress(MigrationProgress {
416 stage: MigrationStage::RecreateContainers,
417 detail,
418 resource_type: Some("container".to_string()),
419 resource_name: Some(container.name.clone()),
420 current,
421 total: Some(total),
422 });
423 }
424 (created, warnings)
425}
426
427async fn attach_extra_networks(
429 target: &DockerCliRunner,
430 container: &ContainerPlan,
431 container_id: &str,
432) -> Result<()> {
433 if container.spec.network_mode.forbids_extra_networks() {
434 return Ok(());
435 }
436 for attachment in &container.extra_networks {
437 target
438 .connect_network(&attachment.network, container_id, &attachment.aliases)
439 .await?;
440 }
441 Ok(())
442}
443
444async fn start_containers<F>(
456 target: &DockerCliRunner,
457 plan: &MigrationPlan,
458 created: &BTreeSet<String>,
459 progress: &mut F,
460) -> Vec<String>
461where
462 F: FnMut(MigrationProgress),
463{
464 let running = containers_to_start(plan, created);
465
466 let mut warnings = Vec::new();
467 let total = u32::try_from(running.len()).unwrap_or(0);
468 for (index, container) in running.into_iter().enumerate() {
469 let current = Some(u32::try_from(index + 1).unwrap_or(total));
470 let detail = match target.start_container(&container.name).await {
471 Ok(()) => format!("started container '{}'", container.name),
472 Err(error) => {
473 let warning = format!(
474 "container '{}' was migrated but did not start: {error}",
475 container.name
476 );
477 warnings.push(warning.clone());
478 warning
479 }
480 };
481 progress(MigrationProgress {
482 stage: MigrationStage::StartContainers,
483 detail,
484 resource_type: Some("container".to_string()),
485 resource_name: Some(container.name.clone()),
486 current,
487 total: Some(total),
488 });
489 }
490 warnings
491}
492
493fn containers_to_start<'a>(
500 plan: &'a MigrationPlan,
501 created: &BTreeSet<String>,
502) -> Vec<&'a ContainerPlan> {
503 plan.containers
504 .iter()
505 .filter(|container| container.was_running && created.contains(&container.name))
506 .collect()
507}
508
509fn resolve_image<'a>(plan: &'a ContainerPlan, rewrites: &'a HashMap<String, String>) -> &'a str {
511 rewrites
512 .get(&plan.image_reference)
513 .map_or(plan.image_reference.as_str(), String::as_str)
514}
515
516fn build_create_args(plan: &ContainerPlan, image_reference: &str) -> Vec<String> {
517 let mut args = vec!["--name".to_string(), plan.name.clone()];
518 append_container_spec_args(&mut args, &plan.spec);
519 args.push(image_reference.to_string());
520 args.extend(final_command(&plan.spec));
521 args
522}
523
524fn append_container_spec_args(args: &mut Vec<String>, spec: &ContainerSpec) {
525 if let Some(hostname) = &spec.hostname {
526 args.push("--hostname".to_string());
527 args.push(hostname.clone());
528 }
529 if let Some(domainname) = &spec.domainname {
530 args.push("--domainname".to_string());
531 args.push(domainname.clone());
532 }
533 if let Some(user) = &spec.user {
534 args.push("--user".to_string());
535 args.push(user.clone());
536 }
537 for env in &spec.env {
538 args.push("--env".to_string());
539 args.push(env.clone());
540 }
541 for (key, value) in &spec.labels {
542 args.push("--label".to_string());
543 args.push(format!("{key}={value}"));
544 }
545 for port in &spec.exposed_ports {
546 args.push("--expose".to_string());
547 args.push(port.clone());
548 }
549 if spec.tty {
550 args.push("--tty".to_string());
551 }
552 if spec.open_stdin {
553 args.push("--interactive".to_string());
554 }
555 if let Some(working_dir) = &spec.working_dir {
556 args.push("--workdir".to_string());
557 args.push(working_dir.clone());
558 }
559 if let Some(entrypoint) = spec.entrypoint.first() {
560 args.push("--entrypoint".to_string());
561 args.push(entrypoint.clone());
562 }
563 for mount in &spec.mounts {
564 match mount {
565 ContainerMount::Volume { source, target, rw } => {
566 args.push("--mount".to_string());
567 args.push(format!(
568 "type=volume,src={source},dst={target}{}",
569 if *rw { "" } else { ",readonly" }
570 ));
571 }
572 ContainerMount::Bind { source, target, rw } => {
573 args.push("--mount".to_string());
574 args.push(format!(
575 "type=bind,src={source},dst={target}{}",
576 if *rw { "" } else { ",readonly" }
577 ));
578 }
579 ContainerMount::Tmpfs { target, options } => {
580 args.push("--tmpfs".to_string());
581 args.push(if let Some(options) = options {
582 format!("{target}:{options}")
583 } else {
584 target.clone()
585 });
586 }
587 }
588 }
589 for publish in &spec.publishes {
590 args.push("--publish".to_string());
591 args.push(format_publish(publish));
592 }
593 if let Some(restart_policy) = &spec.restart_policy {
594 args.push("--restart".to_string());
595 let mut value = restart_policy.name.clone();
596 if let Some(count) = restart_policy.maximum_retry_count {
597 if restart_policy.name == "on-failure" {
598 value.push(':');
599 value.push_str(&count.to_string());
600 }
601 }
602 args.push(value);
603 }
604 if spec.privileged {
605 args.push("--privileged".to_string());
606 }
607 if spec.read_only_rootfs {
608 args.push("--read-only".to_string());
609 }
610 for host in &spec.extra_hosts {
611 args.push("--add-host".to_string());
612 args.push(host.clone());
613 }
614 if spec.auto_remove {
615 args.push("--rm".to_string());
616 }
617 if let Some(memory) = spec.memory {
618 args.push("--memory".to_string());
619 args.push(memory.to_string());
620 }
621 if let Some(nano_cpus) = spec.nano_cpus {
622 args.push("--cpus".to_string());
623 args.push(format_cpus(nano_cpus));
624 }
625 for capability in &spec.cap_add {
626 args.push("--cap-add".to_string());
627 args.push(capability.clone());
628 }
629 append_network_args(args, &spec.network_mode);
630}
631
632fn append_network_args(args: &mut Vec<String>, mode: &NetworkModeSpec) {
639 let network = match mode {
640 NetworkModeSpec::Default => return,
641 NetworkModeSpec::Host => "host",
642 NetworkModeSpec::None => "none",
643 NetworkModeSpec::Named(attachment) => {
644 args.push("--network".to_string());
645 args.push(attachment.network.clone());
646 for alias in &attachment.aliases {
648 args.push("--network-alias".to_string());
649 args.push(alias.clone());
650 }
651 return;
652 }
653 };
654 args.push("--network".to_string());
655 args.push(network.to_string());
656}
657
658fn format_cpus(nano_cpus: i64) -> String {
663 const NANOS_PER_CPU: i64 = 1_000_000_000;
664 let whole = nano_cpus / NANOS_PER_CPU;
665 let fraction = nano_cpus % NANOS_PER_CPU;
666 if fraction == 0 {
667 return whole.to_string();
668 }
669 let fraction = format!("{:09}", fraction.abs());
670 format!("{whole}.{}", fraction.trim_end_matches('0'))
671}
672
673fn final_command(spec: &ContainerSpec) -> Vec<String> {
674 let mut command = Vec::new();
675 if spec.entrypoint.len() > 1 {
676 command.extend(spec.entrypoint.iter().skip(1).cloned());
677 }
678 command.extend(spec.cmd.clone());
679 command
680}
681
682fn format_publish(publish: &PortPublish) -> String {
683 let mut out = String::new();
684 if let Some(host_ip) = &publish.host_ip {
685 if !host_ip.is_empty() {
686 out.push_str(host_ip);
687 out.push(':');
688 }
689 }
690 if let Some(host_port) = &publish.host_port {
691 if !host_port.is_empty() {
692 out.push_str(host_port);
693 out.push(':');
694 }
695 }
696 out.push_str(&publish.container_port);
697 out
698}
699
700fn sanitize_name(name: &str) -> String {
701 name.chars()
702 .map(|ch| if ch.is_ascii_alphanumeric() { ch } else { '-' })
703 .collect()
704}
705
706#[cfg(test)]
707mod tests {
708 use super::*;
709 use crate::model::{
710 ContainerNetworkAttachment, ReplacementSummary, RestartPolicySpec, SourceInfo, SourceKind,
711 };
712
713 fn args_for(spec: &ContainerSpec) -> Vec<String> {
714 let mut args = Vec::new();
715 append_container_spec_args(&mut args, spec);
716 args
717 }
718
719 fn contains_pair(args: &[String], flag: &str, value: &str) -> bool {
720 args.windows(2).any(|window| window == [flag, value])
721 }
722
723 #[test]
724 fn final_command_merges_entrypoint_tail_and_cmd() {
725 let spec = ContainerSpec {
726 entrypoint: vec!["/bin/sh".into(), "-c".into()],
727 cmd: vec!["echo hi".into()],
728 ..ContainerSpec::default()
729 };
730 assert_eq!(
731 final_command(&spec),
732 vec!["-c".to_string(), "echo hi".to_string()]
733 );
734 }
735
736 #[test]
737 fn publish_format_handles_host_ip_and_port() {
738 let publish = PortPublish {
739 container_port: "5432/tcp".into(),
740 host_ip: Some("127.0.0.1".into()),
741 host_port: Some("15432".into()),
742 };
743 assert_eq!(format_publish(&publish), "127.0.0.1:15432:5432/tcp");
744 }
745
746 #[test]
747 fn restart_policy_on_failure_keeps_retry_count() {
748 let spec = ContainerSpec {
749 restart_policy: Some(RestartPolicySpec {
750 name: "on-failure".into(),
751 maximum_retry_count: Some(5),
752 }),
753 ..ContainerSpec::default()
754 };
755 assert!(contains_pair(&args_for(&spec), "--restart", "on-failure:5"));
756 }
757
758 #[test]
759 fn default_network_mode_emits_no_network_flag() {
760 let args = args_for(&ContainerSpec::default());
761 assert!(!args.iter().any(|arg| arg == "--network"));
762 }
763
764 #[test]
765 fn host_and_none_network_modes_are_emitted_verbatim() {
766 for (mode, expected) in [
767 (NetworkModeSpec::Host, "host"),
768 (NetworkModeSpec::None, "none"),
769 ] {
770 let spec = ContainerSpec {
771 network_mode: mode,
772 ..ContainerSpec::default()
773 };
774 assert!(contains_pair(&args_for(&spec), "--network", expected));
775 }
776 }
777
778 #[test]
779 fn host_mode_keeps_hostname_and_published_ports() {
780 let spec = ContainerSpec {
783 network_mode: NetworkModeSpec::Host,
784 hostname: Some("api".into()),
785 publishes: vec![PortPublish {
786 container_port: "80/tcp".into(),
787 host_ip: None,
788 host_port: Some("8080".into()),
789 }],
790 ..ContainerSpec::default()
791 };
792 let args = args_for(&spec);
793 assert!(contains_pair(&args, "--hostname", "api"));
794 assert!(contains_pair(&args, "--publish", "8080:80/tcp"));
795 }
796
797 #[test]
798 fn named_network_carries_its_aliases() {
799 let spec = ContainerSpec {
800 network_mode: NetworkModeSpec::Named(ContainerNetworkAttachment {
801 network: "usernet".into(),
802 aliases: vec!["api".into()],
803 }),
804 ..ContainerSpec::default()
805 };
806 let args = args_for(&spec);
807 assert!(contains_pair(&args, "--network", "usernet"));
808 assert!(contains_pair(&args, "--network-alias", "api"));
809 }
810
811 #[test]
812 fn resource_limits_are_emitted_when_set() {
813 let spec = ContainerSpec {
814 memory: Some(536_870_912),
815 nano_cpus: Some(1_500_000_000),
816 cap_add: vec!["NET_ADMIN".into(), "SYS_PTRACE".into()],
817 ..ContainerSpec::default()
818 };
819 let args = args_for(&spec);
820 assert!(contains_pair(&args, "--memory", "536870912"));
821 assert!(contains_pair(&args, "--cpus", "1.5"));
822 assert!(contains_pair(&args, "--cap-add", "NET_ADMIN"));
823 assert!(contains_pair(&args, "--cap-add", "SYS_PTRACE"));
824 }
825
826 #[test]
827 fn cpu_quotas_round_trip_exactly() {
828 assert_eq!(format_cpus(1_234_567_890), "1.23456789");
830 assert_eq!(format_cpus(100_000), "0.0001");
831 assert_eq!(format_cpus(1_500_000_000), "1.5");
832 assert_eq!(format_cpus(2_000_000_000), "2");
833 assert_eq!(format_cpus(1), "0.000000001");
834 }
835
836 #[test]
837 fn unset_resource_limits_emit_no_flags() {
838 let args = args_for(&ContainerSpec::default());
839 for flag in ["--memory", "--cpus", "--cap-add"] {
840 assert!(
841 !args.iter().any(|arg| arg == flag),
842 "{flag} should be absent"
843 );
844 }
845 }
846
847 fn container_on(image_reference: &str) -> ContainerPlan {
848 ContainerPlan {
849 name: "demo".into(),
850 id: "cid".into(),
851 image_reference: image_reference.into(),
852 spec: ContainerSpec::default(),
853 extra_networks: Vec::new(),
854 replace_existing: false,
855 was_running: false,
856 created: String::new(),
857 }
858 }
859
860 #[test]
861 fn an_untagged_image_reference_is_rewritten_to_the_assigned_id() {
862 let rewrites = HashMap::from([("sha256:source".to_string(), "sha256:target".to_string())]);
865 let container = container_on("sha256:source");
866
867 assert_eq!(resolve_image(&container, &rewrites), "sha256:target");
868 assert_eq!(
869 build_create_args(&container, resolve_image(&container, &rewrites)).last(),
870 Some(&"sha256:target".to_string())
871 );
872 }
873
874 #[test]
875 fn tagged_image_references_pass_through_untouched() {
876 let rewrites = HashMap::from([("sha256:source".to_string(), "sha256:target".to_string())]);
877 let container = container_on("myapp:dev");
878 assert_eq!(resolve_image(&container, &rewrites), "myapp:dev");
879 }
880
881 #[test]
882 fn only_host_mode_forbids_extra_networks() {
883 assert!(NetworkModeSpec::Host.forbids_extra_networks());
884 assert!(!NetworkModeSpec::None.forbids_extra_networks());
885 assert!(!NetworkModeSpec::Default.forbids_extra_networks());
886 }
887
888 fn named_container(name: &str, was_running: bool) -> ContainerPlan {
889 ContainerPlan {
890 name: name.into(),
891 was_running,
892 ..container_on("alpine:3.21")
893 }
894 }
895
896 fn plan_of(containers: Vec<ContainerPlan>) -> MigrationPlan {
897 MigrationPlan {
898 source: SourceInfo {
899 kind: SourceKind::OrbStack,
900 socket_path: std::path::PathBuf::new(),
901 daemon_name: String::new(),
902 server_version: String::new(),
903 operating_system: String::new(),
904 architecture: String::new(),
905 },
906 helper_image: String::new(),
907 images: Vec::new(),
908 volumes: Vec::new(),
909 networks: Vec::new(),
910 containers,
911 unsupported_resources: Vec::new(),
912 warnings: Vec::new(),
913 replacements: ReplacementSummary::default(),
914 blockers: Vec::new(),
915 }
916 }
917
918 #[test]
919 fn a_container_that_failed_to_recreate_is_not_started() {
920 let plan = plan_of(vec![
923 named_container("survived", true),
924 named_container("failed-create", true),
925 named_container("was-stopped", false),
926 ]);
927 let created = BTreeSet::from(["survived".to_string(), "was-stopped".to_string()]);
928
929 let names: Vec<_> = containers_to_start(&plan, &created)
930 .iter()
931 .map(|container| container.name.as_str())
932 .collect();
933 assert_eq!(
934 names,
935 vec!["survived"],
936 "only a container that was both running on the source and recreated here"
937 );
938 }
939}