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) <noreply@anthropic.com>
This commit is contained in:
archipelago 2026-06-17 03:09:56 -04:00
parent 1843739e0c
commit 3aea8c5bfa

View File

@ -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/<ui>/`) 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<String> {
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")
);
}
}