|
|
@@ -1,8 +1,8 @@
|
|
|
use crate::config::{Cluster, Config, Node};
|
|
|
use crate::ssh::{run_ssh, run_ssh_long_running};
|
|
|
use anyhow::Result;
|
|
|
-
|
|
|
use tracing::info;
|
|
|
+
|
|
|
/// Get the Docker image digest (short ID) from a node.
|
|
|
async fn get_image_digest(config: &Config, node: &Node, image: &str) -> Result<Option<String>> {
|
|
|
let cmd = format!("docker image ls --format '{{{{.ID}}}}' {image}");
|
|
|
@@ -15,20 +15,38 @@ async fn get_image_digest(config: &Config, node: &Node, image: &str) -> Result<O
|
|
|
}
|
|
|
}
|
|
|
|
|
|
+/// Get the Docker image creation timestamp.
|
|
|
+async fn get_image_created(config: &Config, node: &Node, image: &str) -> Option<String> {
|
|
|
+ let cmd = format!("docker image inspect --format '{{{{.Created}}}}' {image} 2>/dev/null");
|
|
|
+ run_ssh(config, node, cmd, false)
|
|
|
+ .await
|
|
|
+ .ok()
|
|
|
+ .map(|c| c.trim().to_string())
|
|
|
+}
|
|
|
+
|
|
|
/// Sync Docker image across cluster nodes.
|
|
|
-/// Probes all nodes, finds a source (explicit or first with image), then pipes
|
|
|
-/// `docker save | ssh docker load` to every node missing or with a different digest.
|
|
|
+/// Finds the node with the *most recently built* image (by creation timestamp),
|
|
|
+/// then pipes `docker save | ssh docker load` to nodes missing or with a different digest.
|
|
|
pub async fn sync_image_to_nodes(
|
|
|
config: &Config,
|
|
|
cluster: &Cluster,
|
|
|
image: &str,
|
|
|
explicit_source: Option<&str>,
|
|
|
) -> Result<()> {
|
|
|
+ // Collect digests and creation timestamps per node.
|
|
|
let mut digests: Vec<(&Node, Option<String>)> = Vec::new();
|
|
|
+ let mut created: Vec<Option<String>> = Vec::new();
|
|
|
for node in &cluster.nodes {
|
|
|
let d = get_image_digest(config, node, image).await?;
|
|
|
+ let c = if d.is_some() {
|
|
|
+ get_image_created(config, node, image).await
|
|
|
+ } else {
|
|
|
+ None
|
|
|
+ };
|
|
|
digests.push((node, d));
|
|
|
+ created.push(c);
|
|
|
}
|
|
|
+
|
|
|
let source_idx = if let Some(src_name) = explicit_source {
|
|
|
digests
|
|
|
.iter()
|
|
|
@@ -37,19 +55,31 @@ pub async fn sync_image_to_nodes(
|
|
|
anyhow::anyhow!("sync-image-source '{}' not found in cluster", src_name)
|
|
|
})?
|
|
|
} else {
|
|
|
- digests
|
|
|
+ let mut indexed: Vec<usize> = digests
|
|
|
.iter()
|
|
|
- .position(|(_, d)| d.is_some())
|
|
|
- .ok_or_else(|| anyhow::anyhow!("Image '{}' not found on any cluster node", image))?
|
|
|
+ .enumerate()
|
|
|
+ .filter(|(_, (_, d))| d.is_some())
|
|
|
+ .map(|(i, _)| i)
|
|
|
+ .collect();
|
|
|
+ indexed.sort_by(|&a, &b| {
|
|
|
+ created[b]
|
|
|
+ .as_deref()
|
|
|
+ .unwrap_or("")
|
|
|
+ .cmp(&created[a].as_deref().unwrap_or(""))
|
|
|
+ });
|
|
|
+ *indexed
|
|
|
+ .first()
|
|
|
+ .ok_or_else(|| anyhow::anyhow!("Image '{}' not found on any node", image))?
|
|
|
};
|
|
|
- let (source, source_digest_opt) = &digests[source_idx];
|
|
|
- let source_digest = source_digest_opt.as_ref().unwrap();
|
|
|
+
|
|
|
+ let (src_node, src_digest_opt) = &digests[source_idx];
|
|
|
+ let source_digest = src_digest_opt.as_ref().unwrap();
|
|
|
info!(
|
|
|
- "Syncing image '{}' from '{}' to peers...",
|
|
|
- image, source.name
|
|
|
+ "Syncing image '{}' from '{}' (most recent build) to peers...",
|
|
|
+ image, src_node.name
|
|
|
);
|
|
|
for (target, target_digest) in &digests {
|
|
|
- if target.name == source.name {
|
|
|
+ if target.name == src_node.name {
|
|
|
continue;
|
|
|
}
|
|
|
if target_digest.as_deref() == Some(source_digest) {
|
|
|
@@ -64,7 +94,7 @@ pub async fn sync_image_to_nodes(
|
|
|
config.ssh_user,
|
|
|
target.ssh_ip
|
|
|
);
|
|
|
- run_ssh_long_running(config, source, pipe_cmd, false).await?;
|
|
|
+ run_ssh_long_running(config, src_node, pipe_cmd, false).await?;
|
|
|
let verify = get_image_digest(config, target, image).await?;
|
|
|
if verify.as_deref() != Some(source_digest) {
|
|
|
anyhow::bail!(
|