Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions crates/autopack-core/src/generate.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> {
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<BuildPlan> {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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/*",
Expand Down Expand Up @@ -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();
Expand Down
6 changes: 6 additions & 0 deletions crates/autopack-core/src/plan/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,

/// 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<String>,
}

impl BuildPlan {
Expand Down
28 changes: 25 additions & 3 deletions crates/autopack-dockerfile/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
);
}
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
Loading