diff --git a/commons/Cargo.toml b/commons/Cargo.toml index bbfb0d44..2d433f6d 100644 --- a/commons/Cargo.toml +++ b/commons/Cargo.toml @@ -38,3 +38,8 @@ libcnb-test = "=0.26.1" pretty_assertions = "1" toml = "0.8" bullet_stream = "0.3.0" + +[features] +# Auto name layers in the order of their creation so that launch and build are resolved in the same order +auto_layer_ordering = [] +default = ["auto_layer_ordering"] diff --git a/commons/src/cache/app_cache.rs b/commons/src/cache/app_cache.rs index 395aa138..7c20ddef 100644 --- a/commons/src/cache/app_cache.rs +++ b/commons/src/cache/app_cache.rs @@ -1,5 +1,6 @@ use crate::cache::clean::{lru_clean, FilesWithSize}; use crate::cache::{CacheConfig, CacheError, KeepPath}; +use crate::layer::order::ordered_layer_name; use byte_unit::{AdjustedByte, Byte, UnitType}; use fs_extra::dir::CopyOptions; use libcnb::build::BuildContext; @@ -387,9 +388,15 @@ fn create_layer_name(app_root: &Path, path: &Path) -> Result>() .join("_"); - format!("cache_{name}") + let layer_name: LayerName = format!("cache_{name}") .parse() - .map_err(CacheError::InvalidLayerName) + .map_err(CacheError::InvalidLayerName)?; + + if cfg!(feature = "auto_layer_ordering") { + Ok(ordered_layer_name(layer_name)) + } else { + Ok(layer_name) + } } /// Determines if a cache directory in a layer previously existed or not. @@ -420,6 +427,8 @@ fn is_empty_dir(path: &Path) -> bool { #[cfg(test)] mod tests { + use crate::layer::order::strip_order_prefix; + use super::*; use filetime::FileTime; use libcnb::data::layer_name; @@ -429,7 +438,10 @@ mod tests { fn test_to_layer_name() { let dir = PathBuf::from_str("muh_base").unwrap(); let layer = create_layer_name(&dir, &dir.join("my").join("input")).unwrap(); - assert_eq!(layer_name!("cache_my_input"), layer); + assert_eq!( + layer_name!("cache_my_input").as_str(), + &strip_order_prefix(layer.as_str()) + ); } #[test] diff --git a/commons/src/layer.rs b/commons/src/layer.rs index 4ec3cade..759af313 100644 --- a/commons/src/layer.rs +++ b/commons/src/layer.rs @@ -1,6 +1,7 @@ mod configure_env_layer; mod default_env_layer; pub mod diff_migrate; +pub(crate) mod order; #[deprecated(note = "Use the struct layer API in the latest libcnb.rs instead")] pub use self::configure_env_layer::ConfigureEnvLayer; diff --git a/commons/src/layer/diff_migrate.rs b/commons/src/layer/diff_migrate.rs index aaf398c7..b7c1ee73 100644 --- a/commons/src/layer/diff_migrate.rs +++ b/commons/src/layer/diff_migrate.rs @@ -43,6 +43,8 @@ #![doc = include_str!("fixtures/metadata_migration_example.md")] use crate::display::SentenceList; +use crate::layer::order::contains_entry_with_name_or_pattern; +use crate::layer::order::ordered_layer_name; use cache_diff::CacheDiff; use fs_err::PathExt; use libcnb::build::BuildContext; @@ -53,7 +55,7 @@ use libcnb::layer::{ use magic_migrate::TryMigrate; use serde::ser::Serialize; use std::fmt::Debug; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; #[cfg(test)] use bullet_stream as _; @@ -120,6 +122,19 @@ impl DiffMigrateLayer { B: libcnb::Buildpack, M: CacheDiff + TryMigrate + Serialize + Debug + Clone, { + let layer_name = if cfg!(feature = "auto_layer_ordering") { + let layer_name = ordered_layer_name(layer_name); + + // Move NNNN_ or `` to `NNNN_` + if let Some(prior) = layer_to_path(context, &layer_name)? { + move_layer_path(&prior, &context.layers_dir.join(layer_name.as_str())) + .map_err(LayerError::IoError)?; + } + layer_name + } else { + layer_name + }; + let layer_ref = context.cached_layer( layer_name, CachedLayerDefinition { @@ -166,25 +181,28 @@ impl DiffMigrateLayer { if let (Some(prior_dir), None) = ( prior_layers .iter() - .map(|layer_name| is_layer_on_disk(layer_name, context)) + .map(|layer_name| layer_to_path(context, layer_name)) .collect::>, _>>()? .iter() .find_map(std::borrow::ToOwned::to_owned), - is_layer_on_disk(&to_layer, context)?, + layer_to_path(context, &to_layer)?, ) { - let to_dir = context.layers_dir.join(to_layer.as_str()); - std::fs::create_dir_all(&to_dir).map_err(LayerError::IoError)?; - std::fs::rename(&prior_dir, &to_dir).map_err(LayerError::IoError)?; - std::fs::rename( - prior_dir.with_extension("toml"), - to_dir.with_extension("toml"), - ) - .map_err(LayerError::IoError)?; + move_layer_path(&prior_dir, &context.layers_dir.join(to_layer.as_str())) + .map_err(LayerError::IoError)?; } self.cached_layer(to_layer, context, metadata) } } +fn move_layer_path(from_dir: &Path, to_dir: &Path) -> Result<(), std::io::Error> { + std::fs::create_dir_all(to_dir)?; + std::fs::rename(from_dir, to_dir)?; + std::fs::rename( + from_dir.with_extension("toml"), + to_dir.with_extension("toml"), + ) +} + /// Represents when we want to move contents from one (or more) layer names /// pub struct LayerRename { @@ -195,18 +213,24 @@ pub struct LayerRename { } /// Returns Some(PathBuf) when the layer exists on disk -fn is_layer_on_disk( - layer_name: &LayerName, +/// +/// Is aware of auto layer-ordering and will convert `0001_my_layer` => `my_layer` and vise-versa +fn layer_to_path( context: &BuildContext, + layer_name: &LayerName, ) -> libcnb::Result, B::Error> where B: libcnb::Buildpack, { - let path = context.layers_dir.join(layer_name.as_str()); - - path.fs_err_try_exists() - .map_err(|error| libcnb::Error::LayerError(LayerError::IoError(error))) - .map(|exists| exists.then_some(path)) + if cfg!(feature = "auto_layer_ordering") { + contains_entry_with_name_or_pattern(&context.layers_dir, layer_name.as_str()) + .map_err(|error| libcnb::Error::LayerError(LayerError::IoError(error))) + } else { + let path = context.layers_dir.join(layer_name.as_str()); + path.fs_err_try_exists() + .map_err(|error| libcnb::Error::LayerError(LayerError::IoError(error))) + .map(|exists| exists.then_some(path)) + } } /// Standardizes formatting for layer cache clearing behavior @@ -292,6 +316,7 @@ where /// /// - Will only ever contain metadata when the cache is retained. /// - Will contain a message when the cache is cleared, describing why it was cleared. +#[derive(Debug)] pub enum Meta { Message(String), Data(M), @@ -322,6 +347,7 @@ mod tests { use libcnb::layer::{EmptyLayerCause, InvalidMetadataAction, LayerState, RestoredLayerAction}; use magic_migrate::{migrate_toml_chain, try_migrate_deserializer_chain, Migrate, TryMigrate}; use std::convert::Infallible; + /// Struct for asserting the behavior of `CacheBuddy` #[derive(Debug, serde::Serialize, serde::Deserialize, Clone)] #[serde(deny_unknown_fields)] @@ -418,9 +444,9 @@ mod tests { } )); - assert!(context - .layers_dir - .join(old_layer_name.as_str()) + assert!(layer_to_path(&context, &old_layer_name) + .unwrap() + .unwrap() .fs_err_try_exists() .unwrap()); @@ -446,10 +472,14 @@ mod tests { ) .unwrap(); - assert!(matches!(result.state, LayerState::Restored { cause: _ })); - assert!(context - .layers_dir - .join(new_layer_name.as_str()) + assert!( + matches!(result.state, LayerState::Restored { cause: _ }), + "Does not match {:?}", + result.state + ); + assert!(layer_to_path(&context, &new_layer_name) + .unwrap() + .unwrap() .fs_err_try_exists() .unwrap()); } diff --git a/commons/src/layer/order.rs b/commons/src/layer/order.rs new file mode 100644 index 00000000..410897d7 --- /dev/null +++ b/commons/src/layer/order.rs @@ -0,0 +1,68 @@ +use libcnb::data::layer::LayerName; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicUsize, Ordering}; + +/// As layers are created this is incremented +static LAYER_COUNT: AtomicUsize = AtomicUsize::new(1); +/// Formatting for number of leanding zeros. Max number of layers is 1024 (as of 2025). Four digits allows +/// one buildpack to meet or exceed that value +static DIGITS: usize = 4; + +fn prefix(value: usize) -> String { + format!("{value:0DIGITS$}_") +} + +fn next_count() -> usize { + LAYER_COUNT.fetch_add(1, Ordering::Acquire) +} + +/// Removes the `NNNN_` prefix if there is one +pub(crate) fn strip_order_prefix(name: &str) -> String { + let re = regex::Regex::new(&format!("^\\d{{{DIGITS}}}_")) + .expect("internal code bugs caught by unit tests"); + re.replace(name, "").to_string() +} + +/// Searches the given dir for an entry with the exact name or any NNNN_ prefix and returns Ok(Some(PathBuf)) +pub(crate) fn contains_entry_with_name_or_pattern( + dir: &Path, + name: &str, +) -> Result, std::io::Error> { + let name = strip_order_prefix(name); + + let pattern = format!("^\\d{{{DIGITS}}}_{}$", regex::escape(&name)); + let re = regex::Regex::new(&pattern).expect("internal error if this fails to compile"); + + for entry in fs_err::read_dir(dir)?.flatten() { + if let Some(file_name) = entry.file_name().to_str() { + if file_name == name || re.is_match(file_name) { + return Ok(Some(entry.path().clone())); + } + } + } + + Ok(None) +} + +/// Gets and increments the next name +/// +/// # Panics +/// +/// Assumes that prepending a value to a valid layer name is a valid operation +#[must_use] +pub(crate) fn ordered_layer_name(name: LayerName) -> LayerName { + let prefix = prefix(next_count()); + prefix_layer_name(&prefix, name) +} + +/// # Panics +/// +/// Assumes that prepending a value to a valid layer name is a valid operation +#[must_use] +#[allow(clippy::needless_pass_by_value)] +fn prefix_layer_name(prefix: impl AsRef, name: LayerName) -> LayerName { + let prefix = prefix.as_ref(); + format!("{prefix}{}", name.as_str()) + .parse() + .expect("Prepending to a valid layer name is valid") +}