Skip to main content

arcbox_migration/
executor.rs

1//! Migration execution.
2
3use 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/// Execution options approved by the caller.
14#[derive(Debug, Clone, Copy, Default)]
15pub struct MigrationExecutorOptions {
16    /// Whether destructive replace actions are approved.
17    pub confirm_replace: bool,
18    /// Whether stopping blocked source containers is approved.
19    pub confirm_stop_source_containers: bool,
20    /// Whether containers that were running on the source should be started
21    /// once the migration completes.
22    pub start_containers: bool,
23}
24
25/// What a successful migration reports back beyond "it worked".
26#[derive(Debug, Clone, Default, PartialEq, Eq)]
27pub struct MigrationOutcome {
28    /// Non-fatal problems, phrased for the user. Empty on a clean run.
29    pub warnings: Vec<String>,
30}
31
32/// Executes migration plans against a source and target Docker daemon.
33#[derive(Debug, Clone)]
34pub struct MigrationExecutor {
35    target: DockerCliRunner,
36}
37
38impl MigrationExecutor {
39    /// Creates an executor for the provided ArcBox target socket.
40    #[must_use]
41    pub fn new(target: DockerCliRunner) -> Self {
42        Self { target }
43    }
44
45    /// Executes a migration plan, returning any non-fatal problems.
46    ///
47    /// A returned outcome means the migration ran to completion; its warnings
48    /// describe what the caller must surface alongside that result. Completion
49    /// is not "everything arrived": failures scoped to one container — it could
50    /// not be created, could not join every network, or did not start — are
51    /// reported per container and the run continues, because the alternative
52    /// abandons an otherwise migrated environment partway through. Only a
53    /// failure that invalidates the whole plan (a rejected precondition, or an
54    /// image, volume or network that every later step depends on) returns `Err`.
55    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        // No completion event here: returning `Ok` is the signal. Only the
101        // caller knows whether the run as a whole succeeded, so it owns the
102        // single terminal event -- emitting one here too printed `[complete]`
103        // twice, with only the caller's carrying `done`.
104        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
183/// Imports every planned image, returning the references that changed on the
184/// way across.
185///
186/// `docker load` reassigns image IDs when the two daemons use different image
187/// stores. Tagged images are unaffected because containers reference them by
188/// tag, but an untagged image is referenced by ID, and the source ID does not
189/// exist on the target — so the container must be rewritten to the ID the
190/// target actually assigned.
191async 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        // Untagged: without the assigned ID there is no reference that resolves
219        // on the target, so fail here rather than at container create.
220        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        // The prefix is what keeps planning from mistaking a stranded helper
268        // for a user container; see `helper_image::HELPER_OBJECT_PREFIX`.
269        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        // Clear strays before creating: an interrupted run leaves its helper
275        // holding this exact name, and create refuses a duplicate.
276        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
358/// Recreates every planned container, reporting the ones that could not be
359/// created instead of abandoning the run.
360///
361/// A single container's failure is not the migration's failure. The dominant
362/// cause is a bind mount whose host path the target daemon cannot see — ArcBox
363/// shares some host directories into the guest and not others, so a source
364/// container bound to an unshared path (`/tmp`, `/opt`, ...) is rejected at
365/// create time even though the path exists on this host and planning found
366/// nothing wrong with it. Propagating that aborted the whole run partway:
367/// images, volumes and networks were already created, every later container was
368/// never attempted, the source container stayed stopped, and the retry then
369/// demanded replacement confirmation for the resources the first attempt left
370/// behind. Reporting per container keeps the rest of the environment.
371///
372/// Returns the names that were created, so the start pass skips the ones that
373/// never existed rather than reporting each of them a second time.
374async 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                        // The container exists; only an extra attachment failed,
396                        // so it is still worth starting on a reduced network set.
397                        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
427/// Joins the networks a container needs beyond the one it was created on.
428async 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
444/// Starts the containers that were running on the source.
445///
446/// Runs last so every network and volume the containers depend on already
447/// exists, and follows plan order, which is source creation order.
448///
449/// A start failure is reported and skipped rather than propagated. By this
450/// point every image, volume, network and container has already been created,
451/// so failing the run would describe a completed migration as a failed one and
452/// invite a destructive re-run. Start failures also have causes that are not
453/// migration defects at all — most commonly a published host port still held
454/// by the source container, which is not stopped unless it blocks a volume.
455async 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
493/// Selects the containers the start pass should attempt.
494///
495/// A container that was running on the source but could not be recreated is
496/// omitted: `recreate_containers` already reported why it is missing, and
497/// starting a name that does not exist would report the same container twice
498/// with a less useful error the second time.
499fn 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
509/// Returns the reference that resolves on the target for this container.
510fn 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
632/// Emits the `--network` flag for the container's network mode.
633///
634/// No flag suppression is needed here: Docker only rejects `--hostname`,
635/// `--dns*`, `--add-host`, `--publish` and `--expose` in `container:<id>` mode,
636/// which planning rejects outright. Under `host` the daemon supplies the
637/// hostname itself and simply discards published ports.
638fn 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            // Network-scoped aliases are only valid on user-defined networks.
647            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
658/// Renders a `NanoCpus` quota as the decimal `--cpus` expects.
659///
660/// Done in integer arithmetic so the value round-trips exactly: a fixed number
661/// of decimal places would round a quota like 1.234567890 into a different one.
662fn 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        // Docker only rejects these under container:<id> mode, which planning
781        // refuses outright; suppressing them here would lose real settings.
782        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        // A fixed decimal width would turn these into different quotas.
829        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        // docker load reassigns IDs across image stores, so the source ID in
863        // the plan would not resolve on the target.
864        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        // Its absence was already reported once; starting a name that does not
921        // exist would report the same container again with a worse error.
922        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}