1use crate::docker_types::{
4 ContainerInspect, DockerInfo, ImageInspect, MountPoint, NetworkInspect, RestartPolicy,
5 VolumeInspect,
6};
7use crate::error::Result;
8use crate::helper_image::helper_image_reference;
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#[derive(Debug, Clone)]
19pub struct MigrationPlanner {
20 target: DockerCliRunner,
21}
22
23impl MigrationPlanner {
24 #[must_use]
26 pub fn new(target: DockerCliRunner) -> Self {
27 Self { target }
28 }
29
30 pub async fn plan(&self, source: SourceConfig) -> Result<MigrationPlan> {
32 let source_runner = DockerCliRunner::new(source.socket_path.clone())?;
33
34 let source_info = source_runner.info().await?;
35 let target_images = self.target.list_images().await?;
36 let target_image_tags = collect_target_tags(&target_images);
37 let target_volumes =
38 collect_names(self.target.list_volumes().await?, |volume| &volume.name);
39 let target_networks =
40 collect_names(self.target.list_networks().await?, |network| &network.name);
41 let target_containers = collect_names(self.target.list_containers().await?, |container| {
42 trimmed_name(container)
43 });
44
45 let source_images = source_runner.list_images().await?;
46 let source_volumes = source_runner.list_volumes().await?;
47 let source_networks = source_runner.list_networks().await?;
48 let source_containers = 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}