From 5159164f091ca48d2e8ba2da31b86659b732dc79 Mon Sep 17 00:00:00 2001 From: RishabhSaini Date: Fri, 9 Dec 2022 17:08:18 -0500 Subject: [PATCH] chunking: Bin packing algorithm which allows to minimize layer deltas using historical builds Revamp basic_packing to follow the prior packing structure if the --prior-build flag exists. This simply modifies existing layers with upgrades/downgrades/removal of packages. The last layer contains any new addition to packages. In the case where --prior-build flag does not exist, the frequency of updates of the packages (frequencyinfo) and size is utilized to segment packages into different partitions (all combinations of low, medium, high frequency and low, medium, high size). The partition that each package falls into is decided by its deviation from mean. Then the packages are alloted to different layers to ensure 1) low frequency packages don't mix with high frequency packages 2) High sized packages are alloted separate bins 3) Low sized packages can be put together in the same bin This problem is aka multi-objective bin packing problem with constraints aka multiple knapsack problem. The objectives are conflicting given our constraints and hence a compromise is taken to minimize layer deltas while respecting the hard limit of overlayfs that the kernel can handle. --- lib/src/chunking.rs | 630 ++++++++++++++++-- lib/src/cli.rs | 2 +- lib/src/container/encapsulate.rs | 48 +- lib/src/container/mod.rs | 4 + lib/src/container/ocidir.rs | 5 +- lib/src/fixture.rs | 11 +- .../fedora-coreos-contentmeta.json.gz | Bin 10233 -> 11361 bytes lib/src/lib.rs | 1 + lib/src/objectsource.rs | 6 +- lib/src/statistics.rs | 109 +++ lib/tests/it/main.rs | 17 +- 11 files changed, 745 insertions(+), 88 deletions(-) create mode 100644 lib/src/statistics.rs diff --git a/lib/src/chunking.rs b/lib/src/chunking.rs index 873fbb7a..c8fdc333 100644 --- a/lib/src/chunking.rs +++ b/lib/src/chunking.rs @@ -3,15 +3,20 @@ // SPDX-License-Identifier: Apache-2.0 OR MIT use std::borrow::{Borrow, Cow}; -use std::collections::{BTreeMap, BTreeSet, HashMap}; +use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet}; use std::fmt::Write; +use std::hash::{Hash, Hasher}; use std::num::NonZeroU32; use std::rc::Rc; +use std::time::Instant; +use crate::container::CONTENT_ANNOTATION; use crate::objectsource::{ContentID, ObjectMeta, ObjectMetaMap, ObjectSourceMeta}; use crate::objgv::*; +use crate::statistics; use anyhow::{anyhow, Result}; use camino::Utf8PathBuf; +use containers_image_proxy::oci_spec; use gvariant::aligned_bytes::TryAsAligned; use gvariant::{Marker, Structure}; use ostree::{gio, glib}; @@ -24,12 +29,17 @@ pub(crate) const MAX_CHUNKS: u32 = 64; type RcStr = Rc; pub(crate) type ChunkMapping = BTreeMap)>; +// TODO type PackageSet = HashSet; + +const LOW_PARTITION: &str = "2ls"; +const HIGH_PARTITION: &str = "1hs"; #[derive(Debug, Default)] pub(crate) struct Chunk { pub(crate) name: String, pub(crate) content: ChunkMapping, pub(crate) size: u64, + pub(crate) packages: Vec, } #[derive(Debug, Deserialize, Serialize)] @@ -42,6 +52,20 @@ pub struct ObjectSourceMetaSized { size: u64, } +impl Hash for ObjectSourceMetaSized { + fn hash(&self, state: &mut H) { + self.meta.identifier.hash(state); + } +} + +impl Eq for ObjectSourceMetaSized {} + +impl PartialEq for ObjectSourceMetaSized { + fn eq(&self, other: &Self) -> bool { + self.meta.identifier == other.meta.identifier + } +} + /// Extend content source metadata with sizes. #[derive(Debug)] pub struct ObjectMetaSized { @@ -243,10 +267,11 @@ impl Chunking { repo: &ostree::Repo, rev: &str, meta: ObjectMetaSized, - max_layers: Option, + max_layers: &Option, + prior_build_metadata: Option<&oci_spec::image::ImageManifest>, ) -> Result { let mut r = Self::new(repo, rev)?; - r.process_mapping(meta, max_layers)?; + r.process_mapping(meta, max_layers, prior_build_metadata)?; Ok(r) } @@ -260,7 +285,8 @@ impl Chunking { pub fn process_mapping( &mut self, meta: ObjectMetaSized, - max_layers: Option, + max_layers: &Option, + prior_build_metadata: Option<&oci_spec::image::ImageManifest>, ) -> Result<()> { self.max = max_layers .unwrap_or(NonZeroU32::new(MAX_CHUNKS).unwrap()) @@ -291,16 +317,27 @@ impl Chunking { .unwrap(); // TODO: Compute bin packing in a better way - let packing = basic_packing(sizes, NonZeroU32::new(self.max).unwrap()); + let start = Instant::now(); + let packing = basic_packing( + sizes, + NonZeroU32::new(self.max).unwrap(), + prior_build_metadata, + )?; + let duration = start.elapsed(); + tracing::debug!("Time elapsed in packing: {:#?}", duration); for bin in packing.into_iter() { - let first = bin[0]; - let first_name = &*first.meta.name; let name = match bin.len() { - 0 => unreachable!(), - 1 => Cow::Borrowed(first_name), + 0 => Cow::Borrowed("Reserved for new packages"), + 1 => { + let first = bin[0]; + let first_name = &*first.meta.identifier; + Cow::Borrowed(first_name) + } 2..=5 => { - let r = bin.iter().map(|v| &*v.meta.name).skip(1).fold( + let first = bin[0]; + let first_name = &*first.meta.identifier; + let r = bin.iter().map(|v| &*v.meta.identifier).skip(1).fold( String::from(first_name), |mut acc, v| { write!(acc, " and {}", v).unwrap(); @@ -312,14 +349,13 @@ impl Chunking { n => Cow::Owned(format!("{n} components")), }; let mut chunk = Chunk::new(&name); + chunk.packages = bin.iter().map(|v| String::from(&*v.meta.name)).collect(); for szmeta in bin { for &obj in rmap.get(&szmeta.meta.identifier).unwrap() { self.remainder.move_obj(&mut chunk, obj.as_str()); } } - if !chunk.content.is_empty() { - self.chunks.push(chunk); - } + self.chunks.push(chunk); } assert_eq!(self.remainder.content.len(), 0); @@ -364,79 +400,366 @@ impl Chunking { } } -type ChunkedComponents<'a> = Vec<&'a ObjectSourceMetaSized>; - +#[cfg(test)] fn components_size(components: &[&ObjectSourceMetaSized]) -> u64 { components.iter().map(|k| k.size).sum() } /// Compute the total size of a packing #[cfg(test)] -fn packing_size(packing: &[ChunkedComponents]) -> u64 { +fn packing_size(packing: &[Vec<&ObjectSourceMetaSized>]) -> u64 { packing.iter().map(|v| components_size(v)).sum() } -fn sort_packing(packing: &mut [ChunkedComponents]) { - packing.sort_by(|a, b| { - let a: u64 = components_size(a); - let b: u64 = components_size(b); - b.cmp(&a) +///Given a certain threshold, divide a list of packages into all combinations +///of (high, medium, low) size and (high,medium,low) using the following +///outlier detection methods: +///- Median and Median Absolute Deviation Method +/// Aggressively detects outliers in size and classifies them by +/// high, medium, low. The high size and low size are separate partitions +/// and deserve bins of their own +///- Mean and Standard Deviation Method +/// The medium partition from the previous step is less aggressively +/// classified by using mean for both size and frequency + +//Assumes components is sorted by descending size +fn get_partitions_with_threshold( + components: Vec<&ObjectSourceMetaSized>, + limit_hs_bins: usize, + threshold: f64, +) -> Option>> { + let mut partitions: BTreeMap> = BTreeMap::new(); + let mut med_size: Vec<&ObjectSourceMetaSized> = Vec::new(); + let mut high_size: Vec<&ObjectSourceMetaSized> = Vec::new(); + + let mut sizes: Vec = components.iter().map(|a| a.size).collect(); + let (median_size, mad_size) = statistics::median_absolute_deviation(&mut sizes)?; + + //Avoids lower limit being negative + let size_low_limit = 0.5 * f64::abs(median_size - threshold * mad_size); + let size_high_limit = median_size + threshold * mad_size; + + for pkg in components { + let size = pkg.size as f64; + + //high size (hs) + if size >= size_high_limit { + high_size.push(pkg); + } + //low size (ls) + else if size <= size_low_limit { + partitions + .entry(LOW_PARTITION.to_string()) + .and_modify(|bin| bin.push(pkg)) + .or_insert_with(|| vec![pkg]); + } + //medium size (ms) + else { + med_size.push(pkg); + } + } + + //Extra hs packages + let mut remaining_pkgs: Vec<_> = high_size.drain(limit_hs_bins..).collect(); + assert_eq!(high_size.len(), limit_hs_bins); + + //Concatenate extra hs packages + med_sizes to keep it descending sorted + remaining_pkgs.append(&mut med_size); + partitions.insert(HIGH_PARTITION.to_string(), high_size); + + //Ascending sorted by frequency, so each partition within ms is freq sorted + remaining_pkgs.sort_by(|a, b| { + a.meta + .change_frequency + .partial_cmp(&b.meta.change_frequency) + .unwrap() }); + let med_sizes: Vec = remaining_pkgs.iter().map(|a| a.size).collect(); + let med_frequencies: Vec = remaining_pkgs + .iter() + .map(|a| a.meta.change_frequency.into()) + .collect(); + + let med_mean_freq = statistics::mean(&med_frequencies)?; + let med_stddev_freq = statistics::std_deviation(&med_frequencies)?; + let med_mean_size = statistics::mean(&med_sizes)?; + let med_stddev_size = statistics::std_deviation(&med_sizes)?; + + //Avoids lower limit being negative + let med_freq_low_limit = 0.5f64 * f64::abs(med_mean_freq - threshold * med_stddev_freq); + let med_freq_high_limit = med_mean_freq + threshold * med_stddev_freq; + let med_size_low_limit = 0.5f64 * f64::abs(med_mean_size - threshold * med_stddev_size); + let med_size_high_limit = med_mean_size + threshold * med_stddev_size; + + for pkg in remaining_pkgs { + let size = pkg.size as f64; + let freq = pkg.meta.change_frequency as f64; + + let size_name; + if size >= med_size_high_limit { + size_name = "hs"; + } else if size <= med_size_low_limit { + size_name = "ls"; + } else { + size_name = "ms"; + } + + //Numbered to maintain order of partitions in a BTreeMap of hf, mf, lf + let freq_name; + if freq >= med_freq_high_limit { + freq_name = "3hf"; + } else if freq <= med_freq_low_limit { + freq_name = "5lf"; + } else { + freq_name = "4mf"; + } + + let bucket = format!("{freq_name}_{size_name}"); + partitions + .entry(bucket.to_string()) + .and_modify(|bin| bin.push(pkg)) + .or_insert_with(|| vec![pkg]); + } + + for (name, pkgs) in &partitions { + tracing::debug!("{:#?}: {:#?}", name, pkgs.len()); + } + + Some(partitions) } /// Given a set of components with size metadata (e.g. boxes of a certain size) /// and a number of bins (possible container layers) to use, determine which components /// go in which bin. This algorithm is pretty simple: -/// -/// - order by size -/// - If we have fewer components than bins, we're done -/// - Take the "tail" (all components past maximum), and group by source package -/// - If we have fewer components than bins, we're done -/// - Take the whole tail and group them toether (this is the overly simplistic part) -fn basic_packing(components: &[ObjectSourceMetaSized], bins: NonZeroU32) -> Vec { - // let total_size: u64 = components.iter().map(|v| v.size).sum(); - // let avg_size: u64 = total_size / components.len() as u64; + +// Total available bins = n +// +// 1 bin for all the u32_max frequency pkgs +// 1 bin for all newly added pkgs +// 1 bin for all low size pkgs +// +// 60% of n-3 bins for high size pkgs +// 40% of n-3 bins for medium size pkgs +// +// If HS bins > limit, spillover to MS to package +// If MS bins > limit, fold by merging 2 bins from the end +// +fn basic_packing<'a>( + components: &'a [ObjectSourceMetaSized], + bin_size: NonZeroU32, + prior_build_metadata: Option<&oci_spec::image::ImageManifest>, +) -> Result>> { let mut r = Vec::new(); - // And handle the easy case of enough bins for all components - // TODO: Possibly try to split off large files? - if components.len() <= bins.get() as usize { - r.extend(components.iter().map(|v| vec![v])); - return r; - } - // Create a mutable copy let mut components: Vec<_> = components.iter().collect(); - // Iterate over the component tail, folding by source id - let mut by_src = HashMap::<_, Vec<&ObjectSourceMetaSized>>::new(); - // Take the tail off components, then build up mapping from srcid -> Vec - for component in components.split_off(bins.get() as usize) { - by_src - .entry(&component.meta.srcid) - .or_default() - .push(component); + let before_processing_pkgs_len = components.len(); + + //If the current rpm-ostree commit to be encapsulated is not the one in which packing structure changes, then + // Flatten out prior_build_metadata to view all the packages in prior build as a single vec + // Compare the flattened vector to components to see if pkgs added, updated, + // removed or kept same + // if pkgs added, then add them to the last bin of prior + // if pkgs removed, then remove them from the prior[i] + // iterate through prior[i] and make bins according to the name in nevra of pkgs to update + // required packages + //else if pkg structure to be changed || prior build not specified + // Recompute optimal packaging strcuture (Compute partitions, place packages and optimize build) + + if let Some(prior_build) = prior_build_metadata { + tracing::debug!("Keeping old package structure"); + + //1st layer is skipped as packing doesn't manage ostree_commit layer + let curr_build: Result>> = prior_build + .layers() + .iter() + .skip(1) + .map(|layer| -> Result<_> { + let annotation_layer = layer + .annotations() + .as_ref() + .and_then(|annos| annos.get(CONTENT_ANNOTATION)) + .ok_or_else(|| anyhow!("Missing {CONTENT_ANNOTATION} on prior build"))?; + Ok(annotation_layer.split(',').map(ToOwned::to_owned).collect()) + }) + .collect(); + let mut curr_build = curr_build?; + + // View the packages as unordered sets for lookups and differencing + let prev_pkgs_set: HashSet = curr_build + .iter() + .flat_map(|v| v.iter().cloned()) + .filter(|name| !name.is_empty()) + .collect(); + let curr_pkgs_set: HashSet = components + .iter() + .map(|pkg| pkg.meta.name.to_string()) + .collect(); + + //Handle added packages + if let Some(last_bin) = curr_build.last_mut() { + let added = curr_pkgs_set.difference(&prev_pkgs_set); + last_bin.retain(|name| !name.is_empty()); + last_bin.extend(added.into_iter().cloned()); + } else { + panic!("No empty last bin for added packages"); + } + + //Handle removed packages + let removed: HashSet<&String> = prev_pkgs_set.difference(&curr_pkgs_set).collect(); + for bin in curr_build.iter_mut() { + bin.retain(|pkg| !removed.contains(pkg)); + } + + //Handle updated packages + let mut name_to_component: HashMap = HashMap::new(); + for component in &components { + name_to_component + .entry(component.meta.name.to_string()) + .or_insert(component); + } + let mut modified_build: Vec> = Vec::new(); + for bin in curr_build { + let mut mod_bin = Vec::new(); + for pkg in bin { + mod_bin.push(name_to_component[&pkg]); + } + modified_build.push(mod_bin); + } + + //Verify all packages are included + let after_processing_pkgs_len: usize = modified_build.iter().map(|b| b.len()).sum(); + assert_eq!(after_processing_pkgs_len, before_processing_pkgs_len); + assert!(modified_build.len() <= bin_size.get() as usize); + return Ok(modified_build); } - // Take all the non-tail (largest) components, and append them first - r.extend(components.into_iter().map(|v| vec![v])); - // Add the tail - r.extend(by_src.into_values()); - // And order the new list - sort_packing(&mut r); - // It's possible that merging components gave us enough space; if so - // we're done! - if r.len() <= bins.get() as usize { - return r; + + tracing::debug!("Creating new packing structure"); + + //Handle trivial case of no pkgs < bins + if before_processing_pkgs_len < bin_size.get() as usize { + components.into_iter().for_each(|pkg| r.push(vec![pkg])); + if before_processing_pkgs_len > 0 { + let new_pkgs_bin: Vec<&ObjectSourceMetaSized> = Vec::new(); + r.push(new_pkgs_bin); + } + return Ok(r); } - let last = (bins.get().checked_sub(1).unwrap()) as usize; - // The "tail" is components past our maximum. For now, we simply group all of that together as a single unit. - if let Some(tail) = r.drain(last..).reduce(|mut a, b| { - a.extend(b.into_iter()); - a - }) { - r.push(tail); + let mut max_freq_components: Vec<&ObjectSourceMetaSized> = Vec::new(); + components.retain(|pkg| { + let retain: bool = pkg.meta.change_frequency != u32::MAX; + if !retain { + max_freq_components.push(pkg); + } + retain + }); + let components_len_after_max_freq = components.len(); + match components_len_after_max_freq { + 0 => (), + _ => { + //Defining Limits of each bins + let limit_ls_bins = 1usize; + let limit_new_bins = 1usize; + let _limit_new_pkgs = 0usize; + let limit_max_frequency_pkgs = max_freq_components.len(); + let limit_max_frequency_bins = limit_max_frequency_pkgs.min(1); + let limit_hs_bins = (0.6 + * (bin_size.get() + - (limit_ls_bins + limit_new_bins + limit_max_frequency_bins) as u32) + as f32) + .floor() as usize; + let limit_ms_bins = (bin_size.get() + - (limit_hs_bins + limit_ls_bins + limit_new_bins + limit_max_frequency_bins) + as u32) as usize; + let partitions = get_partitions_with_threshold(components, limit_hs_bins, 2f64) + .expect("Partitioning components into sets"); + + let limit_ls_pkgs = match partitions.get(LOW_PARTITION) { + Some(n) => n.len(), + None => 0usize, + }; + + let pkg_per_bin_ms: usize = + (components_len_after_max_freq - limit_hs_bins - limit_ls_pkgs) + .checked_div(limit_ms_bins) + .expect("number of bins should be >= 4"); + + //Bins assignment + for (partition, pkgs) in partitions.iter() { + if partition == HIGH_PARTITION { + for pkg in pkgs { + r.push(vec![*pkg]); + } + } else if partition == LOW_PARTITION { + let mut bin: Vec<&ObjectSourceMetaSized> = Vec::new(); + for pkg in pkgs { + bin.push(*pkg); + } + r.push(bin); + } else { + let mut bin: Vec<&ObjectSourceMetaSized> = Vec::new(); + for (i, pkg) in pkgs.iter().enumerate() { + if bin.len() < pkg_per_bin_ms { + bin.push(*pkg); + } else { + r.push(bin.clone()); + bin.clear(); + bin.push(*pkg); + } + if i == pkgs.len() - 1 && !bin.is_empty() { + r.push(bin.clone()); + bin.clear(); + } + } + } + } + tracing::debug!("Bins before unoptimized build: {}", r.len()); + + //Despite allocation certain number of pkgs per bin in MS partitions, the + //hard limit of number of MS bins can be exceeded. This is because the pkg_per_bin_ms + //is only upper limit and there is no lower limit. Thus, if a partition in MS has only 1 pkg + //but pkg_per_bin_ms > 1, then the entire bin will have 1 pkg. This prevents partition + //mixing. + // + //Addressing MS bins limit breach by mergin internal MS partitions + //The partitions in MS are merged beginnign from the end so to not mix hf bins with lf bins. The + //bins are kept in this order: hf, mf, lf by design. + while r.len() > (bin_size.get() as usize - limit_new_bins - limit_max_frequency_bins) { + for i in (limit_ls_bins + limit_hs_bins..r.len() - 1) + .step_by(2) + .rev() + { + if r.len() + <= (bin_size.get() as usize - limit_new_bins - limit_max_frequency_bins) + { + break; + } + let prev = &r[i - 1]; + let curr = &r[i]; + let mut merge: Vec<&ObjectSourceMetaSized> = Vec::new(); + merge.extend(prev.iter()); + merge.extend(curr.iter()); + r.remove(i); + r.remove(i - 1); + r.insert(i, merge); + } + } + tracing::debug!("Bins after optimization: {}", r.len()); + } } - assert!(r.len() <= bins.get() as usize); - r + if !max_freq_components.is_empty() { + r.push(max_freq_components); + } + + let new_pkgs_bin: Vec<&ObjectSourceMetaSized> = Vec::new(); + r.push(new_pkgs_bin); + let mut after_processing_pkgs_len = 0; + r.iter().for_each(|bin| { + after_processing_pkgs_len += bin.len(); + }); + assert_eq!(after_processing_pkgs_len, before_processing_pkgs_len); + assert!(r.len() <= bin_size.get() as usize); + Ok(r) } #[cfg(test)] @@ -449,7 +772,7 @@ mod test { fn test_packing_basics() -> Result<()> { // null cases for v in [1u32, 7].map(|v| NonZeroU32::new(v).unwrap()) { - assert_eq!(basic_packing(&[], v).len(), 0); + assert_eq!(basic_packing(&[], v, None).unwrap().len(), 0); } Ok(()) } @@ -460,7 +783,8 @@ mod test { serde_json::from_reader(flate2::read::GzDecoder::new(FCOS_CONTENTMETA))?; let total_size = contentmeta.iter().map(|v| v.size).sum::(); - let packing = basic_packing(&contentmeta, NonZeroU32::new(MAX_CHUNKS).unwrap()); + let packing = + basic_packing(&contentmeta, NonZeroU32::new(MAX_CHUNKS).unwrap(), None).unwrap(); assert!(!contentmeta.is_empty()); // We should fit into the assigned chunk size assert_eq!(packing.len() as u32, MAX_CHUNKS); @@ -469,4 +793,178 @@ mod test { assert_eq!(total_size, packed_total_size); Ok(()) } + + fn create_manifest(prev_expected_structure: Vec>) -> oci_spec::image::ImageManifest { + let mut p = prev_expected_structure + .iter() + .map(|b| { + b.iter() + .map(|p| p.split(".").collect::>()[0].to_string()) + .collect() + }) + .collect(); + let mut metadata_with_ostree_commit = vec![vec![String::from("ostree_commit")]]; + metadata_with_ostree_commit.append(&mut p); + + let config = oci_spec::image::DescriptorBuilder::default() + .media_type(oci_spec::image::MediaType::ImageConfig) + .size(7023) + .digest("sha256:imageconfig") + .build() + .expect("build config descriptor"); + + let layers: Vec = metadata_with_ostree_commit + .iter() + .map(|l| { + oci_spec::image::DescriptorBuilder::default() + .media_type(oci_spec::image::MediaType::ImageLayerGzip) + .size(100) + .digest(format!("sha256:{}", l.len())) + .annotations(HashMap::from([( + CONTENT_ANNOTATION.to_string(), + l.join(","), + )])) + .build() + .expect("build layer") + }) + .collect(); + + let image_manifest = oci_spec::image::ImageManifestBuilder::default() + .schema_version(oci_spec::image::SCHEMA_VERSION) + .config(config) + .layers(layers) + .build() + .expect("build image manifest"); + image_manifest + } + + #[test] + fn test_advanced_packing() -> Result<()> { + //Step1 : Initial build (Packing sructure computed) + let contentmeta_v0: Vec = vec![ + vec![1, u32::MAX, 100000], + vec![2, u32::MAX, 99999], + vec![3, 30, 99998], + vec![4, 100, 99997], + vec![10, 51, 1000], + vec![8, 50, 500], + vec![9, 1, 200], + vec![11, 100000, 199], + vec![6, 30, 2], + vec![7, 30, 1], + ] + .iter() + .map(|data| ObjectSourceMetaSized { + meta: ObjectSourceMeta { + identifier: RcStr::from(format!("pkg{}.0", data[0])), + name: RcStr::from(format!("pkg{}", data[0])), + srcid: RcStr::from(format!("srcpkg{}", data[0])), + change_time_offset: 0, + change_frequency: data[1], + }, + size: data[2] as u64, + }) + .collect(); + + let packing = basic_packing( + &contentmeta_v0.as_slice(), + NonZeroU32::new(6).unwrap(), + None, + ) + .unwrap(); + let structure: Vec> = packing + .iter() + .map(|bin| bin.iter().map(|pkg| &*pkg.meta.identifier).collect()) + .collect(); + let v0_expected_structure = vec![ + vec!["pkg3.0"], + vec!["pkg4.0"], + vec!["pkg6.0", "pkg7.0", "pkg11.0"], + vec!["pkg9.0", "pkg8.0", "pkg10.0"], + vec!["pkg1.0", "pkg2.0"], + vec![], + ]; + assert_eq!(structure, v0_expected_structure); + + //Step 2: Derive packing structure from last build + + let mut contentmeta_v1: Vec = contentmeta_v0; + //Upgrade pkg1.0 to 1.1 + contentmeta_v1[0].meta.identifier = RcStr::from("pkg1.1"); + //Remove pkg7 + contentmeta_v1.remove(contentmeta_v1.len() - 1); + //Add pkg5 + contentmeta_v1.push(ObjectSourceMetaSized { + meta: ObjectSourceMeta { + identifier: RcStr::from("pkg5.0"), + name: RcStr::from("pkg5"), + srcid: RcStr::from("srcpkg5"), + change_time_offset: 0, + change_frequency: 42, + }, + size: 100000, + }); + + let image_manifest_v0 = create_manifest(v0_expected_structure); + let packing_derived = basic_packing( + &contentmeta_v1.as_slice(), + NonZeroU32::new(6).unwrap(), + Some(&image_manifest_v0), + ) + .unwrap(); + let structure_derived: Vec> = packing_derived + .iter() + .map(|bin| bin.iter().map(|pkg| &*pkg.meta.identifier).collect()) + .collect(); + let v1_expected_structure = vec![ + vec!["pkg3.0"], + vec!["pkg4.0"], + vec!["pkg6.0", "pkg11.0"], + vec!["pkg9.0", "pkg8.0", "pkg10.0"], + vec!["pkg1.1", "pkg2.0"], + vec!["pkg5.0"], + ]; + + assert_eq!(structure_derived, v1_expected_structure); + + //Step 3: Another update on derived where the pkg in the last bin updates + + let mut contentmeta_v2: Vec = contentmeta_v1; + //Upgrade pkg5.0 to 5.1 + contentmeta_v2[9].meta.identifier = RcStr::from("pkg5.1"); + //Add pkg12 + contentmeta_v2.push(ObjectSourceMetaSized { + meta: ObjectSourceMeta { + identifier: RcStr::from("pkg12.0"), + name: RcStr::from("pkg12"), + srcid: RcStr::from("srcpkg12"), + change_time_offset: 0, + change_frequency: 42, + }, + size: 100000, + }); + + let image_manifest_v1 = create_manifest(v1_expected_structure); + let packing_derived = basic_packing( + &contentmeta_v2.as_slice(), + NonZeroU32::new(6).unwrap(), + Some(&image_manifest_v1), + ) + .unwrap(); + let structure_derived: Vec> = packing_derived + .iter() + .map(|bin| bin.iter().map(|pkg| &*pkg.meta.identifier).collect()) + .collect(); + let v2_expected_structure = vec![ + vec!["pkg3.0"], + vec!["pkg4.0"], + vec!["pkg6.0", "pkg11.0"], + vec!["pkg9.0", "pkg8.0", "pkg10.0"], + vec!["pkg1.1", "pkg2.0"], + vec!["pkg5.1", "pkg12.0"], + ]; + + assert_eq!(structure_derived, v2_expected_structure); + Ok(()) + } } diff --git a/lib/src/cli.rs b/lib/src/cli.rs index ba3c6f19..f5a3ef68 100644 --- a/lib/src/cli.rs +++ b/lib/src/cli.rs @@ -584,7 +584,7 @@ async fn container_export( ..Default::default() }; let pushed = - crate::container::encapsulate(repo, rev, &config, Some(opts), None, imgref).await?; + crate::container::encapsulate(repo, rev, &config, None, Some(opts), None, imgref).await?; println!("{}", pushed); Ok(()) } diff --git a/lib/src/container/encapsulate.rs b/lib/src/container/encapsulate.rs index 2e37c97b..23e164c0 100644 --- a/lib/src/container/encapsulate.rs +++ b/lib/src/container/encapsulate.rs @@ -1,7 +1,7 @@ //! APIs for creating container images from OSTree commits use super::ocidir::{Layer, OciDir}; -use super::{ocidir, OstreeImageReference, Transport}; +use super::{ocidir, OstreeImageReference, Transport, CONTENT_ANNOTATION}; use super::{ImageReference, SignatureSource, OSTREE_COMMIT_LABEL}; use crate::chunking::{Chunk, Chunking, ObjectMetaSized}; use crate::container::skopeo; @@ -104,7 +104,7 @@ fn export_chunks( ociw: &mut OciDir, chunks: Vec, opts: &ExportOpts, -) -> Result> { +) -> Result)>> { chunks .into_iter() .enumerate() @@ -113,7 +113,7 @@ fn export_chunks( ostree_tar::export_chunk(repo, commit, chunk.content, &mut w) .with_context(|| format!("Exporting chunk {i}"))?; let w = w.into_inner()?; - Ok((w.complete()?, chunk.name)) + Ok((w.complete()?, chunk.name, chunk.packages)) }) .collect() } @@ -151,11 +151,20 @@ fn export_chunked( .clone(); // Add the ostree layer - ociw.push_layer(manifest, imgcfg, ostree_layer, description); + ociw.push_layer(manifest, imgcfg, ostree_layer, description, None); // Add the component/content layers - for (layer, name) in layers { - ociw.push_layer(manifest, imgcfg, layer, name.as_str()); + for (layer, name, packages) in layers { + let mut annotation_component_layer = HashMap::new(); + annotation_component_layer.insert(CONTENT_ANNOTATION.to_string(), packages.join(",")); + ociw.push_layer( + manifest, + imgcfg, + layer, + name.as_str(), + Some(annotation_component_layer), + ); } + // This label (mentioned above) points to the last layer that is part of // the ostree commit. labels.insert( @@ -167,6 +176,7 @@ fn export_chunked( /// Generate an OCI image from a given ostree root #[context("Building oci")] +#[allow(clippy::too_many_arguments)] fn build_oci( repo: &ostree::Repo, rev: &str, @@ -174,6 +184,7 @@ fn build_oci( tag: Option<&str>, config: &Config, opts: ExportOpts, + prior_build: Option<&oci_image::ImageManifest>, contentmeta: Option, ) -> Result { if !ocidir_path.exists() { @@ -209,7 +220,15 @@ fn build_oci( let mut manifest = ocidir::new_empty_manifest().build().unwrap(); let chunking = contentmeta - .map(|meta| crate::chunking::Chunking::from_mapping(repo, commit, meta, opts.max_layers)) + .map(|meta| { + crate::chunking::Chunking::from_mapping( + repo, + commit, + meta, + &opts.max_layers, + prior_build, + ) + }) .transpose()?; // If no chunking was provided, create a logical single chunk. let chunking = chunking @@ -291,6 +310,7 @@ async fn build_impl( repo: &ostree::Repo, ostree_ref: &str, config: &Config, + prior_build: Option<&oci_image::ImageManifest>, opts: Option, contentmeta: Option, dest: &ImageReference, @@ -308,6 +328,7 @@ async fn build_impl( tag, config, opts, + prior_build, contentmeta, )?; None @@ -323,6 +344,7 @@ async fn build_impl( None, config, opts, + prior_build, contentmeta, )?; @@ -377,9 +399,19 @@ pub async fn encapsulate>( repo: &ostree::Repo, ostree_ref: S, config: &Config, + prior_build: Option<&oci_image::ImageManifest>, opts: Option, contentmeta: Option, dest: &ImageReference, ) -> Result { - build_impl(repo, ostree_ref.as_ref(), config, opts, contentmeta, dest).await + build_impl( + repo, + ostree_ref.as_ref(), + config, + prior_build, + opts, + contentmeta, + dest, + ) + .await } diff --git a/lib/src/container/mod.rs b/lib/src/container/mod.rs index e2bb7970..115912ca 100644 --- a/lib/src/container/mod.rs +++ b/lib/src/container/mod.rs @@ -37,6 +37,10 @@ use std::str::FromStr; /// The label injected into a container image that contains the ostree commit SHA-256. pub const OSTREE_COMMIT_LABEL: &str = "ostree.commit"; +/// The name of an annotation attached to a layer which names the packages/components +/// which are part of it. +pub(crate) const CONTENT_ANNOTATION: &str = "ostree.components"; + /// Our generic catchall fatal error, expected to be converted /// to a string to output to a terminal or logs. type Result = anyhow::Result; diff --git a/lib/src/container/ocidir.rs b/lib/src/container/ocidir.rs index 424ede35..8ed72bf1 100644 --- a/lib/src/container/ocidir.rs +++ b/lib/src/container/ocidir.rs @@ -203,8 +203,8 @@ impl OciDir { config: &mut oci_image::ImageConfiguration, layer: Layer, description: &str, + annotations: Option>, ) { - let annotations: Option> = None; self.push_layer_annotated(manifest, config, layer, annotations, description); } @@ -531,7 +531,8 @@ mod tests { let mut config = oci_image::ImageConfigurationBuilder::default() .build() .unwrap(); - w.push_layer(&mut manifest, &mut config, root_layer, "root"); + let annotations: Option> = None; + w.push_layer(&mut manifest, &mut config, root_layer, "root", annotations); let config = w.write_config(config)?; manifest.set_config(config); w.replace_with_single_manifest(manifest.clone(), oci_image::Platform::default())?; diff --git a/lib/src/fixture.rs b/lib/src/fixture.rs index 5218bc27..fbf649e1 100644 --- a/lib/src/fixture.rs +++ b/lib/src/fixture.rs @@ -168,7 +168,9 @@ d tmp "## }; pub const CONTENTS_CHECKSUM_V0: &str = "5e41de82f9f861fa51e53ce6dd640a260e4fb29b7657f5a3f14157e93d2c0659"; -pub static CONTENTS_V0_LEN: Lazy = Lazy::new(|| OWNERS.len().checked_sub(1).unwrap()); +// 1 for ostree commit, 2 for max frequency packages, 3 as empty layer +pub const LAYERS_V0_LEN: usize = 3usize; +pub const PKGS_V0_LEN: usize = 7usize; #[derive(Debug, PartialEq, Eq)] enum SeLabel { @@ -317,6 +319,7 @@ fn build_mapping_recurse( name: Rc::clone(&owner), srcid: Rc::clone(&owner), change_time_offset: u32::MAX, + change_frequency: u32::MAX, }); } @@ -661,11 +664,15 @@ impl Fixture { let contentmeta = self.get_object_meta().context("Computing object meta")?; let contentmeta = ObjectMetaSized::compute_sizes(self.srcrepo(), contentmeta) .context("Computing sizes")?; - let opts = ExportOpts::default(); + let opts = ExportOpts { + max_layers: std::num::NonZeroU32::new(PKGS_V0_LEN as u32), + ..Default::default() + }; let digest = crate::container::encapsulate( self.srcrepo(), self.testref(), &config, + None, Some(opts), Some(contentmeta), &imgref, diff --git a/lib/src/fixtures/fedora-coreos-contentmeta.json.gz b/lib/src/fixtures/fedora-coreos-contentmeta.json.gz index a1276a3f34046a9ecd83fbc96c1c3f7fee818f6e..285d587a7c52bb3725c14a104e522af0a6d9e8be 100644 GIT binary patch literal 11361 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 jkij%pVtA+8yqlYc7R{y)c%#!Ii_^A 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 diff --git a/lib/src/lib.rs b/lib/src/lib.rs index 54730c77..c9a424b3 100644 --- a/lib/src/lib.rs +++ b/lib/src/lib.rs @@ -51,6 +51,7 @@ pub mod objectsource; pub(crate) mod objgv; #[cfg(feature = "internal-testing-api")] pub mod ostree_manual; +pub(crate) mod statistics; mod utils; diff --git a/lib/src/objectsource.rs b/lib/src/objectsource.rs index d8258c16..64a3eb13 100644 --- a/lib/src/objectsource.rs +++ b/lib/src/objectsource.rs @@ -41,9 +41,7 @@ pub struct ObjectSourceMeta { /// Unique identifier, does not need to be human readable, but can be. #[serde(with = "rcstr_serialize")] pub identifier: ContentID, - /// Identifier for this source (e.g. package name-version, git repo). - /// Unlike the [`ContentID`], this should be human readable. It likely comes from an external source, - /// and may be re-serialized. + /// Just the name of the package (no version), needs to be human readable. #[serde(with = "rcstr_serialize")] pub name: Rc, /// Identifier for the *source* of this content; for example, if multiple binary @@ -54,6 +52,8 @@ pub struct ObjectSourceMeta { /// One suggested way to generate this number is to have it be in units of hours or days /// since the earliest changed item. pub change_time_offset: u32, + /// Change frequency + pub change_frequency: u32, } impl PartialEq for ObjectSourceMeta { diff --git a/lib/src/statistics.rs b/lib/src/statistics.rs new file mode 100644 index 00000000..7b0102fb --- /dev/null +++ b/lib/src/statistics.rs @@ -0,0 +1,109 @@ +//! This module holds implementations of some basic statistical properties, such as mean and standard deviation. + +pub(crate) fn mean(data: &[u64]) -> Option { + if data.is_empty() { + None + } else { + Some(data.iter().sum::() as f64 / data.len() as f64) + } +} + +pub(crate) fn std_deviation(data: &[u64]) -> Option { + match (mean(data), data.len()) { + (Some(data_mean), count) if count > 0 => { + let variance = data + .iter() + .map(|value| { + let diff = data_mean - (*value as f64); + diff * diff + }) + .sum::() + / count as f64; + Some(variance.sqrt()) + } + _ => None, + } +} + +//Assumed sorted +pub(crate) fn median_absolute_deviation(data: &mut [u64]) -> Option<(f64, f64)> { + if data.is_empty() { + None + } else { + //Sort data + //data.sort_by(|a, b| a.partial_cmp(b).unwrap()); + + //Find median of data + let median_data: f64 = match data.len() % 2 { + 1 => data[data.len() / 2] as f64, + _ => 0.5 * (data[data.len() / 2 - 1] + data[data.len() / 2]) as f64, + }; + + //Absolute deviations + let mut absolute_deviations = Vec::new(); + for size in data { + absolute_deviations.push(f64::abs(*size as f64 - median_data)) + } + + absolute_deviations.sort_by(|a, b| a.partial_cmp(b).unwrap()); + let l = absolute_deviations.len(); + let mad: f64 = match l % 2 { + 1 => absolute_deviations[l / 2], + _ => 0.5 * (absolute_deviations[l / 2 - 1] + absolute_deviations[l / 2]), + }; + + Some((median_data, mad)) + } +} + +#[test] +fn test_mean() { + assert_eq!(mean(&[]), None); + for v in [0u64, 1, 5, 100] { + assert_eq!(mean(&[v]), Some(v as f64)); + } + assert_eq!(mean(&[0, 1]), Some(0.5)); + assert_eq!(mean(&[0, 5, 100]), Some(35.0)); + assert_eq!(mean(&[7, 4, 30, 14]), Some(13.75)); +} + +#[test] +fn test_std_deviation() { + assert_eq!(std_deviation(&[]), None); + for v in [0u64, 1, 5, 100] { + assert_eq!(std_deviation(&[v]), Some(0 as f64)); + } + assert_eq!(std_deviation(&[1, 4]), Some(1.5)); + assert_eq!(std_deviation(&[2, 2, 2, 2]), Some(0.0)); + assert_eq!( + std_deviation(&[1, 20, 300, 4000, 50000, 600000, 7000000, 80000000]), + Some(26193874.56387471) + ); +} + +#[test] +fn test_median_absolute_deviation() { + //Assumes sorted + assert_eq!(median_absolute_deviation(&mut []), None); + for v in [0u64, 1, 5, 100] { + assert_eq!(median_absolute_deviation(&mut [v]), Some((v as f64, 0.0))); + } + assert_eq!(median_absolute_deviation(&mut [1, 4]), Some((2.5, 1.5))); + assert_eq!( + median_absolute_deviation(&mut [2, 2, 2, 2]), + Some((2.0, 0.0)) + ); + assert_eq!( + median_absolute_deviation(&mut [ + 1, 2, 3, 3, 4, 4, 4, 5, 5, 6, 6, 6, 7, 7, 7, 8, 9, 12, 52, 90 + ]), + Some((6.0, 2.0)) + ); + + //if more than half of the data has the same value, MAD = 0, thus any + //value different from the residual median is classified as an outlier + assert_eq!( + median_absolute_deviation(&mut [0, 1, 1, 1, 1, 1, 1, 1, 0]), + Some((1.0, 0.0)) + ); +} diff --git a/lib/tests/it/main.rs b/lib/tests/it/main.rs index 63eda872..a9e5b69c 100644 --- a/lib/tests/it/main.rs +++ b/lib/tests/it/main.rs @@ -21,7 +21,7 @@ use std::process::Command; use std::time::SystemTime; use xshell::cmd; -use ostree_ext::fixture::{FileDef, Fixture, CONTENTS_CHECKSUM_V0, CONTENTS_V0_LEN}; +use ostree_ext::fixture::{FileDef, Fixture, CONTENTS_CHECKSUM_V0, LAYERS_V0_LEN, PKGS_V0_LEN}; const EXAMPLE_TAR_LAYER: &[u8] = include_bytes!("fixtures/hlinks.tar.gz"); const TEST_REGISTRY_DEFAULT: &str = "localhost:5000"; @@ -480,12 +480,14 @@ async fn impl_test_container_import_export(chunked: bool) -> Result<()> { let opts = ExportOpts { copy_meta_keys: vec!["buildsys.checksum".to_string()], copy_meta_opt_keys: vec!["nosuchvalue".to_string()], + max_layers: std::num::NonZeroU32::new(PKGS_V0_LEN as u32), ..Default::default() }; let digest = ostree_ext::container::encapsulate( fixture.srcrepo(), fixture.testref(), &config, + None, Some(opts), contentmeta, &srcoci_imgref, @@ -520,7 +522,7 @@ async fn impl_test_container_import_export(chunked: bool) -> Result<()> { "/usr/bin/bash" ); - let n_chunks = if chunked { *CONTENTS_V0_LEN } else { 1 }; + let n_chunks = if chunked { LAYERS_V0_LEN } else { 1 }; assert_eq!(cfg.rootfs().diff_ids().len(), n_chunks); assert_eq!(cfg.history().len(), n_chunks); @@ -537,6 +539,7 @@ async fn impl_test_container_import_export(chunked: bool) -> Result<()> { &config, None, None, + None, &ociarchive_dest, ) .await @@ -625,7 +628,7 @@ fn validate_chunked_structure(oci_path: &Utf8Path) -> Result<()> { let d = Dir::open_ambient_dir(oci_path, cap_std::ambient_authority())?; let d = ocidir::OciDir::open(&d)?; let manifest = d.read_manifest()?; - assert_eq!(manifest.layers().len(), *CONTENTS_V0_LEN); + assert_eq!(manifest.layers().len(), LAYERS_V0_LEN); let ostree_layer = manifest.layers().first().unwrap(); let mut ostree_layer_blob = d .read_blob(ostree_layer) @@ -658,7 +661,7 @@ fn validate_chunked_structure(oci_path: &Utf8Path) -> Result<()> { #[tokio::test] async fn test_container_chunked() -> Result<()> { - let nlayers = *CONTENTS_V0_LEN - 1; + let nlayers = LAYERS_V0_LEN - 1; let mut fixture = Fixture::new_v1()?; let (imgref, expected_digest) = fixture.export_container().await.unwrap(); @@ -729,7 +732,7 @@ r usr/bin/bash bash-v0 first.1, "ostree export of commit 38ab1f9da373a0184b0b48db6e280076ab4b5d4691773475ae24825aae2272d4" ); - assert_eq!(second.1, "bash"); + assert_eq!(second.1, "7 components"); assert_eq!(store::list_images(fixture.destrepo()).unwrap().len(), 1); let n = store::count_layer_references(fixture.destrepo())? as i64; @@ -803,7 +806,7 @@ r usr/bin/bash bash-v0 store::remove_images(fixture.destrepo(), [&derived_imgref.imgref]).unwrap(); assert_eq!(store::list_images(fixture.destrepo()).unwrap().len(), 0); let n_removed = store::gc_image_layers(fixture.destrepo())?; - assert_eq!(n_removed, (*CONTENTS_V0_LEN + 1) as u32); + assert_eq!(n_removed, (LAYERS_V0_LEN + 1) as u32); // Repo should be clean now assert_eq!(store::count_layer_references(fixture.destrepo())?, 0); @@ -910,6 +913,7 @@ async fn test_container_write_derive() -> Result<()> { }, None, None, + None, &ImageReference { transport: Transport::OciDir, name: base_oci_path.to_string(), @@ -1298,6 +1302,7 @@ async fn test_container_import_export_registry() -> Result<()> { &config, None, None, + None, &src_imgref, ) .await