stale.rs 3.1 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798
  1. //! Serve-time image staleness check.
  2. //!
  3. //! When a recipe declares `docker_image_dir`, sm121 owns the image's build
  4. //! and stamps a `sm121.checksum` label on it (see [`super::build`]). A
  5. //! `serve` **without** `--build-node` skips [`build::handle_build`] — so
  6. //! nothing compares that label to the current `docker-images/<dir>/`
  7. //! checksum. This module closes that gap: it computes the expected checksum
  8. //! locally, reads the label off the head node's image, and WARNs on
  9. //! mismatch (non-blocking — the operator may intentionally serve a known
  10. //! image). A stale image is still started; this only surfaces the drift.
  11. use super::build_dir::build_checksum;
  12. use super::shq;
  13. use crate::config::{Cluster, Config, Recipe};
  14. use crate::ssh::run_ssh;
  15. use anyhow::{Context, Result};
  16. use std::path::Path;
  17. use tracing::warn;
  18. /// Warn if the image that `serve` is about to use carries a
  19. /// `sm121.checksum` label that doesn't match the current
  20. /// `docker-images/<dir>/` checksum (folded with the recipe's
  21. /// `[build.versions]` overrides). No-op when the recipe has no
  22. /// `docker_image_dir` or the build dir is absent.
  23. pub(crate) async fn warn_if_stale(
  24. config: &Config,
  25. cluster: &Cluster,
  26. recipe: &Recipe,
  27. ) -> Result<()> {
  28. let Some(dir) = &recipe.docker_image_dir else {
  29. return Ok(());
  30. };
  31. let Ok((local, _source)) = super::build_dir::resolve_build_dir(
  32. Path::new("."),
  33. &recipe.name,
  34. dir,
  35. &config.docker_image_sources,
  36. ) else {
  37. return Ok(()); // no build context visible from here — nothing to compare
  38. };
  39. let (_merged, expected) = build_checksum(
  40. &local,
  41. recipe.build_profile.as_deref(),
  42. &recipe.build.versions,
  43. )?;
  44. let head = cluster.nodes.first().context("No nodes in cluster")?;
  45. let label = run_ssh(
  46. config,
  47. head,
  48. format!(
  49. "docker inspect --format '{{{{index .Config.Labels \"sm121.checksum\"}}}}' {} 2>/dev/null || echo MISSING",
  50. shq(&recipe.docker_image)
  51. ),
  52. false,
  53. )
  54. .await?;
  55. if is_stale(&label, &expected) {
  56. warn!(
  57. "Image '{}' is STALE: sm121.checksum label '{}' != expected '{}' \
  58. (docker-images/{} changed since last build). \
  59. Rebuild with: sm121 build --recipe <name> --build-node <node>",
  60. recipe.docker_image,
  61. label.trim(),
  62. expected,
  63. dir,
  64. );
  65. }
  66. Ok(())
  67. }
  68. /// Pure decision used by [`warn_if_stale`]: does the label read off the
  69. /// image differ from the expected checksum? Trims whitespace (SSH output
  70. /// may carry a trailing newline). `MISSING` (no label / pre-sm121 image)
  71. /// counts as stale.
  72. fn is_stale(label: &str, expected: &str) -> bool {
  73. label.trim() != expected
  74. }
  75. #[cfg(test)]
  76. mod tests {
  77. use super::is_stale;
  78. #[test]
  79. fn stale_when_label_differs() {
  80. assert!(is_stale("a4eaa928\n", "4daa35f1"));
  81. }
  82. #[test]
  83. fn fresh_when_label_matches() {
  84. assert!(!is_stale("4daa35f1\n", "4daa35f1"));
  85. }
  86. #[test]
  87. fn stale_when_label_missing() {
  88. assert!(is_stale("MISSING\n", "4daa35f1"));
  89. assert!(is_stale("", "4daa35f1"));
  90. }
  91. }