diff --git a/Cargo.lock b/Cargo.lock index c571ba0..56ca303 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -516,9 +516,9 @@ checksum = "d2fabcfbdc87f4758337ca535fb41a6d701b65693ce38287d856d1674551ec9b" [[package]] name = "globset" -version = "0.4.14" +version = "0.4.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57da3b9b5b85bd66f31093f8c408b90a74431672542466497dcbdfdc02034be1" +checksum = "15f1ce686646e7f1e19bf7d5533fe443a45dbfb990e00629110797578b42fb19" dependencies = [ "aho-corasick", "bstr", @@ -622,9 +622,9 @@ dependencies = [ [[package]] name = "ignore" -version = "0.4.22" +version = "0.4.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b46810df39e66e925525d6e38ce1e7f6e1d208f72dc39757880fcb66e2c58af1" +checksum = "6d89fd380afde86567dfba715db065673989d6253f42b88179abd3eae47bda4b" dependencies = [ "crossbeam-deque", "globset", @@ -711,13 +711,13 @@ dependencies = [ [[package]] name = "libcnb" -version = "0.22.0" +version = "0.25.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8627f5e9c7116a1deddb30a1c9ae79bdf2b5677c6948f21d662d6185a867951c" +checksum = "545de2d94471f534c49a2e8c7baaf1e0a3a2ba3e61632e7e367a1b6b37453df6" dependencies = [ - "libcnb-common 0.22.0", - "libcnb-data 0.22.0", - "libcnb-proc-macros 0.22.0", + "libcnb-common 0.25.0", + "libcnb-data 0.25.0", + "libcnb-proc-macros 0.25.0", "serde", "thiserror", "toml", @@ -736,9 +736,9 @@ dependencies = [ [[package]] name = "libcnb-common" -version = "0.22.0" +version = "0.25.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4862a2ef99cfcaabcb1e84e6c3babe2c281bd1328295a49ee1a2b14aed2fea9" +checksum = "b2ffd438b74436cda55c229df0a3cbf1161643d6d572aa49614702fab11b7c3f" dependencies = [ "serde", "thiserror", @@ -761,12 +761,12 @@ dependencies = [ [[package]] name = "libcnb-data" -version = "0.22.0" +version = "0.25.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b6c2902672cc78276832bc57ca7013d7ed037067d203f34edce3d77981e8762" +checksum = "a31f4a9a5369004c1bc2ab3bc00ccc8379bb4ad7afdc052e215fb608ba101583" dependencies = [ "fancy-regex", - "libcnb-proc-macros 0.22.0", + "libcnb-proc-macros 0.25.0", "serde", "thiserror", "toml", @@ -775,15 +775,15 @@ dependencies = [ [[package]] name = "libcnb-package" -version = "0.22.0" +version = "0.25.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed93a039d62d0840d55112b5873a00faa17201c6e36342e4e1e8cbab151267ab" +checksum = "e20ab5ac21894450f28099b698175c57deb8566498f0b9d2d6f77359b04f78fb" dependencies = [ "cargo_metadata", "ignore", "indoc", - "libcnb-common 0.22.0", - "libcnb-data 0.22.0", + "libcnb-common 0.25.0", + "libcnb-data 0.25.0", "petgraph", "thiserror", "uriparse", @@ -804,9 +804,9 @@ dependencies = [ [[package]] name = "libcnb-proc-macros" -version = "0.22.0" +version = "0.25.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e5d7c591359e5b24c0cea9bfc4bbd929cbf2c7c1a20b677258aa8fe104cbb1db" +checksum = "25e64d8020861920c69802d712684d900393b50a5605c92eb21fc8e4580368bb" dependencies = [ "cargo_metadata", "fancy-regex", @@ -816,14 +816,14 @@ dependencies = [ [[package]] name = "libcnb-test" -version = "0.22.0" +version = "0.25.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "95d9be983eb6527dbe6830c63e10f97a06705f9d1f45c91964e98aa86d48f9f2" +checksum = "5a874169e98674d20662ca09c7c3520c9cdee99f8c4e3c3e31d1e5e3c075321b" dependencies = [ "fastrand", "fs_extra", - "libcnb-common 0.22.0", - "libcnb-data 0.22.0", + "libcnb-common 0.25.0", + "libcnb-data 0.25.0", "libcnb-package", "regex", "tempfile", @@ -1146,9 +1146,9 @@ dependencies = [ [[package]] name = "regex" -version = "1.10.6" +version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4219d74c6b67a3654a9fbebc4b419e22126d13d2f3c4a07ee0cb61ff79a79619" +checksum = "38200e5ee88914975b69f657f0801b6f6dccafd44fd9326302a4aaeecfacb1d8" dependencies = [ "aho-corasick", "memchr", @@ -1158,9 +1158,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.7" +version = "0.4.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38caf58cc5ef2fed281f89292ef23f6365465ed9a41b7a7754eb4e26496c92df" +checksum = "368758f23274712b504848e9d5a6f010445cc8b87a7cdb4d7cbee666c1288da3" dependencies = [ "aho-corasick", "memchr", @@ -1169,9 +1169,9 @@ dependencies = [ [[package]] name = "regex-syntax" -version = "0.8.4" +version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a66a03ae7c801facd77a29370b4faec201768915ac14a721ba36f20bc9c209b" +checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" [[package]] name = "ring" @@ -1190,9 +1190,9 @@ dependencies = [ [[package]] name = "rustix" -version = "0.38.35" +version = "0.38.37" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a85d50532239da68e9addb745ba38ff4612a242c1c7ceea689c4bc7c2f43c36f" +checksum = "8acb788b847c24f28525660c4d7758620a7210875711f79e7f663cc152726811" dependencies = [ "bitflags", "errno", @@ -1259,9 +1259,9 @@ dependencies = [ [[package]] name = "serde" -version = "1.0.209" +version = "1.0.213" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "99fce0ffe7310761ca6bf9faf5115afbc19688edd00171d81b1bb1b116c63e09" +checksum = "3ea7893ff5e2466df8d720bb615088341b295f849602c6956047f8f80f0e9bc1" dependencies = [ "serde_derive", ] @@ -1280,9 +1280,9 @@ dependencies = [ [[package]] name = "serde_derive" -version = "1.0.209" +version = "1.0.213" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5831b979fd7b5439637af1752d535ff49f4860c0f341d1baeb6faf0f4242170" +checksum = "7e85ad2009c50b58e87caa8cd6dac16bdf511bbfb7af6c33df902396aa480fa5" dependencies = [ "proc-macro2", "quote", @@ -1349,7 +1349,7 @@ dependencies = [ "commons", "indexmap", "indoc", - "libcnb 0.22.0", + "libcnb 0.25.0", "libcnb-test", "libherokubuildpack 0.22.0", "serde", @@ -1365,7 +1365,7 @@ dependencies = [ name = "static_web_server_utils" version = "0.0.0" dependencies = [ - "libcnb 0.22.0", + "libcnb 0.25.0", "libherokubuildpack 0.22.0", "toml", ] @@ -1378,9 +1378,9 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" [[package]] name = "syn" -version = "2.0.77" +version = "2.0.85" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f35bcdf61fd8e7be6caf75f429fdca8beb3ed76584befb503b1569faee373ed" +checksum = "5023162dfcd14ef8f32034d8bcd4cc5ddc61ef7a247c024a33e24e1f24d21b56" dependencies = [ "proc-macro2", "quote", @@ -1399,9 +1399,9 @@ dependencies = [ [[package]] name = "tempfile" -version = "3.12.0" +version = "3.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04cbcdd0c794ebb0d4cf35e88edd2f7d2c4c3e9a5a6dab322839b321c6a87a64" +checksum = "f0f2c9fc62d0beef6951ccffd757e241266a2c833136efbe35af6cd2567dca5b" dependencies = [ "cfg-if", "fastrand", @@ -1423,25 +1423,25 @@ dependencies = [ name = "test_support" version = "0.0.0" dependencies = [ - "libcnb 0.22.0", + "libcnb 0.25.0", "libcnb-test", "ureq", ] [[package]] name = "thiserror" -version = "1.0.63" +version = "1.0.65" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0342370b38b6a11b6cc11d6a805569958d54cfa061a29969c3b5ce2ea405724" +checksum = "5d11abd9594d9b38965ef50805c5e469ca9cc6f197f883f717e0269a3057b3d5" dependencies = [ "thiserror-impl", ] [[package]] name = "thiserror-impl" -version = "1.0.63" +version = "1.0.65" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4558b58466b9ad7ca0f102865eccc95938dca1a74a856f2b57b6629050da261" +checksum = "ae71770322cbd277e69d762a16c444af02aa0575ac0d174f0b9562d3b37f8602" dependencies = [ "proc-macro2", "quote", @@ -1594,6 +1594,16 @@ version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "86bd8d4e895da8537e5315b8254664e6b769c4ff3db18321b297a1e7004392e3" +[[package]] +name = "uuid" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8c5f0a0af699448548ad1a2fbf920fb4bee257eae39953ba95cb84891a0446a" +dependencies = [ + "getrandom", + "serde", +] + [[package]] name = "vcpkg" version = "0.2.15" @@ -1693,11 +1703,14 @@ dependencies = [ "commons", "heroku-nodejs-utils", "indoc", - "libcnb 0.22.0", + "libcnb 0.25.0", "libcnb-test", "libherokubuildpack 0.22.0", + "tempfile", "test_support", "toml", + "ureq", + "uuid", ] [[package]] @@ -1706,7 +1719,7 @@ version = "0.0.0" dependencies = [ "commons", "indoc", - "libcnb 0.22.0", + "libcnb 0.25.0", "libcnb-test", "libherokubuildpack 0.22.0", "static_web_server_utils", diff --git a/buildpacks/static-web-server/Cargo.toml b/buildpacks/static-web-server/Cargo.toml index dc76813..2f94ed3 100644 --- a/buildpacks/static-web-server/Cargo.toml +++ b/buildpacks/static-web-server/Cargo.toml @@ -7,7 +7,7 @@ edition.workspace = true workspace = true [dependencies] -libcnb = "0.22.0" +libcnb = "=0.25.0" commons_ruby = { git = "https://github.com/heroku/buildpacks-ruby", branch = "main", package = "commons" } libherokubuildpack = { version = "=0.22.0", default-features = false, features = ["download", "fs", "log", "tar", "toml"] } indoc = "2" @@ -20,5 +20,5 @@ ureq = "2" indexmap = "2" [dev-dependencies] -libcnb-test = "=0.22.0" +libcnb-test = "=0.25.0" test_support.workspace = true diff --git a/buildpacks/static-web-server/src/caddy_config.rs b/buildpacks/static-web-server/src/caddy_config.rs index d18b258..18f459f 100644 --- a/buildpacks/static-web-server/src/caddy_config.rs +++ b/buildpacks/static-web-server/src/caddy_config.rs @@ -1,11 +1,9 @@ use crate::errors::StaticWebServerBuildpackError; -use crate::errors::StaticWebServerBuildpackError::CannotReadCustom404File; use crate::heroku_web_server_config::{ ErrorsConfig, Header, HerokuWebServerConfig, DEFAULT_DOC_INDEX, DEFAULT_DOC_ROOT, }; use indexmap::IndexMap; use serde::{Deserialize, Serialize}; -use std::fs; use std::path::{Path, PathBuf}; #[derive(Serialize, Deserialize, Debug)] @@ -63,6 +61,7 @@ pub(crate) struct MatchPath { pub(crate) enum CaddyHTTPServerRouteHandler { FileServer(FileServer), Headers(Headers), + Rewrite(Rewrite), StaticResponse(StaticResponse), } @@ -73,6 +72,7 @@ pub(crate) struct FileServer { pub(crate) root: String, pub(crate) index_names: Vec, pub(crate) pass_thru: bool, + pub(crate) status_code: Option, } // https://caddyserver.com/docs/json/apps/http/servers/routes/handle/headers/ @@ -88,6 +88,13 @@ pub(crate) struct HeadersResponse { pub(crate) deferred: bool, } +// https://caddyserver.com/docs/json/apps/http/servers/routes/handle/rewrite/ +#[derive(Serialize, Deserialize, Debug)] +pub(crate) struct Rewrite { + pub(crate) handler: String, + pub(crate) uri: Option, +} + // https://caddyserver.com/docs/json/apps/http/servers/routes/handle/static_response/ #[derive(Serialize, Deserialize, Debug)] pub(crate) struct StaticResponse { @@ -127,13 +134,17 @@ impl TryFrom for CaddyConfig { handle: vec![CaddyHTTPServerRouteHandler::FileServer(FileServer { handler: "file_server".to_owned(), root: doc_root.to_string_lossy().to_string(), - index_names: vec![doc_index], - // Any not found request paths continue to the next handler. + index_names: vec![doc_index.clone()], pass_thru: true, + status_code: None, })], }); - routes.push(generate_error_404_route(&doc_root, &value.errors)?); + routes.push(generate_error_404_route( + &doc_root, + &doc_index, + &value.errors, + )?); // Assemble into the caddy.json structure // https://caddyserver.com/docs/json/ @@ -193,6 +204,7 @@ fn generate_response_headers_routes(headers: &Vec
) -> Vec, ) -> Result { let error_config = errors @@ -204,53 +216,59 @@ fn generate_error_404_route( .map_or(404, |error| error.status.unwrap_or(404)) .to_string(); - let not_found_response_content = error_config + let not_found_response_handlers = error_config .as_ref() .map(|error| error.file_path.clone()) .map_or( { - let default = r#" - - - - 404 Not Found - - -

404 Not Found

- - "#; - - Ok(String::from(default)) + Ok(vec![CaddyHTTPServerRouteHandler::StaticResponse( + StaticResponse { + handler: "static_response".to_owned(), + status_code: status_code.clone(), + headers: Some({ + let mut h = serde_json::Map::new(); + h.insert( + "Content-Type".to_string(), + serde_json::Value::Array(vec![serde_json::Value::String( + "text/html".to_string(), + )]), + ); + h + }), + body: r#" + + + + 404 Not Found + + +

404 Not Found

+ + "# + .to_string(), + }, + )]) }, |file| { - let content = fs::read_to_string(doc_root.join(file)).map_err(CannotReadCustom404File)?; - if status_code == "404" { - Ok(content) - } else { - Ok(format!("\n{content}")) - } + Ok(vec![ + CaddyHTTPServerRouteHandler::Rewrite(Rewrite { + handler: "rewrite".to_owned(), + uri: Some(file.to_string_lossy().to_string()), + }), + CaddyHTTPServerRouteHandler::FileServer(FileServer { + handler: "file_server".to_owned(), + root: doc_root.to_string_lossy().to_string(), + status_code: Some(status_code.clone()), + index_names: vec![doc_index.to_string()], + pass_thru: false, + }), + ]) }, )?; Ok(CaddyHTTPServerRoute { r#match: None, - handle: vec![CaddyHTTPServerRouteHandler::StaticResponse( - StaticResponse { - handler: "static_response".to_owned(), - status_code: status_code.clone(), - headers: Some({ - let mut h = serde_json::Map::new(); - h.insert( - "Content-Type".to_string(), - serde_json::Value::Array(vec![serde_json::Value::String( - "text/html".to_string(), - )]), - ); - h - }), - body: not_found_response_content.clone(), - }, - )], + handle: not_found_response_handlers, }) } @@ -258,7 +276,6 @@ fn generate_error_404_route( mod tests { use super::*; use crate::heroku_web_server_config::{ErrorConfig, ErrorsConfig}; - use libherokubuildpack::log::log_info; use std::path::PathBuf; #[test] @@ -412,6 +429,7 @@ mod tests { #[test] fn generates_custom_404_error_route() { let doc_root = PathBuf::from("tests/fixtures/custom_errors/public"); + let doc_index = "index.html".to_string(); let heroku_config = HerokuWebServerConfig { errors: Some(ErrorsConfig { @@ -423,31 +441,46 @@ mod tests { ..HerokuWebServerConfig::default() }; - let routes = generate_error_404_route(&doc_root, &heroku_config.errors).unwrap(); + let routes = + generate_error_404_route(&doc_root, &doc_index, &heroku_config.errors).unwrap(); - let CaddyHTTPServerRouteHandler::StaticResponse(generated_handler) = &routes.handle[0] + let CaddyHTTPServerRouteHandler::Rewrite(generated_rewrite_handler) = &routes.handle[0] else { unreachable!() }; assert_eq!( - generated_handler.handler, "static_response", - "should be a static_response route" + generated_rewrite_handler.handler, "rewrite", + "should be a rewrite handler" + ); + + assert_eq!( + generated_rewrite_handler.uri, + Some("error-404.html".to_string()), + "should rewrite to error-404.html" ); - let generated_status = &generated_handler.status_code; - assert!(generated_status.eq("404"), "status_code should by 404"); + let CaddyHTTPServerRouteHandler::FileServer(generated_fs_handler) = &routes.handle[1] + else { + unreachable!() + }; + + assert_eq!( + generated_fs_handler.handler, "file_server", + "should be a file_server handler" + ); - let generated_body = &generated_handler.body; + let generated_status = &generated_fs_handler.status_code; assert!( - generated_body.contains("Custom 404"), - "body should contain Custom 404" + generated_status.eq(&Some("404".to_string())), + "status_code should be 404" ); } #[test] fn generates_custom_404_to_200_error_route() { let doc_root = PathBuf::from("tests/fixtures/client_side_routing/public"); + let doc_index = "index.html".to_string(); let heroku_config = HerokuWebServerConfig { errors: Some(ErrorsConfig { @@ -459,49 +492,39 @@ mod tests { ..HerokuWebServerConfig::default() }; - let routes = generate_error_404_route(&doc_root, &heroku_config.errors).unwrap(); + let routes = + generate_error_404_route(&doc_root, &doc_index, &heroku_config.errors).unwrap(); - let CaddyHTTPServerRouteHandler::StaticResponse(generated_handler) = &routes.handle[0] + let CaddyHTTPServerRouteHandler::Rewrite(generated_rewrite_handler) = &routes.handle[0] else { unreachable!() }; assert_eq!( - generated_handler.handler, "static_response", - "should be a static_response route" + generated_rewrite_handler.handler, "rewrite", + "should be a rewrite handler" ); - let generated_status = &generated_handler.status_code; - assert!(generated_status.eq("200"), "status_code should by 200"); - - let generated_body = &generated_handler.body; - assert!( - generated_body.contains("Client Side Routing Test"), - "body should contain Client Side Routing Test" + assert_eq!( + generated_rewrite_handler.uri, + Some("index.html".to_string()), + "should rewrite to index.html" ); - } - #[test] - fn missing_custom_404_error_file() { - let doc_root = PathBuf::from(DEFAULT_DOC_ROOT); - - let heroku_config = HerokuWebServerConfig { - errors: Some(ErrorsConfig { - custom_404_page: Some(ErrorConfig { - file_path: PathBuf::from("non-existent-path"), - status: None, - }), - }), - ..HerokuWebServerConfig::default() + let CaddyHTTPServerRouteHandler::FileServer(generated_fs_handler) = &routes.handle[1] + else { + unreachable!() }; - match generate_error_404_route(&doc_root, &heroku_config.errors) { - Ok(_) => { - panic!("should fail to find custom 404 file"); - } - Err(e) => { - log_info(format!("Missing 404 file error: {e:?}")); - } - }; + assert_eq!( + generated_fs_handler.handler, "file_server", + "should be a file_server handler" + ); + + let generated_status = &generated_fs_handler.status_code; + assert!( + generated_status.eq(&Some("200".to_string())), + "status_code should be 200" + ); } } diff --git a/buildpacks/static-web-server/src/errors.rs b/buildpacks/static-web-server/src/errors.rs index cda6808..7a02c92 100644 --- a/buildpacks/static-web-server/src/errors.rs +++ b/buildpacks/static-web-server/src/errors.rs @@ -18,7 +18,6 @@ pub(crate) enum StaticWebServerBuildpackError { CannotParseHerokuWebServerConfiguration(toml::de::Error), CannotReadProjectToml(TomlFileError), CannotWriteCaddyConfiguration(std::io::Error), - CannotReadCustom404File(std::io::Error), CannotUnpackCaddyTarball(std::io::Error), CannotCreateCaddyInstallationDir(std::io::Error), CannotCreateCaddyTarballFile(std::io::Error), @@ -43,29 +42,38 @@ fn on_buildpack_error(error: StaticWebServerBuildpackError, logger: Box { on_config_error(&e, logger); } - StaticWebServerBuildpackError::CannotWriteCaddyConfiguration(error) - | StaticWebServerBuildpackError::CannotReadCustom404File(error) - | StaticWebServerBuildpackError::CannotUnpackCaddyTarball(error) - | StaticWebServerBuildpackError::CannotCreateCaddyInstallationDir(error) - | StaticWebServerBuildpackError::CannotCreateCaddyTarballFile(error) => { - on_unexpected_io_error(&error, logger); + StaticWebServerBuildpackError::CannotWriteCaddyConfiguration(error) => { + print_error_details(logger, &error) + .announce() + .error(&formatdoc! {" + Cannot write Caddy configuration for {buildpack_name} + ", buildpack_name = fmt::value(BUILDPACK_NAME) }); + } + StaticWebServerBuildpackError::CannotUnpackCaddyTarball(error) => { + print_error_details(logger, &error) + .announce() + .error(&formatdoc! {" + Cannot unpack Caddy tarball for {buildpack_name} + ", buildpack_name = fmt::value(BUILDPACK_NAME) }); + } + StaticWebServerBuildpackError::CannotCreateCaddyInstallationDir(error) => { + print_error_details(logger, &error) + .announce() + .error(&formatdoc! {" + Cannot create Caddy installation directory for {buildpack_name} + ", buildpack_name = fmt::value(BUILDPACK_NAME) }); + } + StaticWebServerBuildpackError::CannotCreateCaddyTarballFile(error) => { + print_error_details(logger, &error) + .announce() + .error(&formatdoc! {" + Cannot create Caddy tarball file for {buildpack_name} + ", buildpack_name = fmt::value(BUILDPACK_NAME) }); } StaticWebServerBuildpackError::BuildCommandFailed(e) => on_build_command_error(&e, logger), } } -fn on_unexpected_io_error(error: &std::io::Error, logger: Box) { - print_error_details(logger, &error) - .announce() - .error(&formatdoc! {" - Unexpected IO Error - - An unexpected IO error occurred. Please try again. - - {SUBMIT_AN_ISSUE} - "}); -} - fn on_download_error( error: &libherokubuildpack::download::DownloadError, logger: Box, diff --git a/buildpacks/static-web-server/tests/integration_test.rs b/buildpacks/static-web-server/tests/integration_test.rs index a715d4f..14b2105 100644 --- a/buildpacks/static-web-server/tests/integration_test.rs +++ b/buildpacks/static-web-server/tests/integration_test.rs @@ -2,7 +2,7 @@ #![allow(unused_crate_dependencies)] #![allow(clippy::unwrap_used)] -use libcnb_test::assert_contains; +use libcnb_test::{assert_contains, ContainerConfig}; use test_support::{ assert_web_response, retry, start_container, static_web_server_integration_test, DEFAULT_RETRIES, DEFAULT_RETRY_DELAY, @@ -13,30 +13,34 @@ use test_support::{ fn build_command() { static_web_server_integration_test("./fixtures/build_command", |ctx| { assert_contains!(ctx.pack_stdout, "Static Web Server"); - start_container(&ctx, |_container, socket_addr| { - // Test for successful response - let response = retry(DEFAULT_RETRIES, DEFAULT_RETRY_DELAY, || { - ureq::get(&format!("http://{socket_addr}/")).call() - }) - .unwrap(); - let response_status = response.status(); - assert_eq!(response_status, 200); - let response_body = response.into_string().unwrap(); - assert_contains!( - response_body, - "Welcome to CNB Static Web Server Build Command Test!" - ); + start_container( + &ctx, + &mut ContainerConfig::new(), + |_container, socket_addr| { + // Test for successful response + let response = retry(DEFAULT_RETRIES, DEFAULT_RETRY_DELAY, || { + ureq::get(&format!("http://{socket_addr}/")).call() + }) + .unwrap(); + let response_status = response.status(); + assert_eq!(response_status, 200); + let response_body = response.into_string().unwrap(); + assert_contains!( + response_body, + "Welcome to CNB Static Web Server Build Command Test!" + ); - // Test for default Not Found response - let response = retry(DEFAULT_RETRIES, DEFAULT_RETRY_DELAY, || { - ureq::get(&format!("http://{socket_addr}/test-output.txt")).call() - }) - .unwrap(); - let response_status = response.status(); - assert_eq!(response_status, 200); - let response_body = response.into_string().unwrap(); - assert_contains!(response_body, "Build Command Output Test!"); - }); + // Test for default Not Found response + let response = retry(DEFAULT_RETRIES, DEFAULT_RETRY_DELAY, || { + ureq::get(&format!("http://{socket_addr}/test-output.txt")).call() + }) + .unwrap(); + let response_status = response.status(); + assert_eq!(response_status, 200); + let response_body = response.into_string().unwrap(); + assert_contains!(response_body, "Build Command Output Test!"); + }, + ); }); } @@ -63,45 +67,49 @@ fn custom_index() { fn top_level_doc_root() { static_web_server_integration_test("./fixtures/top_level_doc_root", |ctx| { assert_contains!(ctx.pack_stdout, "Static Web Server"); - start_container(&ctx, |_container, socket_addr| { - let response_result = retry(DEFAULT_RETRIES, DEFAULT_RETRY_DELAY, || { - ureq::get(&format!("http://{socket_addr}/")).call() - }); - match response_result { - Ok(response) => { - assert_eq!(response.status(), 200); - let h = response.header("Content-Type").unwrap_or_default(); - assert_contains!(h, "text/html"); - let response_body = response.into_string().unwrap(); - assert_contains!( - response_body, - "Welcome to CNB Static Web Server Top-level Doc Root Test!" - ); + start_container( + &ctx, + &mut ContainerConfig::new(), + |_container, socket_addr| { + let response_result = retry(DEFAULT_RETRIES, DEFAULT_RETRY_DELAY, || { + ureq::get(&format!("http://{socket_addr}/")).call() + }); + match response_result { + Ok(response) => { + assert_eq!(response.status(), 200); + let h = response.header("Content-Type").unwrap_or_default(); + assert_contains!(h, "text/html"); + let response_body = response.into_string().unwrap(); + assert_contains!( + response_body, + "Welcome to CNB Static Web Server Top-level Doc Root Test!" + ); + } + Err(error) => { + panic!("should respond 200 ok, but got other error: {error:?}"); + } } - Err(error) => { - panic!("should respond 200 ok, but got other error: {error:?}"); - } - } - let response_result = retry(DEFAULT_RETRIES, DEFAULT_RETRY_DELAY, || { - ureq::get(&format!("http://{socket_addr}/non-existent-path")).call() - }); - match response_result { - Err(ureq::Error::Status(code, response)) => { - assert_eq!(code, 404); - let h = response.header("Content-Type").unwrap_or_default(); - assert_contains!(h, "text/html"); - let response_body = response.into_string().unwrap(); - assert_contains!(response_body, "Custom 404"); - } - Ok(_) => { - panic!("should respond 404 Not Found, but got 200 ok"); - } - Err(error) => { - panic!("should respond 404 Not Found, but got other error: {error:?}"); + let response_result = retry(DEFAULT_RETRIES, DEFAULT_RETRY_DELAY, || { + ureq::get(&format!("http://{socket_addr}/non-existent-path")).call() + }); + match response_result { + Err(ureq::Error::Status(code, response)) => { + assert_eq!(code, 404); + let h = response.header("Content-Type").unwrap_or_default(); + assert_contains!(h, "text/html"); + let response_body = response.into_string().unwrap(); + assert_contains!(response_body, "Custom 404"); + } + Ok(_) => { + panic!("should respond 404 Not Found, but got 200 ok"); + } + Err(error) => { + panic!("should respond 404 Not Found, but got other error: {error:?}"); + } } - } - }); + }, + ); }); } @@ -110,35 +118,39 @@ fn top_level_doc_root() { fn custom_headers() { static_web_server_integration_test("./fixtures/custom_headers", |ctx| { assert_contains!(ctx.pack_stdout, "Static Web Server"); - start_container(&ctx, |_container, socket_addr| { - let response = retry(DEFAULT_RETRIES, DEFAULT_RETRY_DELAY, || { - ureq::get(&format!("http://{socket_addr}/")).call() - }) - .unwrap(); - let h = response.header("X-Global").unwrap_or_default(); - assert_contains!(h, "Hello"); - let h = response.header("X-Only-Default").unwrap_or_default(); - assert_contains!(h, "Hiii"); - assert!( - !response - .headers_names() - .contains(&String::from("X-Only-HTML")), - "should not include X-Only-HTML header" - ); + start_container( + &ctx, + &mut ContainerConfig::new(), + |_container, socket_addr| { + let response = retry(DEFAULT_RETRIES, DEFAULT_RETRY_DELAY, || { + ureq::get(&format!("http://{socket_addr}/")).call() + }) + .unwrap(); + let h = response.header("X-Global").unwrap_or_default(); + assert_contains!(h, "Hello"); + let h = response.header("X-Only-Default").unwrap_or_default(); + assert_contains!(h, "Hiii"); + assert!( + !response + .headers_names() + .contains(&String::from("X-Only-HTML")), + "should not include X-Only-HTML header" + ); - let response = retry(DEFAULT_RETRIES, DEFAULT_RETRY_DELAY, || { - ureq::get(&format!("http://{socket_addr}/page2.html")).call() - }) - .unwrap(); - let h = response.header("X-Only-HTML").unwrap_or_default(); - assert_contains!(h, "Hi"); - assert!( - !response - .headers_names() - .contains(&String::from("X-Only-Default")), - "should not include X-Only-Default header" - ); - }); + let response = retry(DEFAULT_RETRIES, DEFAULT_RETRY_DELAY, || { + ureq::get(&format!("http://{socket_addr}/page2.html")).call() + }) + .unwrap(); + let h = response.header("X-Only-HTML").unwrap_or_default(); + assert_contains!(h, "Hi"); + assert!( + !response + .headers_names() + .contains(&String::from("X-Only-Default")), + "should not include X-Only-Default header" + ); + }, + ); }); } @@ -147,26 +159,30 @@ fn custom_headers() { fn custom_errors() { static_web_server_integration_test("./fixtures/custom_errors", |ctx| { assert_contains!(ctx.pack_stdout, "Static Web Server"); - start_container(&ctx, |_container, socket_addr| { - let response_result = retry(DEFAULT_RETRIES, DEFAULT_RETRY_DELAY, || { - ureq::get(&format!("http://{socket_addr}/non-existent-path")).call() - }); - match response_result { - Err(ureq::Error::Status(code, response)) => { - assert_eq!(code, 404); - let h = response.header("Content-Type").unwrap_or_default(); - assert_contains!(h, "text/html"); - let response_body = response.into_string().unwrap(); - assert_contains!(response_body, "Custom 404"); + start_container( + &ctx, + &mut ContainerConfig::new(), + |_container, socket_addr| { + let response_result = retry(DEFAULT_RETRIES, DEFAULT_RETRY_DELAY, || { + ureq::get(&format!("http://{socket_addr}/non-existent-path")).call() + }); + match response_result { + Err(ureq::Error::Status(code, response)) => { + assert_eq!(code, 404); + let h = response.header("Content-Type").unwrap_or_default(); + assert_contains!(h, "text/html"); + let response_body = response.into_string().unwrap(); + assert_contains!(response_body, "Custom 404"); + } + Ok(_) => { + panic!("should respond 404 Not Found, but got 200 ok"); + } + Err(error) => { + panic!("should respond 404 Not Found, but got other error: {error:?}"); + } } - Ok(_) => { - panic!("should respond 404 Not Found, but got 200 ok"); - } - Err(error) => { - panic!("should respond 404 Not Found, but got other error: {error:?}"); - } - } - }); + }, + ); }); } @@ -175,26 +191,29 @@ fn custom_errors() { fn client_side_routing() { static_web_server_integration_test("./fixtures/client_side_routing", |ctx| { assert_contains!(ctx.pack_stdout, "Static Web Server"); - start_container(&ctx, |_container, socket_addr| { - let response_result = retry(DEFAULT_RETRIES, DEFAULT_RETRY_DELAY, || { - ureq::get(&format!("http://{socket_addr}/non-existent-path")).call() - }); - match response_result { - Ok(response) => { - assert_eq!(response.status(), 200); - let h = response.header("Content-Type").unwrap_or_default(); - assert_contains!(h, "text/html"); - let response_body = response.into_string().unwrap(); - assert_contains!(response_body, "actually a Not Found response"); - assert_contains!( - response_body, - "Welcome to CNB Static Web Server Client Side Routing Test!" - ); - } - Err(error) => { - panic!("should respond 200 ok, but got other error: {error:?}"); + start_container( + &ctx, + &mut ContainerConfig::new(), + |_container, socket_addr| { + let response_result = retry(DEFAULT_RETRIES, DEFAULT_RETRY_DELAY, || { + ureq::get(&format!("http://{socket_addr}/non-existent-path")).call() + }); + match response_result { + Ok(response) => { + assert_eq!(response.status(), 200); + let h = response.header("Content-Type").unwrap_or_default(); + assert_contains!(h, "text/html"); + let response_body = response.into_string().unwrap(); + assert_contains!( + response_body, + "Welcome to CNB Static Web Server Client Side Routing Test!" + ); + } + Err(error) => { + panic!("should respond 200 ok, but got other error: {error:?}"); + } } - } - }); + }, + ); }); } diff --git a/buildpacks/website-ember/Cargo.toml b/buildpacks/website-ember/Cargo.toml index edd45db..3e7fe52 100644 --- a/buildpacks/website-ember/Cargo.toml +++ b/buildpacks/website-ember/Cargo.toml @@ -7,7 +7,7 @@ edition.workspace = true workspace = true [dependencies] -libcnb = "0.22.0" +libcnb = "=0.25.0" commons_ruby = { git = "https://github.com/heroku/buildpacks-ruby", branch = "main", package = "commons" } heroku_nodejs_utils = { git = "https://github.com/heroku/buildpacks-nodejs", branch = "main", package = "heroku-nodejs-utils" } libherokubuildpack = { version = "=0.22.0", default-features = false, features = ["fs", "log"] } @@ -15,5 +15,8 @@ indoc = "2" toml = { version = "0.8", features = ["preserve_order"] } [dev-dependencies] -libcnb-test = "=0.22.0" +libcnb-test = "=0.25.0" +tempfile = "3" test_support.workspace = true +ureq = "2" +uuid = { version = "1.10.0", features = ["v4", "serde"] } diff --git a/buildpacks/website-ember/src/errors.rs b/buildpacks/website-ember/src/errors.rs index e4ac691..4b31c57 100644 --- a/buildpacks/website-ember/src/errors.rs +++ b/buildpacks/website-ember/src/errors.rs @@ -15,6 +15,7 @@ locally with a minimal example and open an issue in the buildpack's GitHub repos #[derive(Debug)] pub(crate) enum WebsiteEmberBuildpackError { Detect(io::Error), + SettingBuildPlanMetadata(toml::ser::Error), } pub(crate) fn on_error(error: libcnb::Error) { @@ -30,6 +31,9 @@ pub(crate) fn on_error(error: libcnb::Error) { fn on_buildpack_error(error: WebsiteEmberBuildpackError, logger: Box) { match error { WebsiteEmberBuildpackError::Detect(e) => on_detect_error(&e, logger), + WebsiteEmberBuildpackError::SettingBuildPlanMetadata(e) => { + on_toml_serialization_error(&e, logger); + } } } @@ -44,6 +48,14 @@ fn on_detect_error(error: &io::Error, logger: Box) { ", buildpack_name = fmt::value(BUILDPACK_NAME) }); } +fn on_toml_serialization_error(error: &toml::ser::Error, logger: Box) { + print_error_details(logger, &error) + .announce() + .error(&formatdoc! {" + TOML serialization error from {buildpack_name}. + ", buildpack_name = fmt::value(BUILDPACK_NAME) }); +} + fn on_framework_error( error: &libcnb::Error, logger: Box, diff --git a/buildpacks/website-ember/src/main.rs b/buildpacks/website-ember/src/main.rs index f2510ed..2ee948c 100644 --- a/buildpacks/website-ember/src/main.rs +++ b/buildpacks/website-ember/src/main.rs @@ -15,7 +15,13 @@ use toml::toml; #[cfg(test)] use libcnb_test as _; #[cfg(test)] +use tempfile as _; +#[cfg(test)] use test_support as _; +#[cfg(test)] +use ureq as _; +#[cfg(test)] +use uuid as _; const BUILDPACK_NAME: &str = "Heroku Website (Ember.js) Buildpack"; @@ -44,20 +50,45 @@ impl Buildpack for WebsiteEmberBuildpack { }; let mut static_web_server_req = Require::new("static-web-server"); - let _ = static_web_server_req.metadata(toml! { - // The package.json build script will automatically execute by heroku/nodejs buildpack. - // Eventually, that build execution will be made optional, so this one will take over. - // build.command = "sh" - // build.args = ["-c", "ember build --environment=production"] - - root = "dist" - index = "index.html" - - [errors.404] - file_path = "index.html" - status = 200 - }); - let plan_builder = BuildPlanBuilder::new().requires(static_web_server_req); + static_web_server_req + .metadata(toml! { + // The package.json build script will automatically execute by heroku/nodejs buildpack. + // Eventually, that build execution will be made optional, so this one will take over. + // build.command = "sh" + // build.args = ["-c", "ember build --environment=production"] + + root = "/workspace/static-artifacts" + index = "index.html" + + [errors.404] + file_path = "index.html" + status = 200 + }) + .map_err(WebsiteEmberBuildpackError::SettingBuildPlanMetadata)?; + + let mut release_phase_req = Require::new("release-phase"); + + let mut release_phase_metadata = toml::Table::new(); + let mut release_build_command = toml::Table::new(); + release_build_command.insert("command".to_string(), "bash".to_string().into()); + release_build_command.insert( + "args".to_string(), + vec![ + "-c", + "npm run build && mkdir -p static-artifacts && cp -rL dist/* static-artifacts/", + ] + .into(), + ); + release_build_command.insert("source".to_string(), BUILDPACK_NAME.to_string().into()); + + release_phase_metadata.insert("release-build".to_string(), release_build_command.into()); + release_phase_req + .metadata(release_phase_metadata) + .map_err(WebsiteEmberBuildpackError::SettingBuildPlanMetadata)?; + + let plan_builder = BuildPlanBuilder::new() + .requires(static_web_server_req) + .requires(release_phase_req); if depends_on_ember_cli { DetectResultBuilder::pass() diff --git a/buildpacks/website-ember/tests/integration_test.rs b/buildpacks/website-ember/tests/integration_test.rs index 1a4a0a5..2716272 100644 --- a/buildpacks/website-ember/tests/integration_test.rs +++ b/buildpacks/website-ember/tests/integration_test.rs @@ -1,15 +1,94 @@ // Required due to: https://github.com/rust-lang/rust/issues/95513 #![allow(unused_crate_dependencies)] -use libcnb_test::assert_contains; -use test_support::{assert_web_response, website_nodejs_integration_test}; +use std::{fs, os::unix::fs::PermissionsExt}; + +use libcnb_test::{assert_contains, ContainerConfig}; +use tempfile::tempdir; +use test_support::{ + retry, start_container, start_container_entrypoint, website_nodejs_integration_test, + DEFAULT_RETRIES, DEFAULT_RETRY_DELAY, +}; +use uuid::Uuid; #[test] #[ignore = "integration test"] fn ember_cli_app() { website_nodejs_integration_test("./fixtures/ember_cli_app", |ctx| { + let unique = Uuid::new_v4(); + + let temp_dir = tempdir().expect("should create temporary directory for artifact storage"); + let temp_sub_dir = "static-artifacts-storage"; + let local_storage_path = temp_dir.path().join(temp_sub_dir); + println!("local_storage_path: {local_storage_path:?}"); + + // Workaround for GitHub Runner & Docker container not running with same gid/uid/permissions: + // create & set the temp local storage dir permissions to be world-accessible. + fs::create_dir_all(&local_storage_path) + .expect("local_storage_path directory should be created"); + let mut perms = fs::metadata(&local_storage_path) + .expect("local dir already exists") + .permissions(); + perms.set_mode(0o777); + fs::set_permissions(&local_storage_path, perms).expect("local dir permission can be set"); + + let container_volume_path = "/static-artifacts-storage"; + let container_volume_url = "file://".to_owned() + container_volume_path; + assert_contains!(ctx.pack_stdout, "Website (Ember.js)"); assert_contains!(ctx.pack_stdout, "Static Web Server"); - assert_web_response(&ctx, "cnb-ember-web-app/config/environment"); + assert_contains!(ctx.pack_stdout, "Release Phase"); + start_container_entrypoint( + &ctx, + ContainerConfig::new() + .env("RELEASE_ID", unique) + .env("STATIC_ARTIFACTS_URL", &container_volume_url) + .bind_mount(&local_storage_path, container_volume_path), + &"release".to_string(), + |container| { + let log_output = container.logs_now(); + assert_contains!(log_output.stderr, "release-phase plan"); + assert_contains!( + log_output.stdout, + format!("save-release-artifacts writing archive: release-{unique}.tgz") + .as_str() + ); + assert_contains!(log_output.stderr, "release-phase complete."); + }, + ); + start_container( + &ctx, + ContainerConfig::new() + .env("RELEASE_ID", unique) + .env("STATIC_ARTIFACTS_URL", &container_volume_url) + .bind_mount(&local_storage_path, container_volume_path), + |container, socket_addr| { + let log_output = container.logs_now(); + assert_contains!( + log_output.stderr, + format!("load-release-artifacts reading archive: release-{unique}.tgz") + .as_str(), + ); + assert_contains!(log_output.stderr, "load-release-artifacts complete."); + + let response = retry(DEFAULT_RETRIES, DEFAULT_RETRY_DELAY, || { + ureq::get(&format!("http://{socket_addr}/")).call() + }) + .unwrap(); + let response_body = response.into_string().unwrap(); + + // This is a unique part of the Ember app's rendered HTML. + assert_contains!(response_body, "cnb-ember-web-app/config/environment"); + + let response = retry(DEFAULT_RETRIES, DEFAULT_RETRY_DELAY, || { + ureq::get(&format!("http://{socket_addr}/test-client-side-routing")).call() + }) + .unwrap(); + let response_body = response.into_string().unwrap(); + + // This is a unique part of the Ember app's rendered HTML. + assert_contains!(response_body, "cnb-ember-web-app/config/environment"); + }, + ); }); } diff --git a/buildpacks/website-public-html/Cargo.toml b/buildpacks/website-public-html/Cargo.toml index bad41d9..e5e070e 100644 --- a/buildpacks/website-public-html/Cargo.toml +++ b/buildpacks/website-public-html/Cargo.toml @@ -7,7 +7,7 @@ edition.workspace = true workspace = true [dependencies] -libcnb = "0.22.0" +libcnb = "=0.25.0" commons_ruby = { git = "https://github.com/heroku/buildpacks-ruby", branch = "main", package = "commons" } libherokubuildpack = { version = "=0.22.0", default-features = false, features = ["fs", "log"] } indoc = "2" @@ -15,5 +15,5 @@ static_web_server_utils = { path = "../../common/static_web_server_utils" } toml = { version = "0.8", features = ["preserve_order"] } [dev-dependencies] -libcnb-test = "=0.22.0" +libcnb-test = "=0.25.0" test_support.workspace = true diff --git a/common/static_web_server_utils/Cargo.toml b/common/static_web_server_utils/Cargo.toml index 77dde1b..3dd86fc 100644 --- a/common/static_web_server_utils/Cargo.toml +++ b/common/static_web_server_utils/Cargo.toml @@ -7,6 +7,6 @@ edition.workspace = true workspace = true [dependencies] -libcnb = "0.22.0" +libcnb = "=0.25.0" libherokubuildpack = { version = "=0.22.0", default-features = false, features = ["toml"] } toml = { version = "0.8", features = ["preserve_order"] } diff --git a/test_support/Cargo.toml b/test_support/Cargo.toml index fb8c86c..f0dbb93 100644 --- a/test_support/Cargo.toml +++ b/test_support/Cargo.toml @@ -7,6 +7,6 @@ edition.workspace = true workspace = true [dependencies] -libcnb = "=0.22.0" -libcnb-test = "=0.22.0" +libcnb = "=0.25.0" +libcnb-test = "=0.25.0" ureq = "2" diff --git a/test_support/src/lib.rs b/test_support/src/lib.rs index 5fbc3a3..614ecff 100644 --- a/test_support/src/lib.rs +++ b/test_support/src/lib.rs @@ -123,11 +123,13 @@ pub fn retry( result } -pub fn start_container(ctx: &TestContext, in_container: impl Fn(&ContainerContext, &SocketAddr)) { +pub fn start_container( + ctx: &TestContext, + config: &mut ContainerConfig, + in_container: impl Fn(&ContainerContext, &SocketAddr), +) { ctx.start_container( - ContainerConfig::new() - .env("PORT", PORT.to_string()) - .expose_port(PORT), + config.env("PORT", PORT.to_string()).expose_port(PORT), |container| { let socket_addr = retry(DEFAULT_RETRIES, DEFAULT_RETRY_DELAY, || { std::panic::catch_unwind(|| container.address_for_port(PORT)) @@ -146,17 +148,40 @@ pub fn start_container(ctx: &TestContext, in_container: impl Fn(&ContainerContex ); } -pub fn assert_web_response(ctx: &TestContext, expected_response_body: &'static str) { - start_container(ctx, |_container, socket_addr| { - let response = retry(DEFAULT_RETRIES, DEFAULT_RETRY_DELAY, || { - ureq::get(&format!("http://{socket_addr}/")).call() - }) - .unwrap(); - let response_body = response.into_string().unwrap(); - assert_contains!(response_body, expected_response_body); +pub fn start_container_entrypoint( + ctx: &TestContext, + config: &mut ContainerConfig, + entrypoint: &String, + in_container: impl Fn(&ContainerContext), +) { + ctx.start_container(config.entrypoint(entrypoint), |container| { + let container_logs = container.logs_wait(); + println!( + " +------ begin {} logs (stderr) ------ +{}------ end (stderr) & begin (stdout) ------ +{}------ end {} logs ------", + entrypoint, container_logs.stderr, container_logs.stdout, entrypoint + ); + in_container(&container); }); } +pub fn assert_web_response(ctx: &TestContext, expected_response_body: &'static str) { + start_container( + ctx, + &mut ContainerConfig::new(), + |_container, socket_addr| { + let response = retry(DEFAULT_RETRIES, DEFAULT_RETRY_DELAY, || { + ureq::get(&format!("http://{socket_addr}/")).call() + }) + .unwrap(); + let response_body = response.into_string().unwrap(); + assert_contains!(response_body, expected_response_body); + }, + ); +} + pub fn wait_for(condition: F, max_wait_time: Duration) where F: Fn() + panic::RefUnwindSafe,