Ver código fonte

feat(build): checksum-based rebuild trigger with sm121.checksum label

- compute_dir_checksum() hashes all files under docker-images/<recipe>/
  sorted by relative path for reproducible digests
- build_image_on_node() stamps sm121.checksum label after build via
  'echo FROM <tag> | docker build --label sm121.checksum=...'
- handle_build() compares local checksum against image label at serve
  time; skips build if they match, triggers rebuild on mismatch
- --force-build flag bypasses checksum check entirely
- Extracted the build-node logic into build.rs to stay under 500-line
  limit in mod.rs

Closes: automatic image rebuild when docker build context changes
Alexander Korolev 3 meses atrás
pai
commit
7fe2423a26
6 arquivos alterados com 149 adições e 42 exclusões
  1. 1 0
      Cargo.lock
  2. 1 0
      Cargo.toml
  3. 4 0
      src/cli.rs
  4. 135 3
      src/docker/build.rs
  5. 6 39
      src/docker/mod.rs
  6. 2 0
      src/main.rs

+ 1 - 0
Cargo.lock

@@ -3454,6 +3454,7 @@ dependencies = [
  "toml",
  "tracing",
  "tracing-subscriber",
+ "walkdir",
 ]
 
 [[package]]

+ 1 - 0
Cargo.toml

@@ -17,6 +17,7 @@ tracing-subscriber = { version = "0.3", features = ["env-filter"] }
 sha2 = "0.11"
 base64 = "0.22"
 futures = "0.3"
+walkdir = "2"
 dirs = "6"
 reqwest = { version = "0.13", default-features = false, features = ["json", "rustls"] }
 fluent-i18n = "0.1.0"

+ 4 - 0
src/cli.rs

@@ -54,6 +54,10 @@ pub enum Commands {
         /// Build image on this node if not found (requires docker-images/<recipe>/).
         #[arg(long)]
         build_node: Option<String>,
+
+        /// Force rebuild even if checksum matches.
+        #[arg(long, default_value = "false")]
+        force_build: bool,
     },
 
     /// Show container logs

+ 135 - 3
src/docker/build.rs

@@ -1,16 +1,55 @@
-use crate::config::{Config, Node};
+use crate::config::{Config, Cluster, Node, Recipe};
+use super::ServeOpts;
 use crate::ssh::run_ssh;
 use anyhow::Result;
+use sha2::{Digest, Sha256};
+use std::io::Read;
+use std::path::Path;
 use tracing::info;
 
+/// Compute a SHA-256 checksum over all files in `dir` (recursive).
+/// Sorted by relative path to ensure reproducibility.
+pub fn compute_dir_checksum(dir: &Path) -> Result<String> {
+    let mut hasher = Sha256::new();
+    let mut entries: Vec<_> = Vec::new();
+
+    for entry in walkdir::WalkDir::new(dir).sort_by_file_name() {
+        let entry = entry?;
+        if entry.file_type().is_dir() {
+            continue;
+        }
+        let rel = entry
+            .path()
+            .strip_prefix(dir)
+            .unwrap_or(entry.path())
+            .to_string_lossy()
+            .to_string();
+        entries.push((rel, entry.path().to_path_buf()));
+    }
+    entries.sort_by(|a, b| a.0.cmp(&b.0));
+
+    for (rel, path) in &entries {
+        let mut file = std::fs::File::open(path)?;
+        let mut buf = Vec::new();
+        file.read_to_end(&mut buf)?;
+        hasher.update(rel.as_bytes());
+        hasher.update(b"\0");
+        hasher.update(&buf);
+    }
+
+    let hash = hasher.finalize();
+    Ok(hash.iter().map(|b| format!("{:02x}", b)).collect())
+}
+
 /// Start a Docker image build on a remote node in a tmux session.
-/// Polls every 30s, streams log output, and returns when the build completes.
+/// After the build completes, stamps the checksum label on the image.
 pub async fn build_image_on_node(
     config: &Config,
     node: &Node,
-    local_dir: &std::path::Path,
+    local_dir: &Path,
     recipe_name: &str,
     image_tag: &str,
+    checksum: &str,
 ) -> Result<()> {
     let session_name = format!("glm52-build-{}", recipe_name);
     let logfile = format!("/tmp/{}-build.log", recipe_name);
@@ -77,5 +116,98 @@ pub async fn build_image_on_node(
         }
         info!("  Build running for {}m...", elapsed.as_secs() / 60);
     }
