From d7f632ee6d789aa159f8df52f4ea6c983fb4e4f4 Mon Sep 17 00:00:00 2001 From: David Viejo Date: Thu, 6 Aug 2026 10:07:37 +0200 Subject: [PATCH] feat: let an operator scope cache mounts per app MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cache mount ids are derived from the cache name and its mount point, both constants per ecosystem, so every project built on a worker shares one /cache/npm, one /cache/pnpm, one /cache/yarn. For a content-addressed package store that is the point, and it is most of the value of caching at all, so it stays the default. But the mount is not a tenant boundary: install steps run app-controlled code as root and hold the store read-write, so on a worker shared between projects that do not trust each other, one build writes to a store the others read. AUTOPACK_CACHE_SCOPE=app mixes a digest of the app path into every id for operators who need that isolation, at the cost of a cold store per project. FNV-1a rather than DefaultHasher, whose output Rust does not promise to keep stable — an id that moved between autopack builds would silently discard every cache on upgrade. Refs #13 --- crates/autopack-core/src/generate.rs | 50 +++++++++++++++++++++++++++ crates/autopack-core/src/plan/mod.rs | 6 ++++ crates/autopack-dockerfile/src/lib.rs | 28 +++++++++++++-- 3 files changed, 81 insertions(+), 3 deletions(-) diff --git a/crates/autopack-core/src/generate.rs b/crates/autopack-core/src/generate.rs index bcd083a..a692815 100644 --- a/crates/autopack-core/src/generate.rs +++ b/crates/autopack-core/src/generate.rs @@ -312,6 +312,29 @@ impl<'a> BuildContext<'a> { self.metadata.insert(key.into(), value.into()); } + /// Token that isolates this app's cache mounts, if the operator asked for + /// isolation. + /// + /// Cache mounts are shared by default, and for a content-addressed package + /// store that is the point — it is most of the value of caching at all. + /// But the mount is not a tenant boundary: install steps run app-controlled + /// code as root and hold the store read-write, so on a worker shared + /// between projects that are not mutually trusting, one build writes to a + /// store the others read. `AUTOPACK_CACHE_SCOPE=app` opts into per-app + /// caches for those operators, at the cost of a cold store per project. + /// + /// The token is a digest of the app's absolute path rather than the path + /// itself, which keeps the id short and free of characters the mount + /// syntax would object to. + fn cache_scope(&self) -> Option { + match self.env.config("CACHE_SCOPE")? { + "app" => Some(short_digest(&self.app.source().to_string_lossy())), + // "shared" is the default; anything else is treated as such rather + // than failing a build over a cache setting. + _ => None, + } + } + /// Assemble the final plan: runtime layer, provider steps, runtime image, /// user configuration, normalization, and validation. pub fn generate(&mut self) -> Result { @@ -345,6 +368,7 @@ impl<'a> BuildContext<'a> { let mut plan = BuildPlan::new(); plan.caches = self.caches.clone(); + plan.cache_scope = self.cache_scope(); let needs_packages_step = !self.packages.is_empty() || !self.build_apt_packages.is_empty(); if needs_packages_step { @@ -537,6 +561,20 @@ impl<'a> BuildContext<'a> { /// /// `apt-get update` and `install` must share one command: splitting them lets /// Docker reuse a stale package index and install versions that no longer exist. +/// A short, stable digest of `value`, for use inside a cache mount id. +/// +/// FNV-1a rather than `DefaultHasher`, whose output Rust does not promise to +/// keep stable across releases — a cache id that changed when autopack was +/// rebuilt would silently discard every cache. +fn short_digest(value: &str) -> String { + let mut hash: u64 = 0xcbf2_9ce4_8422_2325; + for byte in value.as_bytes() { + hash ^= u64::from(*byte); + hash = hash.wrapping_mul(0x1000_0000_01b3); + } + format!("{hash:016x}") +} + fn apt_install(packages: &[String]) -> String { format!( "apt-get update && apt-get install -y --no-install-recommends {} && rm -rf /var/lib/apt/lists/*", @@ -659,6 +697,18 @@ mod tests { assert!(check_apt_packages(&["libpq5".to_string()]).is_ok()); } + #[test] + fn cache_scope_is_opt_in_and_stable() { + // Default: no token, so every project on a worker shares one store. + // That is deliberate — see `cache_scope`. + assert_eq!(short_digest("/srv/app-a"), short_digest("/srv/app-a")); + assert_ne!(short_digest("/srv/app-a"), short_digest("/srv/app-b")); + // Stability matters: a digest that moved between autopack builds + // would silently throw away every cache on upgrade. + assert_eq!(short_digest("/srv/app-a").len(), 16); + assert_eq!(short_digest(""), "cbf29ce484222325"); + } + #[test] fn generates_packages_and_runtime_steps() { let dir = app_fixture(); diff --git a/crates/autopack-core/src/plan/mod.rs b/crates/autopack-core/src/plan/mod.rs index 4d61b41..c73e5cb 100644 --- a/crates/autopack-core/src/plan/mod.rs +++ b/crates/autopack-core/src/plan/mod.rs @@ -48,6 +48,12 @@ pub struct BuildPlan { /// Paths never uploaded into the build context. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub exclude: Vec, + + /// Token mixed into every cache mount id, isolating this app's caches + /// from other projects built on the same worker. `None` shares them, + /// which is the default. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cache_scope: Option, } impl BuildPlan { diff --git a/crates/autopack-dockerfile/src/lib.rs b/crates/autopack-dockerfile/src/lib.rs index a2f8395..e8983bc 100644 --- a/crates/autopack-dockerfile/src/lib.rs +++ b/crates/autopack-dockerfile/src/lib.rs @@ -396,7 +396,7 @@ fn mount_flags(plan: &BuildPlan, step: &Step) -> String { let _ = write!( flags, " --mount=type=cache,id={id},target={target},sharing={sharing}", - id = cache_id(name, cache), + id = cache_id(name, cache, plan.cache_scope.as_deref()), target = cache.directory, ); } @@ -430,8 +430,11 @@ fn one_line(value: &str) -> String { value.replace(['\n', '\r'], " ").trim_end().to_string() } -fn cache_id(name: &str, cache: &Cache) -> String { - format!("autopack-{name}-{}", sanitize(&cache.directory)) +fn cache_id(name: &str, cache: &Cache, scope: Option<&str>) -> String { + match scope { + Some(scope) => format!("autopack-{scope}-{name}-{}", sanitize(&cache.directory)), + None => format!("autopack-{name}-{}", sanitize(&cache.directory)), + } } /// A Dockerfile stage name derived from a step name. @@ -497,6 +500,25 @@ fn json_string(value: &str) -> String { #[cfg(test)] mod tests { + #[test] + fn cache_ids_are_shared_by_default_and_isolated_on_request() { + let cache = Cache::shared("/cache/npm"); + // Two projects on one worker land on the same volume unless asked. + assert_eq!( + cache_id("npm-store", &cache, None), + "autopack-npm-store-cache-npm" + ); + assert_ne!( + cache_id("npm-store", &cache, Some("abc123")), + cache_id("npm-store", &cache, Some("def456")) + ); + // The scoped form still separates two caches within one app. + assert_ne!( + cache_id("npm-store", &cache, Some("abc123")), + cache_id("pnpm-store", &cache, Some("abc123")) + ); + } + #[test] fn a_newline_in_a_display_name_cannot_inject_a_directive() { // The name is rendered as a `#` comment. A newline would end it and