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:
parent
1843739e0c
commit
3aea8c5bfa
@ -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")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user