1use crate::docker_types::{
4 ContainerInspect, DockerInfo, ImageInspect, MountPoint, NetworkInspect, RestartPolicy,
5 VolumeInspect,
6};
7use crate::error::Result;
8use crate::helper_image::{helper_image_reference, is_helper_object};
9use crate::model::{
10 ContainerMount, ContainerNetworkAttachment, ContainerPlan, ContainerSpec, ImagePlan,
11 MigrationPlan, NetworkModeSpec, NetworkPlan, PortPublish, ReplacementSummary,
12 RestartPolicySpec, RunningVolumeBlocker, SourceConfig, SourceInfo, VolumePlan,
13};
14use crate::runner::DockerCliRunner;
15use std::collections::{BTreeMap, BTreeSet, HashMap};
16
17#[derive(Debug, Clone)]
19pub struct MigrationPlanner {
20 target: DockerCliRunner,
21}
22
23impl MigrationPlanner {
24 #[must_use]
26 pub fn new(target: DockerCliRunner) -> Self {
27 Self { target }
28 }
29
30 pub async fn plan(&self, source: SourceConfig) -> Result<MigrationPlan> {
32 let source_runner = DockerCliRunner::new(source.socket_path.clone())?;
33
34 let source_info = source_runner.info().await?;
35 let target_images = self.target.list_images().await?;
36 let target_image_tags = collect_target_tags(&target_images);
37 let target_volumes =
38 collect_names(self.target.list_volumes().await?, |volume| &volume.name);
39 let target_networks =
40 collect_names(self.target.list_networks().await?, |network| &network.name);
41 let target_containers = collect_names(self.target.list_containers().await?, |container| {
42 trimmed_name(container)
43 });
44
45 let source_images = source_runner.list_images().await?;
46 let source_volumes = source_runner.list_volumes().await?;
47 let source_networks = source_runner.list_networks().await?;
48 let source_containers: Vec<_> = source_runner
54 .list_containers()
55 .await?
56 .into_iter()
57 .filter(|container| !is_helper_object(trimmed_name(container)))
58 .collect();
59
60 let mut unsupported_resources = Vec::new();
61
62 let mut volume_usage: HashMap<String, Vec<(String, bool)>> = HashMap::new();
63 for container in &source_containers {
64 let container_name = trimmed_name(container).to_string();
65 for mount in &container.mounts {
66 if mount.mount_type == "volume" && !mount.name.is_empty() {
67 volume_usage
68 .entry(mount.name.clone())
69 .or_default()
70 .push((container_name.clone(), container.state.running));
71 } else if mount.mount_type != "volume"
72 && mount.mount_type != "bind"
73 && mount.mount_type != "tmpfs"
74 {
75 unsupported_resources.push(format!(
76 "container '{}' uses unsupported mount type '{}'",
77 container_name, mount.mount_type
78 ));
79 }
80 }
81 }
82
83 let volume_plans: Vec<_> = source_volumes
84 .into_iter()
85 .map(|volume| normalize_volume(volume, &target_volumes, &volume_usage))
86 .inspect(|plan| {
87 if plan.driver != "local" {
88 unsupported_resources.push(format!(
89 "volume '{}' uses unsupported driver '{}'",
90 plan.name, plan.driver
91 ));
92 }
93 })
94 .collect();
95
96 let blockers = volume_plans
97 .iter()
98 .filter_map(|volume| {
99 let running: Vec<_> = volume_usage
100 .get(&volume.name)?
101 .iter()
102 .filter(|(_, running)| *running)
103 .map(|(name, _)| name.clone())
104 .collect();
105 if running.is_empty() {
106 None
107 } else {
108 Some(RunningVolumeBlocker {
109 volume_name: volume.name.clone(),
110 containers: running,
111 })
112 }
113 })
114 .collect();
115
116 let network_plans: Vec<_> = source_networks
117 .into_iter()
118 .map(|network| normalize_network(network, &target_networks))
119 .inspect(|plan| {
120 if plan.driver != "bridge" {
121 unsupported_resources.push(format!(
122 "network '{}' uses unsupported driver '{}'",
123 plan.name, plan.driver
124 ));
125 }
126 })
127 .collect();
128 let migrated_network_names: BTreeSet<_> = network_plans
129 .iter()
130 .map(|network| network.name.clone())
131 .collect();
132
133 let image_plan_data =
134 normalize_images(&source_images, &source_containers, &target_image_tags);
135 let image_refs_by_id: HashMap<_, _> = image_plan_data
136 .iter()
137 .map(|image| (image.image_id.clone(), image.export_references.clone()))
138 .collect();
139
140 let mut warnings = Vec::new();
141 let mut container_plans: Vec<_> = source_containers
142 .into_iter()
143 .map(|container| {
144 normalize_container(
145 container,
146 &target_containers,
147 &image_refs_by_id,
148 &migrated_network_names,
149 &mut warnings,
150 &mut unsupported_resources,
151 )
152 })
153 .collect();
154 container_plans.sort_by_key(|plan| created_at(&plan.created));
161
162 let replacements = build_replacements(
163 &image_plan_data,
164 &volume_plans,
165 &network_plans,
166 &container_plans,
167 );
168 warnings.extend(collect_missing_bind_sources(&container_plans));
169
170 Ok(MigrationPlan {
171 source: normalize_source_info(source, source_info),
172 helper_image: helper_image_reference().to_string(),
173 images: image_plan_data,
174 volumes: volume_plans,
175 networks: network_plans,
176 containers: container_plans,
177 unsupported_resources,
178 warnings,
179 replacements,
180 blockers,
181 })
182 }
183}
184
185fn normalize_source_info(source: SourceConfig, info: DockerInfo) -> SourceInfo {
186 SourceInfo {
187 kind: source.kind,
188 socket_path: source.socket_path,
189 daemon_name: info.name,
190 server_version: info.server_version,
191 operating_system: info.operating_system,
192 architecture: info.architecture,
193 }
194}
195
196fn normalize_images(
197 images: &[ImageInspect],
198 containers: &[ContainerInspect],
199 target_tags: &BTreeSet<String>,
200) -> Vec<ImagePlan> {
201 let mut ordered = BTreeMap::new();
202
203 for image in images {
204 let tags = meaningful_tags(&image.repo_tags);
205 if tags.iter().any(|tag| tag == helper_image_reference()) {
209 continue;
210 }
211 if !tags.is_empty() {
212 ordered.insert(
213 image.id.clone(),
214 ImagePlan {
215 image_id: image.id.clone(),
216 export_references: tags.clone(),
217 replace_tags: tags
218 .iter()
219 .filter(|tag| target_tags.contains(*tag))
220 .cloned()
221 .collect(),
222 repo_tags: tags,
223 },
224 );
225 }
226 }
227
228 for container in containers {
233 ordered
234 .entry(container.image.clone())
235 .or_insert_with(|| ImagePlan {
236 image_id: container.image.clone(),
237 export_references: vec![container.image.clone()],
238 repo_tags: Vec::new(),
239 replace_tags: Vec::new(),
240 });
241 }
242
243 ordered.into_values().collect()
244}
245
246fn normalize_volume(
247 volume: VolumeInspect,
248 target_volumes: &BTreeSet<String>,
249 usage: &HashMap<String, Vec<(String, bool)>>,
250) -> VolumePlan {
251 let attached_containers = usage
252 .get(&volume.name)
253 .map(|items| items.iter().map(|(name, _)| name.clone()).collect())
254 .unwrap_or_default();
255
256 VolumePlan {
257 name: volume.name.clone(),
258 driver: volume.driver,
259 labels: volume.labels.unwrap_or_default(),
260 options: volume.options.unwrap_or_default(),
261 replace_existing: target_volumes.contains(&volume.name),
262 attached_containers,
263 }
264}
265
266fn normalize_network(network: NetworkInspect, target_networks: &BTreeSet<String>) -> NetworkPlan {
267 NetworkPlan {
268 name: network.name.clone(),
269 id: network.id,
270 driver: network.driver,
271 internal: network.internal,
272 enable_ipv6: network.enable_ipv6,
273 attachable: network.attachable,
274 labels: network.labels.unwrap_or_default(),
275 options: network.options.unwrap_or_default(),
276 ipam: network.ipam.config,
277 replace_existing: target_networks.contains(&network.name),
278 }
279}
280
281enum NetworkModeOutcome {
283 Resolved(NetworkModeSpec),
284 Unsupported(String),
286}
287
288fn classify_network_mode(
293 mode: &str,
294 container_name: &str,
295 attachments: &[ContainerNetworkAttachment],
296 warnings: &mut Vec<String>,
297) -> NetworkModeOutcome {
298 match mode {
299 "" | "default" | "bridge" => NetworkModeOutcome::Resolved(NetworkModeSpec::Default),
300 "host" => NetworkModeOutcome::Resolved(NetworkModeSpec::Host),
301 "none" => NetworkModeOutcome::Resolved(NetworkModeSpec::None),
302 other if other.starts_with("container:") => NetworkModeOutcome::Unsupported(format!(
303 "container '{container_name}' shares another container's network namespace ('{other}'), which migration cannot reproduce"
304 )),
305 named => attachments
306 .iter()
307 .find(|attachment| attachment.network == named)
308 .cloned()
309 .map_or_else(
310 || {
311 warnings.push(format!(
315 "container '{container_name}' was on network '{named}', which is not part of this migration; it will join the default bridge instead"
316 ));
317 NetworkModeOutcome::Resolved(NetworkModeSpec::Default)
318 },
319 |attachment| NetworkModeOutcome::Resolved(NetworkModeSpec::Named(attachment)),
320 ),
321 }
322}
323
324fn preferred_reference(references: &[String], requested: &str) -> Option<String> {
331 references
332 .iter()
333 .find(|reference| reference.as_str() == requested)
334 .or_else(|| references.first())
335 .cloned()
336}
337
338fn normalize_container(
339 container: ContainerInspect,
340 target_containers: &BTreeSet<String>,
341 image_refs_by_id: &HashMap<String, Vec<String>>,
342 migrated_network_names: &BTreeSet<String>,
343 warnings: &mut Vec<String>,
344 unsupported: &mut Vec<String>,
345) -> ContainerPlan {
346 let name = trimmed_name(&container).to_string();
347 let image_reference = image_refs_by_id
348 .get(&container.image)
349 .and_then(|references| preferred_reference(references, &container.config.image))
350 .unwrap_or_else(|| {
351 if container.config.image.is_empty() {
352 container.image.clone()
353 } else {
354 container.config.image.clone()
355 }
356 });
357
358 let mut attachments = normalized_network_attachments(&container, migrated_network_names);
359 let network_mode = match classify_network_mode(
362 &container.host_config.network_mode,
363 &name,
364 &attachments,
365 warnings,
366 ) {
367 NetworkModeOutcome::Resolved(mode) => mode,
368 NetworkModeOutcome::Unsupported(reason) => {
369 unsupported.push(reason);
370 NetworkModeSpec::Default
371 }
372 };
373 if let NetworkModeSpec::Named(primary) = &network_mode {
374 attachments.retain(|attachment| attachment.network != primary.network);
375 }
376
377 ContainerPlan {
378 name: name.clone(),
379 id: container.id,
380 image_reference,
381 spec: ContainerSpec {
382 hostname: non_empty(&container.config.hostname),
383 domainname: non_empty(&container.config.domainname),
384 user: non_empty(&container.config.user),
385 env: container.config.env.unwrap_or_default(),
386 labels: container.config.labels.unwrap_or_default(),
387 exposed_ports: sorted(
388 container
389 .config
390 .exposed_ports
391 .unwrap_or_default()
392 .into_keys()
393 .collect(),
394 ),
395 tty: container.config.tty,
396 open_stdin: container.config.open_stdin,
397 working_dir: non_empty(&container.config.working_dir),
398 entrypoint: container.config.entrypoint.unwrap_or_default(),
399 cmd: container.config.cmd.unwrap_or_default(),
400 mounts: container.mounts.iter().map(normalize_mount).collect(),
401 publishes: normalized_publishes(container.host_config.port_bindings),
402 restart_policy: normalize_restart_policy(container.host_config.restart_policy),
403 privileged: container.host_config.privileged,
404 read_only_rootfs: container.host_config.readonly_rootfs,
405 extra_hosts: container.host_config.extra_hosts.unwrap_or_default(),
406 auto_remove: container.host_config.auto_remove,
407 memory: positive(container.host_config.memory),
408 nano_cpus: positive(container.host_config.nano_cpus),
409 cap_add: sorted(container.host_config.cap_add.unwrap_or_default()),
410 network_mode,
411 },
412 extra_networks: attachments,
413 replace_existing: target_containers.contains(&name),
414 was_running: container.state.running,
415 created: container.created,
416 }
417}
418
419fn normalize_mount(mount: &MountPoint) -> ContainerMount {
420 match mount.mount_type.as_str() {
421 "bind" => ContainerMount::Bind {
422 source: mount.source.clone(),
423 target: mount.destination.clone(),
424 rw: mount.rw,
425 },
426 "tmpfs" => ContainerMount::Tmpfs {
427 target: mount.destination.clone(),
428 options: non_empty(&mount.mode),
429 },
430 _ => ContainerMount::Volume {
431 source: if mount.name.is_empty() {
432 mount.source.clone()
433 } else {
434 mount.name.clone()
435 },
436 target: mount.destination.clone(),
437 rw: mount.rw,
438 },
439 }
440}
441
442fn normalized_publishes(
443 port_bindings: Option<HashMap<String, Option<Vec<crate::docker_types::PortBinding>>>>,
444) -> Vec<PortPublish> {
445 let Some(port_bindings) = port_bindings else {
446 return Vec::new();
447 };
448
449 let mut publishes = Vec::new();
450 let mut ports: Vec<_> = port_bindings.into_iter().collect();
451 ports.sort_by(|(left, _), (right, _)| left.cmp(right));
452 for (container_port, bindings) in ports {
453 match bindings {
454 Some(bindings) if !bindings.is_empty() => {
455 for binding in bindings {
456 publishes.push(PortPublish {
457 container_port: container_port.clone(),
458 host_ip: non_empty(&binding.host_ip),
459 host_port: non_empty(&binding.host_port),
460 });
461 }
462 }
463 _ => publishes.push(PortPublish {
464 container_port,
465 host_ip: None,
466 host_port: None,
467 }),
468 }
469 }
470 publishes
471}
472
473fn normalize_restart_policy(policy: Option<RestartPolicy>) -> Option<RestartPolicySpec> {
474 let policy = policy?;
475 if policy.name.is_empty() || policy.name == "no" {
476 None
477 } else {
478 Some(RestartPolicySpec {
479 name: policy.name,
480 maximum_retry_count: if policy.maximum_retry_count > 0 {
481 Some(policy.maximum_retry_count)
482 } else {
483 None
484 },
485 })
486 }
487}
488
489fn normalized_network_attachments(
490 container: &ContainerInspect,
491 migrated_network_names: &BTreeSet<String>,
492) -> Vec<ContainerNetworkAttachment> {
493 let name = trimmed_name(container);
494 let mut attachments: Vec<_> = container
495 .network_settings
496 .networks
497 .iter()
498 .filter(|(network, _)| migrated_network_names.contains(*network))
499 .map(|(network, endpoint)| ContainerNetworkAttachment {
500 network: network.clone(),
501 aliases: endpoint
502 .aliases
503 .clone()
504 .unwrap_or_default()
505 .into_iter()
506 .filter(|alias| alias != name)
507 .collect(),
508 })
509 .collect();
510 attachments.sort_by(|left, right| left.network.cmp(&right.network));
511 attachments
512}
513
514fn collect_missing_bind_sources(containers: &[ContainerPlan]) -> Vec<String> {
521 let mut warnings = Vec::new();
522 for container in containers {
523 for mount in &container.spec.mounts {
524 if let ContainerMount::Bind { source, target, .. } = mount {
525 if !std::path::Path::new(source).exists() {
526 warnings.push(format!(
527 "container '{}' binds '{source}' to '{target}', but that path does not exist on this host",
528 container.name
529 ));
530 }
531 }
532 }
533 }
534 warnings
535}
536
537fn build_replacements(
538 images: &[ImagePlan],
539 volumes: &[VolumePlan],
540 networks: &[NetworkPlan],
541 containers: &[ContainerPlan],
542) -> ReplacementSummary {
543 ReplacementSummary {
544 image_tags: images
545 .iter()
546 .flat_map(|image| image.replace_tags.clone())
547 .collect(),
548 volumes: volumes
549 .iter()
550 .filter(|volume| volume.replace_existing)
551 .map(|volume| volume.name.clone())
552 .collect(),
553 networks: networks
554 .iter()
555 .filter(|network| network.replace_existing)
556 .map(|network| network.name.clone())
557 .collect(),
558 containers: containers
559 .iter()
560 .filter(|container| container.replace_existing)
561 .map(|container| container.name.clone())
562 .collect(),
563 }
564}
565
566fn meaningful_tags(tags: &[String]) -> Vec<String> {
567 tags.iter()
568 .filter(|tag| *tag != "<none>:<none>")
569 .cloned()
570 .collect()
571}
572
573fn collect_names<T, F>(items: Vec<T>, name_fn: F) -> BTreeSet<String>
574where
575 F: Fn(&T) -> &str,
576{
577 items
578 .into_iter()
579 .map(|item| name_fn(&item).to_string())
580 .collect()
581}
582
583fn collect_target_tags(images: &[ImageInspect]) -> BTreeSet<String> {
584 images
585 .iter()
586 .flat_map(|image| meaningful_tags(&image.repo_tags))
587 .collect()
588}
589
590fn trimmed_name(container: &ContainerInspect) -> &str {
591 container.name.trim_start_matches('/')
592}
593
594fn created_at(created: &str) -> i64 {
599 chrono::DateTime::parse_from_rfc3339(created)
600 .ok()
601 .and_then(|stamp| stamp.timestamp_nanos_opt())
602 .unwrap_or(i64::MIN)
603}
604
605fn positive(value: i64) -> Option<i64> {
607 (value > 0).then_some(value)
608}
609
610fn sorted(mut values: Vec<String>) -> Vec<String> {
613 values.sort();
614 values
615}
616
617fn non_empty(value: &str) -> Option<String> {
618 if value.is_empty() {
619 None
620 } else {
621 Some(value.to_string())
622 }
623}
624
625#[cfg(test)]
626mod tests {
627 use super::*;
628 use crate::docker_types::{EndpointSettings, NetworkSettings};
629 use std::collections::HashMap;
630
631 #[test]
632 fn meaningful_tags_filters_none_entries() {
633 let tags = meaningful_tags(&["<none>:<none>".into(), "nginx:latest".into()]);
634 assert_eq!(tags, vec!["nginx:latest"]);
635 }
636
637 fn image_inspect(id: &str, tags: &[&str]) -> ImageInspect {
638 ImageInspect {
639 id: id.into(),
640 repo_tags: tags.iter().map(|tag| (*tag).to_string()).collect(),
641 repo_digests: Vec::new(),
642 }
643 }
644
645 #[test]
646 fn a_container_keeps_the_tag_it_was_created_under() {
647 let references = vec![
650 "alpine:3.21".to_string(),
651 "smoke-app:dev".to_string(),
652 "smoke-app:latest".to_string(),
653 ];
654 assert_eq!(
655 preferred_reference(&references, "smoke-app:dev").as_deref(),
656 Some("smoke-app:dev")
657 );
658 }
659
660 #[test]
661 fn an_unknown_original_name_falls_back_to_the_primary_reference() {
662 let references = vec!["alpine:3.21".to_string(), "smoke-app:dev".to_string()];
663 assert_eq!(
665 preferred_reference(&references, "smoke-app:gone").as_deref(),
666 Some("alpine:3.21")
667 );
668 assert_eq!(
671 preferred_reference(&["sha256:abc".to_string()], "abc").as_deref(),
672 Some("sha256:abc")
673 );
674 assert_eq!(preferred_reference(&[], "anything"), None);
675 }
676
677 #[test]
678 fn the_helper_image_is_never_planned() {
679 let images = [
682 image_inspect(
683 "sha256:helper",
684 &[crate::helper_image::helper_image_reference()],
685 ),
686 image_inspect("sha256:real", &["postgres:16"]),
687 ];
688 let plans = normalize_images(&images, &[], &BTreeSet::new());
689
690 let refs: Vec<_> = plans
691 .iter()
692 .flat_map(|plan| plan.export_references.clone())
693 .collect();
694 assert_eq!(refs, vec!["postgres:16".to_string()]);
695 }
696
697 #[test]
698 fn only_the_exact_helper_image_is_excluded() {
699 let images = [
702 image_inspect("sha256:neighbour", &["arcbox-migration-tools:latest"]),
703 image_inspect("sha256:helper-ish", &["arcbox-migration-helper:v2"]),
704 ];
705 let plans = normalize_images(&images, &[], &BTreeSet::new());
706
707 let mut refs: Vec<_> = plans
708 .iter()
709 .flat_map(|plan| plan.export_references.clone())
710 .collect();
711 refs.sort();
712 assert_eq!(
713 refs,
714 vec![
715 "arcbox-migration-helper:v2".to_string(),
716 "arcbox-migration-tools:latest".to_string(),
717 ]
718 );
719 }
720
721 #[test]
722 fn every_tag_of_an_image_is_exported() {
723 let images = [image_inspect(
724 "sha256:abc",
725 &["myapp:dev", "myapp:latest", "<none>:<none>"],
726 )];
727 let plans = normalize_images(&images, &[], &BTreeSet::new());
728
729 assert_eq!(plans.len(), 1);
730 assert_eq!(
732 plans[0].export_references,
733 vec!["myapp:dev".to_string(), "myapp:latest".to_string()]
734 );
735 assert_eq!(plans[0].primary_reference(), "myapp:dev");
736 }
737
738 fn classify(mode: &str, attachments: &[ContainerNetworkAttachment]) -> NetworkModeOutcome {
739 let mut warnings = Vec::new();
740 classify_network_mode(mode, "demo", attachments, &mut warnings)
741 }
742
743 fn resolved(mode: &str, attachments: &[ContainerNetworkAttachment]) -> NetworkModeSpec {
744 match classify(mode, attachments) {
745 NetworkModeOutcome::Resolved(spec) => spec,
746 NetworkModeOutcome::Unsupported(reason) => panic!("unexpectedly unsupported: {reason}"),
747 }
748 }
749
750 #[test]
751 fn standard_network_modes_are_classified() {
752 for mode in ["", "default", "bridge"] {
753 assert_eq!(resolved(mode, &[]), NetworkModeSpec::Default);
754 }
755 assert_eq!(resolved("host", &[]), NetworkModeSpec::Host);
756 assert_eq!(resolved("none", &[]), NetworkModeSpec::None);
757 }
758
759 #[test]
760 fn named_network_mode_selects_its_attachment() {
761 let attachments = [
762 ContainerNetworkAttachment {
763 network: "aaa-first-alphabetically".into(),
764 aliases: Vec::new(),
765 },
766 ContainerNetworkAttachment {
767 network: "usernet".into(),
768 aliases: vec!["api".into()],
769 },
770 ];
771 assert_eq!(
773 resolved("usernet", &attachments),
774 NetworkModeSpec::Named(attachments[1].clone())
775 );
776 }
777
778 #[test]
779 fn container_network_mode_is_unsupported() {
780 let outcome = classify("container:abc123", &[]);
781 let NetworkModeOutcome::Unsupported(reason) = outcome else {
782 panic!("container mode must be rejected");
783 };
784 assert!(reason.contains("container:abc123"));
785 }
786
787 #[test]
788 fn unmigrated_named_network_falls_back_with_a_warning() {
789 let mut warnings = Vec::new();
790 let outcome = classify_network_mode("macvlan0", "demo", &[], &mut warnings);
791 assert!(matches!(
792 outcome,
793 NetworkModeOutcome::Resolved(NetworkModeSpec::Default)
794 ));
795 assert_eq!(warnings.len(), 1);
796 assert!(warnings[0].contains("macvlan0"));
797 }
798
799 fn plan_with_bind(name: &str, source: &str) -> ContainerPlan {
800 ContainerPlan {
801 name: name.into(),
802 id: "id".into(),
803 image_reference: "img".into(),
804 spec: ContainerSpec {
805 mounts: vec![ContainerMount::Bind {
806 source: source.into(),
807 target: "/app".into(),
808 rw: true,
809 }],
810 ..ContainerSpec::default()
811 },
812 extra_networks: Vec::new(),
813 replace_existing: false,
814 was_running: false,
815 created: String::new(),
816 }
817 }
818
819 #[test]
820 fn creation_order_survives_stripped_trailing_zeros() {
821 let earlier = "2026-08-01T08:09:16.84759688Z"; let later = "2026-08-01T08:09:16.847596885Z";
826 assert!(earlier > later, "precondition: byte order is inverted here");
827 assert!(
828 created_at(earlier) < created_at(later),
829 "parsed order must be chronological"
830 );
831 }
832
833 #[test]
834 fn unparseable_creation_stamps_sort_first() {
835 assert_eq!(created_at(""), i64::MIN);
836 assert!(created_at("") < created_at("2026-08-01T08:09:16Z"));
837 }
838
839 #[test]
840 fn zero_resource_limits_are_treated_as_unset() {
841 assert_eq!(positive(0), None);
842 assert_eq!(positive(-1), None);
843 assert_eq!(positive(512), Some(512));
844 }
845
846 #[test]
847 fn running_state_and_creation_order_are_carried_into_the_plan() {
848 let mut warnings = Vec::new();
849 let mut unsupported = Vec::new();
850 let plan = normalize_container(
851 ContainerInspect {
852 id: "cid".into(),
853 name: "/db".into(),
854 image: "postgres".into(),
855 created: "2024-05-02T10:00:00Z".into(),
856 state: crate::docker_types::ContainerState {
857 status: "running".into(),
858 running: true,
859 },
860 config: crate::docker_types::ContainerConfig::default(),
861 host_config: crate::docker_types::HostConfig::default(),
862 network_settings: NetworkSettings::default(),
863 mounts: Vec::new(),
864 },
865 &BTreeSet::new(),
866 &HashMap::new(),
867 &BTreeSet::new(),
868 &mut warnings,
869 &mut unsupported,
870 );
871
872 assert!(plan.was_running);
873 assert_eq!(plan.created, "2024-05-02T10:00:00Z");
874 }
875
876 #[test]
877 fn missing_bind_source_is_warned_about() {
878 let present = tempfile::tempdir().unwrap();
879 let plans = [
880 plan_with_bind("ok", &present.path().to_string_lossy()),
881 plan_with_bind("broken", "/definitely/not/a/real/path"),
882 ];
883
884 let warnings = collect_missing_bind_sources(&plans);
885 assert_eq!(warnings.len(), 1);
886 assert!(warnings[0].contains("broken"));
887 assert!(warnings[0].contains("/definitely/not/a/real/path"));
888 }
889
890 #[test]
891 fn untagged_image_referenced_by_container_exports_by_id() {
892 let container = ContainerInspect {
893 id: "cid".into(),
894 name: "/demo".into(),
895 image: "sha256:dangling".into(),
896 created: "2024-01-01T00:00:00Z".into(),
897 state: crate::docker_types::ContainerState {
898 status: "exited".into(),
899 running: false,
900 },
901 config: crate::docker_types::ContainerConfig::default(),
902 host_config: crate::docker_types::HostConfig::default(),
903 network_settings: NetworkSettings::default(),
904 mounts: Vec::new(),
905 };
906 let plans = normalize_images(&[], std::slice::from_ref(&container), &BTreeSet::new());
907
908 assert_eq!(plans.len(), 1);
909 assert_eq!(plans[0].export_references, vec!["sha256:dangling"]);
910 assert!(plans[0].repo_tags.is_empty());
911 }
912
913 #[test]
914 fn network_aliases_filter_container_name() {
915 let mut networks = HashMap::new();
916 networks.insert(
917 "usernet".to_string(),
918 EndpointSettings {
919 aliases: Some(vec!["demo".into(), "api".into()]),
920 },
921 );
922 let container = ContainerInspect {
923 id: "id".into(),
924 name: "/demo".into(),
925 image: "img".into(),
926 created: "2024-01-01T00:00:00Z".into(),
927 state: crate::docker_types::ContainerState {
928 status: "running".into(),
929 running: true,
930 },
931 config: crate::docker_types::ContainerConfig::default(),
932 host_config: crate::docker_types::HostConfig::default(),
933 network_settings: NetworkSettings { networks },
934 mounts: Vec::new(),
935 };
936 let migrated_network_names = BTreeSet::from(["usernet".to_string()]);
937 let attachments = normalized_network_attachments(&container, &migrated_network_names);
938 assert_eq!(attachments[0].aliases, vec!["api".to_string()]);
939 }
940}