Skip to main content

arcbox_migration/
executor.rs

1//! Migration execution.
2
3use crate::error::{MigrationError, Result};
4use crate::model::{
5    ContainerMount, ContainerPlan, ContainerSpec, MigrationPlan, PortPublish, SourceConfig,
6};
7use crate::progress::{MigrationProgress, MigrationStage};
8use crate::runner::{CreateNetworkOptions, DockerCliRunner};
9
10/// Execution options approved by the caller.
11#[derive(Debug, Clone, Copy, Default)]
12pub struct MigrationExecutorOptions {
13    /// Whether destructive replace actions are approved.
14    pub confirm_replace: bool,
15    /// Whether stopping blocked source containers is approved.
16    pub confirm_stop_source_containers: bool,
17}
18
19/// Executes migration plans against a source and target Docker daemon.
20#[derive(Debug, Clone)]
21pub struct MigrationExecutor {
22    target: DockerCliRunner,
23}
24
25impl MigrationExecutor {
26    /// Creates an executor for the provided ArcBox target socket.
27    #[must_use]
28    pub fn new(target: DockerCliRunner) -> Self {
29        Self { target }
30    }
31
32    /// Executes a migration plan.
33    pub async fn execute<F>(
34        &self,
35        source: SourceConfig,
36        plan: &MigrationPlan,
37        options: MigrationExecutorOptions,
38        mut progress: F,
39    ) -> Result<()>
40    where
41        F: FnMut(MigrationProgress),
42    {
43        if !plan.unsupported_resources.is_empty() {
44            return Err(MigrationError::Blocked(format!(
45                "unsupported resources: {}",
46                plan.unsupported_resources.join(", ")
47            )));
48        }
49        if !plan.replacements.is_empty() && !options.confirm_replace {
50            return Err(MigrationError::Blocked(
51                "replace confirmation is required".to_string(),
52            ));
53        }
54        if !plan.blockers.is_empty() && !options.confirm_stop_source_containers {
55            return Err(MigrationError::Blocked(
56                "stopping source containers is required".to_string(),
57            ));
58        }
59
60        let source_runner = DockerCliRunner::new(source.socket_path)?;
61
62        stop_source_blockers(&source_runner, plan, &mut progress).await?;
63        remove_target_conflicts(&self.target, plan, &mut progress).await?;
64
65        source_runner.ensure_helper_image().await?;
66        self.target.ensure_helper_image().await?;
67
68        import_images(&source_runner, &self.target, plan, &mut progress).await?;
69        import_volumes(&source_runner, &self.target, plan, &mut progress).await?;
70        recreate_networks(&self.target, plan, &mut progress).await?;
71        recreate_containers(&self.target, plan, &mut progress).await?;
72
73        progress(MigrationProgress {
74            stage: MigrationStage::Complete,
75            detail: "migration completed".to_string(),
76            resource_type: None,
77            resource_name: None,
78            current: None,
79            total: None,
80        });
81        Ok(())
82    }
83}
84
85async fn stop_source_blockers<F>(
86    source: &DockerCliRunner,
87    plan: &MigrationPlan,
88    progress: &mut F,
89) -> Result<()>
90where
91    F: FnMut(MigrationProgress),
92{
93    let mut containers = plan
94        .blockers
95        .iter()
96        .flat_map(|blocker| blocker.containers.clone())
97        .collect::<Vec<_>>();
98    containers.sort();
99    containers.dedup();
100
101    let total = u32::try_from(containers.len()).unwrap_or(0);
102    for (index, container) in containers.into_iter().enumerate() {
103        progress(MigrationProgress {
104            stage: MigrationStage::StopSourceContainers,
105            detail: format!("stopping source container '{container}'"),
106            resource_type: Some("container".to_string()),
107            resource_name: Some(container.clone()),
108            current: Some(u32::try_from(index + 1).unwrap_or(total)),
109            total: Some(total),
110        });
111        source.stop_container(&container).await?;
112    }
113    Ok(())
114}
115
116async fn remove_target_conflicts<F>(
117    target: &DockerCliRunner,
118    plan: &MigrationPlan,
119    progress: &mut F,
120) -> Result<()>
121where
122    F: FnMut(MigrationProgress),
123{
124    for container in &plan.replacements.containers {
125        progress(MigrationProgress {
126            stage: MigrationStage::Cleanup,
127            detail: format!("removing target container '{container}'"),
128            resource_type: Some("container".to_string()),
129            resource_name: Some(container.clone()),
130            current: None,
131            total: None,
132        });
133        target.remove_container(container).await?;
134    }
135    for network in &plan.replacements.networks {
136        progress(MigrationProgress {
137            stage: MigrationStage::Cleanup,
138            detail: format!("removing target network '{network}'"),
139            resource_type: Some("network".to_string()),
140            resource_name: Some(network.clone()),
141            current: None,
142            total: None,
143        });
144        target.remove_network(network).await?;
145    }
146    for volume in &plan.replacements.volumes {
147        progress(MigrationProgress {
148            stage: MigrationStage::Cleanup,
149            detail: format!("removing target volume '{volume}'"),
150            resource_type: Some("volume".to_string()),
151            resource_name: Some(volume.clone()),
152            current: None,
153            total: None,
154        });
155        target.remove_volume(volume).await?;
156    }
157    Ok(())
158}
159
160async fn import_images<F>(
161    source: &DockerCliRunner,
162    target: &DockerCliRunner,
163    plan: &MigrationPlan,
164    progress: &mut F,
165) -> Result<()>
166where
167    F: FnMut(MigrationProgress),
168{
169    let total = u32::try_from(plan.images.len()).unwrap_or(0);
170    for (index, image) in plan.images.iter().enumerate() {
171        progress(MigrationProgress {
172            stage: MigrationStage::ImportImages,
173            detail: format!("importing image '{}'", image.export_reference),
174            resource_type: Some("image".to_string()),
175            resource_name: Some(image.export_reference.clone()),
176            current: Some(u32::try_from(index + 1).unwrap_or(total)),
177            total: Some(total),
178        });
179        let archive = source.save_image(&image.export_reference).await?;
180        target.load_image(archive.path()).await?;
181    }
182    Ok(())
183}
184
185async fn import_volumes<F>(
186    source: &DockerCliRunner,
187    target: &DockerCliRunner,
188    plan: &MigrationPlan,
189    progress: &mut F,
190) -> Result<()>
191where
192    F: FnMut(MigrationProgress),
193{
194    let total = u32::try_from(plan.volumes.len()).unwrap_or(0);
195    for (index, volume) in plan.volumes.iter().enumerate() {
196        progress(MigrationProgress {
197            stage: MigrationStage::ImportVolumes,
198            detail: format!("importing volume '{}'", volume.name),
199            resource_type: Some("volume".to_string()),
200            resource_name: Some(volume.name.clone()),
201            current: Some(u32::try_from(index + 1).unwrap_or(total)),
202            total: Some(total),
203        });
204
205        target
206            .create_volume(
207                &volume.name,
208                &volume
209                    .labels
210                    .iter()
211                    .map(|(key, value)| (key.clone(), value.clone()))
212                    .collect::<Vec<_>>(),
213                &volume
214                    .options
215                    .iter()
216                    .map(|(key, value)| (key.clone(), value.clone()))
217                    .collect::<Vec<_>>(),
218            )
219            .await?;
220
221        let source_helper_name = format!("arcbox-migration-src-{}", sanitize_name(&volume.name));
222        let target_helper_name = format!("arcbox-migration-dst-{}", sanitize_name(&volume.name));
223
224        let source_helper = source
225            .create_helper_container(&source_helper_name, &volume.name)
226            .await?;
227        let target_helper = target
228            .create_helper_container(&target_helper_name, &volume.name)
229            .await?;
230
231        let archive_result = source.copy_from_container(&source_helper, "/volume").await;
232        let copy_result = match archive_result {
233            Ok(archive) => {
234                target
235                    .copy_to_container(archive.path(), &target_helper, "/")
236                    .await
237            }
238            Err(err) => Err(err),
239        };
240
241        let cleanup_source = source.remove_container(&source_helper).await;
242        let cleanup_target = target.remove_container(&target_helper).await;
243
244        copy_result?;
245        cleanup_source?;
246        cleanup_target?;
247    }
248    Ok(())
249}
250
251async fn recreate_networks<F>(
252    target: &DockerCliRunner,
253    plan: &MigrationPlan,
254    progress: &mut F,
255) -> Result<()>
256where
257    F: FnMut(MigrationProgress),
258{
259    let total = u32::try_from(plan.networks.len()).unwrap_or(0);
260    for (index, network) in plan.networks.iter().enumerate() {
261        progress(MigrationProgress {
262            stage: MigrationStage::RecreateNetworks,
263            detail: format!("recreating network '{}'", network.name),
264            resource_type: Some("network".to_string()),
265            resource_name: Some(network.name.clone()),
266            current: Some(u32::try_from(index + 1).unwrap_or(total)),
267            total: Some(total),
268        });
269        let ipam = network
270            .ipam
271            .iter()
272            .map(|entry| {
273                (
274                    entry.subnet.clone(),
275                    entry.gateway.clone(),
276                    entry.ip_range.clone(),
277                )
278            })
279            .collect::<Vec<_>>();
280        let create_options = CreateNetworkOptions {
281            internal: network.internal,
282            enable_ipv6: network.enable_ipv6,
283            attachable: network.attachable,
284            labels: network
285                .labels
286                .iter()
287                .map(|(key, value)| (key.clone(), value.clone()))
288                .collect(),
289            options: network
290                .options
291                .iter()
292                .map(|(key, value)| (key.clone(), value.clone()))
293                .collect(),
294            ipam,
295        };
296        target
297            .create_network(&network.name, &create_options)
298            .await?;
299    }
300    Ok(())
301}
302
303async fn recreate_containers<F>(
304    target: &DockerCliRunner,
305    plan: &MigrationPlan,
306    progress: &mut F,
307) -> Result<()>
308where
309    F: FnMut(MigrationProgress),
310{
311    let total = u32::try_from(plan.containers.len()).unwrap_or(0);
312    for (index, container) in plan.containers.iter().enumerate() {
313        progress(MigrationProgress {
314            stage: MigrationStage::RecreateContainers,
315            detail: format!("recreating container '{}'", container.name),
316            resource_type: Some("container".to_string()),
317            resource_name: Some(container.name.clone()),
318            current: Some(u32::try_from(index + 1).unwrap_or(total)),
319            total: Some(total),
320        });
321        let create_args = build_create_args(container);
322        let container_id = target.create_container(create_args).await?;
323        for attachment in &container.extra_networks {
324            target
325                .connect_network(&attachment.network, &container_id, &attachment.aliases)
326                .await?;
327        }
328    }
329    Ok(())
330}
331
332fn build_create_args(plan: &ContainerPlan) -> Vec<String> {
333    let mut args = vec!["--name".to_string(), plan.name.clone()];
334    append_container_spec_args(&mut args, &plan.spec);
335    args.push(plan.image_reference.clone());
336    args.extend(final_command(&plan.spec));
337    args
338}
339
340fn append_container_spec_args(args: &mut Vec<String>, spec: &ContainerSpec) {
341    if let Some(hostname) = &spec.hostname {
342        args.push("--hostname".to_string());
343        args.push(hostname.clone());
344    }
345    if let Some(domainname) = &spec.domainname {
346        args.push("--domainname".to_string());
347        args.push(domainname.clone());
348    }
349    if let Some(user) = &spec.user {
350        args.push("--user".to_string());
351        args.push(user.clone());
352    }
353    for env in &spec.env {
354        args.push("--env".to_string());
355        args.push(env.clone());
356    }
357    for (key, value) in &spec.labels {
358        args.push("--label".to_string());
359        args.push(format!("{key}={value}"));
360    }
361    for port in &spec.exposed_ports {
362        args.push("--expose".to_string());
363        args.push(port.clone());
364    }
365    if spec.tty {
366        args.push("--tty".to_string());
367    }
368    if spec.open_stdin {
369        args.push("--interactive".to_string());
370    }
371    if let Some(working_dir) = &spec.working_dir {
372        args.push("--workdir".to_string());
373        args.push(working_dir.clone());
374    }
375    if let Some(entrypoint) = spec.entrypoint.first() {
376        args.push("--entrypoint".to_string());
377        args.push(entrypoint.clone());
378    }
379    for mount in &spec.mounts {
380        match mount {
381            ContainerMount::Volume { source, target, rw } => {
382                args.push("--mount".to_string());
383                args.push(format!(
384                    "type=volume,src={source},dst={target}{}",
385                    if *rw { "" } else { ",readonly" }
386                ));
387            }
388            ContainerMount::Bind { source, target, rw } => {
389                args.push("--mount".to_string());
390                args.push(format!(
391                    "type=bind,src={source},dst={target}{}",
392                    if *rw { "" } else { ",readonly" }
393                ));
394            }
395            ContainerMount::Tmpfs { target, options } => {
396                args.push("--tmpfs".to_string());
397                args.push(if let Some(options) = options {
398                    format!("{target}:{options}")
399                } else {
400                    target.clone()
401                });
402            }
403        }
404    }
405    for publish in &spec.publishes {
406        args.push("--publish".to_string());
407        args.push(format_publish(publish));
408    }
409    if let Some(restart_policy) = &spec.restart_policy {
410        args.push("--restart".to_string());
411        let mut value = restart_policy.name.clone();
412        if let Some(count) = restart_policy.maximum_retry_count {
413            if restart_policy.name == "on-failure" {
414                value.push(':');
415                value.push_str(&count.to_string());
416            }
417        }
418        args.push(value);
419    }
420    if spec.privileged {
421        args.push("--privileged".to_string());
422    }
423    if spec.read_only_rootfs {
424        args.push("--read-only".to_string());
425    }
426    for host in &spec.extra_hosts {
427        args.push("--add-host".to_string());
428        args.push(host.clone());
429    }
430    if spec.auto_remove {
431        args.push("--rm".to_string());
432    }
433    if let Some(primary_network) = &spec.primary_network {
434        args.push("--network".to_string());
435        args.push(primary_network.network.clone());
436        for alias in &primary_network.aliases {
437            args.push("--network-alias".to_string());
438            args.push(alias.clone());
439        }
440    }
441}
442
443fn final_command(spec: &ContainerSpec) -> Vec<String> {
444    let mut command = Vec::new();
445    if spec.entrypoint.len() > 1 {
446        command.extend(spec.entrypoint.iter().skip(1).cloned());
447    }
448    command.extend(spec.cmd.clone());
449    command
450}
451
452fn format_publish(publish: &PortPublish) -> String {
453    let mut out = String::new();
454    if let Some(host_ip) = &publish.host_ip {
455        if !host_ip.is_empty() {
456            out.push_str(host_ip);
457            out.push(':');
458        }
459    }
460    if let Some(host_port) = &publish.host_port {
461        if !host_port.is_empty() {
462            out.push_str(host_port);
463            out.push(':');
464        }
465    }
466    out.push_str(&publish.container_port);
467    out
468}
469
470fn sanitize_name(name: &str) -> String {
471    name.chars()
472        .map(|ch| if ch.is_ascii_alphanumeric() { ch } else { '-' })
473        .collect()
474}
475
476#[cfg(test)]
477mod tests {
478    use super::*;
479    use crate::model::RestartPolicySpec;
480    use std::collections::HashMap;
481
482    #[test]
483    fn final_command_merges_entrypoint_tail_and_cmd() {
484        let spec = ContainerSpec {
485            hostname: None,
486            domainname: None,
487            user: None,
488            env: Vec::new(),
489            labels: HashMap::new(),
490            exposed_ports: Vec::new(),
491            tty: false,
492            open_stdin: false,
493            working_dir: None,
494            entrypoint: vec!["/bin/sh".into(), "-c".into()],
495            cmd: vec!["echo hi".into()],
496            mounts: Vec::new(),
497            publishes: Vec::new(),
498            restart_policy: None,
499            privileged: false,
500            read_only_rootfs: false,
501            extra_hosts: Vec::new(),
502            auto_remove: false,
503            primary_network: None,
504        };
505        assert_eq!(
506            final_command(&spec),
507            vec!["-c".to_string(), "echo hi".to_string()]
508        );
509    }
510
511    #[test]
512    fn publish_format_handles_host_ip_and_port() {
513        let publish = PortPublish {
514            container_port: "5432/tcp".into(),
515            host_ip: Some("127.0.0.1".into()),
516            host_port: Some("15432".into()),
517        };
518        assert_eq!(format_publish(&publish), "127.0.0.1:15432:5432/tcp");
519    }
520
521    #[test]
522    fn restart_policy_on_failure_keeps_retry_count() {
523        let spec = ContainerSpec {
524            hostname: None,
525            domainname: None,
526            user: None,
527            env: Vec::new(),
528            labels: HashMap::new(),
529            exposed_ports: Vec::new(),
530            tty: false,
531            open_stdin: false,
532            working_dir: None,
533            entrypoint: Vec::new(),
534            cmd: Vec::new(),
535            mounts: Vec::new(),
536            publishes: Vec::new(),
537            restart_policy: Some(RestartPolicySpec {
538                name: "on-failure".into(),
539                maximum_retry_count: Some(5),
540            }),
541            privileged: false,
542            read_only_rootfs: false,
543            extra_hosts: Vec::new(),
544            auto_remove: false,
545            primary_network: None,
546        };
547        let mut args = Vec::new();
548        append_container_spec_args(&mut args, &spec);
549        assert!(
550            args.windows(2)
551                .any(|window| window == ["--restart", "on-failure:5"])
552        );
553    }
554}