//! 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"));
}
}