+
+    // Stamp checksum label on the built image.
+    let stamp_cmd = format!(
+        "echo 'FROM {}' | docker build --label sm121.checksum={} -t {} -",
+        image_tag, checksum, image_tag
+    );
+    let _stamp = run_ssh(config, node, stamp_cmd, false).await?;
+    info!("Stamped checksum label '{}' on '{}'", checksum, image_tag);
+    Ok(())
+}
+
+/// Decide whether to build and dispatch the build for the serve flow.
+pub async fn handle_build(
+    config: &Config,
+    cluster: &Cluster,
+    recipe_name: &str,
+    recipe: &Recipe,
+    opts: &ServeOpts,
+    build_node_name: &str,
+) -> Result<()> {
+    let image_dir = recipe
+        .docker_image_dir
+        .clone()
+        .unwrap_or_else(|| recipe_name.to_string());
+    let local_dir = std::path::Path::new("docker-images").join(&image_dir);
+    if !local_dir.join("build.sh").exists() {
+        anyhow::bail!(
+            "No build system at '{}' (missing build.sh)",
+            local_dir.display()
+        );
+    }
+    let build_node = cluster
+        .nodes
+        .iter()
+        .find(|n| n.name == build_node_name)
+        .expect("build-node already validated");
+
+    let checksum = compute_dir_checksum(local_dir.as_path())?;
+
+    let should_build = if opts.force_build {
+        true
+    } else {
+        let img_id = run_ssh(
+            config,
+            build_node,
+            format!(
+                "docker image ls --format '{{{{.ID}}}}' {} | head -1",
+                recipe.docker_image
+            ),
+            false,
+        )
+        .await?;
+        if img_id.trim().is_empty() {
+            true
+        } else {
+            let label = run_ssh(
+                config,
+                build_node,
+                format!(
+                    "docker inspect --format '{{{{index .Config.Labels \"sm121.checksum\"}}}}' {} 2>/dev/null || echo MISSING",
+                    recipe.docker_image
+                ),
+                false,
+            )
+            .await?;
+            let label = label.trim();
+            if label == checksum {
+                info!(
+                    "Image '{}' checksum matches on '{}' — skipping build",
+                    recipe.docker_image, build_node.name
+                );
+                false
+            } else {
+                info!(
+                    "Image '{}' checksum mismatch on '{}' (got {}, want {}) — rebuilding",
+                    recipe.docker_image, build_node.name, label, checksum
+                );
+                true
+            }
+        }
+    };
+
+    if should_build {
+        build_image_on_node(
+            config,
+            build_node,
+            local_dir.as_path(),
+            recipe_name,
+            &recipe.docker_image,
+            &checksum,
+        )
+        .await?;
+    }
     Ok(())
 }

+ 6 - 39
src/docker/mod.rs

@@ -25,6 +25,7 @@ pub struct ServeOpts {
     pub sync_image: bool,
     pub sync_image_source: Option<String>,
     pub build_node: Option<String>,
+    pub force_build: bool,
 }
 pub(crate) fn shq(s: &str) -> String {
     let mut out = String::with_capacity(s.len() + 2);
@@ -84,50 +85,16 @@ pub async fn serve(
 
     use self::sync::sync_image_to_nodes;
 
-    // Build image on specified node if not found on any cluster node.
+    // Build image on specified node if needed.
     if let Some(ref build_node_name) = opts.build_node {
         if !opts.dry_run && !cluster.nodes.iter().any(|n| n.name == *build_node_name) {
             anyhow::bail!("build-node '{}' not in cluster", build_node_name);
         }
         if !opts.dry_run {
-            let image_dir = recipe
-                .docker_image_dir
-                .clone()
-                .unwrap_or_else(|| recipe_name.to_string());
-            let local_dir = std::path::Path::new("docker-images").join(&image_dir);
-            if !local_dir.join("build.sh").exists() {
-                anyhow::bail!(
-                    "No build system at '{}' (missing build.sh)",
-                    local_dir.display()
-                );
-            }
-            let build_node = cluster
-                .nodes
-                .iter()
-                .find(|n| n.name == *build_node_name)
-                .expect("build-node already validated");
-
-            // Check image presence on the build node.
-            let check = format!(
-                "docker image ls --format '{{{{.ID}}}}' {} | head -1",
-                recipe.docker_image
-            );
-            let present = run_ssh(config, build_node, check, false).await?;
-            if present.trim().is_empty() {
-                crate::docker::build::build_image_on_node(
-                    config,
-                    build_node,
-                    local_dir.as_path(),
-                    recipe_name,
-                    &recipe.docker_image,
-                )
-                .await?;
-            } else {
-                info!(
-                    "Image '{}' already present on '{}'",
-                    recipe.docker_image, build_node.name
-                );
-            }
+            crate::docker::build::handle_build(
+                config, &cluster, recipe_name, &recipe, &opts, build_node_name,
+            )
+            .await?;
         }
     }
 

+ 2 - 0
src/main.rs

@@ -91,6 +91,7 @@ async fn main() -> Result<()> {
             sync_image,
             sync_image_source,
             build_node,
+            force_build,
         } => {
             let cluster = docker::serve(
                 &config,
@@ -105,6 +106,7 @@ async fn main() -> Result<()> {
                     sync_image,
                     sync_image_source,
                     build_node,
+                    force_build,
                 },
             )
             .await?;