diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 59eaff47..1641c940 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -53,8 +53,6 @@ jobs: fail-fast: false matrix: buildpack-directory: ${{ fromJson(needs.gather-repo-metadata.outputs.buildpack_dirs) }} - env: - INTEGRATION_TEST_CNB_BUILDER: "heroku/builder:22" steps: - name: Checkout uses: actions/checkout@v3 diff --git a/Cargo.lock b/Cargo.lock index 04476a5c..74c72583 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1181,6 +1181,7 @@ dependencies = [ "shell-words", "tempfile", "thiserror", + "ureq", ] [[package]] diff --git a/buildpacks/jvm-function-invoker/tests/integration/smoke.rs b/buildpacks/jvm-function-invoker/tests/integration/smoke.rs index 8bf7e33a..b9cdd165 100644 --- a/buildpacks/jvm-function-invoker/tests/integration/smoke.rs +++ b/buildpacks/jvm-function-invoker/tests/integration/smoke.rs @@ -1,5 +1,8 @@ use base64::Engine; -use buildpacks_jvm_shared_test::DEFAULT_INTEGRATION_TEST_BUILDER; +use buildpacks_jvm_shared_test::{ + ADDRESS_FOR_PORT_EXPECT_MESSAGE, DEFAULT_INTEGRATION_TEST_BUILDER, + UREQ_RESPONSE_AS_STRING_EXPECT_MESSAGE, UREQ_RESPONSE_RESULT_EXPECT_MESSAGE, +}; use libcnb_test::{BuildConfig, BuildpackReference, ContainerConfig, TestRunner}; use std::time::Duration; @@ -24,7 +27,7 @@ fn smoke_test_simple_function() { let request_payload = "\"All those moments will be lost in time, like tears in rain...\""; // Absolute minimum request that can be served by the function runtime. - let response_payload = ureq::post(&format!("http://{}", container.address_for_port(PORT).expect("couldn't get container address"))) + let response_payload = ureq::post(&format!("http://{}", container.address_for_port(PORT).expect(ADDRESS_FOR_PORT_EXPECT_MESSAGE))) .set("Content-Type", "application/json") .set("Authorization", "") .set("ce-id", "function") @@ -35,9 +38,9 @@ fn smoke_test_simple_function() { .set("ce-sfcontext", &base64::engine::general_purpose::STANDARD.encode(r#"{ "apiVersion": "", "payloadVersion": "", "userContext": { "orgId": "", "userId": "", "username": "", "orgDomainUrl": "", "onBehalfOfUserId": null, "salesforceBaseUrl": "" } }"#)) .set("ce-sffncontext", &base64::engine::general_purpose::STANDARD.encode(r#"{ "resource": "", "requestId": "", "accessToken": "", "apexClassId": null, "apexClassFQN": null, "functionName": "", "functionInvocationId": null }"#)) .send_string(request_payload) - .unwrap() + .expect(UREQ_RESPONSE_RESULT_EXPECT_MESSAGE) .into_string() - .expect("response read error"); + .expect(UREQ_RESPONSE_AS_STRING_EXPECT_MESSAGE); assert_eq!(response_payload, request_payload.chars().rev().collect::()); }, diff --git a/buildpacks/sbt/Cargo.toml b/buildpacks/sbt/Cargo.toml index f573abc2..a77cd9e3 100644 --- a/buildpacks/sbt/Cargo.toml +++ b/buildpacks/sbt/Cargo.toml @@ -20,3 +20,4 @@ buildpacks-jvm-shared.workspace = true [dev-dependencies] libcnb-test.workspace = true buildpacks-jvm-shared-test.workspace = true +ureq = "2.6.2" diff --git a/buildpacks/sbt/assets/heroku_buildpack_plugin_sbt_v0.scala b/buildpacks/sbt/sbt-plugins/buildpack-plugin-0.x.scala similarity index 92% rename from buildpacks/sbt/assets/heroku_buildpack_plugin_sbt_v0.scala rename to buildpacks/sbt/sbt-plugins/buildpack-plugin-0.x.scala index 721931fe..ab49facc 100644 --- a/buildpacks/sbt/assets/heroku_buildpack_plugin_sbt_v0.scala +++ b/buildpacks/sbt/sbt-plugins/buildpack-plugin-0.x.scala @@ -1,5 +1,5 @@ import sbt._ -import Keys._ +import sbt.Keys._ object HerokuBuildpackPlugin extends Plugin { override def settings = Seq( diff --git a/buildpacks/sbt/assets/heroku_buildpack_plugin_sbt_v1.scala b/buildpacks/sbt/sbt-plugins/buildpack-plugin-1.x.scala similarity index 100% rename from buildpacks/sbt/assets/heroku_buildpack_plugin_sbt_v1.scala rename to buildpacks/sbt/sbt-plugins/buildpack-plugin-1.x.scala diff --git a/buildpacks/sbt/src/cleanup.rs b/buildpacks/sbt/src/cleanup.rs deleted file mode 100644 index c627b4d1..00000000 --- a/buildpacks/sbt/src/cleanup.rs +++ /dev/null @@ -1,50 +0,0 @@ -use buildpacks_jvm_shared::fs::list_directory_contents; -use buildpacks_jvm_shared::result::default_on_not_found; -use std::ffi::OsStr; -use std::fs; -use std::path::Path; - -// the native package plugin produces binaries in the target/universal/stage directory which is not included -// in the list of directories to clean up at the end of the build since a Procfile may reference this -// location to provide the entry point for an application. wiping the directory before the application build -// kicks off will ensure that no leftover artifacts are being carried around between builds. -pub(crate) fn cleanup_native_packager_directories(app_dir: &Path) -> std::io::Result<()> { - default_on_not_found(fs::remove_dir_all( - app_dir.join("target").join("universal").join("stage"), - )) -} - -pub(crate) fn cleanup_compilation_artifacts(app_dir: &Path) -> std::io::Result<()> { - let target_dir = app_dir.join("target"); - - let target_dir_files = list_directory_contents(&target_dir)? - .filter(|path| match path.file_name().and_then(OsStr::to_str) { - Some(file_name) => file_name.starts_with("scala-") || file_name == "streams", - None => false, - }) - .collect::>(); - - let resolution_cache_files = default_on_not_found( - list_directory_contents(target_dir.join("resolution-cache")).map(|directory_contents| { - directory_contents - .filter(|path| match path.file_name().and_then(OsStr::to_str) { - Some(file_name) => { - !(file_name.ends_with("-compile.xml") || file_name == "reports") - } - None => true, - }) - .collect::>() - }), - )?; - - [target_dir_files, resolution_cache_files] - .into_iter() - .flatten() - .try_for_each(|path| { - if path.is_dir() { - fs::remove_dir_all(path) - } else { - fs::remove_file(path) - } - }) -} diff --git a/buildpacks/sbt/src/errors.rs b/buildpacks/sbt/src/errors.rs index 3a7dd37f..2b3018e1 100644 --- a/buildpacks/sbt/src/errors.rs +++ b/buildpacks/sbt/src/errors.rs @@ -1,7 +1,8 @@ use crate::configuration::ReadSbtBuildpackConfigurationError; use crate::layers::sbt_extras::SbtExtrasLayerError; use crate::layers::sbt_global::SbtGlobalLayerError; -use crate::sbt_version::ReadSbtVersionError; +use crate::sbt::output::SbtError; +use crate::sbt::version::ReadSbtVersionError; use buildpacks_jvm_shared::log::log_please_try_again_error; use buildpacks_jvm_shared::system_properties::ReadSystemPropertiesError; use indoc::formatdoc; @@ -19,9 +20,7 @@ pub(crate) enum SbtBuildpackError { UnsupportedSbtVersion(Version), DetectPhaseIoError(std::io::Error), SbtBuildIoError(std::io::Error), - SbtBuildUnexpectedExitCode(ExitStatus), - MissingStageTask, - AlreadyDefinedAsObject, + SbtBuildUnexpectedExitStatus(ExitStatus, Option), ReadSbtBuildpackConfigurationError(ReadSbtBuildpackConfigurationError), ReadSystemPropertiesError(ReadSystemPropertiesError), } @@ -153,7 +152,7 @@ pub(crate) fn log_user_errors(error: SbtBuildpackError) { " }, ), - SbtBuildpackError::SbtBuildUnexpectedExitCode(exit_status) => log_error( + SbtBuildpackError::SbtBuildUnexpectedExitStatus(exit_status, None) => log_error( "Running sbt failed", formatdoc! { " We're sorry this build is failing! If you can't find the issue in application code, @@ -163,33 +162,16 @@ pub(crate) fn log_user_errors(error: SbtBuildpackError) { ", exit_code = exit_code_string(exit_status) }, ), - SbtBuildpackError::MissingStageTask => log_error( + SbtBuildpackError::SbtBuildUnexpectedExitStatus(_, Some(SbtError::MissingTask(task_name))) => log_error( "Failed to run sbt!", formatdoc! {" - It looks like your build.sbt does not have a valid 'stage' task. Please reference our Dev Center article for + It looks like your build.sbt does not have a valid '{task_name}' task. Please reference our Dev Center article for information on how to create one: https://devcenter.heroku.com/articles/scala-support#build-behavior "}, ), - SbtBuildpackError::AlreadyDefinedAsObject => log_error( - "Failed to run sbt!", - formatdoc! {" - We're sorry this build is failing. It looks like you may need to run a clean build to remove any - stale SBT caches. You can do this by setting a configuration variable like this: - - $ heroku config:set SBT_CLEAN=true - - Then deploy you application with 'git push' again. If the build succeeds you can remove the variable by running this command: - - $ heroku config:unset SBT_CLEAN - - If this does not resolve the problem, please submit a ticket so we can help: - https://help.heroku.com - "}, - ), - SbtBuildpackError::DetectPhaseIoError(error) => log_please_try_again_error( "Unexpected I/O error", "An unexpected error occurred during the detect phase.", diff --git a/buildpacks/sbt/src/layers/sbt_global.rs b/buildpacks/sbt/src/layers/sbt_global.rs index 4fe65c46..fe2e96d2 100644 --- a/buildpacks/sbt/src/layers/sbt_global.rs +++ b/buildpacks/sbt/src/layers/sbt_global.rs @@ -77,10 +77,10 @@ fn get_layer_env_scope(available_at_launch: bool) -> Scope { fn heroku_sbt_plugin_for_version(version: &semver::Version) -> Option<&'static [u8]> { match version { semver::Version { major: 0, .. } => Some(include_bytes!( - "../../assets/heroku_buildpack_plugin_sbt_v0.scala" + "../../sbt-plugins/buildpack-plugin-0.x.scala" )), semver::Version { major: 1, .. } => Some(include_bytes!( - "../../assets/heroku_buildpack_plugin_sbt_v1.scala" + "../../sbt-plugins/buildpack-plugin-1.x.scala" )), _ => None, } diff --git a/buildpacks/sbt/src/main.rs b/buildpacks/sbt/src/main.rs index 93cfb321..754940d8 100644 --- a/buildpacks/sbt/src/main.rs +++ b/buildpacks/sbt/src/main.rs @@ -4,25 +4,21 @@ // This lint is too noisy and enforces a style that reduces readability in many cases. #![allow(clippy::module_name_repetitions)] -mod cleanup; mod configuration; mod detect; mod errors; mod layers; -mod sbt_version; +mod sbt; -use crate::cleanup::{cleanup_compilation_artifacts, cleanup_native_packager_directories}; -use crate::configuration::{read_sbt_buildpack_configuration, SbtBuildpackConfiguration}; +use crate::configuration::read_sbt_buildpack_configuration; use crate::detect::is_sbt_project_directory; use crate::errors::{log_user_errors, SbtBuildpackError}; use crate::layers::dependency_resolver_home::{DependencyResolver, DependencyResolverHomeLayer}; use crate::layers::sbt_boot::SbtBootLayer; use crate::layers::sbt_extras::SbtExtrasLayer; use crate::layers::sbt_global::SbtGlobalLayer; -use crate::sbt_version::{is_supported_sbt_version, read_sbt_version}; use buildpacks_jvm_shared::env::extend_build_env; use buildpacks_jvm_shared::system_properties::read_system_properties; -use indoc::formatdoc; use libcnb::build::{BuildContext, BuildResult, BuildResultBuilder}; use libcnb::data::build_plan::BuildPlanBuilder; use libcnb::data::layer_name; @@ -31,9 +27,8 @@ use libcnb::generic::{GenericMetadata, GenericPlatform}; use libcnb::{buildpack_main, Buildpack, Env, Error, Platform}; use libherokubuildpack::command::CommandExt; use libherokubuildpack::error::on_error as on_buildpack_error; -use libherokubuildpack::log::{log_header, log_info, log_warning}; +use libherokubuildpack::log::{log_header, log_info}; use std::io::{stderr, stdout}; -use std::path::PathBuf; use std::process::Command; pub(crate) struct SbtBuildpack; @@ -70,11 +65,11 @@ impl Buildpack for SbtBuildpack { .map_err(SbtBuildpackError::ReadSbtBuildpackConfigurationError) })?; - let sbt_version = read_sbt_version(&context.app_dir) + let sbt_version = sbt::version::read_sbt_version(&context.app_dir) .map_err(SbtBuildpackError::ReadSbtVersionError) .and_then(|version| version.ok_or(SbtBuildpackError::UnknownSbtVersion))?; - if !is_supported_sbt_version(&sbt_version) { + if !sbt::version::is_supported_sbt_version(&sbt_version) { Err(SbtBuildpackError::UnsupportedSbtVersion( sbt_version.clone(), ))?; @@ -140,32 +135,24 @@ impl Buildpack for SbtBuildpack { &mut env, ); - if let Err(error) = cleanup_native_packager_directories(&context.app_dir) { - log_warning( - "Removal of native package directory failed", - formatdoc! {" - This error should not affect your built application but it may cause the container image - to be larger than expected. + log_header("Building Scala project"); - Details: {error:?} - "}, - ); - } - - run_sbt_tasks(&context.app_dir, &buildpack_configuration, &env)?; + let tasks = sbt::tasks::from_config(&buildpack_configuration); + log_info(format!("Running: sbt {}", shell_words::join(&tasks))); - log_info("Dropping compilation artifacts from the build"); - if let Err(error) = cleanup_compilation_artifacts(&context.app_dir) { - log_warning( - "Removal of compilation artifacts failed", - formatdoc! {" - This error should not affect your built application but it may cause the container image - to be larger than expected. + let output = Command::new("sbt") + .current_dir(&context.app_dir) + .args(tasks) + .envs(&env) + .output_and_write_streams(stdout(), stderr()) + .map_err(SbtBuildpackError::SbtBuildIoError)?; - Details: {error:?} - " }, - ); - } + output.status.success().then_some(()).ok_or( + SbtBuildpackError::SbtBuildUnexpectedExitStatus( + output.status, + sbt::output::parse_errors(&output.stdout), + ), + )?; BuildResultBuilder::new().build() } @@ -176,212 +163,3 @@ impl Buildpack for SbtBuildpack { } buildpack_main!(SbtBuildpack); - -fn run_sbt_tasks( - app_dir: &PathBuf, - buildpack_configuration: &SbtBuildpackConfiguration, - env: &Env, -) -> Result<(), SbtBuildpackError> { - log_header("Building Scala project"); - - let tasks = get_sbt_build_tasks(buildpack_configuration); - log_info(format!("Running: sbt {}", shell_words::join(&tasks))); - - let output = Command::new("sbt") - .current_dir(app_dir) - .args(tasks) - .envs(env) - .output_and_write_streams(stdout(), stderr()) - .map_err(SbtBuildpackError::SbtBuildIoError)?; - - output.status.success().then_some(()).ok_or( - extract_error_from_sbt_output(&output.stdout) - .unwrap_or(SbtBuildpackError::SbtBuildUnexpectedExitCode(output.status)), - ) -} - -fn extract_error_from_sbt_output(stdout: &[u8]) -> Option { - let stdout = String::from_utf8_lossy(stdout); - - if stdout.contains("Not a valid key: stage") { - Some(SbtBuildpackError::MissingStageTask) - } else if stdout.contains("is already defined as object") { - Some(SbtBuildpackError::AlreadyDefinedAsObject) - } else { - None - } -} - -fn get_sbt_build_tasks(build_config: &SbtBuildpackConfiguration) -> Vec { - let mut tasks: Vec = Vec::new(); - - if let Some(true) = &build_config.sbt_clean { - tasks.push(String::from("clean")); - } - - if let Some(sbt_pre_tasks) = &build_config.sbt_pre_tasks { - sbt_pre_tasks - .iter() - .for_each(|task| tasks.push(task.to_string())); - } - - if let Some(sbt_tasks) = &build_config.sbt_tasks { - sbt_tasks - .iter() - .for_each(|task| tasks.push(task.to_string())); - } else { - let default_tasks = vec![String::from("compile"), String::from("stage")]; - for default_task in &default_tasks { - tasks.push(match &build_config.sbt_project { - Some(project) => format!("{project}/{default_task}"), - None => default_task.to_string(), - }); - } - } - - tasks -} - -#[cfg(test)] -mod tests { - use crate::configuration::SbtBuildpackConfiguration; - use crate::errors::SbtBuildpackError; - use crate::extract_error_from_sbt_output; - use crate::get_sbt_build_tasks; - use indoc::formatdoc; - - #[test] - fn check_missing_stage_error_is_reported() { - let stdout = formatdoc! {" - [error] Expected ';' - [error] Not a valid command: stage (similar: last-grep, set, last) - [error] Not a valid project ID: stage - [error] Expected ':' - [error] Not a valid key: stage (similar: state, target, tags) - [error] stage - [error] ^ - "} - .into_bytes(); - - match extract_error_from_sbt_output(&stdout) { - Some(SbtBuildpackError::MissingStageTask) => {} - _ => panic!("expected ScalaBuildpackError::MissingStageTask"), - }; - } - - #[test] - fn check_already_defined_as_error_is_reported() { - let stdout = formatdoc! {" - [error] Expected ';' - [error] Not a valid command: stage (similar: last-grep, set, last) - [error] Not a valid project ID: stage - [error] Expected ':' - [error] Blah is already defined as object Blah - "} - .into_bytes(); - - match extract_error_from_sbt_output(&stdout) { - Some(SbtBuildpackError::AlreadyDefinedAsObject) => {} - _ => panic!("expected ScalaBuildpackError::AlreadyDefinedAsObject"), - }; - } - - #[test] - fn get_sbt_build_tasks_with_no_configured_options() { - let config = SbtBuildpackConfiguration { - sbt_project: None, - sbt_pre_tasks: None, - sbt_tasks: None, - sbt_clean: None, - sbt_available_at_launch: None, - }; - assert_eq!(get_sbt_build_tasks(&config), vec!["compile", "stage"]); - } - - #[test] - fn get_sbt_build_tasks_with_all_configured_options() { - let config = SbtBuildpackConfiguration { - sbt_project: Some("projectName".to_string()), - sbt_pre_tasks: Some(vec!["preTask".to_string()]), - sbt_tasks: Some(vec!["task".to_string()]), - sbt_clean: Some(true), - sbt_available_at_launch: None, - }; - assert_eq!( - get_sbt_build_tasks(&config), - vec!["clean", "preTask", "task"] - ); - } - - #[test] - fn get_sbt_build_tasks_with_clean_set_to_true() { - let config = SbtBuildpackConfiguration { - sbt_project: None, - sbt_pre_tasks: None, - sbt_tasks: None, - sbt_clean: Some(true), - sbt_available_at_launch: None, - }; - assert_eq!( - get_sbt_build_tasks(&config), - vec!["clean", "compile", "stage"] - ); - } - - #[test] - fn get_sbt_build_tasks_with_clean_set_to_false() { - let config = SbtBuildpackConfiguration { - sbt_project: None, - sbt_pre_tasks: None, - sbt_tasks: None, - sbt_clean: Some(false), - sbt_available_at_launch: None, - }; - assert_eq!(get_sbt_build_tasks(&config), vec!["compile", "stage"]); - } - - #[test] - fn get_sbt_build_tasks_with_project_set() { - let config = SbtBuildpackConfiguration { - sbt_project: Some("projectName".to_string()), - sbt_pre_tasks: None, - sbt_tasks: None, - sbt_clean: None, - sbt_available_at_launch: None, - }; - assert_eq!( - get_sbt_build_tasks(&config), - vec!["projectName/compile", "projectName/stage"] - ); - } - - #[test] - fn get_sbt_build_tasks_with_project_and_pre_tasks_set() { - let config = SbtBuildpackConfiguration { - sbt_project: Some("projectName".to_string()), - sbt_pre_tasks: Some(vec!["preTask".to_string()]), - sbt_tasks: None, - sbt_clean: None, - sbt_available_at_launch: None, - }; - assert_eq!( - get_sbt_build_tasks(&config), - vec!["preTask", "projectName/compile", "projectName/stage"] - ); - } - - #[test] - fn get_sbt_build_tasks_with_project_and_clean_set() { - let config = SbtBuildpackConfiguration { - sbt_project: Some("projectName".to_string()), - sbt_pre_tasks: None, - sbt_tasks: None, - sbt_clean: Some(true), - sbt_available_at_launch: None, - }; - assert_eq!( - get_sbt_build_tasks(&config), - vec!["clean", "projectName/compile", "projectName/stage"] - ); - } -} diff --git a/buildpacks/sbt/src/sbt/mod.rs b/buildpacks/sbt/src/sbt/mod.rs new file mode 100644 index 00000000..df45ca13 --- /dev/null +++ b/buildpacks/sbt/src/sbt/mod.rs @@ -0,0 +1,3 @@ +pub(crate) mod output; +pub(crate) mod tasks; +pub(crate) mod version; diff --git a/buildpacks/sbt/src/sbt/output.rs b/buildpacks/sbt/src/sbt/output.rs new file mode 100644 index 00000000..91ffe475 --- /dev/null +++ b/buildpacks/sbt/src/sbt/output.rs @@ -0,0 +1,36 @@ +pub(crate) fn parse_errors(stdout: &[u8]) -> Option { + String::from_utf8_lossy(stdout) + .contains("Not a valid key: stage") + .then_some(SbtError::MissingTask(String::from("stage"))) +} + +#[derive(Debug, Eq, PartialEq)] +pub(crate) enum SbtError { + MissingTask(String), +} + +#[cfg(test)] +mod test { + use super::parse_errors; + use super::SbtError; + use indoc::formatdoc; + + #[test] + fn check_missing_stage_error_is_reported() { + let stdout = formatdoc! {" + [error] Expected ';' + [error] Not a valid command: stage (similar: last-grep, set, last) + [error] Not a valid project ID: stage + [error] Expected ':' + [error] Not a valid key: stage (similar: state, target, tags) + [error] stage + [error] ^ + "} + .into_bytes(); + + assert_eq!( + parse_errors(&stdout), + Some(SbtError::MissingTask(String::from("stage"))) + ); + } +} diff --git a/buildpacks/sbt/src/sbt/tasks.rs b/buildpacks/sbt/src/sbt/tasks.rs new file mode 100644 index 00000000..f1b64d3b --- /dev/null +++ b/buildpacks/sbt/src/sbt/tasks.rs @@ -0,0 +1,133 @@ +use crate::configuration::SbtBuildpackConfiguration; + +pub(crate) fn from_config(build_config: &SbtBuildpackConfiguration) -> Vec { + let mut tasks: Vec = Vec::new(); + + if let Some(true) = &build_config.sbt_clean { + tasks.push(String::from("clean")); + } + + if let Some(sbt_pre_tasks) = &build_config.sbt_pre_tasks { + sbt_pre_tasks + .iter() + .for_each(|task| tasks.push(task.to_string())); + } + + if let Some(sbt_tasks) = &build_config.sbt_tasks { + sbt_tasks + .iter() + .for_each(|task| tasks.push(task.to_string())); + } else { + let default_tasks = vec![String::from("compile"), String::from("stage")]; + for default_task in &default_tasks { + tasks.push(match &build_config.sbt_project { + Some(project) => format!("{project}/{default_task}"), + None => default_task.to_string(), + }); + } + } + + tasks +} + +#[cfg(test)] +mod test { + use super::from_config; + use crate::configuration::SbtBuildpackConfiguration; + + #[test] + fn from_config_with_no_configured_options() { + let config = SbtBuildpackConfiguration { + sbt_project: None, + sbt_pre_tasks: None, + sbt_tasks: None, + sbt_clean: None, + sbt_available_at_launch: None, + }; + + assert_eq!(from_config(&config), vec!["compile", "stage"]); + } + + #[test] + fn from_config_with_all_configured_options() { + let config = SbtBuildpackConfiguration { + sbt_project: Some("projectName".to_string()), + sbt_pre_tasks: Some(vec!["preTask".to_string()]), + sbt_tasks: Some(vec!["task".to_string()]), + sbt_clean: Some(true), + sbt_available_at_launch: None, + }; + + assert_eq!(from_config(&config), vec!["clean", "preTask", "task"]); + } + + #[test] + fn from_config_with_clean_set_to_true() { + let config = SbtBuildpackConfiguration { + sbt_project: None, + sbt_pre_tasks: None, + sbt_tasks: None, + sbt_clean: Some(true), + sbt_available_at_launch: None, + }; + + assert_eq!(from_config(&config), vec!["clean", "compile", "stage"]); + } + + #[test] + fn from_config_with_clean_set_to_false() { + let config = SbtBuildpackConfiguration { + sbt_project: None, + sbt_pre_tasks: None, + sbt_tasks: None, + sbt_clean: Some(false), + sbt_available_at_launch: None, + }; + assert_eq!(from_config(&config), vec!["compile", "stage"]); + } + + #[test] + fn from_config_with_project_set() { + let config = SbtBuildpackConfiguration { + sbt_project: Some("projectName".to_string()), + sbt_pre_tasks: None, + sbt_tasks: None, + sbt_clean: None, + sbt_available_at_launch: None, + }; + assert_eq!( + from_config(&config), + vec!["projectName/compile", "projectName/stage"] + ); + } + + #[test] + fn from_config_with_project_and_pre_tasks_set() { + let config = SbtBuildpackConfiguration { + sbt_project: Some("projectName".to_string()), + sbt_pre_tasks: Some(vec!["preTask".to_string()]), + sbt_tasks: None, + sbt_clean: None, + sbt_available_at_launch: None, + }; + assert_eq!( + from_config(&config), + vec!["preTask", "projectName/compile", "projectName/stage"] + ); + } + + #[test] + fn from_config_with_project_and_clean_set() { + let config = SbtBuildpackConfiguration { + sbt_project: Some("projectName".to_string()), + sbt_pre_tasks: None, + sbt_tasks: None, + sbt_clean: Some(true), + sbt_available_at_launch: None, + }; + assert_eq!( + from_config(&config), + vec!["clean", "projectName/compile", "projectName/stage"] + ); + } +} diff --git a/buildpacks/sbt/src/sbt_version.rs b/buildpacks/sbt/src/sbt/version.rs similarity index 100% rename from buildpacks/sbt/src/sbt_version.rs rename to buildpacks/sbt/src/sbt/version.rs diff --git a/buildpacks/sbt/test-apps/sbt-1.8.2-scala-2.13.10-no-native-packager/.gitignore b/buildpacks/sbt/test-apps/sbt-1.8.2-scala-2.13.10-no-native-packager/.gitignore new file mode 100644 index 00000000..dce73038 --- /dev/null +++ b/buildpacks/sbt/test-apps/sbt-1.8.2-scala-2.13.10-no-native-packager/.gitignore @@ -0,0 +1,9 @@ +logs +target +/.bsp +/.idea +/.idea_modules +/.classpath +/.project +/.settings +/RUNNING_PID diff --git a/buildpacks/sbt/test-apps/sbt-1.8.2-scala-2.13.10-no-native-packager/Procfile b/buildpacks/sbt/test-apps/sbt-1.8.2-scala-2.13.10-no-native-packager/Procfile new file mode 100644 index 00000000..eb2feb6a --- /dev/null +++ b/buildpacks/sbt/test-apps/sbt-1.8.2-scala-2.13.10-no-native-packager/Procfile @@ -0,0 +1 @@ +web: sbt run diff --git a/buildpacks/sbt/test-apps/sbt-1.8.2-scala-2.13.10-no-native-packager/build.sbt b/buildpacks/sbt/test-apps/sbt-1.8.2-scala-2.13.10-no-native-packager/build.sbt new file mode 100644 index 00000000..e9de12d7 --- /dev/null +++ b/buildpacks/sbt/test-apps/sbt-1.8.2-scala-2.13.10-no-native-packager/build.sbt @@ -0,0 +1,23 @@ +name := """test-app""" + +version := "1.0" + +scalaVersion := "2.13.10" + +Compile / mainClass := Some("com.heroku.Server") + +libraryDependencies ++= Seq( + "com.twitter" %% "finagle-http" % "22.12.0" +) + +lazy val bogusBuildpackTestTask1 = taskKey[Unit]("Bogus task used by the buildpack tests to ensure it's being run") + +bogusBuildpackTestTask1 := println("Running bogusBuildpackTestTask1...") + +lazy val bogusBuildpackTestTask2 = taskKey[Unit]("Bogus task used by the buildpack tests to ensure it's being run") + +bogusBuildpackTestTask2 := println("Running bogusBuildpackTestTask2...") + +lazy val bogusBuildpackTestTask3 = taskKey[Unit]("Bogus task used by the buildpack tests to ensure it's being run") + +bogusBuildpackTestTask3 := println("Running bogusBuildpackTestTask3...") diff --git a/buildpacks/sbt/test-apps/sbt-1.8.2-scala-2.13.10-no-native-packager/project/build.properties b/buildpacks/sbt/test-apps/sbt-1.8.2-scala-2.13.10-no-native-packager/project/build.properties new file mode 100644 index 00000000..46e43a97 --- /dev/null +++ b/buildpacks/sbt/test-apps/sbt-1.8.2-scala-2.13.10-no-native-packager/project/build.properties @@ -0,0 +1 @@ +sbt.version=1.8.2 diff --git a/buildpacks/sbt/test-apps/sbt-1.8.2-scala-2.13.10-no-native-packager/project/plugins.sbt b/buildpacks/sbt/test-apps/sbt-1.8.2-scala-2.13.10-no-native-packager/project/plugins.sbt new file mode 100644 index 00000000..e69de29b diff --git a/buildpacks/sbt/test-apps/sbt-1.8.2-scala-2.13.10-no-native-packager/src/main/scala/com/heroku/Server.scala b/buildpacks/sbt/test-apps/sbt-1.8.2-scala-2.13.10-no-native-packager/src/main/scala/com/heroku/Server.scala new file mode 100644 index 00000000..02cbe263 --- /dev/null +++ b/buildpacks/sbt/test-apps/sbt-1.8.2-scala-2.13.10-no-native-packager/src/main/scala/com/heroku/Server.scala @@ -0,0 +1,19 @@ +package com.heroku + +import com.twitter.finagle.{Http, Service} +import com.twitter.finagle.http +import com.twitter.util.{Await, Future} + +object Server extends App { + private val service = new Service[http.Request, http.Response] { + def apply(req: http.Request): Future[http.Response] = { + val response = http.Response() + response.setContentString("Hello from Scala!") + + Future.value(response) + } + } + + private val server = Http.serve(sys.env.get("PORT").map(":" + _).get, service) + Await.ready(server) +} diff --git a/buildpacks/sbt/test-apps/sbt-1.8.2-scala-2.13.10-no-native-packager/system.properties b/buildpacks/sbt/test-apps/sbt-1.8.2-scala-2.13.10-no-native-packager/system.properties new file mode 100644 index 00000000..eafd676c --- /dev/null +++ b/buildpacks/sbt/test-apps/sbt-1.8.2-scala-2.13.10-no-native-packager/system.properties @@ -0,0 +1 @@ +java.runtime.version=17 diff --git a/buildpacks/sbt/tests/integration/main.rs b/buildpacks/sbt/tests/integration/main.rs index 53b08e80..75276869 100644 --- a/buildpacks/sbt/tests/integration/main.rs +++ b/buildpacks/sbt/tests/integration/main.rs @@ -8,6 +8,7 @@ use libcnb_test::BuildpackReference; mod caching; +mod sbt_at_launch; mod smoke; mod ux; diff --git a/buildpacks/sbt/tests/integration/sbt_at_launch.rs b/buildpacks/sbt/tests/integration/sbt_at_launch.rs new file mode 100644 index 00000000..6f821fa0 --- /dev/null +++ b/buildpacks/sbt/tests/integration/sbt_at_launch.rs @@ -0,0 +1,67 @@ +use crate::default_buildpacks; +use buildpacks_jvm_shared_test::{ + http_request_backoff, ADDRESS_FOR_PORT_EXPECT_MESSAGE, DEFAULT_INTEGRATION_TEST_BUILDER, + UREQ_RESPONSE_AS_STRING_EXPECT_MESSAGE, UREQ_RESPONSE_RESULT_EXPECT_MESSAGE, +}; +use libcnb_test::{assert_contains, assert_not_contains, BuildConfig, ContainerConfig, TestRunner}; + +/// Users can request to have sbt and all caches to be available at launch. One use-case for this +/// is not using native-packager and wanting to rely on `sbt run` to run the application in prod. +/// +/// This test uses an app that is deployed this way and configures the buildpack accordingly. +#[test] +#[ignore = "integration test"] +fn test_the_thing() { + let build_config = BuildConfig::new( + DEFAULT_INTEGRATION_TEST_BUILDER, + "test-apps/sbt-1.8.2-scala-2.13.10-no-native-packager", + ) + .buildpacks(default_buildpacks()) + .env("SBT_TASKS", "compile") + .env("SBT_AVAILABLE_AT_LAUNCH", "true") + .to_owned(); + + TestRunner::default().build(&build_config, |context| { + context.start_container( + ContainerConfig::default() + .expose_port(PORT) + .env("PORT", PORT.to_string()), + |context| { + let addr = context.address_for_port(PORT).expect(ADDRESS_FOR_PORT_EXPECT_MESSAGE); + + let response = http_request_backoff(|| ureq::get(&format!("http://{addr}")).call()) + .expect(UREQ_RESPONSE_RESULT_EXPECT_MESSAGE) + .into_string() + .expect(UREQ_RESPONSE_AS_STRING_EXPECT_MESSAGE); + + assert_contains!(&response, "Hello from Scala!"); + + // The caches written during the build for sbt, Scala and application dependencies + // are expected to be available at launch as well to avoid duplicate downloads and + // compilation: + + let logs = context.logs_now(); + assert_not_contains!( + &logs.stderr, + "[info] [launcher] getting org.scala-sbt sbt 1.8.2 (this may take some time)..." + ); + assert_not_contains!( + &logs.stderr, + "[info] [launcher] getting Scala 2.12.17 (for sbt)..." + ); + assert_not_contains!( + &logs.stdout, + "[info] Non-compiled module 'compiler-bridge_2.12' for Scala 2.12.17. Compiling..." + ); + assert_not_contains!( + &logs.stdout, + "[info] Non-compiled module 'compiler-bridge_2.13' for Scala 2.13.10. Compiling..." + ); + + assert_not_contains!(&logs.stderr, "Downloading sbt launcher for 1.8.2:"); + }, + ); + }); +} + +const PORT: u16 = 8080; diff --git a/buildpacks/sbt/tests/integration/smoke.rs b/buildpacks/sbt/tests/integration/smoke.rs index 0ffcecd2..cbb8075a 100644 --- a/buildpacks/sbt/tests/integration/smoke.rs +++ b/buildpacks/sbt/tests/integration/smoke.rs @@ -6,13 +6,13 @@ //! These tests are strictly happy-path tests and do not assert any output of the buildpack. use crate::default_buildpacks; -use buildpacks_jvm_shared_test::smoke_test; +use buildpacks_jvm_shared_test::{smoke_test, DEFAULT_INTEGRATION_TEST_BUILDER}; #[test] #[ignore = "integration test"] fn smoke_test_play_framework_2_8_19() { smoke_test( - "heroku/builder:22", + DEFAULT_INTEGRATION_TEST_BUILDER, "test-apps/play-framework-2.8.19", default_buildpacks(), "Welcome to Play!", @@ -23,7 +23,7 @@ fn smoke_test_play_framework_2_8_19() { #[ignore = "integration test"] fn smoke_test_sbt_1_8_2_coursier_scala_2_13_10() { smoke_test( - "heroku/builder:22", + DEFAULT_INTEGRATION_TEST_BUILDER, "test-apps/sbt-1.8.2-coursier-scala-2.13.10", default_buildpacks(), "Hello from Scala!", @@ -34,7 +34,7 @@ fn smoke_test_sbt_1_8_2_coursier_scala_2_13_10() { #[ignore = "integration test"] fn smoke_test_sbt_1_8_2_ivy_scala_2_13_10() { smoke_test( - "heroku/builder:22", + DEFAULT_INTEGRATION_TEST_BUILDER, "test-apps/sbt-1.8.2-ivy-scala-2.13.10", default_buildpacks(), "Hello from Scala!", @@ -45,7 +45,7 @@ fn smoke_test_sbt_1_8_2_ivy_scala_2_13_10() { #[ignore = "integration test"] fn smoke_test_sbt_0_13_16_ivy_scala_2_13_10() { smoke_test( - "heroku/builder:22", + DEFAULT_INTEGRATION_TEST_BUILDER, "test-apps/sbt-0.13.16-scala-2.13.10", default_buildpacks(), "Hello from Scala!", @@ -56,7 +56,7 @@ fn smoke_test_sbt_0_13_16_ivy_scala_2_13_10() { #[ignore = "integration test"] fn smoke_test_getting_started_guide() { smoke_test( - "heroku/builder:22", + DEFAULT_INTEGRATION_TEST_BUILDER, "test-apps/heroku-scala-getting-started", default_buildpacks(), "Getting Started with Scala on Heroku", diff --git a/buildpacks/sbt/tests/integration/ux.rs b/buildpacks/sbt/tests/integration/ux.rs index 9c0a3035..87668054 100644 --- a/buildpacks/sbt/tests/integration/ux.rs +++ b/buildpacks/sbt/tests/integration/ux.rs @@ -1,6 +1,6 @@ use crate::default_buildpacks; use buildpacks_jvm_shared_test::DEFAULT_INTEGRATION_TEST_BUILDER; -use libcnb_test::{assert_not_contains, BuildConfig, TestRunner}; +use libcnb_test::{assert_contains, assert_not_contains, BuildConfig, PackResult, TestRunner}; /// Tests that no confusing or non-actionable warnings caused by the buildpack are shown in the /// sbt 1.x log during build. @@ -21,3 +21,30 @@ fn test_sbt_1_x_logging() { ); }); } + +/// The buildpack requires (unless otherwise configured) that the application build defines a +/// `stage` task. That task is not a default sbt task but is usually added by sbt-native-packager. +/// +/// To guide new users that might not be aware that they need a `stage` task, we need to output a +/// descriptive message that explains the issue instead of only relying on sbt telling the user +/// that the `stage` task could not be found. +#[test] +#[ignore = "integration test"] +fn test_missing_stage_task_logging() { + let build_config = BuildConfig::new( + DEFAULT_INTEGRATION_TEST_BUILDER, + "test-apps/sbt-1.8.2-scala-2.13.10-no-native-packager", + ) + .buildpacks(default_buildpacks()) + .expected_pack_result(PackResult::Failure) + .to_owned(); + + TestRunner::default().build(&build_config, |context| { + assert_contains!(&context.pack_stdout, "[error] Not a valid key: stage"); + + assert_contains!( + &context.pack_stderr, + "It looks like your build.sbt does not have a valid 'stage' task." + ); + }); +} diff --git a/shared-test/src/lib.rs b/shared-test/src/lib.rs index 05b3ce19..e32f2e2d 100644 --- a/shared-test/src/lib.rs +++ b/shared-test/src/lib.rs @@ -36,37 +36,43 @@ pub fn start_container_assert_basic_http_response( "http://{}", context .address_for_port(PORT) - .expect("address for container port should be available from libcnb-test") + .expect(ADDRESS_FOR_PORT_EXPECT_MESSAGE) ); - let backoff = exponential_backoff::Backoff::new( - 32, - Duration::from_secs(10), - Duration::from_secs(5 * 60), - ); - - let mut last_response = None; - for delay in backoff.iter() { - std::thread::sleep(delay); - - match ureq::get(&url).call() { - Err(_) => continue, - Ok(response) => { - last_response = Some(response); - break; - } - } - } - - let response_body = last_response - .and_then(|response| response.into_string().ok()) - .expect("response body should be available"); + let response_body = http_request_backoff(|| ureq::get(&url).call()) + .expect(UREQ_RESPONSE_RESULT_EXPECT_MESSAGE) + .into_string() + .expect(UREQ_RESPONSE_AS_STRING_EXPECT_MESSAGE); assert_contains!(&response_body, expected_http_response_body_contains); }, ); } +#[allow(clippy::missing_errors_doc)] +pub fn http_request_backoff(request_fn: F) -> Result +where + F: Fn() -> Result, +{ + let backoff = + exponential_backoff::Backoff::new(32, Duration::from_secs(1), Duration::from_secs(5 * 60)); + + let mut backoff_durations = backoff.into_iter(); + + loop { + match request_fn() { + result @ Ok(_) => return result, + result @ Err(_) => match backoff_durations.next() { + None => return result, + Some(backoff_duration) => { + std::thread::sleep(backoff_duration); + continue; + } + }, + } + } +} + /// Opinionated helper for smoke-testing JVM buildpacks. /// /// Builds the app with the given buildpacks, asserts that the build finished successfully and @@ -101,4 +107,12 @@ pub fn smoke_test( pub const DEFAULT_INTEGRATION_TEST_BUILDER: &str = "heroku/builder:22"; +pub const ADDRESS_FOR_PORT_EXPECT_MESSAGE: &str = + "address for container port should be available from libcnb-test"; + +pub const UREQ_RESPONSE_RESULT_EXPECT_MESSAGE: &str = "http request should be successful"; + +pub const UREQ_RESPONSE_AS_STRING_EXPECT_MESSAGE: &str = + "http response body should be convertable to a string"; + const PORT: u16 = 8080;