From 3aea8c5bfa8591a3f716b2b491a588cb76b8d514 Mon Sep 17 00:00:00 2001 From: archipelago Date: Wed, 17 Jun 2026 03:09:56 -0400 Subject: [PATCH] fix(orchestrator): rebuild local UI images when source changes (#34) The prod orchestrator only checked whether a build-image tag was *present* before deciding to skip the build. The local UI images (bitcoin-ui, lnd-ui, electrs-ui) COPY a built neode-ui dist, so a UI update changed the source but left the old tag in place and the new UI never shipped. Gate the build on a content fingerprint of the build context (sorted relative path + length + mtime, SHA-256) recorded in a per-tag stamp under data_dir. Rebuild whenever the fingerprint differs from the one that produced the existing image; podman's own COPY-layer cache keeps a no-op rebuild cheap. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/container/prod_orchestrator.rs | 134 +++++++++++++++++- 1 file changed, 132 insertions(+), 2 deletions(-) diff --git a/core/archipelago/src/container/prod_orchestrator.rs b/core/archipelago/src/container/prod_orchestrator.rs index 83abfb9c..cf9739c5 100644 --- a/core/archipelago/src/container/prod_orchestrator.rs +++ b/core/archipelago/src/container/prod_orchestrator.rs @@ -176,6 +176,68 @@ pub fn compute_container_name(manifest: &AppManifest) -> String { } } +/// Fingerprint a local build context so a changed source tree (e.g. a rebuilt +/// `neode-ui` dist copied into `docker//`) forces an image rebuild even +/// when the image tag already exists (#34). Walks the context directory and +/// hashes each file's relative path, length, and mtime. +/// +/// Metadata-only by design: it's cheap enough to recompute on every reconcile, +/// and podman's own COPY-layer cache still skips the actual layer work when the +/// file *content* is unchanged, so a spurious mtime bump costs almost nothing. +/// Returns `None` if the context can't be read (caller falls back to building). +fn fingerprint_build_context(context: &Path) -> Option { + use sha2::{Digest, Sha256}; + let mut entries: Vec<(String, u64, i128)> = Vec::new(); + let mut stack = vec![context.to_path_buf()]; + while let Some(dir) = stack.pop() { + let rd = std::fs::read_dir(&dir).ok()?; + for entry in rd.flatten() { + let path = entry.path(); + let meta = match entry.metadata() { + Ok(m) => m, + Err(_) => continue, + }; + if meta.is_dir() { + stack.push(path); + continue; + } + let rel = path + .strip_prefix(context) + .unwrap_or(&path) + .to_string_lossy() + .into_owned(); + let mtime = meta + .modified() + .ok() + .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) + .map(|d| d.as_nanos() as i128) + .unwrap_or(0); + entries.push((rel, meta.len(), mtime)); + } + } + // Sort so the hash is independent of directory-walk order. + entries.sort(); + let mut hasher = Sha256::new(); + for (rel, len, mtime) in &entries { + hasher.update(rel.as_bytes()); + hasher.update(b"\0"); + hasher.update(len.to_le_bytes()); + hasher.update(mtime.to_le_bytes()); + } + Some(hex::encode(hasher.finalize())) +} + +/// Path of the stamp file recording the build-context fingerprint that produced +/// the currently-built image for `tag`. Keyed by a filesystem-safe form of the +/// tag so distinct UI images don't collide. +fn build_fingerprint_stamp_path(data_dir: &Path, tag: &str) -> PathBuf { + let safe: String = tag + .chars() + .map(|c| if c.is_ascii_alphanumeric() { c } else { '_' }) + .collect(); + data_dir.join(".image-build").join(format!("{safe}.fingerprint")) +} + async fn chown_for_rootless_container(uid_gid: &str, path: &str) -> Result<()> { let uid = uid_gid .split_once(':') @@ -1435,7 +1497,7 @@ impl ProdContainerOrchestrator { .to_string_lossy() .into_owned(); } - let already = match self.runtime.image_exists(&bcfg.tag).await { + let exists = match self.runtime.image_exists(&bcfg.tag).await { Ok(exists) => exists, Err(err) => { tracing::warn!( @@ -1446,11 +1508,51 @@ impl ProdContainerOrchestrator { false } }; - if !already { + // Presence alone isn't enough: the local UI images (bitcoin-ui, + // lnd-ui, electrs-ui) COPY a built `neode-ui` dist, so a UI + // update changes the source but leaves the old tag in place. + // Rebuild whenever the build context's fingerprint differs from + // the one that produced the existing image (#34). podman's + // COPY-layer cache keeps the rebuild cheap when content is + // actually unchanged. + let fingerprint = fingerprint_build_context(Path::new(&bcfg.context)); + let stamp_path = build_fingerprint_stamp_path(&self.data_dir, &bcfg.tag); + let stale = match &fingerprint { + Some(current) => match tokio::fs::read_to_string(&stamp_path).await { + Ok(prev) => prev.trim() != current, + // No stamp recorded → treat as stale so we rebuild and + // capture the fingerprint going forward. + Err(_) => true, + }, + // Couldn't fingerprint the context — don't skip on staleness. + None => true, + }; + if !exists || stale { + if exists && stale { + tracing::info!( + image = %bcfg.tag, + context = %bcfg.context, + "build context changed since last build; rebuilding image" + ); + } self.runtime .build_image(&bcfg) .await .with_context(|| format!("build_image {}", bcfg.tag))?; + // Record the fingerprint that this image was built from so + // the next reconcile skips the build until the source moves. + if let Some(current) = &fingerprint { + if let Some(parent) = stamp_path.parent() { + let _ = tokio::fs::create_dir_all(parent).await; + } + if let Err(err) = tokio::fs::write(&stamp_path, current).await { + tracing::warn!( + image = %bcfg.tag, + error = %err, + "failed to write build fingerprint stamp" + ); + } + } } } } @@ -4356,4 +4458,32 @@ app: let calls = rt.calls(); assert!(calls.iter().any(|c| c == "create_container:lnd:offset=0")); } + + #[test] + fn fingerprint_build_context_detects_source_changes() { + let tmp = tempfile::TempDir::new().unwrap(); + let ctx = tmp.path(); + std::fs::write(ctx.join("Dockerfile"), "FROM nginx\n").unwrap(); + std::fs::create_dir_all(ctx.join("assets")).unwrap(); + std::fs::write(ctx.join("assets/app.js"), b"v1").unwrap(); + + let a = fingerprint_build_context(ctx).expect("fingerprint"); + // Recomputing over the same tree is stable. + let b = fingerprint_build_context(ctx).expect("fingerprint"); + assert_eq!(a, b, "fingerprint must be stable for an unchanged tree"); + + // Changing a COPYed source file (different length) changes the fingerprint. + std::fs::write(ctx.join("assets/app.js"), b"v2-longer").unwrap(); + let c = fingerprint_build_context(ctx).expect("fingerprint"); + assert_ne!(a, c, "changed source file must change the fingerprint"); + } + + #[test] + fn build_fingerprint_stamp_path_sanitizes_tag() { + let p = build_fingerprint_stamp_path(Path::new("/var/lib/archipelago"), "localhost/bitcoin-ui:local"); + assert_eq!( + p, + PathBuf::from("/var/lib/archipelago/.image-build/localhost_bitcoin_ui_local.fingerprint") + ); + } }