//! Serve-time image staleness check. //! //! When a recipe declares `docker_image_dir`, sm121 owns the image's build //! and stamps a `sm121.checksum` label on it (see [`super::build`]). A //! `serve` **without** `--build-node` skips [`build::handle_build`] — so //! nothing compares that label to the current `docker-images//` //! checksum. This module closes that gap: it computes the expected checksum //! locally, reads the label off the head node's image, and WARNs on //! mismatch (non-blocking — the operator may intentionally serve a known //! image). A stale image is still started; this only surfaces the drift. use super::build_dir::build_checksum; use super::shq; use crate::config::{Cluster, Config, Recipe}; use crate::ssh::run_ssh; use anyhow::{Context, Result}; use std::path::Path; use tracing::warn; /// Warn if the image that `serve` is about to use carries a /// `sm121.checksum` label that doesn't match the current /// `docker-images//` checksum (folded with the recipe's /// `[build.versions]` overrides). No-op when the recipe has no /// `docker_image_dir` or the build dir is absent. pub(crate) async fn warn_if_stale( config: &Config, cluster: &Cluster, recipe: &Recipe, ) -> Result<()> { let Some(dir) = &recipe.docker_image_dir else { return Ok(()); }; let Ok((local, _source)) = super::build_dir::resolve_build_dir( Path::new("."), &recipe.name, dir, &config.docker_image_sources, ) else { return Ok(()); // no build context visible from here — nothing to compare }; let (_merged, expected) = build_checksum( &local, recipe.build_profile.as_deref(), &recipe.build.versions, )?; let head = cluster.nodes.first().context("No nodes in cluster")?; let label = run_ssh( config, head, format!( "docker inspect --format '{{{{index .Config.Labels \"sm121.checksum\"}}}}' {} 2>/dev/null || echo MISSING", shq(&recipe.docker_image) ), false, ) .await?; if is_stale(&label, &expected) { warn!( "Image '{}' is STALE: sm121.checksum label '{}' != expected '{}' \ (docker-images/{} changed since last build). \ Rebuild with: sm121 build --recipe --build-node ", recipe.docker_image, label.trim(), expected, dir, ); } Ok(()) } /// Pure decision used by [`warn_if_stale`]: does the label read off the /// image differ from the expected checksum? Trims whitespace (SSH output /// may carry a trailing newline). `MISSING` (no label / pre-sm121 image) /// counts as stale. fn is_stale(label: &str, expected: &str) -> bool { label.trim() != expected } #[cfg(test)] mod tests { use super::is_stale; #[test] fn stale_when_label_differs() { assert!(is_stale("a4eaa928\n", "4daa35f1")); } #[test] fn fresh_when_label_matches() { assert!(!is_stale("4daa35f1\n", "4daa35f1")); } #[test] fn stale_when_label_missing() { assert!(is_stale("MISSING\n", "4daa35f1")); assert!(is_stale("", "4daa35f1")); } }