Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Migrate inventory code #861

Merged
merged 19 commits into from
Oct 16, 2024
Merged
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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- `libherokubuildpack`:
- Added `inventory` module. ([#861](https://github.com/heroku/libcnb.rs/pull/861))

## [0.23.0] - 2024-08-28

Expand Down
9 changes: 8 additions & 1 deletion libherokubuildpack/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,13 @@ all-features = true
workspace = true

[features]
default = ["command", "download", "digest", "error", "log", "tar", "toml", "fs", "write", "buildpack_output"]
default = ["command", "download", "digest", "error", "inventory", "log", "inventory-semver", "inventory-sha2", "tar", "toml", "fs", "write", "buildpack_output"]
download = ["dep:ureq", "dep:thiserror"]
digest = ["dep:sha2"]
error = ["log", "dep:libcnb"]
inventory = ["dep:hex", "dep:serde", "dep:thiserror", "dep:toml"]
inventory-semver = ["dep:semver"]
inventory-sha2 = ["dep:sha2"]
log = ["dep:termcolor"]
tar = ["dep:tar", "dep:flate2"]
toml = ["dep:toml"]
Expand All @@ -38,8 +41,11 @@ crossbeam-utils = { version = "0.8.20", optional = true }
# https://github.com/rust-lang/libz-sys/issues/93
# As such we have to use the next best alternate backend, which is `zlib`.
flate2 = { version = "1.0.33", default-features = false, features = ["zlib"], optional = true }
hex = { version = "0.4.3", optional = true }
libcnb = { workspace = true, optional = true }
pathdiff = { version = "0.2.1", optional = true }
semver = { version = "1.0.21", features = ["serde"], optional = true }
serde = { version = "1.0.209", features = ["derive"], optional = true }
sha2 = { version = "0.10.8", optional = true }
tar = { version = "0.4.41", default-features = false, optional = true }
termcolor = { version = "1.4.1", optional = true }
Expand All @@ -50,4 +56,5 @@ ureq = { version = "2.10.1", default-features = false, features = ["tls"], optio
[dev-dependencies]
indoc = "2.0.5"
libcnb-test = { workspace = true }
serde_test = "1.0.177"
tempfile = "3.12.0"
284 changes: 284 additions & 0 deletions libherokubuildpack/src/inventory.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,284 @@
//! # Inventory
//!
//! Many buildpacks need to provide artifacts from different URLs. A helpful pattern
//! is to provide a list of artifacts in a TOML file, which can be parsed and used by
//! the buildpack to download the correct artifact. For example, a Ruby buildpack
//! might need to download pre-compiled Ruby binaries hosted on S3.
//!
//! This module can be used to produce and consume such an inventory file.
//!
//! ## Features
//!
//! - Version lookup and comparison: To implement the inventory, you'll need to define how
//! versions are compared. This allows the inventory code to find an appropriate artifact
//! based on whatever custom version logic you need. If you don't need custom logic, you can
//! use the included `inventory-semver` feature.
//! - Architecture aware: Beyond version specifiers, buildpack authors may need to provide different
//! artifacts for different computer architectures such as ARM64 or AMD64. The inventory encodes
//! this information which is used to select the correct artifact.
//! - Checksum validation: In addition to knowing the URL of an artifact, buildp authors
//! want to be confident that the artifact they download is the correct one. To accomplish this
//! the inventory contains a checksum of the download and can be used to validate the download
//! has not been modified or tampered with. To use sha256 or sha512 checksums out of the box,
//! enable the `inventory-sha2` feature
//! - Extensible with metadata: The default inventory format covers a lot of common use cases,
//! but if you need more, you can extend it by adding custom metadata to each artifact.
//!
//! ## Example usage
//!
//! This example demonstrates:
//! * Creating an artifact using the `inventory-sha2` and `inventory-semver` features.
//! * Adding the artifact to an inventory.
//! * Serializing and deserializing the inventory [to](Inventory#method.fmt) and [from](Inventory::from_str) TOML.
//! * [Resolving an inventory artifact](Inventory::resolve) specifying relevant OS, architecture, and version requirements.
//! * Using the resolved artifact's checksum value to verify "downloaded" data.
//!
//! ```rust
//! use libherokubuildpack::inventory::{artifact::{Arch, Artifact, Os}, Inventory, checksum::Checksum};
//! use semver::{Version, VersionReq};
//! use sha2::{Sha256, Digest};
//!
//! // Create an artifact with a SHA256 checksum and `semver::Version`
//! let new_artifact = Artifact {
//! version: Version::new(1, 0, 0),
//! os: Os::Linux,
//! arch: Arch::Arm64,
//! url: "https://example.com/foo.txt".to_string(),
//! checksum: "sha256:2c26b46b68ffc68ff99b453c1d30413413422d706483bfa0f98a5e886266e7ae"
//! .parse::<Checksum<Sha256>>()
//! .unwrap(),
//! metadata: None,
//! };
//!
//! // Create an inventory and add the artifact
//! let mut inventory = Inventory::<Version, Sha256, Option<()>>::new();
//! inventory.push(new_artifact.clone());
//!
//! // Serialize the inventory to TOML
//! let inventory_toml = inventory.to_string();
//! assert_eq!(
//! r#"[[artifacts]]
//! version = "1.0.0"
//! os = "linux"
//! arch = "arm64"
//! url = "https://example.com/foo.txt"
//! checksum = "sha256:2c26b46b68ffc68ff99b453c1d30413413422d706483bfa0f98a5e886266e7ae"
//! "#,
//! inventory_toml
//! );
//!
//! // Deserialize the inventory from TOML
//! let parsed_inventory = inventory_toml
//! .parse::<Inventory<Version, Sha256, Option<()>>>()
//! .unwrap();
//!
//! // Resolve the artifact by OS, architecture, and version requirement
//! let version_req = VersionReq::parse("=1.0.0").unwrap();
//! let resolved_artifact = parsed_inventory.resolve(Os::Linux, Arch::Arm64, &version_req).unwrap();
//!
//! assert_eq!(&new_artifact, resolved_artifact);
//!
//! // Verify checksum of the resolved artifact
//! let downloaded_data = "foo"; // Example downloaded file content
//! let downloaded_checksum = Sha256::digest(downloaded_data).to_vec();
//!
//! assert_eq!(downloaded_checksum, resolved_artifact.checksum.value);
//! println!(
//! "Successfully downloaded {} with checksum {}",
//! resolved_artifact.url,
//! hex::encode(&resolved_artifact.checksum.value)
//! );
//! ```
pub mod artifact;
pub mod checksum;
pub mod version;

#[cfg(feature = "inventory-semver")]
mod semver;
#[cfg(feature = "inventory-sha2")]
mod sha2;
mod unit;

use crate::inventory::artifact::{Arch, Artifact, Os};
use crate::inventory::checksum::Digest;
use crate::inventory::version::ArtifactRequirement;
use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize};
use std::cmp::Ordering;
use std::fmt::Formatter;
use std::str::FromStr;

/// Represents an inventory of artifacts.
///
/// An inventory can be read directly from a TOML file on disk and used by a buildpack to resolve
/// requirements for a specific artifact to download.
///
/// The inventory can be manipulated in-memory and then re-serialized to disk to facilitate both
/// reading and writing inventory files.
#[derive(Debug, Serialize, Deserialize)]
pub struct Inventory<V, D, M> {
#[serde(bound = "V: Serialize + DeserializeOwned, D: Digest, M: Serialize + DeserializeOwned")]
pub artifacts: Vec<Artifact<V, D, M>>,
}

impl<V, D, M> Default for Inventory<V, D, M> {
fn default() -> Self {
Self { artifacts: vec![] }
}
}

impl<V, D, M> Inventory<V, D, M> {
/// Creates a new empty inventory
#[must_use]
pub fn new() -> Self {
Self::default()
}

/// Add a new artifact to the in-memory inventory
pub fn push(&mut self, artifact: Artifact<V, D, M>) {
self.artifacts.push(artifact);
}

/// Return a single artifact as the best match given the input constraints
///
/// If multiple artifacts match the constraints, the one with the highest version is returned.
pub fn resolve<R>(&self, os: Os, arch: Arch, requirement: &R) -> Option<&Artifact<V, D, M>>
joshwlewis marked this conversation as resolved.
Show resolved Hide resolved
where
V: Ord,
R: ArtifactRequirement<V, M>,
{
self.artifacts
.iter()
.filter(|artifact| {
artifact.os == os
&& artifact.arch == arch
&& requirement.satisfies_version(&artifact.version)
&& requirement.satisfies_metadata(&artifact.metadata)
})
.max_by_key(|artifact| &artifact.version)
}

/// Resolve logic for Artifacts that implement `PartialOrd` rather than `Ord`
///
/// Some version implementations are only partially ordered. One example could be f32 which is not totally ordered
/// because NaN is not comparable to any other number.
pub fn partial_resolve<R>(
joshwlewis marked this conversation as resolved.
Show resolved Hide resolved
&self,
os: Os,
arch: Arch,
requirement: &R,
) -> Option<&Artifact<V, D, M>>
where
V: PartialOrd,
R: ArtifactRequirement<V, M>,
{
#[inline]
fn partial_max_by_key<I, F, A>(iterator: I, f: F) -> Option<I::Item>
where
I: Iterator,
F: Fn(&I::Item) -> A,
A: PartialOrd,
{
iterator.fold(None, |acc, item| match acc {
None => Some(item),
Some(acc) => match f(&item).partial_cmp(&f(&acc)) {
Some(Ordering::Greater | Ordering::Equal) => Some(item),
None | Some(Ordering::Less) => Some(acc),
},
})
}

partial_max_by_key(
self.artifacts.iter().filter(|artifact| {
artifact.os == os
&& artifact.arch == arch
&& requirement.satisfies_version(&artifact.version)
&& requirement.satisfies_metadata(&artifact.metadata)
}),
|artifact| &artifact.version,
)
}
}

#[derive(thiserror::Error, Debug)]
pub enum ParseInventoryError {
#[error("TOML parsing error: {0}")]
TomlError(toml::de::Error),
}

impl<V, D, M> FromStr for Inventory<V, D, M>
where
V: Serialize + DeserializeOwned,
D: Digest,
M: Serialize + DeserializeOwned,
{
type Err = ParseInventoryError;

fn from_str(s: &str) -> Result<Self, Self::Err> {
toml::from_str(s).map_err(ParseInventoryError::TomlError)
}
}

impl<V, D, M> std::fmt::Display for Inventory<V, D, M>
where
V: Serialize + DeserializeOwned,
D: Digest,
M: Serialize + DeserializeOwned,
{
#![allow(clippy::unwrap_used)]
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
f.write_str(&toml::to_string(self).unwrap())
}
}

#[cfg(test)]
mod test {
use crate::inventory::artifact::{Arch, Artifact, Os};
use crate::inventory::checksum::tests::BogusDigest;
use crate::inventory::Inventory;

#[test]
fn test_matching_artifact_resolution() {
let mut inventory = Inventory::new();
inventory.push(create_artifact("foo", Os::Linux, Arch::Arm64));

assert_eq!(
"foo",
&inventory
.resolve(Os::Linux, Arch::Arm64, &String::from("foo"))
.expect("should resolve matching artifact")
.version,
);
}

#[test]
fn test_dont_resolve_artifact_with_wrong_arch() {
let mut inventory = Inventory::new();
inventory.push(create_artifact("foo", Os::Linux, Arch::Arm64));

assert!(inventory
.resolve(Os::Linux, Arch::Amd64, &String::from("foo"))
.is_none());
}

#[test]
fn test_dont_resolve_artifact_with_wrong_version() {
let mut inventory = Inventory::new();
inventory.push(create_artifact("foo", Os::Linux, Arch::Arm64));

assert!(inventory
.resolve(Os::Linux, Arch::Arm64, &String::from("bar"))
.is_none());
}

fn create_artifact(version: &str, os: Os, arch: Arch) -> Artifact<String, BogusDigest, ()> {
Artifact {
version: String::from(version),
os,
arch,
url: "https://example.com".to_string(),
checksum: BogusDigest::checksum("cafebabe"),
metadata: (),
}
}
}
Loading