sync.rs 4.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133
  1. use crate::config::{Cluster, Config, Node};
  2. use crate::ssh::{run_ssh, run_ssh_long_running};
  3. use anyhow::Result;
  4. use tracing::info;
  5. use super::shq;
  6. /// Get the Docker image digest (short ID) from a node.
  7. async fn get_image_digest(config: &Config, node: &Node, image: &str) -> Result<Option<String>> {
  8. let cmd = format!("docker image ls --format '{{{{.ID}}}}' {}", shq(image));
  9. let out = run_ssh(config, node, cmd, false).await?;
  10. let digest = out.trim().to_string();
  11. if digest.is_empty() {
  12. Ok(None)
  13. } else {
  14. Ok(Some(digest))
  15. }
  16. }
  17. /// Get the Docker image creation timestamp.
  18. async fn get_image_created(config: &Config, node: &Node, image: &str) -> Option<String> {
  19. let cmd = format!(
  20. "docker image inspect --format '{{{{.Created}}}}' {} 2>/dev/null",
  21. shq(image)
  22. );
  23. run_ssh(config, node, cmd, false)
  24. .await
  25. .ok()
  26. .map(|c| c.trim().to_string())
  27. }
  28. /// Sync Docker image across cluster nodes.
  29. /// Finds the node with the *most recently built* image (by creation timestamp),
  30. /// then pipes `docker save | ssh docker load` to nodes missing or with a different digest.
  31. ///
  32. /// **Security note:** The daisy-chain SSH pipe (`docker save | ssh -o
  33. /// StrictHostKeyChecking=no ... docker load`) bypasses host key verification for
  34. /// the *inner* SSH hop. The outer SSH session (via russh) is verified, but the
  35. /// piped `ssh` command trusts the target host unconditionally for the duration
  36. /// of the pipe. This is a design trade-off: re-using the verified russh session
  37. /// for the pipe would require piping the data through the agent, which adds
  38. /// complexity. Acceptable for trusted-cluster deployments where the config
  39. /// file is the trust anchor.
  40. pub async fn sync_image_to_nodes(
  41. config: &Config,
  42. cluster: &Cluster,
  43. image: &str,
  44. explicit_source: Option<&str>,
  45. ) -> Result<()> {
  46. // Collect digests and creation timestamps per node.
  47. let mut digests: Vec<(&Node, Option<String>)> = Vec::new();
  48. let mut created: Vec<Option<String>> = Vec::new();
  49. for node in &cluster.nodes {
  50. let d = get_image_digest(config, node, image).await?;
  51. let c = if d.is_some() {
  52. get_image_created(config, node, image).await
  53. } else {
  54. None
  55. };
  56. digests.push((node, d));
  57. created.push(c);
  58. }
  59. let source_idx = if let Some(src_name) = explicit_source {
  60. digests
  61. .iter()
  62. .position(|(n, _)| n.name == src_name)
  63. .ok_or_else(|| {
  64. anyhow::anyhow!("sync-image-source '{}' not found in cluster", src_name)
  65. })?
  66. } else {
  67. let mut indexed: Vec<usize> = digests
  68. .iter()
  69. .enumerate()
  70. .filter(|(_, (_, d))| d.is_some())
  71. .map(|(i, _)| i)
  72. .collect();
  73. indexed.sort_by(|&a, &b| {
  74. created[b]
  75. .as_deref()
  76. .unwrap_or("")
  77. .cmp(created[a].as_deref().unwrap_or(""))
  78. });
  79. *indexed
  80. .first()
  81. .ok_or_else(|| anyhow::anyhow!("Image '{}' not found on any node", image))?
  82. };
  83. let (src_node, src_digest_opt) = &digests[source_idx];
  84. let source_digest = src_digest_opt.as_ref().unwrap();
  85. info!(
  86. "Syncing image '{}' from '{}' (most recent build) to peers...",
  87. image, src_node.name
  88. );
  89. for (target, target_digest) in &digests {
  90. if target.name == src_node.name {
  91. continue;
  92. }
  93. if target_digest.as_deref() == Some(source_digest) {
  94. info!(
  95. "{}",
  96. fluent_i18n::t!("sync-digest-match", { "name" => &target.name })
  97. );
  98. continue;
  99. }
  100. info!(
  101. "{}",
  102. fluent_i18n::t!("sync-syncing", { "name" => &target.name, "ip" => &target.ssh_ip })
  103. );
  104. let pipe_cmd = format!(
  105. "docker save {} | ssh -o StrictHostKeyChecking=no -i {} {}@{} docker load",
  106. shq(image),
  107. shq(&config.ssh_key_path.display().to_string()),
  108. shq(&config.ssh_user),
  109. shq(&target.ssh_ip)
  110. );
  111. run_ssh_long_running(config, src_node, pipe_cmd, false).await?;
  112. let verify = get_image_digest(config, target, image).await?;
  113. if verify.as_deref() != Some(source_digest) {
  114. anyhow::bail!(
  115. "Image sync to '{}' failed: digest mismatch (source={}, target={:?})",
  116. target.name,
  117. source_digest,
  118. verify
  119. );
  120. }
  121. info!(
  122. "{}",
  123. fluent_i18n::t!("sync-synced", { "name" => &target.name })
  124. );
  125. }
  126. Ok(())
  127. }