From 6966b889697edff04935f8e0b28fcb8ad776f7a5 Mon Sep 17 00:00:00 2001 From: Richard Schneeman Date: Wed, 15 May 2024 15:35:54 -0500 Subject: [PATCH] Switch from stacks to targets AKA "Stay on target" (#283) * Bump the libcnb group across 1 directory with 3 updates Bumps the libcnb group with 3 updates in the / directory: [libcnb](https://github.com/heroku/libcnb.rs), [libherokubuildpack](https://github.com/heroku/libcnb.rs) and [libcnb-test](https://github.com/heroku/libcnb.rs). Updates `libcnb` from 0.17.0 to 0.19.0 - [Release notes](https://github.com/heroku/libcnb.rs/releases) - [Changelog](https://github.com/heroku/libcnb.rs/blob/main/CHANGELOG.md) - [Commits](https://github.com/heroku/libcnb.rs/compare/v0.17.0...v0.19.0) Updates `libherokubuildpack` from 0.17.0 to 0.21.0 - [Release notes](https://github.com/heroku/libcnb.rs/releases) - [Changelog](https://github.com/heroku/libcnb.rs/blob/main/CHANGELOG.md) - [Commits](https://github.com/heroku/libcnb.rs/compare/v0.17.0...v0.21.0) Updates `libcnb-test` from 0.17.0 to 0.19.0 - [Release notes](https://github.com/heroku/libcnb.rs/releases) - [Changelog](https://github.com/heroku/libcnb.rs/blob/main/CHANGELOG.md) - [Commits](https://github.com/heroku/libcnb.rs/compare/v0.17.0...v0.19.0) --- updated-dependencies: - dependency-name: libcnb dependency-type: direct:production update-type: version-update:semver-minor dependency-group: libcnb - dependency-name: libherokubuildpack dependency-type: direct:production update-type: version-update:semver-minor dependency-group: libcnb - dependency-name: libcnb-test dependency-type: direct:production update-type: version-update:semver-minor dependency-group: libcnb ... Signed-off-by: dependabot[bot] * Change to mutable layers The layer trait interface was changed to mutable in https://github.com/heroku/libcnb.rs/pull/669 * Switch from stacks to targets AKA "Stay on target" Buildpack API 0.10 removed the concept of stacks in favor of targets https://github.com/heroku/libcnb.rs/pull/773. This commit works to upgrade applications in place by migrating metadata to support the new serialization format. This is supported by implementing TryMigrate from the `magic_migrate` crate https://docs.rs/magic_migrate/latest/magic_migrate/macro.try_migrate_link.html. https://www.youtube.com/watch?v=NnP5iDKwuwk * Add explicit Distro/Architecture cache messages * Use updated magic_migrate macros * Apply suggestions from code review Co-authored-by: Ed Morley <501702+edmorley@users.noreply.github.com> * Document breaking changes * Align warning messages and error names * Update buildpack.toml - Add explanation for when stacks can be removed - Move "targets" closer to "stacks" as they're logically tied together. * Integration test for metadata migration * Support heroku-20 and heroku-22 (only) This buildpack does not support heroku-24 (yet) remove this stack from the TargetId struct. It also supports heroku-20 which was previously not present. * Fix changelog * Standardize println in integration tests * Consistent cache clear messages --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Ed Morley <501702+edmorley@users.noreply.github.com> --- Cargo.lock | 178 +++++--------- buildpacks/ruby/CHANGELOG.md | 2 + buildpacks/ruby/Cargo.toml | 9 +- buildpacks/ruby/buildpack.toml | 19 +- .../ruby/src/layers/bundle_download_layer.rs | 11 +- .../ruby/src/layers/bundle_install_layer.rs | 223 +++++++++++++++--- .../ruby/src/layers/metrics_agent_install.rs | 8 +- .../ruby/src/layers/ruby_install_layer.rs | 215 ++++++++++++++--- buildpacks/ruby/src/main.rs | 11 +- buildpacks/ruby/src/target_id.rs | 101 ++++++++ buildpacks/ruby/tests/integration_test.rs | 54 ++++- commons/Cargo.toml | 6 +- commons/src/cache/in_app_dir_cache_layer.rs | 4 +- commons/src/layer/configure_env_layer.rs | 2 +- commons/src/metadata_digest.rs | 6 +- docs/application_contract.md | 6 +- 16 files changed, 634 insertions(+), 221 deletions(-) create mode 100644 buildpacks/ruby/src/target_id.rs diff --git a/Cargo.lock b/Cargo.lock index 8daca83b..23c51f5e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -190,7 +190,7 @@ dependencies = [ "ascii_table", "byte-unit", "const_format", - "fancy-regex 0.13.0", + "fancy-regex", "filetime", "fs-err", "fs_extra", @@ -319,17 +319,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a258e46cdc063eb8519c00b9fc845fc47bcfca4130e2f08e88665ceda8474245" dependencies = [ "libc", - "windows-sys 0.52.0", -] - -[[package]] -name = "fancy-regex" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7493d4c459da9f84325ad297371a6b2b8a162800873a22e3b6b6512e61d18c05" -dependencies = [ - "bit-set", - "regex", + "windows-sys", ] [[package]] @@ -345,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" @@ -358,7 +348,7 @@ dependencies = [ "cfg-if", "libc", "redox_syscall", - "windows-sys 0.52.0", + "windows-sys", ] [[package]] @@ -485,6 +475,7 @@ dependencies = [ "libcnb", "libcnb-test", "libherokubuildpack", + "magic_migrate", "rand", "regex", "serde", @@ -502,7 +493,7 @@ version = "0.5.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3d1354bf6b7235cb4a0576c2619fd4ed18183f689b12b006a0ee7329eeff9a5" dependencies = [ - "windows-sys 0.52.0", + "windows-sys", ] [[package]] @@ -585,9 +576,9 @@ checksum = "9c198f91728a82281a64e1f4f9eeb25d82cb32a5de251c6bd1b5154d63a8e7bd" [[package]] name = "libcnb" -version = "0.17.0" +version = "0.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2c385c618fa8afebe2d1b499b74bc0a3682507b0d91aa4aad09708b81681e2ca" +checksum = "aacc89bfeaef5f43cdee664798e3c0aa36e052a412ab1391f0750aee4df1f407" dependencies = [ "libcnb-common", "libcnb-data", @@ -599,9 +590,9 @@ dependencies = [ [[package]] name = "libcnb-common" -version = "0.17.0" +version = "0.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "28fede7cd4353004ff1976ce66c34bb266fa35095be12c6d3d4c2358ef790778" +checksum = "a356bd77381b51f1ca42450694f4c7d1c7533a57c5f6a49553a96af96963b6e3" dependencies = [ "serde", "thiserror", @@ -610,11 +601,11 @@ dependencies = [ [[package]] name = "libcnb-data" -version = "0.17.0" +version = "0.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20c0c825002ee57279d0c9e23309863804536f0c45687436d574dd3e8c7420fb" +checksum = "dfcd102bfb1bf98ee4c18da0b29be6f23a19681937924bf758e9ea8499668b18" dependencies = [ - "fancy-regex 0.12.0", + "fancy-regex", "libcnb-proc-macros", "serde", "thiserror", @@ -624,12 +615,13 @@ dependencies = [ [[package]] name = "libcnb-package" -version = "0.17.0" +version = "0.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "934ec4398991f7e926889a6e5046d83935e39de5c047feb591ed0333b83abf75" +checksum = "3b8d9b42112212a875c07fb3acf19504cf330edaa63cddd1823e9d03a5e2b934" dependencies = [ "cargo_metadata", "ignore", + "indoc", "libcnb-common", "libcnb-data", "petgraph", @@ -640,21 +632,21 @@ dependencies = [ [[package]] name = "libcnb-proc-macros" -version = "0.17.0" +version = "0.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0f0afde3a7327936afd743e2cb52f6de3a0d4a4894f6f13bdae1a41e6879c17" +checksum = "f83bba477c3a6cd69b29f77a6591411bac15ab7b341ad3d3cd38943bfbbd412f" dependencies = [ "cargo_metadata", - "fancy-regex 0.12.0", + "fancy-regex", "quote", "syn", ] [[package]] name = "libcnb-test" -version = "0.17.0" +version = "0.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2471f098af746db385e0e254dd423de21db3347ea26cfd4c758a37cccaa1674a" +checksum = "9471152703833b74d565c7f7c910b4d5e084f955c327eba2bdb6658e86bd6dd6" dependencies = [ "fastrand", "fs_extra", @@ -667,9 +659,9 @@ dependencies = [ [[package]] name = "libherokubuildpack" -version = "0.17.0" +version = "0.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e800ca80376b707d57d55ea95f48c88d2621864a0250cc41f54eab8e9481887" +checksum = "146f61983fd384cb5ab5373acdd8f53fcb4b27ecb200435a6bfb6a70b421bc9d" dependencies = [ "crossbeam-utils", "sha2", @@ -698,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.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c05b570dc24563cf1720263bbeeb78822c8d3ce75debe510ca6bae90dd0cccf" +dependencies = [ + "serde", +] + [[package]] name = "memchr" version = "2.7.1" @@ -777,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", ] @@ -893,7 +894,7 @@ dependencies = [ "libc", "spin", "untrusted", - "windows-sys 0.52.0", + "windows-sys", ] [[package]] @@ -906,7 +907,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.52.0", + "windows-sys", ] [[package]] @@ -1035,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", @@ -1063,7 +1064,7 @@ dependencies = [ "cfg-if", "fastrand", "rustix", - "windows-sys 0.52.0", + "windows-sys", ] [[package]] @@ -1256,15 +1257,14 @@ dependencies = [ [[package]] name = "which" -version = "5.0.0" +version = "6.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9bf3ea8596f3a0dd5980b46430f2058dfe2c36a27ccfbb1845d6fbfcd9ba6e14" +checksum = "8211e4f58a2b2805adfbefbc07bab82958fc91e3836339b1ab7ae32465dce0d7" dependencies = [ "either", "home", - "once_cell", "rustix", - "windows-sys 0.48.0", + "winsafe", ] [[package]] @@ -1311,37 +1311,13 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" -[[package]] -name = "windows-sys" -version = "0.48.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" -dependencies = [ - "windows-targets 0.48.5", -] - [[package]] name = "windows-sys" version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" dependencies = [ - "windows-targets 0.52.4", -] - -[[package]] -name = "windows-targets" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" -dependencies = [ - "windows_aarch64_gnullvm 0.48.5", - "windows_aarch64_msvc 0.48.5", - "windows_i686_gnu 0.48.5", - "windows_i686_msvc 0.48.5", - "windows_x86_64_gnu 0.48.5", - "windows_x86_64_gnullvm 0.48.5", - "windows_x86_64_msvc 0.48.5", + "windows-targets", ] [[package]] @@ -1350,93 +1326,51 @@ version = "0.52.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7dd37b7e5ab9018759f893a1952c9420d060016fc19a472b4bb20d1bdd694d1b" dependencies = [ - "windows_aarch64_gnullvm 0.52.4", - "windows_aarch64_msvc 0.52.4", - "windows_i686_gnu 0.52.4", - "windows_i686_msvc 0.52.4", - "windows_x86_64_gnu 0.52.4", - "windows_x86_64_gnullvm 0.52.4", - "windows_x86_64_msvc 0.52.4", + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", ] -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" - [[package]] name = "windows_aarch64_gnullvm" version = "0.52.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bcf46cf4c365c6f2d1cc93ce535f2c8b244591df96ceee75d8e83deb70a9cac9" -[[package]] -name = "windows_aarch64_msvc" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" - [[package]] name = "windows_aarch64_msvc" version = "0.52.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "da9f259dd3bcf6990b55bffd094c4f7235817ba4ceebde8e6d11cd0c5633b675" -[[package]] -name = "windows_i686_gnu" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" - [[package]] name = "windows_i686_gnu" version = "0.52.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b474d8268f99e0995f25b9f095bc7434632601028cf86590aea5c8a5cb7801d3" -[[package]] -name = "windows_i686_msvc" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" - [[package]] name = "windows_i686_msvc" version = "0.52.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1515e9a29e5bed743cb4415a9ecf5dfca648ce85ee42e15873c3cd8610ff8e02" -[[package]] -name = "windows_x86_64_gnu" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" - [[package]] name = "windows_x86_64_gnu" version = "0.52.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5eee091590e89cc02ad514ffe3ead9eb6b660aedca2183455434b93546371a03" -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" - [[package]] name = "windows_x86_64_gnullvm" version = "0.52.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77ca79f2451b49fa9e2af39f0747fe999fcda4f5e241b2898624dca97a1f2177" -[[package]] -name = "windows_x86_64_msvc" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" - [[package]] name = "windows_x86_64_msvc" version = "0.52.4" @@ -1452,6 +1386,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "winsafe" +version = "0.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d135d17ab770252ad95e9a872d365cf3090e3be864a34ab46f48555993efc904" + [[package]] name = "yansi" version = "0.5.1" diff --git a/buildpacks/ruby/CHANGELOG.md b/buildpacks/ruby/CHANGELOG.md index 17e51171..f3fc2868 100644 --- a/buildpacks/ruby/CHANGELOG.md +++ b/buildpacks/ruby/CHANGELOG.md @@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +- The buildpack now implements Buildpack API 0.10 instead of 0.9, and so requires `lifecycle` 0.17.x or newer. ([#283](https://github.com/heroku/buildpacks-ruby/pull/283/files#commit-suggestions)) + ## [2.1.3] - 2024-03-18 ### Changed diff --git a/buildpacks/ruby/Cargo.toml b/buildpacks/ruby/Cargo.toml index d19c17f5..f3aff1dd 100644 --- a/buildpacks/ruby/Cargo.toml +++ b/buildpacks/ruby/Cargo.toml @@ -16,8 +16,8 @@ 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.17.0" -libherokubuildpack = { version = "=0.17.0", default-features = false, features = ["digest"] } +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. regex = "1" @@ -27,7 +27,8 @@ tempfile = "3" thiserror = "1" ureq = { version = "2", default-features = false, features = ["tls"] } url = "2" +magic_migrate = "0.2" +toml = "0.8" [dev-dependencies] -libcnb-test = "=0.17.0" -toml = "0.8" +libcnb-test = "=0.21.0" diff --git a/buildpacks/ruby/buildpack.toml b/buildpacks/ruby/buildpack.toml index 4871fc40..502b2fc3 100644 --- a/buildpacks/ruby/buildpack.toml +++ b/buildpacks/ruby/buildpack.toml @@ -1,4 +1,4 @@ -api = "0.9" +api = "0.10" [buildpack] id = "heroku/ruby" @@ -11,11 +11,22 @@ keywords = ["ruby", "rails", "heroku"] [[buildpack.licenses]] type = "BSD-3-Clause" +# This workaround can be removed once a new Pack release ships that includes: +# https://github.com/buildpacks/pack/pull/2081 [[stacks]] -id = "heroku-20" +id = "*" -[[stacks]] -id = "heroku-22" +[[targets]] +os = "linux" +arch = "amd64" + +[[targets.distros]] +name = "ubuntu" +version = "20.04" + +[[targets.distros]] +name = "ubuntu" +version = "22.04" [metadata.release] image = { repository = "docker.io/heroku/buildpack-ruby" } diff --git a/buildpacks/ruby/src/layers/bundle_download_layer.rs b/buildpacks/ruby/src/layers/bundle_download_layer.rs index 7917c4f8..933ae722 100644 --- a/buildpacks/ruby/src/layers/bundle_download_layer.rs +++ b/buildpacks/ruby/src/layers/bundle_download_layer.rs @@ -1,11 +1,10 @@ +use crate::RubyBuildpack; +use crate::RubyBuildpackError; +use commons::gemfile_lock::ResolvedBundlerVersion; use commons::output::{ fmt, section_log::{log_step, log_step_timed, SectionLogger}, }; - -use crate::RubyBuildpack; -use crate::RubyBuildpackError; -use commons::gemfile_lock::ResolvedBundlerVersion; use fun_run::{self, CommandWithName}; use libcnb::build::BuildContext; use libcnb::data::layer_content_metadata::LayerTypes; @@ -46,7 +45,7 @@ impl<'a> Layer for BundleDownloadLayer<'a> { } fn create( - &self, + &mut self, _context: &BuildContext, layer_path: &Path, ) -> Result, RubyBuildpackError> { @@ -106,7 +105,7 @@ impl<'a> Layer for BundleDownloadLayer<'a> { } fn existing_layer_strategy( - &self, + &mut self, _context: &BuildContext, layer_data: &LayerData, ) -> Result { diff --git a/buildpacks/ruby/src/layers/bundle_install_layer.rs b/buildpacks/ruby/src/layers/bundle_install_layer.rs index 05e7d741..368a0ca2 100644 --- a/buildpacks/ruby/src/layers/bundle_install_layer.rs +++ b/buildpacks/ruby/src/layers/bundle_install_layer.rs @@ -1,9 +1,8 @@ +use crate::{BundleWithout, RubyBuildpack, RubyBuildpackError}; use commons::output::{ fmt::{self, HELP}, section_log::{log_step, log_step_stream, SectionLogger}, }; - -use crate::{BundleWithout, RubyBuildpack, RubyBuildpackError}; use commons::{ display::SentenceList, gemfile_lock::ResolvedRubyVersion, metadata_digest::MetadataDigest, }; @@ -11,14 +10,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_deserializer_chain, 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 +41,18 @@ 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) distro_name: String, + pub(crate) distro_version: String, + pub(crate) cpu_architecture: String, pub(crate) ruby_version: ResolvedRubyVersion, pub(crate) force_bundle_install_key: String, @@ -57,6 +70,39 @@ 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_deserializer_chain!( + chain: [BundleInstallLayerMetadataV1, BundleInstallLayerMetadataV2], + error: MetadataMigrateError, + deserializer: toml::Deserializer::new, +); +pub(crate) type BundleInstallLayerMetadata = BundleInstallLayerMetadataV2; + +#[derive(thiserror::Error, Debug)] +pub(crate) enum MetadataMigrateError { + #[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 = MetadataMigrateError; + + fn try_from(v1: BundleInstallLayerMetadataV1) -> Result { + let target_id = + TargetId::from_stack(&v1.stack).map_err(MetadataMigrateError::UnsupportedStack)?; + + Ok(Self { + distro_name: target_id.distro_name.clone(), + distro_version: target_id.distro_version.clone(), + cpu_architecture: target_id.cpu_architecture.clone(), + ruby_version: v1.ruby_version, + force_bundle_install_key: v1.force_bundle_install_key, + digest: v1.digest, + }) + } +} + impl<'a> BundleInstallLayer<'a> { #[allow(clippy::unnecessary_wraps)] fn build_layer_env( @@ -114,9 +160,10 @@ impl Layer for BundleInstallLayer<'_> { cache: true, } } + /// Runs with gems cache from last execution fn update( - &self, + &mut self, context: &BuildContext, layer_data: &LayerData, ) -> Result, RubyBuildpackError> { @@ -150,7 +197,7 @@ impl Layer for BundleInstallLayer<'_> { /// Runs when with empty cache fn create( - &self, + &mut self, context: &BuildContext, layer_path: &Path, ) -> Result, RubyBuildpackError> { @@ -172,7 +219,7 @@ impl Layer for BundleInstallLayer<'_> { /// if a coder updates env vars they won't be set unless update or /// create is run. fn existing_layer_strategy( - &self, + &mut self, _context: &BuildContext, layer_data: &LayerData, ) -> Result { @@ -184,58 +231,97 @@ impl Layer for BundleInstallLayer<'_> { match cache_state(old.clone(), now) { Changed::Nothing => { - log_step("Loading cache"); + log_step("Loading cached gems"); keep_and_run } - Changed::Stack(_old, _now) => { - log_step(format!("Clearing cache {}", fmt::details("stack changed"))); + Changed::DistroName(old, now) => { + log_step(format!( + "Clearing cache {}", + fmt::details(format!("distro name changed: {old} to {now}")) + )); + + clear_and_run + } + Changed::DistroVersion(old, now) => { + log_step(format!( + "Clearing cache {}", + fmt::details(format!("distro version changed: {old} to {now}")) + )); + + clear_and_run + } + Changed::CpuArchitecture(old, now) => { + log_step(format!( + "Clearing cache {}", + fmt::details(format!("cpu architecture changed: {old} to {now}")) + )); clear_and_run } - Changed::RubyVersion(_old, _now) => { + Changed::RubyVersion(old, now) => { log_step(format!( "Clearing cache {}", - fmt::details("ruby version changed") + fmt::details(format!("Ruby version changed: {old} to {now}")) )); clear_and_run } } } + + 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` #[derive(Debug)] enum Changed { Nothing, - - /// The stack changed i.e. from `heroku-20` to `heroku-22` - /// When that happens we must invalidate native dependency gems - /// because they're compiled against system dependencies - /// i.e. - /// TODO: Only clear native dependencies instead of the whole cache - Stack(StackId, StackId), // (old, now) - - /// Ruby version changed i.e. 3.0.2 to 3.1.2 - /// When that happens we must invalidate native dependency gems - /// because they're linked to a specific compiled version of Ruby. - /// TODO: Only clear native dependencies instead of the whole cache - RubyVersion(ResolvedRubyVersion, ResolvedRubyVersion), // (old, now) + DistroName(String, String), + DistroVersion(String, String), + CpuArchitecture(String, String), + RubyVersion(ResolvedRubyVersion, ResolvedRubyVersion), } // Compare the old metadata to current metadata to determine the state of the // cache. Based on that state, we can log and determine `ExistingLayerStrategy` fn cache_state(old: BundleInstallLayerMetadata, now: BundleInstallLayerMetadata) -> Changed { let BundleInstallLayerMetadata { - stack, + distro_name, + distro_version, + cpu_architecture, 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.distro_name != distro_name { + Changed::DistroName(old.distro_name, distro_name) + } else if old.distro_version != distro_version { + Changed::DistroVersion(old.distro_version, distro_version) + } else if old.cpu_architecture != cpu_architecture { + Changed::CpuArchitecture(old.cpu_architecture, cpu_architecture) } else if old.ruby_version != ruby_version { Changed::RubyVersion(old.ruby_version, ruby_version) } else { @@ -347,7 +433,6 @@ pub(crate) struct BundleDigest { #[cfg(test)] mod test { use super::*; - use libcnb::data::stack_id; use std::path::PathBuf; #[cfg(test)] @@ -400,8 +485,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(); @@ -417,8 +503,11 @@ GEM_PATH=layer_path }; std::fs::write(&gemfile, "iamagemfile").unwrap(); + let target_id = TargetId::from_stack("heroku-22").unwrap(); let metadata = BundleInstallLayerMetadata { - stack: stack_id!("heroku-22"), + distro_name: target_id.distro_name, + distro_version: target_id.distro_version, + cpu_architecture: target_id.cpu_architecture, ruby_version: ResolvedRubyVersion(String::from("3.1.3")), force_bundle_install_key: String::from("v1"), digest: MetadataDigest::new_env_files( @@ -432,7 +521,9 @@ GEM_PATH=layer_path let gemfile_path = gemfile.display(); let toml_string = format!( r#" -stack = "heroku-22" +distro_name = "ubuntu" +distro_version = "22.04" +cpu_architecture = "amd64" ruby_version = "3.1.3" force_bundle_install_key = "v1" @@ -451,4 +542,66 @@ 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 target_id = TargetId::from_stack(&metadata.stack).unwrap(); + let expected = BundleInstallLayerMetadataV2 { + distro_name: target_id.distro_name, + distro_version: target_id.distro_version, + cpu_architecture: target_id.cpu_architecture, + 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/metrics_agent_install.rs b/buildpacks/ruby/src/layers/metrics_agent_install.rs index 1c65e72a..6e1f74bb 100644 --- a/buildpacks/ruby/src/layers/metrics_agent_install.rs +++ b/buildpacks/ruby/src/layers/metrics_agent_install.rs @@ -77,7 +77,7 @@ impl<'a> Layer for MetricsAgentInstall<'a> { } fn create( - &self, + &mut self, _context: &libcnb::build::BuildContext, layer_path: &std::path::Path, ) -> Result< @@ -102,7 +102,7 @@ impl<'a> Layer for MetricsAgentInstall<'a> { } fn update( - &self, + &mut self, _context: &libcnb::build::BuildContext, layer_data: &libcnb::layer::LayerData, ) -> Result< @@ -123,7 +123,7 @@ impl<'a> Layer for MetricsAgentInstall<'a> { } fn existing_layer_strategy( - &self, + &mut self, _context: &libcnb::build::BuildContext, layer_data: &libcnb::layer::LayerData, ) -> Result::Error> @@ -144,7 +144,7 @@ impl<'a> Layer for MetricsAgentInstall<'a> { } fn migrate_incompatible_metadata( - &self, + &mut self, _context: &libcnb::build::BuildContext, _metadata: &GenericMetadata, ) -> Result< diff --git a/buildpacks/ruby/src/layers/ruby_install_layer.rs b/buildpacks/ruby/src/layers/ruby_install_layer.rs index 35f0238d..cd6f90cd 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_deserializer_chain, 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,11 +40,58 @@ 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) distro_name: String, + pub(crate) distro_version: String, + pub(crate) cpu_architecture: String, + pub(crate) ruby_version: ResolvedRubyVersion, +} + +impl RubyInstallLayerMetadataV2 { + pub(crate) fn target_id(&self) -> TargetId { + TargetId { + cpu_architecture: self.cpu_architecture.clone(), + distro_name: self.distro_name.clone(), + distro_version: self.distro_version.clone(), + } + } +} + +try_migrate_deserializer_chain!( + chain: [RubyInstallLayerMetadataV1, RubyInstallLayerMetadataV2], + error: MetadataMigrateError, + deserializer: toml::Deserializer::new, +); +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 { + let target_id = + TargetId::from_stack(&v1.stack).map_err(MetadataMigrateError::TargetIdError)?; + + Ok(Self { + distro_name: target_id.distro_name, + distro_version: target_id.distro_version, + cpu_architecture: target_id.cpu_architecture, + ruby_version: v1.version, + }) + } +} + impl<'a> Layer for RubyInstallLayer<'a> { type Buildpack = RubyBuildpack; type Metadata = RubyInstallLayerMetadata; @@ -54,7 +105,7 @@ impl<'a> Layer for RubyInstallLayer<'a> { } fn create( - &self, + &mut self, _context: &BuildContext, layer_path: &Path, ) -> Result, RubyBuildpackError> { @@ -63,7 +114,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.ruby_version) .map_err(RubyBuildpackError::RubyInstallError)?; download(url.as_ref(), tmp_ruby_tgz.path()) @@ -75,8 +126,31 @@ 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( - &self, + &mut self, _context: &BuildContext, layer_data: &LayerData, ) -> Result { @@ -84,20 +158,39 @@ impl<'a> Layer for RubyInstallLayer<'a> { let now = self.metadata.clone(); match cache_state(old.clone(), now) { - Changed::Nothing(_version) => { - log_step("Using cached version"); + Changed::Nothing => { + log_step("Using cached Ruby version"); Ok(ExistingLayerStrategy::Keep) } - Changed::Stack(_old, _now) => { - log_step(format!("Clearing cache {}", fmt::details("stack changed"))); + Changed::CpuArchitecture(old, now) => { + log_step(format!( + "Clearing cache {}", + fmt::details(format!("CPU architecture changed: {old} to {now}")) + )); + + Ok(ExistingLayerStrategy::Recreate) + } + Changed::DistroVersion(old, now) => { + log_step(format!( + "Clearing cache {}", + fmt::details(format!("distro version changed: {old} to {now}")) + )); Ok(ExistingLayerStrategy::Recreate) } - Changed::RubyVersion(_old, _now) => { + Changed::DistroName(old, now) => { log_step(format!( "Clearing cache {}", - fmt::details("ruby version changed") + fmt::details(format!("distro name changed: {old} to {now}")) + )); + + Ok(ExistingLayerStrategy::Recreate) + } + Changed::RubyVersion(old, now) => { + log_step(format!( + "Clearing cache {}", + fmt::details(format!("Ruby version changed: {old} to {now}")) )); Ok(ExistingLayerStrategy::Recreate) @@ -107,32 +200,46 @@ impl<'a> Layer for RubyInstallLayer<'a> { } fn cache_state(old: RubyInstallLayerMetadata, now: RubyInstallLayerMetadata) -> Changed { - let RubyInstallLayerMetadata { stack, version } = now; - - if old.stack != stack { - Changed::Stack(old.stack, stack) - } else if old.version != version { - Changed::RubyVersion(old.version, version) + let RubyInstallLayerMetadata { + distro_name, + distro_version, + cpu_architecture, + ruby_version, + } = now; + + if old.distro_name != distro_name { + Changed::DistroName(old.distro_name, distro_name) + } else if old.distro_version != distro_version { + Changed::DistroVersion(old.distro_version, distro_version) + } else if old.cpu_architecture != cpu_architecture { + Changed::CpuArchitecture(old.cpu_architecture, cpu_architecture) + } else if old.ruby_version != ruby_version { + Changed::RubyVersion(old.ruby_version, ruby_version) } else { - Changed::Nothing(version) + Changed::Nothing } } #[derive(Debug)] enum Changed { - Nothing(ResolvedRubyVersion), - Stack(StackId, StackId), + Nothing, + DistroName(String, String), + DistroVersion(String, String), + CpuArchitecture(String, String), 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 +275,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 +304,35 @@ 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"), + distro_name: String::from("ubuntu"), + distro_version: String::from("22.04"), + cpu_architecture: String::from("amd64"), + ruby_version: ResolvedRubyVersion(String::from("3.1.3")), + }; + + let actual = toml::to_string(&metadata).unwrap(); + let expected = r#" +distro_name = "ubuntu" +distro_version = "22.04" +cpu_architecture = "amd64" +ruby_version = "3.1.3" +"# + .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 +343,36 @@ version = "3.1.3" "# .trim(); assert_eq!(expected, actual.trim()); + + let deserialized: RubyInstallLayerMetadataV2 = + RubyInstallLayerMetadataV2::try_from_str_migrations(&actual) + .unwrap() + .unwrap(); + + let target_id = TargetId::from_stack(&metadata.stack).unwrap(); + let expected = RubyInstallLayerMetadataV2 { + distro_name: target_id.distro_name, + distro_version: target_id.distro_version, + cpu_architecture: target_id.cpu_architecture, + ruby_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 { + cpu_architecture: 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..fb6b6973 100644 --- a/buildpacks/ruby/src/main.rs +++ b/buildpacks/ruby/src/main.rs @@ -29,6 +29,7 @@ mod layers; mod rake_status; mod rake_task_detect; mod steps; +mod target_id; mod user_errors; #[cfg(test)] @@ -170,8 +171,10 @@ impl Buildpack for RubyBuildpack { RubyInstallLayer { _in_section: section.as_ref(), metadata: RubyInstallLayerMetadata { - stack: context.stack_id.clone(), - version: ruby_version.clone(), + distro_name: context.target.distro_name.clone(), + distro_version: context.target.distro_version.clone(), + cpu_architecture: context.target.arch.clone(), + ruby_version: ruby_version.clone(), }, }, )?; @@ -211,7 +214,9 @@ impl Buildpack for RubyBuildpack { without: BundleWithout::new("development:test"), _section_log: section.as_ref(), metadata: BundleInstallLayerMetadata { - stack: context.stack_id.clone(), + distro_name: context.target.distro_name.clone(), + distro_version: context.target.distro_version.clone(), + cpu_architecture: context.target.arch.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..cad39764 --- /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) distro_name: String, + pub(crate) distro_version: String, + pub(crate) cpu_architecture: String, +} + +const DISTRO_VERSION_STACK: &[(&str, &str, &str)] = &[ + ("ubuntu", "20.04", "heroku-20"), + ("ubuntu", "22.04", "heroku-22"), +]; + +#[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 { + cpu_architecture: 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-20"), + TargetId { + cpu_architecture: String::from("amd64"), + distro_name: String::from("ubuntu"), + distro_version: String::from("20.04"), + } + .stack_name() + .unwrap() + ); + + assert_eq!( + String::from("heroku-22"), + TargetId { + cpu_architecture: String::from("amd64"), + distro_name: String::from("ubuntu"), + distro_version: String::from("22.04"), + } + .stack_name() + .unwrap() + ); + } + + #[test] + fn test_from_stack() { + assert_eq!( + TargetId::from_stack("heroku-20").unwrap(), + TargetId { + cpu_architecture: String::from("amd64"), + distro_name: String::from("ubuntu"), + distro_version: String::from("20.04"), + } + ); + + assert_eq!( + TargetId::from_stack("heroku-22").unwrap(), + TargetId { + cpu_architecture: String::from("amd64"), + distro_name: String::from("ubuntu"), + distro_version: String::from("22.04"), + } + ); + } +} diff --git a/buildpacks/ruby/tests/integration_test.rs b/buildpacks/ruby/tests/integration_test.rs index 61decac6..b6e0c4f5 100644 --- a/buildpacks/ruby/tests/integration_test.rs +++ b/buildpacks/ruby/tests/integration_test.rs @@ -11,22 +11,67 @@ use std::thread; use std::time::{Duration, Instant}; use ureq::Response; +// Test that: +// - Cached data "stack" is preserved and will be successfully migrated to "targets" #[test] #[ignore = "integration test"] -fn test_default_app() { +fn test_migrating_metadata() { + let builder = "heroku/builder:22"; + let app_dir = "tests/fixtures/default_ruby"; + + TestRunner::default().build( + BuildConfig::new(builder, app_dir).buildpacks([BuildpackReference::Other( + "docker://docker.io/heroku/buildpack-ruby:2.1.2".to_string(), + )]), + |context| { + println!("{}", context.pack_stdout); + context.rebuild( + BuildConfig::new(builder, app_dir).buildpacks([BuildpackReference::CurrentCrate]), + |rebuild_context| { + println!("{}", rebuild_context.pack_stdout); + + assert_contains!(rebuild_context.pack_stdout, "Using cached Ruby version"); + assert_contains!(rebuild_context.pack_stdout, "Loading cached gems"); + }, + ); + }, + ); +} + +#[test] +#[ignore = "integration test"] +fn test_default_app_ubuntu20() { + TestRunner::default().build( + BuildConfig::new("heroku/builder:20", "tests/fixtures/default_ruby"), + |context| { + println!("{}", context.pack_stdout); + assert_contains!(context.pack_stdout, "# Heroku Ruby Buildpack"); + assert_contains!( + context.pack_stdout, + r#"`BUNDLE_BIN="/layers/heroku_ruby/gems/bin" BUNDLE_CLEAN="1" BUNDLE_DEPLOYMENT="1" BUNDLE_GEMFILE="/workspace/Gemfile" BUNDLE_PATH="/layers/heroku_ruby/gems" BUNDLE_WITHOUT="development:test" bundle install`"#); + + assert_contains!(context.pack_stdout, "Installing webrick"); + }, + ); +} + +#[test] +#[ignore = "integration test"] +fn test_default_app_latest_distro() { TestRunner::default().build( BuildConfig::new("heroku/builder:22", "tests/fixtures/default_ruby"), |context| { + println!("{}", context.pack_stdout); assert_contains!(context.pack_stdout, "# Heroku Ruby Buildpack"); assert_contains!( context.pack_stdout, r#"`BUNDLE_BIN="/layers/heroku_ruby/gems/bin" BUNDLE_CLEAN="1" BUNDLE_DEPLOYMENT="1" BUNDLE_GEMFILE="/workspace/Gemfile" BUNDLE_PATH="/layers/heroku_ruby/gems" BUNDLE_WITHOUT="development:test" bundle install`"#); - println!("{}", context.pack_stdout); // Needed to get full failure as `rebuild` truncates stdout assert_contains!(context.pack_stdout, "Installing webrick"); let config = context.config.clone(); context.rebuild(config, |rebuild_context| { + println!("{}", rebuild_context.pack_stdout); assert_contains!(rebuild_context.pack_stdout, "Skipping `bundle install` (no changes found in /workspace/Gemfile, /workspace/Gemfile.lock, or user configured environment variables)"); rebuild_context.start_container( @@ -85,6 +130,7 @@ DEPENDENCIES BuildpackReference::CurrentCrate, ]), |context| { + println!("{}", context.pack_stdout); assert_contains!(context.pack_stdout, "# Heroku Ruby Buildpack"); assert_contains!( context.pack_stdout, @@ -105,6 +151,7 @@ fn test_ruby_app_with_yarn_app() { BuildpackReference::CurrentCrate, ]), |context| { + println!("{}", context.pack_stdout); assert_contains!(context.pack_stdout, "# Heroku Ruby Buildpack"); assert_contains!( context.pack_stdout, @@ -119,8 +166,9 @@ fn test_barnes_app() { TestRunner::default().build( BuildConfig::new("heroku/builder:22", "tests/fixtures/barnes_app"), |context| { - assert_contains!(context.pack_stdout, "# Heroku Ruby Buildpack"); + println!("{}", context.pack_stdout); + assert_contains!(context.pack_stdout, "# Heroku Ruby Buildpack"); context.start_container( ContainerConfig::new() .entrypoint("launcher") diff --git a/commons/Cargo.toml b/commons/Cargo.toml index 4a655fd3..3e28a5f6 100644 --- a/commons/Cargo.toml +++ b/commons/Cargo.toml @@ -24,8 +24,8 @@ 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.17.0" -libherokubuildpack = { version = "=0.17.0", default-features = false, features = ["command"] } +libcnb = "=0.21.0" +libherokubuildpack = { version = "=0.21.0", default-features = false, features = ["command"] } regex = "1" serde = "1" sha2 = "0.10" @@ -36,6 +36,6 @@ walkdir = "2" [dev-dependencies] filetime = "0.2" indoc = "2" -libcnb-test = "=0.17.0" +libcnb-test = "=0.21.0" pretty_assertions = "1" toml = "0.8" diff --git a/commons/src/cache/in_app_dir_cache_layer.rs b/commons/src/cache/in_app_dir_cache_layer.rs index be581e0d..7a60b141 100644 --- a/commons/src/cache/in_app_dir_cache_layer.rs +++ b/commons/src/cache/in_app_dir_cache_layer.rs @@ -57,7 +57,7 @@ where } fn create( - &self, + &mut self, _context: &BuildContext, _layer_path: &Path, ) -> Result, B::Error> { @@ -68,7 +68,7 @@ where } fn existing_layer_strategy( - &self, + &mut self, _context: &BuildContext, layer_data: &LayerData, ) -> Result { diff --git a/commons/src/layer/configure_env_layer.rs b/commons/src/layer/configure_env_layer.rs index 3c063917..34bc868f 100644 --- a/commons/src/layer/configure_env_layer.rs +++ b/commons/src/layer/configure_env_layer.rs @@ -115,7 +115,7 @@ where } fn create( - &self, + &mut self, _context: &BuildContext, _layer_path: &Path, ) -> Result, B::Error> { diff --git a/commons/src/metadata_digest.rs b/commons/src/metadata_digest.rs index 3391e43f..1ef09719 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, /// @@ -101,7 +99,7 @@ const PLATFORM_ENV_VAR: &str = "user configured environment variables"; /// # } /// # /// fn update( -/// &self, +/// &mut self, /// context: &BuildContext, /// layer_data: &LayerData, /// ) -> Result, ::Error> { @@ -124,7 +122,7 @@ const PLATFORM_ENV_VAR: &str = "user configured environment variables"; /// } /// # /// # fn create( -/// # &self, +/// # &mut self, /// # context: &BuildContext, /// # layer_path: &Path, /// # ) -> Result, ::Error> { diff --git a/docs/application_contract.md b/docs/application_contract.md index 08b22fc5..df365d19 100644 --- a/docs/application_contract.md +++ b/docs/application_contract.md @@ -25,7 +25,8 @@ Once an application has passed the detect phase, the build phase will execute to - Given a `Gemfile.lock` with an explicit Ruby version, we will install that Ruby version. - Given a `Gemfile.lock` without an explicit Ruby version, we will install a default Ruby version. - When the default value changes, applications without an explicit Ruby version will receive the updated version on their next deployment. - - We will reinstall Ruby if your stack (operating system) changes. + - We will reinstall Ruby if your distribution name or version (operating system) changes. + - We will reinstall Ruby if your CPU architecture (i.e. amd64) changes. - Bundler version: - Given a `Gemfile.lock` with an explicit Bundler version we will install that bundler version. - Given a `Gemfile.lock` without an explicit Bundler version we will install a default Ruby version. @@ -39,7 +40,8 @@ Once an application has passed the detect phase, the build phase will execute to -To always run `bundle install` even if there are changes if the environment variable `HEROKU_SKIP_BUNDLE_DIGEST=1` is found. - We will always run `bundle clean` after a successful `bundle install` via setting `BUNDLE_CLEAN=1` environment variable. - We will always cache the contents of your gem dependencies. - - We will always invalidate the dependency cache if your stack (operating system) changes. + - We will always invalidate the dependency cache if your distribution name or version (operating system) changes. + - We will always invalidate the dependency cache if your CPU architecture (i.e. amd64) changes. - We will always invalidate the dependency cache if your Ruby version changes. - We may invalidate the dependency cache if there was a bug in a prior buildpack version that needs to be fixed. - Gem specific behavior - We will parse your `Gemfile.lock` to determine what dependencies your app need for use in specializing your install behavior (i.e. Rails 5 versus Rails 4). The inclusion of these gems may trigger different behavior: