1use 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#[derive(Debug, Clone, Copy, Default)]
12pub struct MigrationExecutorOptions {
13 pub confirm_replace: bool,
15 pub confirm_stop_source_containers: bool,
17}
18
19#[derive(Debug, Clone)]
21pub struct MigrationExecutor {
22 target: DockerCliRunner,
23}
24
25impl MigrationExecutor {
26 #[must_use]
28 pub fn new(target: DockerCliRunner) -> Self {
29 Self { target }
30 }
31
32 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}