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<()> {
|
async fn chown_for_rootless_container(uid_gid: &str, path: &str) -> Result<()> {
|
||||||
let uid = uid_gid
|
let uid = uid_gid
|
||||||
.split_once(':')
|
.split_once(':')
|
||||||
@ -1435,7 +1497,7 @@ impl ProdContainerOrchestrator {
|
|||||||
.to_string_lossy()
|
.to_string_lossy()
|
||||||
.into_owned();
|
.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,
|
Ok(exists) => exists,
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
@ -1446,11 +1508,51 @@ impl ProdContainerOrchestrator {
|
|||||||
false
|
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
|
self.runtime
|
||||||
.build_image(&bcfg)
|
.build_image(&bcfg)
|
||||||
.await
|
.await
|
||||||
.with_context(|| format!("build_image {}", bcfg.tag))?;
|
.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();
|
let calls = rt.calls();
|
||||||
assert!(calls.iter().any(|c| c == "create_container:lnd:offset=0"));
|
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