Skip to main content

arcbox_migration/
planner.rs

1//! Migration plan construction.
2
3use 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/// Builds migration plans from source and target Docker daemons.
18#[derive(Debug, Clone)]
19pub struct MigrationPlanner {
20    target: DockerCliRunner,
21}
22
23impl MigrationPlanner {
24    /// Creates a planner for the provided ArcBox target socket.
25    #[must_use]
26    pub fn new(target: DockerCliRunner) -> Self {
27        Self { target }
28    }
29
30    /// Plans a migration from the provided source into the configured target.
31    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        // Drop migration's own scaffolding before anything derives from it, so
49        // it cannot leak into volume usage, blockers, or the container plan: a
50        // crashed run can strand helper containers. The filter keys on the
51        // UUID-bearing prefix, so it excludes only this probe's containers and
52        // not everything in the `arcbox-migration-` namespace.
53        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        // Recreate and start in source creation order: for a compose project
155        // that is the order the services were brought up in.
156        //
157        // Sort on the parsed instant, not the string. Docker formats `Created`
158        // with Go's RFC3339Nano, which strips trailing zeros from the fraction,
159        // so the field is variable-width and byte order can invert real order.
160        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        // Matched exactly, not by prefix: migration creates one helper image and
206        // knows its full reference, so a prefix test would only add a way to
207        // drop somebody else's image from the plan.
208        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    // Untagged images are only worth carrying when a container references them;
229    // they export by ID and land untagged on the target. The plan records the
230    // *source* ID here, which the target reassigns on load — the executor
231    // rewrites the container's reference to the assigned ID after import.
232    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
281/// Outcome of reading `HostConfig.NetworkMode`.
282enum NetworkModeOutcome {
283    Resolved(NetworkModeSpec),
284    /// The mode cannot be reproduced; carries the blocking explanation.
285    Unsupported(String),
286}
287
288/// Classifies `HostConfig.NetworkMode` against the networks being migrated.
289///
290/// Docker's contract: `bridge`, `host`, `none` and `container:<name|id>` are
291/// the standard values, and any other value names a user-defined network.
292fn 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                    // The named network was filtered out of the migration (for
312                    // example a non-bridge driver). Falling back to the default
313                    // bridge keeps the container creatable.
314                    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
324/// Picks which of an image's references to recreate a container against.
325///
326/// Prefers the name the container was originally created under, so a container
327/// built from `myapp:dev` does not come back reporting a sibling tag that
328/// happens to sort first. Falls back to the plan's primary reference when the
329/// original name is not one the image still carries.
330fn 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    // The primary network comes from NetworkMode, not from an arbitrary pick
360    // out of the (alphabetically sorted) attachment list.
361    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
514/// Flags bind mounts whose source path is absent on this host.
515///
516/// Source and target run on the same machine, so a bind mount that resolved
517/// under the old runtime normally still resolves. When it does not the
518/// container starts against an empty directory instead of failing, which is
519/// hard to attribute afterwards — surface it before the migration runs.
520fn 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
594/// Parses a Docker `Created` timestamp into a sortable instant.
595///
596/// Unparseable stamps sort first, which keeps them out of the way of the
597/// containers whose order actually matters.
598fn 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
605/// Treats Docker's "unset" sentinel of zero (or a negative) as absent.
606fn positive(value: i64) -> Option<i64> {
607    (value > 0).then_some(value)
608}
609
610/// Sorts a list so generated argument order does not depend on Docker's
611/// response ordering, which keeps generated commands reproducible.
612fn 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        // One image, three tags: the container must come back as the tag it
648        // was created from, not whichever one Docker happens to list first.
649        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        // The tag it was created under is gone from the image.
664        assert_eq!(
665            preferred_reference(&references, "smoke-app:gone").as_deref(),
666            Some("alpine:3.21")
667        );
668        // An untagged image exports by ID, which is also the fallback, so the
669        // executor's rewrite key still matches.
670        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        // ensure_helper_image leaves it behind on both daemons, so without
680        // this every run after the first would offer it as user data.
681        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        // The `arcbox-migration-` namespace is ours to use elsewhere; matching
700        // it by prefix would drop an internal service's image from the plan.
701        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        // All tags must reach `docker save`, otherwise the others are dropped.
731        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        // The primary must come from NetworkMode, not from sort order.
772        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        // Docker uses Go's RFC3339Nano, which elides trailing zeros, so the
822        // earlier stamp can be a byte-wise *prefix* of the later one and sort
823        // after it. Whole-second fixtures never expose this.
824        let earlier = "2026-08-01T08:09:16.84759688Z"; // .847596880, zero stripped
825        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}