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

Move bundle download to struct API and bullet stream #330

Merged
merged 9 commits into from
Oct 7, 2024
256 changes: 129 additions & 127 deletions buildpacks/ruby/src/layers/bundle_download_layer.rs
Original file line number Diff line number Diff line change
@@ -1,161 +1,163 @@
//! # Install the bundler gem
//!
//! ## Layer dir: Install bundler to disk
//!
//! Installs a copy of `bundler` to the `<layer-dir>` with a bundler executable in
//! `<layer-dir>/bin`. Must run before [`crate.steps.bundle_install`].
use crate::layers::shared::{cached_layer_write_metadata, MetadataDiff};
use crate::RubyBuildpack;
use crate::RubyBuildpackError;
use bullet_stream::state::SubBullet;
use bullet_stream::{style, Print};
use commons::gemfile_lock::ResolvedBundlerVersion;
use commons::output::{
fmt,
section_log::{log_step, log_step_timed, SectionLogger},
};
use fun_run::{self, CommandWithName};
use libcnb::build::BuildContext;
use libcnb::data::layer_content_metadata::LayerTypes;
#[allow(deprecated)]
use libcnb::layer::{ExistingLayerStrategy, Layer, LayerData, LayerResult, LayerResultBuilder};
use libcnb::data::layer_name;
use libcnb::layer::{EmptyLayerCause, LayerState};
use libcnb::layer_env::{LayerEnv, ModificationBehavior, Scope};
use libcnb::Env;
use serde::{Deserialize, Serialize};
use magic_migrate::{try_migrate_deserializer_chain, TryMigrate};
use serde::{Deserialize, Deserializer, Serialize};
use std::convert::Infallible;
use std::io::Stdout;
use std::path::Path;
use std::process::Command;

#[derive(Deserialize, Serialize, Debug, Clone)]
pub(crate) struct BundleDownloadLayerMetadata {
pub(crate) version: ResolvedBundlerVersion,
}
pub(crate) fn handle(
context: &libcnb::build::BuildContext<RubyBuildpack>,
env: &Env,
mut bullet: Print<SubBullet<Stdout>>,
metadata: &Metadata,
) -> libcnb::Result<(Print<SubBullet<Stdout>>, LayerEnv), RubyBuildpackError> {
let layer_ref = cached_layer_write_metadata(layer_name!("bundler"), context, metadata)?;
match &layer_ref.state {
LayerState::Restored { cause } => {
bullet = bullet.sub_bullet(cause);
Ok((bullet, layer_ref.read_env()?))
}
LayerState::Empty { cause } => {
match cause {
EmptyLayerCause::NewlyCreated => {}
EmptyLayerCause::InvalidMetadataAction { cause }
| EmptyLayerCause::RestoredLayerAction { cause } => {
bullet = bullet.sub_bullet(cause);
}
}
let (bullet, layer_env) = download_bundler(bullet, env, metadata, &layer_ref.path())?;
layer_ref.write_env(&layer_env)?;

/// # Install the bundler gem
///
/// ## Layer dir: Install bundler to disk
///
/// Installs a copy of `bundler` to the `<layer-dir>` with a bundler executable in
/// `<layer-dir>/bin`. Must run before [`crate.steps.bundle_install`].
pub(crate) struct BundleDownloadLayer<'a> {
pub(crate) env: Env,
pub(crate) metadata: BundleDownloadLayerMetadata,
pub(crate) _section_logger: &'a dyn SectionLogger,
Ok((bullet, layer_ref.read_env()?))
}
}
}

