|
@@ -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 crate::ssh::run_ssh;
|
|
|
use anyhow::Result;
|
|
use anyhow::Result;
|
|
|
|
|
+use sha2::{Digest, Sha256};
|
|
|
|
|
+use std::io::Read;
|
|
|
|
|
+use std::path::Path;
|
|
|
use tracing::info;
|
|
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.
|
|
/// 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(
|
|
pub async fn build_image_on_node(
|
|
|
config: &Config,
|
|
config: &Config,
|
|
|
node: &Node,
|
|
node: &Node,
|
|
|
- local_dir: &std::path::Path,
|
|
|
|
|
|
|
+ local_dir: &Path,
|
|
|
recipe_name: &str,
|
|
recipe_name: &str,
|
|
|
image_tag: &str,
|
|
image_tag: &str,
|
|
|
|
|
+ checksum: &str,
|
|
|
) -> Result<()> {
|
|
) -> Result<()> {
|
|
|
let session_name = format!("glm52-build-{}", recipe_name);
|
|
let session_name = format!("glm52-build-{}", recipe_name);
|
|
|
let logfile = format!("/tmp/{}-build.log", 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);
|
|
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(())
|
|
Ok(())
|
|
|
}
|
|
}
|