diff --git a/Cargo.lock b/Cargo.lock index 91386dfb..928cc0fe 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -335,9 +335,9 @@ dependencies = [ [[package]] name = "fastrand" -version = "2.0.1" +version = "2.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25cbce373ec4653f1a01a31e8a5e5ec0c622dc27ff9c4e6606eefef5cbbed4a5" +checksum = "9fc0510504f03c51ada170672ac806f1f105a88aa97a5281117e1ddc3368e51a" [[package]] name = "filetime" @@ -475,6 +475,7 @@ dependencies = [ "libcnb", "libcnb-test", "libherokubuildpack", + "magic_migrate", "rand", "regex", "serde", @@ -575,9 +576,9 @@ checksum = "9c198f91728a82281a64e1f4f9eeb25d82cb32a5de251c6bd1b5154d63a8e7bd" [[package]] name = "libcnb" -version = "0.19.0" +version = "0.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7db217651ab45597152c94ad849defb079fc7ced7d72de2fcc2e9c3dec6e990e" +checksum = "aacc89bfeaef5f43cdee664798e3c0aa36e052a412ab1391f0750aee4df1f407" dependencies = [ "libcnb-common", "libcnb-data", @@ -589,9 +590,9 @@ dependencies = [ [[package]] name = "libcnb-common" -version = "0.19.0" +version = "0.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3abf2056162dd76ade12884e002ba88f068a26594b2eb9579ef8af40cfbca1b" +checksum = "a356bd77381b51f1ca42450694f4c7d1c7533a57c5f6a49553a96af96963b6e3" dependencies = [ "serde", "thiserror", @@ -600,9 +601,9 @@ dependencies = [ [[package]] name = "libcnb-data" -version = "0.19.0" +version = "0.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "afc6b01af8b624193ca6b247667ef82f36dd85d62b90f5a7e8d047b46642ce7c" +checksum = "dfcd102bfb1bf98ee4c18da0b29be6f23a19681937924bf758e9ea8499668b18" dependencies = [ "fancy-regex", "libcnb-proc-macros", @@ -614,9 +615,9 @@ dependencies = [ [[package]] name = "libcnb-package" -version = "0.19.0" +version = "0.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2678c2e0882c622a01d415e64625258849e533aeba8531110a5b3db9593d97d5" +checksum = "3b8d9b42112212a875c07fb3acf19504cf330edaa63cddd1823e9d03a5e2b934" dependencies = [ "cargo_metadata", "ignore", @@ -631,9 +632,9 @@ dependencies = [ [[package]] name = "libcnb-proc-macros" -version = "0.19.0" +version = "0.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b0308e3b554dd8b0b969ab42d19b50b02bdb712dc72652849fa1e33bd1d16709" +checksum = "f83bba477c3a6cd69b29f77a6591411bac15ab7b341ad3d3cd38943bfbbd412f" dependencies = [ "cargo_metadata", "fancy-regex", @@ -643,9 +644,9 @@ dependencies = [ [[package]] name = "libcnb-test" -version = "0.19.0" +version = "0.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f094d9c229c481fb868d36231dcc4b7596491503d0a297eb239b08e942eb483c" +checksum = "9471152703833b74d565c7f7c910b4d5e084f955c327eba2bdb6658e86bd6dd6" dependencies = [ "fastrand", "fs_extra", @@ -689,6 +690,15 @@ version = "0.4.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "90ed8c1e510134f979dbc4f070f87d4313098b704861a105fe34231c70a3901c" +[[package]] +name = "magic_migrate" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6398143367a78d596246f39b67e7ea09eea13318bdaa3ec9e0c4042517d2b44c" +dependencies = [ + "serde", +] + [[package]] name = "memchr" version = "2.7.1" @@ -768,18 +778,18 @@ dependencies = [ [[package]] name = "proc-macro2" -version = "1.0.78" +version = "1.0.82" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2422ad645d89c99f8f3e6b88a9fdeca7fabeac836b1002371c4367c8f984aae" +checksum = "8ad3d49ab951a01fbaafe34f2ec74122942fe18a3f9814c3268f1bb72042131b" dependencies = [ "unicode-ident", ] [[package]] name = "quote" -version = "1.0.35" +version = "1.0.36" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "291ec9ab5efd934aaf503a6466c5d5251535d108ee747472c3977cc5acc868ef" +checksum = "0fa76aaf39101c457836aec0ce2316dbdc3ab723cdda1c6bd4e6ad4208acaca7" dependencies = [ "proc-macro2", ] @@ -1026,9 +1036,9 @@ checksum = "81cdd64d312baedb58e21336b31bc043b77e01cc99033ce76ef539f78e965ebc" [[package]] name = "syn" -version = "2.0.52" +version = "2.0.61" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b699d15b36d1f02c3e7c69f8ffef53de37aefae075d8488d4ba1a7788d574a07" +checksum = "c993ed8ccba56ae856363b1845da7266a7cb78e1d146c8a32d54b45a8b831fc9" dependencies = [ "proc-macro2", "quote", diff --git a/buildpacks/ruby/Cargo.toml b/buildpacks/ruby/Cargo.toml index be30cb6e..9e65ebc8 100644 --- a/buildpacks/ruby/Cargo.toml +++ b/buildpacks/ruby/Cargo.toml @@ -16,7 +16,7 @@ glob = "0.3" indoc = "2" # libcnb has a much bigger impact on buildpack behaviour than any other dependencies, # so it's pinned to an exact version to isolate it from lockfile refreshes. -libcnb = "=0.19.0" +libcnb = "=0.21.0" libherokubuildpack = { version = "=0.21.0", default-features = false, features = ["digest"] } rand = "0.8" # TODO: Consolidate on either the regex crate or the fancy-regex crate, since this repo currently uses both. @@ -27,7 +27,8 @@ tempfile = "3" thiserror = "1" ureq = { version = "2", default-features = false, features = ["tls"] } url = "2" +magic_migrate = "0.1" +toml = "0.8" [dev-dependencies] -libcnb-test = "=0.19.0" -toml = "0.8" +libcnb-test = "=0.21.0" diff --git a/buildpacks/ruby/buildpack.toml b/buildpacks/ruby/buildpack.toml index 4871fc40..6aa7c2c6 100644 --- a/buildpacks/ruby/buildpack.toml +++ b/buildpacks/ruby/buildpack.toml @@ -12,10 +12,23 @@ keywords = ["ruby", "rails", "heroku"] type = "BSD-3-Clause" [[stacks]] -id = "heroku-20" - -[[stacks]] -id = "heroku-22" +id = "*" [metadata.release] image = { repository = "docker.io/heroku/buildpack-ruby" } + +[[targets]] +os = "linux" +arch = "arm64" + +[[targets]] +os = "linux" +arch = "amd64" + +[[targets.distros]] +name = "ubuntu" +version = "20.04" + +[[targets.distros]] +name = "ubuntu" +version = "22.04" diff --git a/buildpacks/ruby/src/layers/bundle_install_layer.rs b/buildpacks/ruby/src/layers/bundle_install_layer.rs index 6ab8bfd7..72f3c7ae 100644 --- a/buildpacks/ruby/src/layers/bundle_install_layer.rs +++ b/buildpacks/ruby/src/layers/bundle_install_layer.rs @@ -11,14 +11,18 @@ use fun_run::CommandWithName; use fun_run::{self, CmdError}; use libcnb::{ build::BuildContext, - data::{buildpack::StackId, layer_content_metadata::LayerTypes}, + data::layer_content_metadata::LayerTypes, layer::{ExistingLayerStrategy, Layer, LayerData, LayerResult, LayerResultBuilder}, layer_env::{LayerEnv, ModificationBehavior, Scope}, Env, }; -use serde::{Deserialize, Serialize}; +use magic_migrate::{try_migrate_link, TryMigrate}; +use serde::{Deserialize, Deserializer, Serialize}; +use std::convert::Infallible; use std::{path::Path, process::Command}; +use crate::target_id::{TargetId, TargetIdError}; + const HEROKU_SKIP_BUNDLE_DIGEST: &str = "HEROKU_SKIP_BUNDLE_DIGEST"; pub(crate) const FORCE_BUNDLE_INSTALL_CACHE_KEY: &str = "v1"; @@ -38,8 +42,16 @@ pub(crate) struct BundleInstallLayer<'a> { } #[derive(Deserialize, Serialize, Debug, Clone, Eq, PartialEq)] -pub(crate) struct BundleInstallLayerMetadata { - pub(crate) stack: StackId, +pub(crate) struct BundleInstallLayerMetadataV1 { + pub(crate) stack: String, + pub(crate) ruby_version: ResolvedRubyVersion, + pub(crate) force_bundle_install_key: String, + pub(crate) digest: MetadataDigest, // Must be last for serde to be happy https://github.com/toml-rs/toml-rs/issues/142 +} + +#[derive(Deserialize, Serialize, Debug, Clone, Eq, PartialEq)] +pub(crate) struct BundleInstallLayerMetadataV2 { + pub(crate) target_id: TargetId, pub(crate) ruby_version: ResolvedRubyVersion, pub(crate) force_bundle_install_key: String, @@ -56,6 +68,45 @@ pub(crate) struct BundleInstallLayerMetadata { /// pub(crate) digest: MetadataDigest, // Must be last for serde to be happy https://github.com/toml-rs/toml-rs/issues/142 } +try_migrate_link!(BundleInstallLayerMetadataV1, BundleInstallLayerMetadataV2); +pub(crate) type BundleInstallLayerMetadata = BundleInstallLayerMetadataV2; + +#[derive(thiserror::Error, Debug)] +pub(crate) enum MigrateMetadataError { + #[error("Could not migrate metadata {0}")] + UnsupportedStack(TargetIdError), +} + +// CNB spec moved from the concept of "stacks" (i.e. "heroku-22" which represented an OS and system dependencies) to finer +// grained "target" which includes the OS, OS version, and architecture. This function converts the old stack id to the new target id. +impl TryFrom for BundleInstallLayerMetadataV2 { + type Error = MigrateMetadataError; + + fn try_from(v1: BundleInstallLayerMetadataV1) -> Result { + Ok(Self { + target_id: TargetId::from_stack(&v1.stack) + .map_err(MigrateMetadataError::UnsupportedStack)?, + ruby_version: v1.ruby_version, + force_bundle_install_key: v1.force_bundle_install_key, + digest: v1.digest, + }) + } +} + +impl From for MigrateMetadataError { + fn from(_: Infallible) -> Self { + unreachable!() + } +} + +impl TryMigrate for BundleInstallLayerMetadataV1 { + type TryFrom = Self; + type Error = MigrateMetadataError; + + fn deserializer<'de>(input: &str) -> impl Deserializer<'de> { + toml::Deserializer::new(input) + } +} impl<'a> BundleInstallLayer<'a> { #[allow(clippy::unnecessary_wraps)] @@ -189,7 +240,7 @@ impl Layer for BundleInstallLayer<'_> { keep_and_run } - Changed::Stack(_old, _now) => { + Changed::Target(_old, _now) => { log_step(format!("Clearing cache {}", fmt::details("stack changed"))); clear_and_run @@ -204,6 +255,29 @@ impl Layer for BundleInstallLayer<'_> { } } } + + fn migrate_incompatible_metadata( + &mut self, + _context: &BuildContext, + metadata: &libcnb::generic::GenericMetadata, + ) -> Result< + libcnb::layer::MetadataMigration, + ::Error, + > { + match Self::Metadata::try_from_str_migrations( + &toml::to_string(&metadata).expect("TOML deserialization of GenericMetadata"), + ) { + Some(Ok(metadata)) => Ok(libcnb::layer::MetadataMigration::ReplaceMetadata(metadata)), + Some(Err(e)) => { + log_step(format!("Clearing cache (metadata migration error {e})")); + Ok(libcnb::layer::MetadataMigration::RecreateLayer) + } + None => { + log_step("Clearing cache (invalid metadata)"); + Ok(libcnb::layer::MetadataMigration::RecreateLayer) + } + } + } } /// The possible states of the cache values, used for determining `ExistingLayerStrategy` @@ -216,7 +290,7 @@ enum Changed { /// because they're compiled against system dependencies /// i.e. /// TODO: Only clear native dependencies instead of the whole cache - Stack(StackId, StackId), // (old, now) + Target(TargetId, TargetId), // (old, now) /// Ruby version changed i.e. 3.0.2 to 3.1.2 /// When that happens we must invalidate native dependency gems @@ -229,14 +303,14 @@ enum Changed { // cache. Based on that state, we can log and determine `ExistingLayerStrategy` fn cache_state(old: BundleInstallLayerMetadata, now: BundleInstallLayerMetadata) -> Changed { let BundleInstallLayerMetadata { - stack, + target_id, ruby_version, force_bundle_install_key: _, digest: _, // digest state handled elsewhere } = now; // ensure all values are handled or we get a clippy warning - if old.stack != stack { - Changed::Stack(old.stack, stack) + if old.target_id != target_id { + Changed::Target(old.target_id, target_id) } else if old.ruby_version != ruby_version { Changed::RubyVersion(old.ruby_version, ruby_version) } else { @@ -348,7 +422,6 @@ pub(crate) struct BundleDigest { #[cfg(test)] mod test { use super::*; - use libcnb::data::stack_id; use std::path::PathBuf; #[cfg(test)] @@ -401,8 +474,9 @@ GEM_PATH=layer_path assert_eq!(expected.trim(), actual.trim()); } - /// If this test fails due to a change you'll need to implement - /// `migrate_incompatible_metadata` for the Layer trait + /// Guards the current metadata deserialization + /// If this fails you need to implement a migration from the last format + /// to the current format. #[test] fn metadata_guard() { let tmpdir = tempfile::tempdir().unwrap(); @@ -419,7 +493,7 @@ GEM_PATH=layer_path std::fs::write(&gemfile, "iamagemfile").unwrap(); let metadata = BundleInstallLayerMetadata { - stack: stack_id!("heroku-22"), + target_id: TargetId::from_stack("heroku-24").unwrap(), ruby_version: ResolvedRubyVersion(String::from("3.1.3")), force_bundle_install_key: String::from("v1"), digest: MetadataDigest::new_env_files( @@ -433,10 +507,14 @@ GEM_PATH=layer_path let gemfile_path = gemfile.display(); let toml_string = format!( r#" -stack = "heroku-22" ruby_version = "3.1.3" force_bundle_install_key = "v1" +[target_id] +arch = "amd64" +distro_name = "ubuntu" +distro_version = "24.04" + [digest] platform_env = "c571543beaded525b7ee46ceb0b42c0fb7b9f6bfc3a211b3bbcfe6956b69ace3" @@ -452,4 +530,63 @@ platform_env = "c571543beaded525b7ee46ceb0b42c0fb7b9f6bfc3a211b3bbcfe6956b69ace3 assert_eq!(metadata, deserialized); } + + #[test] + fn metadata_migrate_v1_to_v2() { + let tmpdir = tempfile::tempdir().unwrap(); + let app_path = tmpdir.path().to_path_buf(); + let gemfile = app_path.join("Gemfile"); + + let mut env = Env::new(); + env.insert("SECRET_KEY_BASE", "abcdgoldfish"); + + let context = FakeContext { + platform: FakePlatform { env }, + app_path, + }; + std::fs::write(&gemfile, "iamagemfile").unwrap(); + + let metadata = BundleInstallLayerMetadataV1 { + stack: String::from("heroku-22"), + ruby_version: ResolvedRubyVersion(String::from("3.1.3")), + force_bundle_install_key: String::from("v1"), + digest: MetadataDigest::new_env_files( + &context.platform, + &[&context.app_path.join("Gemfile")], + ) + .unwrap(), + }; + + let actual = toml::to_string(&metadata).unwrap(); + let gemfile_path = gemfile.display(); + let toml_string = format!( + r#" +stack = "heroku-22" +ruby_version = "3.1.3" +force_bundle_install_key = "v1" + +[digest] +platform_env = "c571543beaded525b7ee46ceb0b42c0fb7b9f6bfc3a211b3bbcfe6956b69ace3" + +[digest.files] +"{gemfile_path}" = "32b27d2934db61b105fea7c2cb6159092fed6e121f8c72a948f341ab5afaa1ab" +"# + ) + .trim() + .to_string(); + assert_eq!(toml_string, actual.trim()); + + let deserialized: BundleInstallLayerMetadataV2 = + BundleInstallLayerMetadataV2::try_from_str_migrations(&toml_string) + .unwrap() + .unwrap(); + + let expected = BundleInstallLayerMetadataV2 { + target_id: TargetId::from_stack(&metadata.stack).unwrap(), + ruby_version: metadata.ruby_version, + force_bundle_install_key: metadata.force_bundle_install_key, + digest: metadata.digest, + }; + assert_eq!(expected, deserialized); + } } diff --git a/buildpacks/ruby/src/layers/ruby_install_layer.rs b/buildpacks/ruby/src/layers/ruby_install_layer.rs index d39b8553..037913dc 100644 --- a/buildpacks/ruby/src/layers/ruby_install_layer.rs +++ b/buildpacks/ruby/src/layers/ruby_install_layer.rs @@ -2,15 +2,19 @@ use commons::output::{ fmt::{self}, section_log::{log_step, log_step_timed, SectionLogger}, }; +use magic_migrate::{try_migrate_link, TryMigrate}; -use crate::{RubyBuildpack, RubyBuildpackError}; +use crate::{ + target_id::{TargetId, TargetIdError}, + RubyBuildpack, RubyBuildpackError, +}; use commons::gemfile_lock::ResolvedRubyVersion; use flate2::read::GzDecoder; use libcnb::build::BuildContext; -use libcnb::data::buildpack::StackId; use libcnb::data::layer_content_metadata::LayerTypes; use libcnb::layer::{ExistingLayerStrategy, Layer, LayerData, LayerResult, LayerResultBuilder}; -use serde::{Deserialize, Serialize}; +use serde::{Deserialize, Deserializer, Serialize}; +use std::convert::Infallible; use std::io; use std::path::Path; use tar::Archive; @@ -36,10 +40,51 @@ pub(crate) struct RubyInstallLayer<'a> { } #[derive(Deserialize, Serialize, Debug, Clone)] -pub(crate) struct RubyInstallLayerMetadata { - pub(crate) stack: StackId, +pub(crate) struct RubyInstallLayerMetadataV1 { + pub(crate) stack: String, + pub(crate) version: ResolvedRubyVersion, +} + +#[derive(Deserialize, Serialize, Debug, Clone, Eq, PartialEq)] +pub(crate) struct RubyInstallLayerMetadataV2 { + pub(crate) target_id: TargetId, pub(crate) version: ResolvedRubyVersion, } +try_migrate_link!(RubyInstallLayerMetadataV1, RubyInstallLayerMetadataV2); +pub(crate) type RubyInstallLayerMetadata = RubyInstallLayerMetadataV2; + +#[derive(thiserror::Error, Debug)] +pub(crate) enum MetadataMigrateError { + #[error("Cannot migrate metadata due to target id error: {0}")] + TargetIdError(TargetIdError), +} + +impl TryFrom for RubyInstallLayerMetadataV2 { + type Error = MetadataMigrateError; + + fn try_from(v1: RubyInstallLayerMetadataV1) -> Result { + Ok(Self { + target_id: TargetId::from_stack(&v1.stack) + .map_err(MetadataMigrateError::TargetIdError)?, + version: v1.version, + }) + } +} + +impl From for MetadataMigrateError { + fn from(_: Infallible) -> Self { + unreachable!() + } +} + +impl TryMigrate for RubyInstallLayerMetadataV1 { + type TryFrom = Self; + type Error = MetadataMigrateError; + + fn deserializer<'de>(input: &str) -> impl Deserializer<'de> { + toml::Deserializer::new(input) + } +} impl<'a> Layer for RubyInstallLayer<'a> { type Buildpack = RubyBuildpack; @@ -63,7 +108,7 @@ impl<'a> Layer for RubyInstallLayer<'a> { .map_err(RubyInstallError::CouldNotCreateDestinationFile) .map_err(RubyBuildpackError::RubyInstallError)?; - let url = download_url(&self.metadata.stack, &self.metadata.version) + let url = download_url(&self.metadata.target_id, &self.metadata.version) .map_err(RubyBuildpackError::RubyInstallError)?; download(url.as_ref(), tmp_ruby_tgz.path()) @@ -75,6 +120,29 @@ impl<'a> Layer for RubyInstallLayer<'a> { }) } + fn migrate_incompatible_metadata( + &mut self, + _context: &BuildContext, + metadata: &libcnb::generic::GenericMetadata, + ) -> Result< + libcnb::layer::MetadataMigration, + ::Error, + > { + match Self::Metadata::try_from_str_migrations( + &toml::to_string(&metadata).expect("TOML deserialization of GenericMetadata"), + ) { + Some(Ok(metadata)) => Ok(libcnb::layer::MetadataMigration::ReplaceMetadata(metadata)), + Some(Err(e)) => { + log_step(format!("Clearing cache (metadata migration error {e})")); + Ok(libcnb::layer::MetadataMigration::RecreateLayer) + } + None => { + log_step("Clearing cache (invalid metadata)"); + Ok(libcnb::layer::MetadataMigration::RecreateLayer) + } + } + } + fn existing_layer_strategy( &mut self, _context: &BuildContext, @@ -89,8 +157,8 @@ impl<'a> Layer for RubyInstallLayer<'a> { Ok(ExistingLayerStrategy::Keep) } - Changed::Stack(_old, _now) => { - log_step(format!("Clearing cache {}", fmt::details("stack changed"))); + Changed::Target(_old, _now) => { + log_step(format!("Clearing cache {}", fmt::details("OS changed"))); Ok(ExistingLayerStrategy::Recreate) } @@ -107,10 +175,10 @@ impl<'a> Layer for RubyInstallLayer<'a> { } fn cache_state(old: RubyInstallLayerMetadata, now: RubyInstallLayerMetadata) -> Changed { - let RubyInstallLayerMetadata { stack, version } = now; + let RubyInstallLayerMetadata { target_id, version } = now; - if old.stack != stack { - Changed::Stack(old.stack, stack) + if old.target_id != target_id { + Changed::Target(old.target_id, target_id) } else if old.version != version { Changed::RubyVersion(old.version, version) } else { @@ -121,18 +189,21 @@ fn cache_state(old: RubyInstallLayerMetadata, now: RubyInstallLayerMetadata) -> #[derive(Debug)] enum Changed { Nothing(ResolvedRubyVersion), - Stack(StackId, StackId), + Target(TargetId, TargetId), RubyVersion(ResolvedRubyVersion, ResolvedRubyVersion), } -fn download_url(stack: &StackId, version: impl std::fmt::Display) -> Result { +fn download_url( + target: &TargetId, + version: impl std::fmt::Display, +) -> Result { let filename = format!("ruby-{version}.tgz"); let base = "https://heroku-buildpack-ruby.s3.us-east-1.amazonaws.com"; let mut url = Url::parse(base).map_err(RubyInstallError::UrlParseError)?; url.path_segments_mut() .map_err(|()| RubyInstallError::InvalidBaseUrl(String::from(base)))? - .push(stack) + .push(&target.stack_name().map_err(RubyInstallError::TargetError)?) .push(&filename); Ok(url) } @@ -168,6 +239,9 @@ pub(crate) fn untar( #[derive(thiserror::Error, Debug)] pub(crate) enum RubyInstallError { + #[error("Unknown install target: {0}")] + TargetError(TargetIdError), + #[error("Could not parse url {0}")] UrlParseError(url::ParseError), @@ -194,14 +268,39 @@ pub(crate) enum RubyInstallError { #[cfg(test)] mod tests { use super::*; - use libcnb::data::stack_id; - /// If this test fails due to a change you'll need to implement - /// `migrate_incompatible_metadata` for the Layer trait + /// If this test fails due to a change you'll need to + /// implement `TryMigrate` for the new layer data and add + /// another test ensuring the latest metadata struct can + /// be built from the previous version. #[test] fn metadata_guard() { let metadata = RubyInstallLayerMetadata { - stack: stack_id!("heroku-22"), + target_id: TargetId { + arch: String::from("amd64"), + distro_name: String::from("ubuntu"), + distro_version: String::from("22.04"), + }, + version: ResolvedRubyVersion(String::from("3.1.3")), + }; + + let actual = toml::to_string(&metadata).unwrap(); + let expected = r#" +version = "3.1.3" + +[target_id] +arch = "amd64" +distro_name = "ubuntu" +distro_version = "22.04" +"# + .trim(); + assert_eq!(expected, actual.trim()); + } + + #[test] + fn metadata_migrate_v1_to_v2() { + let metadata = RubyInstallLayerMetadataV1 { + stack: String::from("heroku-22"), version: ResolvedRubyVersion(String::from("3.1.3")), }; @@ -212,14 +311,33 @@ version = "3.1.3" "# .trim(); assert_eq!(expected, actual.trim()); + + let deserialized: RubyInstallLayerMetadataV2 = + RubyInstallLayerMetadataV2::try_from_str_migrations(&actual) + .unwrap() + .unwrap(); + + let expected = RubyInstallLayerMetadataV2 { + target_id: TargetId::from_stack(&metadata.stack).expect("Valid stack"), + version: metadata.version, + }; + assert_eq!(expected, deserialized); } #[test] fn test_ruby_url() { - let out = download_url(&stack_id!("heroku-20"), "2.7.4").unwrap(); + let out = download_url( + &TargetId { + arch: String::from("amd64"), + distro_name: String::from("ubuntu"), + distro_version: String::from("22.04"), + }, + "2.7.4", + ) + .unwrap(); assert_eq!( out.as_ref(), - "https://heroku-buildpack-ruby.s3.us-east-1.amazonaws.com/heroku-20/ruby-2.7.4.tgz", + "https://heroku-buildpack-ruby.s3.us-east-1.amazonaws.com/heroku-22/ruby-2.7.4.tgz", ); } } diff --git a/buildpacks/ruby/src/main.rs b/buildpacks/ruby/src/main.rs index 751aa65b..9c5de79d 100644 --- a/buildpacks/ruby/src/main.rs +++ b/buildpacks/ruby/src/main.rs @@ -23,12 +23,14 @@ use libcnb::layer_env::Scope; use libcnb::Platform; use libcnb::{buildpack_main, Buildpack}; use std::io::stdout; +use target_id::TargetId; mod gem_list; mod layers; mod rake_status; mod rake_task_detect; mod steps; +mod target_id; mod user_errors; #[cfg(test)] @@ -117,6 +119,12 @@ impl Buildpack for RubyBuildpack { let mut logger = BuildLog::new(stdout()).buildpack_name("Heroku Ruby Buildpack"); let warn_later = WarnGuard::new(stdout()); + let target_id = TargetId { + arch: context.target.arch.clone(), + distro_name: context.target.distro_name.clone(), + distro_version: context.target.distro_version.clone(), + }; + // ## Set default environment let (mut env, store) = crate::steps::default_env(&context, &context.platform.env().clone())?; @@ -170,7 +178,7 @@ impl Buildpack for RubyBuildpack { RubyInstallLayer { _in_section: section.as_ref(), metadata: RubyInstallLayerMetadata { - stack: context.stack_id.clone(), + target_id: target_id.clone(), version: ruby_version.clone(), }, }, @@ -211,7 +219,7 @@ impl Buildpack for RubyBuildpack { without: BundleWithout::new("development:test"), _section_log: section.as_ref(), metadata: BundleInstallLayerMetadata { - stack: context.stack_id.clone(), + target_id: target_id.clone(), ruby_version: ruby_version.clone(), force_bundle_install_key: String::from( crate::layers::bundle_install_layer::FORCE_BUNDLE_INSTALL_CACHE_KEY, diff --git a/buildpacks/ruby/src/target_id.rs b/buildpacks/ruby/src/target_id.rs new file mode 100644 index 00000000..8c16df18 --- /dev/null +++ b/buildpacks/ruby/src/target_id.rs @@ -0,0 +1,101 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Deserialize, Serialize, Debug, Clone, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub(crate) struct TargetId { + pub(crate) arch: String, + pub(crate) distro_name: String, + pub(crate) distro_version: String, +} + +const DISTRO_VERSION_STACK: &[(&str, &str, &str)] = &[ + ("ubuntu", "22.04", "heroku-22"), + ("ubuntu", "24.04", "heroku-24"), +]; + +#[derive(Debug, thiserror::Error)] +pub(crate) enum TargetIdError { + #[error("Distro name and version {0}-{1} is not supported. Must be one of: {}", DISTRO_VERSION_STACK.iter().map(|&(name, version, _)| format!("{name}-{version}")).collect::>().join(", "))] + UnknownDistroNameVersionCombo(String, String), + + #[error("Cannot convert stack name {0} into a target OS. Must be one of: {}", DISTRO_VERSION_STACK.iter().map(|&(_, _, stack)| String::from(stack)).collect::>().join(", "))] + UnknownStack(String), +} + +impl TargetId { + pub(crate) fn stack_name(&self) -> Result { + DISTRO_VERSION_STACK + .iter() + .find(|&&(name, version, _)| name == self.distro_name && version == self.distro_version) + .map(|&(_, _, stack)| stack.to_owned()) + .ok_or_else(|| { + TargetIdError::UnknownDistroNameVersionCombo( + self.distro_name.clone(), + self.distro_version.clone(), + ) + }) + } + + pub(crate) fn from_stack(stack_id: &str) -> Result { + DISTRO_VERSION_STACK + .iter() + .find(|&&(_, _, stack)| stack == stack_id) + .map(|&(name, version, _)| TargetId { + arch: String::from("amd64"), + distro_name: name.to_owned(), + distro_version: version.to_owned(), + }) + .ok_or_else(|| TargetIdError::UnknownStack(stack_id.to_owned())) + } +} + +#[cfg(test)] +mod test { + use super::*; + + #[test] + fn test_stack_name() { + assert_eq!( + String::from("heroku-22"), + TargetId { + arch: String::from("amd64"), + distro_name: String::from("ubuntu"), + distro_version: String::from("22.04"), + } + .stack_name() + .unwrap() + ); + + assert_eq!( + String::from("heroku-24"), + TargetId { + arch: String::from("amd64"), + distro_name: String::from("ubuntu"), + distro_version: String::from("24.04"), + } + .stack_name() + .unwrap() + ); + } + + #[test] + fn test_from_stack() { + assert_eq!( + TargetId::from_stack("heroku-22").unwrap(), + TargetId { + arch: String::from("amd64"), + distro_name: String::from("ubuntu"), + distro_version: String::from("22.04"), + } + ); + + assert_eq!( + TargetId::from_stack("heroku-24").unwrap(), + TargetId { + arch: String::from("amd64"), + distro_name: String::from("ubuntu"), + distro_version: String::from("24.04"), + } + ); + } +} diff --git a/commons/Cargo.toml b/commons/Cargo.toml index ccc1a150..3e28a5f6 100644 --- a/commons/Cargo.toml +++ b/commons/Cargo.toml @@ -24,7 +24,7 @@ indoc = "2" lazy_static = "1" # libcnb has a much bigger impact on buildpack behaviour than any other dependencies, # so it's pinned to an exact version to isolate it from lockfile refreshes. -libcnb = "=0.19.0" +libcnb = "=0.21.0" libherokubuildpack = { version = "=0.21.0", default-features = false, features = ["command"] } regex = "1" serde = "1" @@ -36,6 +36,6 @@ walkdir = "2" [dev-dependencies] filetime = "0.2" indoc = "2" -libcnb-test = "=0.19.0" +libcnb-test = "=0.21.0" pretty_assertions = "1" toml = "0.8" diff --git a/commons/src/metadata_digest.rs b/commons/src/metadata_digest.rs index 3391e43f..61ddae85 100644 --- a/commons/src/metadata_digest.rs +++ b/commons/src/metadata_digest.rs @@ -18,11 +18,9 @@ const PLATFORM_ENV_VAR: &str = "user configured environment variables"; /// ```rust /// use serde::{Deserialize, Serialize}; /// use commons::metadata_digest::MetadataDigest; -/// use libcnb::data::buildpack::StackId; /// /// #[derive(Deserialize, Serialize, Debug, Clone, Eq, PartialEq)] /// pub(crate) struct BundleInstallLayerMetadata { -/// stack: StackId, /// ruby_version: String, /// force_bundle_install_key: String, ///