#[allow(deprecated)]
impl<'a> Layer for BundleDownloadLayer<'a> {
type Buildpack = RubyBuildpack;
type Metadata = BundleDownloadLayerMetadata;

fn types(&self) -> LayerTypes {
LayerTypes {
build: true,
launch: true,
cache: true,
pub(crate) type Metadata = MetadataV1;
try_migrate_deserializer_chain!(
deserializer: toml::Deserializer::new,
error: MetadataError,
chain: [MetadataV1],
);

impl MetadataDiff for Metadata {
fn diff(&self, other: &Self) -> Vec<String> {
let mut differences = Vec::new();
if self.version != other.version {
differences.push(format!(
"Bundler version ({old} to {now})",
old = style::value(other.version.to_string()),
now = style::value(self.version.to_string())
));
}
differences
}
}

fn create(
&mut self,
_context: &BuildContext<Self::Buildpack>,
layer_path: &Path,
) -> Result<LayerResult<Self::Metadata>, RubyBuildpackError> {
let bin_dir = layer_path.join("bin");
let gem_path = layer_path;

let mut cmd = Command::new("gem");
cmd.args([
"install",
"bundler",
"--version", // Specify exact version to install
&self.metadata.version.to_string(),
])
.env_clear()
.envs(&self.env);

// Format `gem install --version <version>` without other content for display
let short_name = fun_run::display(&mut cmd);

// Arguments we don't need in the output
cmd.args([
"--install-dir", // Directory where bundler's contents will live
&layer_path.to_string_lossy(),
"--bindir", // Directory where `bundle` executable lives
&bin_dir.to_string_lossy(),
"--force", // Overwrite if it already exists
"--no-document", // Don't install ri or rdoc documentation, which takes extra time
"--env-shebang", // Start the `bundle` executable with `#! /usr/bin/env ruby`
]);

log_step_timed(format!("Running {}", fmt::command(short_name)), || {
cmd.named_output().map_err(|error| {
fun_run::map_which_problem(error, cmd.mut_cmd(), self.env.get("PATH").cloned())
})
})
.map_err(RubyBuildpackError::GemInstallBundlerCommandError)?;
#[derive(Deserialize, Serialize, Debug, Clone)]
pub(crate) struct MetadataV1 {
pub(crate) version: ResolvedBundlerVersion,
}

LayerResultBuilder::new(self.metadata.clone())
.env(
LayerEnv::new()
.chainable_insert(Scope::All, ModificationBehavior::Delimiter, "PATH", ":")
.chainable_insert(
Scope::All,
ModificationBehavior::Prepend,
"PATH", // Ensure this path comes before default bundler that ships with ruby, don't rely on the lifecycle
bin_dir,
)
.chainable_insert(Scope::All, ModificationBehavior::Delimiter, "GEM_PATH", ":")
.chainable_insert(
Scope::All,
ModificationBehavior::Prepend,
"GEM_PATH", // Bundler is a gem too, allow it to be required
gem_path,
),
)
.build()
}
#[derive(Debug, thiserror::Error)]
pub(crate) enum MetadataError {
schneems marked this conversation as resolved.
Show resolved Hide resolved
// Update if migrating between a metadata version can error
}

fn existing_layer_strategy(
&mut self,
_context: &BuildContext<Self::Buildpack>,
layer_data: &LayerData<Self::Metadata>,
) -> Result<ExistingLayerStrategy, RubyBuildpackError> {
let old = &layer_data.content_metadata.metadata;
let now = self.metadata.clone();
match cache_state(old.clone(), now) {
State::NothingChanged(_version) => {
log_step("Using cached version");

Ok(ExistingLayerStrategy::Keep)
}
State::BundlerVersionChanged(_old, _now) => {
log_step(format!(
"Clearing cache {}",
fmt::details("bundler version changed")
));
fn download_bundler(
bullet: Print<SubBullet<Stdout>>,
env: &Env,
metadata: &Metadata,
path: &Path,
) -> Result<(Print<SubBullet<Stdout>>, LayerEnv), RubyBuildpackError> {
let bin_dir = path.join("bin");
let gem_path = path;

let mut cmd = Command::new("gem");
cmd.args(["install", "bundler"]);
cmd.args(["--version", &metadata.version.to_string()]) // Specify exact version to install
.env_clear()
.envs(env);

Ok(ExistingLayerStrategy::Recreate)
}
}
}
}
let short_name = fun_run::display(&mut cmd); // Format `gem install --version <version>` without other content for display
schneems marked this conversation as resolved.
Show resolved Hide resolved

// [derive(Debug)]
enum State {
NothingChanged(ResolvedBundlerVersion),
BundlerVersionChanged(ResolvedBundlerVersion, ResolvedBundlerVersion),
}
cmd.args(["--install-dir", &format!("{}", gem_path.display())]); // Directory where bundler's contents will live
cmd.args(["--bindir", &format!("{}", bin_dir.display())]); // Directory where `bundle` executable lives
cmd.args([
"--force", // Overwrite if it already exists
"--no-document", // Don't install ri or rdoc documentation, which takes extra time
"--env-shebang", // Start the `bundle` executable with `#! /usr/bin/env ruby`
]);

fn cache_state(old: BundleDownloadLayerMetadata, now: BundleDownloadLayerMetadata) -> State {
let BundleDownloadLayerMetadata { version } = now; // Ensure all properties are checked
let timer = bullet.start_timer(format!("Running {}", style::command(short_name)));

if old.version == version {
State::NothingChanged(version)
} else {
State::BundlerVersionChanged(old.version, version)
}
cmd.named_output()
.map_err(|error| fun_run::map_which_problem(error, cmd.mut_cmd(), env.get("PATH").cloned()))
.map_err(RubyBuildpackError::GemInstallBundlerCommandError)?;

let layer_env = LayerEnv::new()
.chainable_insert(Scope::All, ModificationBehavior::Delimiter, "PATH", ":")
.chainable_insert(
Scope::All,
ModificationBehavior::Prepend,
"PATH", // Ensure this path comes before default bundler that ships with ruby, don't rely on the lifecycle
bin_dir,
)
.chainable_insert(Scope::All, ModificationBehavior::Delimiter, "GEM_PATH", ":")
.chainable_insert(
Scope::All,
ModificationBehavior::Prepend,
"GEM_PATH", // Bundler is a gem too, allow it to be required
gem_path,
);

Ok((timer.done(), layer_env))
}

#[cfg(test)]
mod test {
use super::*;
use crate::layers::shared::strip_ansi;

#[test]
fn test_metadata_diff() {
let old = Metadata {
version: ResolvedBundlerVersion("2.3.5".to_string()),
};
assert!(old.diff(&old).is_empty());

let diff = Metadata {
version: ResolvedBundlerVersion("2.3.6".to_string()),
}
.diff(&old);
assert_eq!(
diff.iter().map(strip_ansi).collect::<Vec<String>>(),
vec!["Bundler version (`2.3.5` to `2.3.6`)"]
);
}

/// If this test fails due to a change you'll need to implement
/// `migrate_incompatible_metadata` for the Layer trait
#[test]
fn metadata_guard() {
let metadata = BundleDownloadLayerMetadata {
let metadata = Metadata {
version: ResolvedBundlerVersion(String::from("2.3.6")),
};

Expand Down
32 changes: 14 additions & 18 deletions buildpacks/ruby/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,13 @@ use bullet_stream::{style, Print};
use commons::cache::CacheError;
use commons::gemfile_lock::GemfileLock;
use commons::metadata_digest::MetadataDigest;
use commons::output::warn_later::WarnGuard;
#[allow(clippy::wildcard_imports)]
use commons::output::{build_log::*, fmt};
use commons::output::build_log::*;
use commons::output::warn_later::WarnGuard;
use core::str::FromStr;
use fs_err::PathExt;
use fun_run::CmdError;
use layers::{
bundle_download_layer::{BundleDownloadLayer, BundleDownloadLayerMetadata},
bundle_install_layer::{BundleInstallLayer, BundleInstallLayerMetadata},
metrics_agent_install::MetricsAgentInstallError,
ruby_install_layer::RubyInstallError,
Expand Down Expand Up @@ -149,7 +148,7 @@ impl Buildpack for RubyBuildpack {
};

// ## Install executable ruby version
(_, env) = {
(build_output, env) = {
let bullet = build_output.bullet(format!(
"Ruby version {} from {}",
style::value(ruby_version.to_string()),
Expand All @@ -170,25 +169,22 @@ impl Buildpack for RubyBuildpack {
};

// ## Setup bundler
(logger, env) = {
let section = logger.section(&format!(
(_, env) = {
let bullet = build_output.bullet(format!(
"Bundler version {} from {}",
fmt::value(bundler_version.to_string()),
fmt::value(gemfile_lock.bundler_source())
style::value(bundler_version.to_string()),
style::value(gemfile_lock.bundler_source())
));
let download_bundler_layer = context.handle_layer(
layer_name!("bundler"),
BundleDownloadLayer {
env: env.clone(),
metadata: BundleDownloadLayerMetadata {
version: bundler_version,
},
_section_logger: section.as_ref(),
let (bullet, layer_env) = layers::bundle_download_layer::handle(
&context,
&env,
bullet,
&layers::bundle_download_layer::Metadata {
version: bundler_version,
},
)?;
let env = download_bundler_layer.env.apply(Scope::Build, &env);

(section.end_section(), env)
(bullet.done(), layer_env.apply(Scope::Build, &env))
};

// ## Bundle install
Expand Down
Loading