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;
9use crate::model::{
10    ContainerMount, ContainerNetworkAttachment, ContainerPlan, ContainerSpec, ImagePlan,
11    MigrationPlan, NetworkPlan, PortPublish, ReplacementSummary, RestartPolicySpec,
12    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        let source_containers = source_runner.list_containers().await?;
49
50        let mut unsupported_resources = Vec::new();
51
52        let mut volume_usage: HashMap<String, Vec<(String, bool)>> = HashMap::new();
53        for container in &source_containers {
54            let container_name = trimmed_name(container).to_string();
55            for mount in &container.mounts {
56                if mount.mount_type == "volume" && !mount.name.is_empty() {
57                    volume_usage
58                        .entry(mount.name.clone())
59                        .or_default()
60                        .push((container_name.clone(), container.state.running));
61                } else if mount.mount_type != "volume"
62                    && mount.mount_type != "bind"
63                    && mount.mount_type != "tmpfs"
64                {
65                    unsupported_resources.push(format!(
66                        "container '{}' uses unsupported mount type '{}'",
67                        container_name, mount.mount_type
68                    ));
69                }
70            }
71        }
72
73        let volume_plans: Vec<_> = source_volumes
74            .into_iter()
75            .map(|volume| normalize_volume(volume, &target_volumes, &volume_usage))
76            .inspect(|plan| {
77                if plan.driver != "local" {
78                    unsupported_resources.push(format!(
79                        "volume '{}' uses unsupported driver '{}'",
80                        plan.name, plan.driver
81                    ));
82                }
83            })
84            .collect();
85
86        let blockers = volume_plans
87            .iter()
88            .filter_map(|volume| {
89                let running: Vec<_> = volume_usage
90                    .get(&volume.name)?
91                    .iter()
92                    .filter(|(_, running)| *running)
93                    .map(|(name, _)| name.clone())
94                    .collect();
95                if running.is_empty() {
96                    None
97                } else {
98                    Some(RunningVolumeBlocker {
99                        volume_name: volume.name.clone(),
100                        containers: running,
101                    })
102                }
103            })
104            .collect();
105
106        let network_plans: Vec<_> = source_networks
107            .into_iter()
108            .map(|network| normalize_network(network, &target_networks))
109            .inspect(|plan| {
110                if plan.driver != "bridge" {
111                    unsupported_resources.push(format!(
112                        "network '{}' uses unsupported driver '{}'",
113                        plan.name, plan.driver
114                    ));
115                }
116            })
117            .collect();
118        let migrated_network_names: BTreeSet<_> = network_plans
119            .iter()
120            .map(|network| network.name.clone())
121            .collect();
122
123        let image_plan_data =
124            normalize_images(&source_images, &source_containers, &target_image_tags);
125        let image_reference_by_id: HashMap<_, _> = image_plan_data
126            .iter()
127            .map(|image| (image.image_id.clone(), image.export_reference.clone()))
128            .collect();
129
130        let container_plans: Vec<_> = source_containers
131            .into_iter()
132            .map(|container| {
133                normalize_container(
134                    container,
135                    &target_containers,
136                    &image_reference_by_id,
137                    &migrated_network_names,
138                )
139            })
140            .collect();
141
142        let replacements = build_replacements(
143            &image_plan_data,
144            &volume_plans,
145            &network_plans,
146            &container_plans,
147        );
148
149        Ok(MigrationPlan {
150            source: normalize_source_info(source, source_info),
151            helper_image: helper_image_reference().to_string(),
152            images: image_plan_data,
153            volumes: volume_plans,
154            networks: network_plans,
155            containers: container_plans,
156            unsupported_resources,
157            replacements,
158            blockers,
159        })
160    }
161}
162
163fn normalize_source_info(source: SourceConfig, info: DockerInfo) -> SourceInfo {
164    SourceInfo {
165        kind: source.kind,
166        socket_path: source.socket_path,
167        daemon_name: info.name,
168        server_version: info.server_version,
169        operating_system: info.operating_system,
170        architecture: info.architecture,
171    }
172}
173
174fn normalize_images(
175    images: &[ImageInspect],
176    containers: &[ContainerInspect],
177    target_tags: &BTreeSet<String>,
178) -> Vec<ImagePlan> {
179    let mut ordered = BTreeMap::new();
180
181    for image in images {
182        let tags = meaningful_tags(&image.repo_tags);
183        if !tags.is_empty() {
184            ordered.insert(
185                image.id.clone(),
186                ImagePlan {
187                    image_id: image.id.clone(),
188                    export_reference: tags[0].clone(),
189                    replace_tags: tags
190                        .iter()
191                        .filter(|tag| target_tags.contains(*tag))
192                        .cloned()
193                        .collect(),
194                    repo_tags: tags,
195                },
196            );
197        }
198    }
199
200    for container in containers {
201        ordered
202            .entry(container.image.clone())
203            .or_insert_with(|| ImagePlan {
204                image_id: container.image.clone(),
205                export_reference: container.image.clone(),
206                repo_tags: meaningful_tags(&[]),
207                replace_tags: Vec::new(),
208            });
209    }
210
211    ordered.into_values().collect()
212}
213
214fn normalize_volume(
215    volume: VolumeInspect,
216    target_volumes: &BTreeSet<String>,
217    usage: &HashMap<String, Vec<(String, bool)>>,
218) -> VolumePlan {
219    let attached_containers = usage
220        .get(&volume.name)
221        .map(|items| items.iter().map(|(name, _)| name.clone()).collect())
222        .unwrap_or_default();
223
224    VolumePlan {
225        name: volume.name.clone(),
226        driver: volume.driver,
227        labels: volume.labels.unwrap_or_default(),
228        options: volume.options.unwrap_or_default(),
229        replace_existing: target_volumes.contains(&volume.name),
230        attached_containers,
231    }
232}
233
234fn normalize_network(network: NetworkInspect, target_networks: &BTreeSet<String>) -> NetworkPlan {
235    NetworkPlan {
236        name: network.name.clone(),
237        id: network.id,
238        driver: network.driver,
239        internal: network.internal,
240        enable_ipv6: network.enable_ipv6,
241        attachable: network.attachable,
242        labels: network.labels.unwrap_or_default(),
243        options: network.options.unwrap_or_default(),
244        ipam: network.ipam.config,
245        replace_existing: target_networks.contains(&network.name),
246    }
247}
248
249fn normalize_container(
250    container: ContainerInspect,
251    target_containers: &BTreeSet<String>,
252    image_reference_by_id: &HashMap<String, String>,
253    migrated_network_names: &BTreeSet<String>,
254) -> ContainerPlan {
255    let name = trimmed_name(&container).to_string();
256    let image_reference = image_reference_by_id
257        .get(&container.image)
258        .cloned()
259        .unwrap_or_else(|| {
260            if container.config.image.is_empty() {
261                container.image.clone()
262            } else {
263                container.config.image.clone()
264            }
265        });
266
267    let mut attachments = normalized_network_attachments(&container, migrated_network_names);
268    let primary_network = attachments.first().cloned();
269    if primary_network.is_some() {
270        let _ = attachments.remove(0);
271    }
272
273    ContainerPlan {
274        name: name.clone(),
275        id: container.id,
276        image_reference,
277        spec: ContainerSpec {
278            hostname: non_empty(&container.config.hostname),
279            domainname: non_empty(&container.config.domainname),
280            user: non_empty(&container.config.user),
281            env: container.config.env.unwrap_or_default(),
282            labels: container.config.labels.unwrap_or_default(),
283            exposed_ports: container
284                .config
285                .exposed_ports
286                .unwrap_or_default()
287                .into_keys()
288                .collect(),
289            tty: container.config.tty,
290            open_stdin: container.config.open_stdin,
291            working_dir: non_empty(&container.config.working_dir),
292            entrypoint: container.config.entrypoint.unwrap_or_default(),
293            cmd: container.config.cmd.unwrap_or_default(),
294            mounts: container.mounts.iter().map(normalize_mount).collect(),
295            publishes: normalized_publishes(container.host_config.port_bindings),
296            restart_policy: normalize_restart_policy(container.host_config.restart_policy),
297            privileged: container.host_config.privileged,
298            read_only_rootfs: container.host_config.readonly_rootfs,
299            extra_hosts: container.host_config.extra_hosts.unwrap_or_default(),
300            auto_remove: container.host_config.auto_remove,
301            primary_network,
302        },
303        extra_networks: attachments,
304        replace_existing: target_containers.contains(&name),
305    }
306}
307
308fn normalize_mount(mount: &MountPoint) -> ContainerMount {
309    match mount.mount_type.as_str() {
310        "bind" => ContainerMount::Bind {
311            source: mount.source.clone(),
312            target: mount.destination.clone(),
313            rw: mount.rw,
314        },
315        "tmpfs" => ContainerMount::Tmpfs {
316            target: mount.destination.clone(),
317            options: non_empty(&mount.mode),
318        },
319        _ => ContainerMount::Volume {
320            source: if mount.name.is_empty() {
321                mount.source.clone()
322            } else {
323                mount.name.clone()
324            },
325            target: mount.destination.clone(),
326            rw: mount.rw,
327        },
328    }
329}
330
331fn normalized_publishes(
332    port_bindings: Option<HashMap<String, Option<Vec<crate::docker_types::PortBinding>>>>,
333) -> Vec<PortPublish> {
334    let Some(port_bindings) = port_bindings else {
335        return Vec::new();
336    };
337
338    let mut publishes = Vec::new();
339    let mut ports: Vec<_> = port_bindings.into_iter().collect();
340    ports.sort_by(|(left, _), (right, _)| left.cmp(right));
341    for (container_port, bindings) in ports {
342        match bindings {
343            Some(bindings) if !bindings.is_empty() => {
344                for binding in bindings {
345                    publishes.push(PortPublish {
346                        container_port: container_port.clone(),
347                        host_ip: non_empty(&binding.host_ip),
348                        host_port: non_empty(&binding.host_port),
349                    });
350                }
351            }
352            _ => publishes.push(PortPublish {
353                container_port,
354                host_ip: None,
355                host_port: None,
356            }),
357        }
358    }
359    publishes
360}
361
362fn normalize_restart_policy(policy: Option<RestartPolicy>) -> Option<RestartPolicySpec> {
363    let policy = policy?;
364    if policy.name.is_empty() || policy.name == "no" {
365        None
366    } else {
367        Some(RestartPolicySpec {
368            name: policy.name,
369            maximum_retry_count: if policy.maximum_retry_count > 0 {
370                Some(policy.maximum_retry_count)
371            } else {
372                None
373            },
374        })
375    }
376}
377
378fn normalized_network_attachments(
379    container: &ContainerInspect,
380    migrated_network_names: &BTreeSet<String>,
381) -> Vec<ContainerNetworkAttachment> {
382    let name = trimmed_name(container);
383    let mut attachments: Vec<_> = container
384        .network_settings
385        .networks
386        .iter()
387        .filter(|(network, _)| migrated_network_names.contains(*network))
388        .map(|(network, endpoint)| ContainerNetworkAttachment {
389            network: network.clone(),
390            aliases: endpoint
391                .aliases
392                .clone()
393                .unwrap_or_default()
394                .into_iter()
395                .filter(|alias| alias != name)
396                .collect(),
397        })
398        .collect();
399    attachments.sort_by(|left, right| left.network.cmp(&right.network));
400    attachments
401}
402
403fn build_replacements(
404    images: &[ImagePlan],
405    volumes: &[VolumePlan],
406    networks: &[NetworkPlan],
407    containers: &[ContainerPlan],
408) -> ReplacementSummary {
409    ReplacementSummary {
410        image_tags: images
411            .iter()
412            .flat_map(|image| image.replace_tags.clone())
413            .collect(),
414        volumes: volumes
415            .iter()
416            .filter(|volume| volume.replace_existing)
417            .map(|volume| volume.name.clone())
418            .collect(),
419        networks: networks
420            .iter()
421            .filter(|network| network.replace_existing)
422            .map(|network| network.name.clone())
423            .collect(),
424        containers: containers
425            .iter()
426            .filter(|container| container.replace_existing)
427            .map(|container| container.name.clone())
428            .collect(),
429    }
430}
431
432fn meaningful_tags(tags: &[String]) -> Vec<String> {
433    tags.iter()
434        .filter(|tag| *tag != "<none>:<none>")
435        .cloned()
436        .collect()
437}
438
439fn collect_names<T, F>(items: Vec<T>, name_fn: F) -> BTreeSet<String>
440where
441    F: Fn(&T) -> &str,
442{
443    items
444        .into_iter()
445        .map(|item| name_fn(&item).to_string())
446        .collect()
447}
448
449fn collect_target_tags(images: &[ImageInspect]) -> BTreeSet<String> {
450    images
451        .iter()
452        .flat_map(|image| meaningful_tags(&image.repo_tags))
453        .collect()
454}
455
456fn trimmed_name(container: &ContainerInspect) -> &str {
457    container.name.trim_start_matches('/')
458}
459
460fn non_empty(value: &str) -> Option<String> {
461    if value.is_empty() {
462        None
463    } else {
464        Some(value.to_string())
465    }
466}
467
468#[cfg(test)]
469mod tests {
470    use super::*;
471    use crate::docker_types::{EndpointSettings, NetworkSettings};
472    use std::collections::HashMap;
473
474    #[test]
475    fn meaningful_tags_filters_none_entries() {
476        let tags = meaningful_tags(&["<none>:<none>".into(), "nginx:latest".into()]);
477        assert_eq!(tags, vec!["nginx:latest"]);
478    }
479
480    #[test]
481    fn network_aliases_filter_container_name() {
482        let mut networks = HashMap::new();
483        networks.insert(
484            "usernet".to_string(),
485            EndpointSettings {
486                aliases: Some(vec!["demo".into(), "api".into()]),
487            },
488        );
489        let container = ContainerInspect {
490            id: "id".into(),
491            name: "/demo".into(),
492            image: "img".into(),
493            state: crate::docker_types::ContainerState {
494                status: "running".into(),
495                running: true,
496            },
497            config: crate::docker_types::ContainerConfig::default(),
498            host_config: crate::docker_types::HostConfig::default(),
499            network_settings: NetworkSettings { networks },
500            mounts: Vec::new(),
501        };
502        let migrated_network_names = BTreeSet::from(["usernet".to_string()]);
503        let attachments = normalized_network_attachments(&container, &migrated_network_names);
504        assert_eq!(attachments[0].aliases, vec!["api".to_string()]);
505    }
506}