diff --git a/Cargo.lock b/Cargo.lock index 6da50e22..c7b25bab 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -17,6 +17,54 @@ dependencies = [ "memchr", ] +[[package]] +name = "anstream" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f58811cfac344940f1a400b6e6231ce35171f614f26439e80f8c1465c5cc0c" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15c4c2c83f81532e5845a733998b6971faca23490340a418e9b72a3ec9de12ea" + +[[package]] +name = "anstyle-parse" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "938874ff5980b03a87c5524b3ae5b59cf99b1d6bc836848df7bc5ada9643c333" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca11d4be1bab0c8bc8734a9aa7bf4ee8316d462a08c6ac5052f888fef5b494b" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "anstyle-wincon" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "58f54d10c6dfa51283a066ceab3ec1ab78d13fae00aa49243a45e4571fb79dfd" +dependencies = [ + "anstyle", + "windows-sys", +] + [[package]] name = "autocfg" version = "1.1.0" @@ -25,9 +73,9 @@ checksum = "d468802bab17cbc0cc575e9b053f41e72aa36bfa6b7f55e3529ffa43161b97fa" [[package]] name = "base64" -version = "0.21.2" +version = "0.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "604178f6c5c21f02dc555784810edfb88d34ac2c73b2eae109655649ee73ce3d" +checksum = "414dcefbc63d77c526a76b3afcf6fbb9b5e2791c19c3aa2297733208750c6e53" [[package]] name = "bit-set" @@ -115,9 +163,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.0.82" +version = "1.0.83" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "305fe645edc1442a0fa8b6726ba61d422798d37a52e12eaecf4b022ebbb88f01" +checksum = "f1174fb0b6ec23863f8b971027804a42614e347eafb0a95bf0b12cdae21fc4d0" dependencies = [ "libc", ] @@ -128,6 +176,53 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" +[[package]] +name = "clap" +version = "4.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c8d502cbaec4595d2e7d5f61e318f05417bd2b66fdc3809498f0d3fdf0bea27" +dependencies = [ + "clap_builder", + "clap_derive", + "once_cell", +] + +[[package]] +name = "clap_builder" +version = "4.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5891c7bc0edb3e1c2204fc5e94009affabeb1821c9e5fdc3959536c5c0bb984d" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9fd1a5729c4548118d7d70ff234a44868d00489a4b6597b0b020918a0e91a1a" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd7cc57abe963c6d3b9d8be5b06ba7c8957a930305ca90304f24ef040aa6f961" + +[[package]] +name = "colorchoice" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acbf1af155f9b9ef647e42cdc158db4b64a1b61f743629225fde6f3e0be2a7c7" + [[package]] name = "commons" version = "0.1.0" @@ -367,15 +462,15 @@ checksum = "d2fabcfbdc87f4758337ca535fb41a6d701b65693ce38287d856d1674551ec9b" [[package]] name = "hashbrown" -version = "0.12.3" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" +checksum = "2c6201b9ff9fd90a5a3bac2e56a830d0caa509576f0e503818ee82c181b3437a" [[package]] -name = "hashbrown" -version = "0.14.0" +name = "heck" +version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2c6201b9ff9fd90a5a3bac2e56a830d0caa509576f0e503818ee82c181b3437a" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" [[package]] name = "hermit-abi" @@ -387,6 +482,7 @@ checksum = "443144c8cdadd93ebf52ddb4056d257f5b52c04d3c804e657d19eb73fc33668b" name = "heroku-ruby-buildpack" version = "0.0.0" dependencies = [ + "clap", "commons", "flate2", "fs-err", @@ -394,6 +490,7 @@ dependencies = [ "indoc", "libcnb", "libcnb-test", + "libherokubuildpack", "rand", "regex", "serde", @@ -415,16 +512,6 @@ dependencies = [ "unicode-normalization", ] -[[package]] -name = "indexmap" -version = "1.9.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" -dependencies = [ - "autocfg", - "hashbrown 0.12.3", -] - [[package]] name = "indexmap" version = "2.0.0" @@ -432,7 +519,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d5477fe2230a79769d8dc68e0eabf5437907c0457a5614a9e8dddb67f65eb65d" dependencies = [ "equivalent", - "hashbrown 0.14.0", + "hashbrown", ] [[package]] @@ -595,9 +682,9 @@ checksum = "b5e6163cb8c49088c2c36f57875e58ccd8c87c7427f7fbd50ea6710b2f3f2e8f" [[package]] name = "memchr" -version = "2.5.0" +version = "2.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2dffe52ecf27772e601905b7522cb4ef790d2cc203488bbd0e2fe85fcb74566d" +checksum = "76fc44e2588d5b436dbc3c6cf62aef290f90dab6235744a93dfe1cc18f451e2c" [[package]] name = "memoffset" @@ -644,9 +731,9 @@ checksum = "dd8b5dd2ae5ed71462c540258bedcb51965123ad7e7ccf4b9a8cafaa4a63576d" [[package]] name = "ordered-float" -version = "3.9.0" +version = "3.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "126d3e6f3926bfb0fb24495b4f4da50626f547e54956594748e3d8882a0320b4" +checksum = "2a54938017eacd63036332b4ae5c8a49fc8c0c1d6d629893057e4f13609edd06" dependencies = [ "num-traits", ] @@ -665,12 +752,12 @@ checksum = "9b2a4787296e9989611394c33f193f676704af1686e70b8f8033ab5ba9a35a94" [[package]] name = "petgraph" -version = "0.6.3" +version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4dd7d28ee937e54fe3080c91faa1c3a46c06de6252988a7f4592ba2310ef22a4" +checksum = "e1d3afd2628e69da2be385eb6f2fd57c8ac7977ceeff6dc166ff1657b0e386a9" dependencies = [ "fixedbitset", - "indexmap 1.9.3", + "indexmap", ] [[package]] @@ -766,9 +853,9 @@ dependencies = [ [[package]] name = "regex" -version = "1.9.3" +version = "1.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81bc1d4caf89fac26a70747fe603c130093b53c773888797a6329091246d651a" +checksum = "12de2eff854e5fa4b1295edd650e227e9d8fb0c9e90b12e7f36d6a6811791a29" dependencies = [ "aho-corasick", "memchr", @@ -778,9 +865,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.3.6" +version = "0.3.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fed1ceff11a1dddaee50c9dc8e4938bd106e9d89ae372f192311e7da498e3b69" +checksum = "49530408a136e16e5b486e883fbb6ba058e8e4e8ae6621a77b048b314336e629" dependencies = [ "aho-corasick", "memchr", @@ -789,9 +876,9 @@ dependencies = [ [[package]] name = "regex-syntax" -version = "0.7.4" +version = "0.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e5ea92a5b6195c6ef2a0295ea818b312502c6fc94dde986c5553242e18fd4ce2" +checksum = "dbb5fb1acd8a1a18b3dd5be62d25485eb770e05afb408a9627d14d451bae12da" [[package]] name = "ring" @@ -810,9 +897,9 @@ dependencies = [ [[package]] name = "rustix" -version = "0.38.8" +version = "0.38.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19ed4fa021d81c8392ce04db050a3da9a60299050b7ae1cf482d862b54a7218f" +checksum = "9bfe0f2582b4931a45d1fa608f8a8722e8b3c7ac54dd6d5f3b3212791fedef49" dependencies = [ "bitflags 2.4.0", "errno", @@ -823,21 +910,21 @@ dependencies = [ [[package]] name = "rustls" -version = "0.21.6" +version = "0.21.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d1feddffcfcc0b33f5c6ce9a29e341e4cd59c3f78e7ee45f4a40c038b1d6cbb" +checksum = "cd8d6c9f025a446bc4d18ad9632e69aec8f287aa84499ee335599fabd20c3fd8" dependencies = [ "log", "ring", - "rustls-webpki 0.101.3", + "rustls-webpki 0.101.4", "sct", ] [[package]] name = "rustls-webpki" -version = "0.100.1" +version = "0.100.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6207cd5ed3d8dca7816f8f3725513a34609c0c765bf652b8c3cb4cfd87db46b" +checksum = "e98ff011474fa39949b7e5c0428f9b4937eda7da7848bbb947786b7be0b27dab" dependencies = [ "ring", "untrusted", @@ -845,9 +932,9 @@ dependencies = [ [[package]] name = "rustls-webpki" -version = "0.101.3" +version = "0.101.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "261e9e0888cba427c3316e6322805653c9425240b6fd96cee7cb671ab70ab8d0" +checksum = "7d93931baf2d282fff8d3a532bbfd7653f734643161b87e3e01e59a04439bf0d" dependencies = [ "ring", "untrusted", @@ -895,18 +982,18 @@ dependencies = [ [[package]] name = "serde" -version = "1.0.183" +version = "1.0.188" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32ac8da02677876d532745a130fc9d8e6edfa81a269b107c5b00829b91d8eb3c" +checksum = "cf9e0fcba69a370eed61bcf2b728575f726b50b55cba78064753d708ddc7549e" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.183" +version = "1.0.188" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aafe972d60b0b9bee71a91b92fee2d4fb3c9d7e8f6b179aa99f27203d99a4816" +checksum = "4eca7ac642d82aa35b60049a6eccb4be6be75e599bd2e9adb5f875a737654af2" dependencies = [ "proc-macro2", "quote", @@ -980,9 +1067,9 @@ dependencies = [ [[package]] name = "tempfile" -version = "3.7.1" +version = "3.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc02fddf48964c42031a0b3fe0428320ecf3a73c401040fc0096f97794310651" +checksum = "cb94d2f3cc536af71caac6b6fcebf65860b347e7ce0cc9ebe8f70d3e521054ef" dependencies = [ "cfg-if", "fastrand", @@ -1062,7 +1149,7 @@ version = "0.19.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8123f27e969974a3dfba720fdb560be359f57b44302d280ba72e76a74480e8a" dependencies = [ - "indexmap 2.0.0", + "indexmap", "serde", "serde_spanned", "toml_datetime", @@ -1113,7 +1200,7 @@ dependencies = [ "log", "once_cell", "rustls", - "rustls-webpki 0.100.1", + "rustls-webpki 0.100.2", "url", "webpki-roots", ] @@ -1130,9 +1217,9 @@ dependencies = [ [[package]] name = "url" -version = "2.4.0" +version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50bff7831e19200a85b17131d085c25d7811bc4e186efdaf54bbd132994a88cb" +checksum = "143b538f18257fac9cad154828a57c6bf5157e1aa604d4816b5995bf6de87ae5" dependencies = [ "form_urlencoded", "idna", @@ -1145,6 +1232,12 @@ version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5190c9442dcdaf0ddd50f37420417d219ae5261bbf5db120d0f9bab996c9cba1" +[[package]] +name = "utf8parse" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "711b9620af191e0cdc7468a8d14e709c3dcdb115b36f838e601583af800a370a" + [[package]] name = "vcpkg" version = "0.2.15" @@ -1243,7 +1336,7 @@ version = "0.23.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b03058f88386e5ff5310d9111d53f48b17d732b401aeb83a8d5190f2ac459338" dependencies = [ - "rustls-webpki 0.100.1", + "rustls-webpki 0.100.2", ] [[package]] @@ -1369,9 +1462,9 @@ checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" [[package]] name = "winnow" -version = "0.5.14" +version = "0.5.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d09770118a7eb1ccaf4a594a221334119a44a814fcb0d31c5b85e83e97227a97" +checksum = "7c2e3184b9c4e92ad5167ca73039d0c42476302ab603e2fec4487511f38ccefc" dependencies = [ "memchr", ] diff --git a/Cargo.toml b/Cargo.toml index 1b1eec91..a1f41d09 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,3 @@ [workspace] resolver = "2" -members = [ - "buildpacks/ruby", - "commons" -] +members = ["buildpacks/ruby", "commons"] diff --git a/buildpacks/ruby/CHANGELOG.md b/buildpacks/ruby/CHANGELOG.md index 0ff092b8..8dcadb46 100644 --- a/buildpacks/ruby/CHANGELOG.md +++ b/buildpacks/ruby/CHANGELOG.md @@ -2,11 +2,16 @@ All notable changes to this project will be documented in this file. -The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### Added + +- Introduce heroku build metrics support (https://github.com/heroku/buildpacks-ruby/pull/172) +- Changelog moved to be per-crate rather than for the whole project (https://github.com/heroku/buildpacks-ruby/pull/154) + ## [2.0.1] - 2023-07-25 - Commons: Introduce `build_output` module (https://github.com/heroku/buildpacks-ruby/pull/155) @@ -19,4 +24,4 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Version 2.0.0 for the first release is not a typo. There was an issue in pack where a builder with the same name and version number would reuse artifacts left on image from [prior runs which caused issues](https://github.com/buildpacks/pack/issues/1322#issuecomment-1038241038). There were prior releases of `heroku/ruby` CNB from different sources that triggered this problem. To ensure no one would encounter that issue we developed and released using a version we know has not been used before. Version 2.0 was the first major version without a prior release of `heroku/ruby` CNB from any source. [unreleased]: https://github.com/heroku/buildpacks-ruby/compare/v2.0.1...HEAD -[2.0.1]: https://github.com/heroku/buildpacks-ruby/releases/tag/v2.0.1 \ No newline at end of file +[2.0.1]: https://github.com/heroku/buildpacks-ruby/releases/tag/v2.0.1 diff --git a/buildpacks/ruby/Cargo.toml b/buildpacks/ruby/Cargo.toml index 4afc6df6..32c24100 100644 --- a/buildpacks/ruby/Cargo.toml +++ b/buildpacks/ruby/Cargo.toml @@ -15,6 +15,7 @@ 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.14.0" +libherokubuildpack = "=0.14.0" rand = "0.8" regex = "1" serde = "1" @@ -23,6 +24,7 @@ tempfile = "3" thiserror = "1" ureq = "2" url = "2" +clap = { version = "4", features = ["derive"] } [dev-dependencies] libcnb-test = "=0.14.0" diff --git a/buildpacks/ruby/src/bin/agentmon_loop.rs b/buildpacks/ruby/src/bin/agentmon_loop.rs new file mode 100644 index 00000000..b5f31b87 --- /dev/null +++ b/buildpacks/ruby/src/bin/agentmon_loop.rs @@ -0,0 +1,207 @@ +// Enable Clippy lints that are disabled by default. +// https://rust-lang.github.io/rust-clippy/stable/index.html +#![warn(clippy::pedantic)] + +use clap::Parser; +use std::ffi::OsStr; +use std::process::ExitStatus; +use std::{ + collections::HashMap, + path::{Path, PathBuf}, + process::{exit, Command}, + thread::sleep, + time::Duration, +}; + +static PORT: &str = "PORT"; +static DYNO: &str = "DYNO"; +static AGENTMON_DEBUG: &str = "AGENTMON_DEBUG"; +static HEROKU_METRICS_URL: &str = "HEROKU_METRICS_URL"; + +const SLEEP_FOR: Duration = Duration::from_secs(1); + +/// Agentmon Loop +/// +/// Boots agentmon (a statsd server) in a loop +/// +/// Example: +/// +/// ```shell +/// $ cargo run --bin agentmon_loop -- --path +/// ``` + +/// Turn CLI arguments into a Rust struct +#[derive(Parser, Debug)] +struct Args { + /// Path to the agentmon executable e.g. --path + #[arg(short, long)] + path: PathBuf, +} + +fn main() { + let agentmon = Args::parse().path; + let agentmon_args = build_args(&std::env::vars().collect::>()) + .unwrap_or_else(|error| { + eprintln!("Cannot start agentmon. {error}"); + exit(1) + }); + + match agentmon.try_exists() { + Ok(true) => { + eprintln!("Booting agentmon_loop"); + loop { + match run(&agentmon, &agentmon_args) { + Ok(status) => { + eprintln!("Process completed with status={status}, sleeping {SLEEP_FOR:?}"); + } + Err(error) => { + eprintln!( + "Process could not run due to error. {error}, sleeping {SLEEP_FOR:?}" + ); + } + }; + sleep(SLEEP_FOR); + } + } + Ok(false) => { + eprintln!("Path does not exist {path}", path = agentmon.display()); + exit(1); + } + Err(error) => { + eprintln!( + "Could not access {path}. {error}", + path = agentmon.display() + ); + exit(1); + } + } +} + +/// Print and run executable +/// +/// Runs an executable at the given path with args and streams the results. +fn run(path: &Path, args: I) -> Result +where + I: IntoIterator, + S: AsRef, +{ + let mut cmd = Command::new(path); + cmd.args(args); + + eprintln!("Running: {}", commons::fun_run::display(&mut cmd)); + + cmd.status() +} + +#[derive(Debug, thiserror::Error, PartialEq)] +enum BuildArgsError { + #[error("{PORT} environment variable is not set")] + MissingPort, + + #[error("{HEROKU_METRICS_URL} environment variable is not set")] + MissingMetricsUrl, + + #[error("One off dyno detected i.e. {DYNO}=\"run.*\"")] + RunDynoDetected, +} + +/// Constructs the arguments for agentmon based on environment variables +/// +/// # Errors +/// +/// - Environment variables: PORT or `HEROKU_METRICS_URL` are not set +/// - Environment variable DYNO starts with `run.` +fn build_args(env: &HashMap) -> Result, BuildArgsError> { + let mut args = Vec::new(); + if env.get(DYNO).is_some_and(|value| value.starts_with("run.")) { + return Err(BuildArgsError::RunDynoDetected); + } + + if let Some(port) = env.get(PORT) { + args.push(format!("-statsd-addr=:{port}")); + } else { + return Err(BuildArgsError::MissingPort); + }; + + if env.get(AGENTMON_DEBUG).is_some_and(|value| value == "true") { + args.push("-debug".to_string()); + }; + + if let Some(url) = env.get(HEROKU_METRICS_URL) { + args.push(url.clone()); + } else { + return Err(BuildArgsError::MissingMetricsUrl); + }; + + Ok(args) +} + +#[cfg(test)] +mod test { + use super::*; + + #[test] + fn missing_run_dyno() { + let result = build_args(&HashMap::from([("DYNO".to_string(), "run.1".to_string())])); + + assert_eq!(result, Err(BuildArgsError::RunDynoDetected)); + } + + #[test] + fn missing_metrics_url() { + let result = build_args(&HashMap::from([("PORT".to_string(), "123".to_string())])); + + assert_eq!(result, Err(BuildArgsError::MissingMetricsUrl)); + } + + #[test] + fn missing_port() { + let result = build_args(&HashMap::new()); + + assert_eq!(result, Err(BuildArgsError::MissingPort)); + } + + #[test] + fn agentmon_statsd_addr() { + let env = HashMap::from([ + ("PORT".to_string(), "90210".to_string()), + ( + "HEROKU_METRICS_URL".to_string(), + "https://example.com".to_string(), + ), + ]); + + let result = build_args(&env); + + assert_eq!( + result, + Ok(vec![ + "-statsd-addr=:90210".to_string(), + "https://example.com".to_string() + ]) + ); + } + + #[test] + fn agentmon_debug_args() { + let env = HashMap::from([ + ("PORT".to_string(), "90210".to_string()), + ( + "HEROKU_METRICS_URL".to_string(), + "https://example.com".to_string(), + ), + ("AGENTMON_DEBUG".to_string(), "true".to_string()), + ]); + + let result = build_args(&env); + + assert_eq!( + result, + Ok(vec![ + "-statsd-addr=:90210".to_string(), + "-debug".to_string(), + "https://example.com".to_string() + ]) + ); + } +} diff --git a/buildpacks/ruby/src/bin/launch_daemon.rs b/buildpacks/ruby/src/bin/launch_daemon.rs new file mode 100644 index 00000000..18c4dec2 --- /dev/null +++ b/buildpacks/ruby/src/bin/launch_daemon.rs @@ -0,0 +1,112 @@ +use clap::Parser; +use std::path::PathBuf; +use std::process::exit; +use std::process::Command; + +static AGENTMON_DEBUG: &str = "AGENTMON_DEBUG"; + +/// Schedules agentmon to run as a background daemon + +/// CLI argument parser +/// +/// ```shell +/// $ cargo run --bin launch_daemon \ +/// --log \ +/// --agentmon \ +/// --loop-path +/// ``` +#[derive(Parser, Debug)] +struct Args { + #[arg(long, value_parser = absolute_path_exists)] + log: PathBuf, + + #[arg(long, value_parser = absolute_path_exists)] + agentmon: PathBuf, + + #[arg(long, value_parser = absolute_path_exists)] + loop_path: PathBuf, +} + +#[derive(Debug, thiserror::Error)] +enum ParseAbsoluteError { + #[error("Cannot determine cannonical path for {0}. {1}")] + CannotCanonicalize(PathBuf, std::io::Error), + + #[error("Path does not exist {0}")] + DoesNotExist(PathBuf), + + #[error("Cannot read {0}. {1}")] + CannotRead(PathBuf, std::io::Error), +} + +/// Used to validate a path pased to the CLI exists and is accessible +fn absolute_path_exists(input: &str) -> Result { + let input = PathBuf::from(input); + let path = input + .canonicalize() + .map_err(|error| ParseAbsoluteError::CannotCanonicalize(input, error))?; + + if path + .try_exists() + .map_err(|error| ParseAbsoluteError::CannotRead(path.clone(), error))? + { + Ok(path) + } else { + Err(ParseAbsoluteError::DoesNotExist(path)) + } +} + +fn main() { + let Args { + log, + loop_path, + agentmon, + } = Args::parse(); + + let mut command = Command::new("start-stop-daemon"); + if let Some(value) = std::env::var_os(AGENTMON_DEBUG) { + fs_err::write( + &log, + format!( + "Logging enabled via `{AGENTMON_DEBUG}={value:?}`. To disable `unset {AGENTMON_DEBUG}`" + ), + ) + .unwrap_or_else(|error| { + eprintln!( + "Could not write to log file {}. Reason: {error}", + log.display() + ) + }); + + command.args(["--output", &log.to_string_lossy()]); + } else { + fs_err::write( + &log, + format!("To enable logging run with {AGENTMON_DEBUG}=1"), + ) + .unwrap_or_else(|error| { + eprintln!( + "Could not write to log file {}. Reason: {error}", + log.display() + ) + }); + } + + command.args([ + "--start", + "--background", + "--exec", + &loop_path.to_string_lossy(), + "--", + "--path", + &agentmon.to_string_lossy(), + ]); + + command.status().unwrap_or_else(|error| { + eprintln!( + "Command failed {}. Details: {error}", + commons::fun_run::display(&mut command) + ); + exit(1) + }); +} diff --git a/buildpacks/ruby/src/layers.rs b/buildpacks/ruby/src/layers.rs index 822c6800..62c00630 100644 --- a/buildpacks/ruby/src/layers.rs +++ b/buildpacks/ruby/src/layers.rs @@ -1,5 +1,6 @@ mod bundle_download_layer; mod bundle_install_layer; +pub(crate) mod metrics_agent_install; mod ruby_install_layer; pub(crate) use self::bundle_download_layer::BundleDownloadLayer; diff --git a/buildpacks/ruby/src/layers/metrics_agent_install.rs b/buildpacks/ruby/src/layers/metrics_agent_install.rs new file mode 100644 index 00000000..af0361c7 --- /dev/null +++ b/buildpacks/ruby/src/layers/metrics_agent_install.rs @@ -0,0 +1,299 @@ +use crate::build_output; +use crate::{RubyBuildpack, RubyBuildpackError}; +use flate2::read::GzDecoder; +use libcnb::data::layer_content_metadata::LayerTypes; +use libcnb::layer::ExistingLayerStrategy; +use libcnb::{ + additional_buildpack_binary_path, + generic::GenericMetadata, + layer::{Layer, LayerResultBuilder}, +}; +use libherokubuildpack::digest::sha256; +use serde::{Deserialize, Serialize}; +use std::os::unix::fs::PermissionsExt; +use std::path::{Path, PathBuf}; +use tar::Archive; +use tempfile::NamedTempFile; + +/// Agentmon URL +/// +/// - Repo: +/// - Releases: +/// +/// To get the latest s3 url: +/// +/// ```shell +/// $ curl https://agentmon-releases.s3.us-east-1.amazonaws.com/latest +/// ``` +const DOWNLOAD_URL: &str = + "https://agentmon-releases.s3.us-east-1.amazonaws.com/agentmon-0.3.1-linux-amd64.tar.gz"; +const DOWNLOAD_SHA: &str = "f9bf9f33c949e15ffed77046ca38f8dae9307b6a0181c6af29a25dec46eb2dac"; + +#[derive(Debug)] +pub(crate) struct MetricsAgentInstall { + pub(crate) section: build_output::Section, +} + +#[derive(Deserialize, Serialize, Debug, Clone)] +pub(crate) struct Metadata { + download_url: Option, +} + +#[derive(thiserror::Error, Debug)] +pub(crate) enum MetricsAgentInstallError { + #[error("Could not read file permissions {0}")] + PermissionError(std::io::Error), + + #[error("Could not open file: {0}")] + CouldNotOpenFile(std::io::Error), + + #[error("Could not untar: {0}")] + CouldNotUnpack(std::io::Error), + + // Boxed to prevent `large_enum_variant` errors since `ureq::Error` is massive. + #[error("Download error: {0}")] + RequestError(Box), + + #[error("Could not create file: {0}")] + CouldNotCreateDestinationFile(std::io::Error), + + #[error("Could not write file: {0}")] + CouldNotWriteDestinationFile(std::io::Error), + + #[error("Checksum of download failed. Expected {DOWNLOAD_SHA} got {0}")] + ChecksumFailed(String), +} + +impl Layer for MetricsAgentInstall { + type Buildpack = RubyBuildpack; + type Metadata = Metadata; + + fn types(&self) -> libcnb::data::layer_content_metadata::LayerTypes { + LayerTypes { + build: true, + launch: true, + cache: true, + } + } + + fn create( + &self, + _context: &libcnb::build::BuildContext, + layer_path: &std::path::Path, + ) -> Result< + libcnb::layer::LayerResult, + ::Error, + > { + let bin_dir = layer_path.join("bin"); + + let mut timer = self.section.say_with_inline_timer("Downloading"); + let agentmon = install_agentmon(&bin_dir).map_err(RubyBuildpackError::MetricsAgentError)?; + + timer.done(); + + self.section.say("Writing scripts"); + let execd = write_execd_script(&agentmon, layer_path) + .map_err(RubyBuildpackError::MetricsAgentError)?; + + LayerResultBuilder::new(Metadata { + download_url: Some(DOWNLOAD_URL.to_string()), + }) + .exec_d_program("spawn_metrics_agent", execd) + .build() + } + + fn update( + &self, + _context: &libcnb::build::BuildContext, + layer_data: &libcnb::layer::LayerData, + ) -> Result< + libcnb::layer::LayerResult, + ::Error, + > { + let layer_path = &layer_data.path; + + self.section.say("Writing scripts"); + let execd = write_execd_script(&layer_path.join("bin").join("agentmon"), layer_path) + .map_err(RubyBuildpackError::MetricsAgentError)?; + + LayerResultBuilder::new(Metadata { + download_url: Some(DOWNLOAD_URL.to_string()), + }) + .exec_d_program("spawn agentmon", execd) + .build() + } + + fn existing_layer_strategy( + &self, + _context: &libcnb::build::BuildContext, + layer_data: &libcnb::layer::LayerData, + ) -> Result::Error> + { + match &layer_data.content_metadata.metadata.download_url { + Some(url) if url == DOWNLOAD_URL => { + self.section.say("Using cached metrics agent"); + Ok(ExistingLayerStrategy::Update) + } + Some(url) => { + self.section + .say_with_details("Updating metrics agent", format!("{url} to {DOWNLOAD_URL}")); + Ok(ExistingLayerStrategy::Recreate) + } + None => Ok(ExistingLayerStrategy::Recreate), + } + } + + fn migrate_incompatible_metadata( + &self, + _context: &libcnb::build::BuildContext, + _metadata: &GenericMetadata, + ) -> Result< + libcnb::layer::MetadataMigration, + ::Error, + > { + self.section + .say_with_details("Clearing cache", "invalid metadata"); + + Ok(libcnb::layer::MetadataMigration::RecreateLayer) + } +} + +fn write_execd_script( + agentmon: &Path, + layer_path: &Path, +) -> Result { + let log = layer_path.join("output.log"); + let execd = layer_path.join("execd"); + let daemon = layer_path.join("launch_daemon"); + let run_loop = layer_path.join("agentmon_loop"); + + // Ensure log file exists + fs_err::write(&log, "").map_err(MetricsAgentInstallError::CouldNotWriteDestinationFile)?; + + // agentmon_loop boots agentmon continuously + fs_err::copy( + additional_buildpack_binary_path!("agentmon_loop"), + &run_loop, + ) + .map_err(MetricsAgentInstallError::CouldNotWriteDestinationFile)?; + + // The `launch_daemon` schedules `agentmon_loop` to run in the background + fs_err::copy(additional_buildpack_binary_path!("launch_daemon"), &daemon) + .map_err(MetricsAgentInstallError::CouldNotWriteDestinationFile)?; + + // The execd bash script will be run by CNB lifecycle, it runs the `launch_daemon` + fs_err::write( + &execd, + format!( + r#"#!/usr/bin/env bash + + {daemon} --log {log} --loop-path {run_loop} --agentmon {agentmon} + "#, + log = log.display(), + daemon = daemon.display(), + run_loop = run_loop.display(), + agentmon = agentmon.display(), + ), + ) + .map_err(MetricsAgentInstallError::CouldNotCreateDestinationFile)?; + chmod_plus_x(&execd).map_err(MetricsAgentInstallError::PermissionError)?; + + Ok(execd) +} + +fn install_agentmon(dir: &Path) -> Result { + let agentmon = download_untar(DOWNLOAD_URL, dir).map(|_| dir.join("agentmon"))?; + + chmod_plus_x(&agentmon).map_err(MetricsAgentInstallError::PermissionError)?; + Ok(agentmon) +} + +fn download_untar( + url: impl AsRef, + destination: &Path, +) -> Result<(), MetricsAgentInstallError> { + let agentmon_tgz = + NamedTempFile::new().map_err(MetricsAgentInstallError::CouldNotCreateDestinationFile)?; + + download(url, agentmon_tgz.path())?; + + sha256(agentmon_tgz.path()) + .map_err(MetricsAgentInstallError::CouldNotOpenFile) + .and_then(|checksum| { + if DOWNLOAD_SHA == checksum { + Ok(()) + } else { + Err(MetricsAgentInstallError::ChecksumFailed(checksum)) + } + })?; + + untar(agentmon_tgz.path(), destination)?; + + Ok(()) +} + +fn untar( + path: impl AsRef, + destination: impl AsRef, +) -> Result<(), MetricsAgentInstallError> { + let file = + fs_err::File::open(path.as_ref()).map_err(MetricsAgentInstallError::CouldNotOpenFile)?; + + Archive::new(GzDecoder::new(file)) + .unpack(destination.as_ref()) + .map_err(MetricsAgentInstallError::CouldNotUnpack) +} + +/// Sets file permissions on the given path to 7xx (similar to `chmod +x `) +/// +/// i.e. chmod +x will ensure that the first digit +/// of the file permission is 7 on unix so if you pass +/// in 0o455 it would be mutated to 0o755 +fn chmod_plus_x(path: &Path) -> Result<(), std::io::Error> { + let mut perms = fs_err::metadata(path)?.permissions(); + let mut mode = perms.mode(); + mode |= 0o700; + perms.set_mode(mode); + + fs_err::set_permissions(path, perms) +} + +fn download( + uri: impl AsRef, + destination: impl AsRef, +) -> Result<(), MetricsAgentInstallError> { + let mut response_reader = ureq::get(uri.as_ref()) + .call() + .map_err(|err| MetricsAgentInstallError::RequestError(Box::new(err)))? + .into_reader(); + + let mut destination_file = fs_err::File::create(destination.as_ref()) + .map_err(MetricsAgentInstallError::CouldNotCreateDestinationFile)?; + + std::io::copy(&mut response_reader, &mut destination_file) + .map_err(MetricsAgentInstallError::CouldNotWriteDestinationFile)?; + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_chmod() { + let tmp = tempfile::tempdir().unwrap(); + let file = tmp.into_path().join("file"); + std::fs::write(&file, "lol").unwrap(); + + let before = file.metadata().unwrap().permissions().mode(); + + chmod_plus_x(&file).unwrap(); + + let after = file.metadata().unwrap().permissions().mode(); + assert!(before != after); + + // Assert executable + assert_eq!(after, after | 0o700); + } +} diff --git a/buildpacks/ruby/src/main.rs b/buildpacks/ruby/src/main.rs index 084197e8..ef09f267 100644 --- a/buildpacks/ruby/src/main.rs +++ b/buildpacks/ruby/src/main.rs @@ -1,6 +1,7 @@ #![warn(unused_crate_dependencies)] #![warn(clippy::pedantic)] #![allow(clippy::module_name_repetitions)] +use crate::layers::metrics_agent_install::{MetricsAgentInstall, MetricsAgentInstallError}; use crate::layers::{RubyInstallError, RubyInstallLayer}; use crate::rake_task_detect::RakeError; use commons::build_output; @@ -30,6 +31,8 @@ mod user_errors; #[cfg(test)] use libcnb_test as _; +use clap as _; + pub(crate) struct RubyBuildpack; impl Buildpack for RubyBuildpack { @@ -77,6 +80,20 @@ impl Buildpack for RubyBuildpack { let bundler_version = gemfile_lock.resolve_bundler("2.4.5"); let ruby_version = gemfile_lock.resolve_ruby("3.1.3"); + // ## Install metrics agent + let section = build_output::section("Metrics agent"); + if lockfile_contents.contains("barnes") { + context.handle_layer( + layer_name!("metrics_agent"), + MetricsAgentInstall { section }, + )?; + } else { + section.say_with_details( + "Skipping install", + "`gem 'barnes'` not found in Gemfile.lock", + ); + }; + // ## Install executable ruby version env = { @@ -179,6 +196,7 @@ pub(crate) enum RubyBuildpackError { RakeDetectError(RakeError), GemListGetError(gem_list::ListError), RubyInstallError(RubyInstallError), + MetricsAgentError(MetricsAgentInstallError), MissingGemfileLock(std::io::Error), InAppDirCacheError(CacheError), BundleInstallDigestError(commons::metadata_digest::DigestError), diff --git a/buildpacks/ruby/src/user_errors.rs b/buildpacks/ruby/src/user_errors.rs index 0af7e170..236bbdac 100644 --- a/buildpacks/ruby/src/user_errors.rs +++ b/buildpacks/ruby/src/user_errors.rs @@ -65,11 +65,22 @@ fn log_our_error(error: RubyBuildpackError) { RubyBuildpackError::InAppDirCacheError(error) => ErrorInfo::header_body_details( "Internal cache error", formatdoc! {" - An internal error occured while caching files. + An internal error occurred while caching files. "}, error, ) .print(), + RubyBuildpackError::MetricsAgentError(error) => ErrorInfo::header_body_details( + formatdoc! { + "Could not install Statsd agent" + }, + formatdoc! { + "An error occured while downloading and installing the metrics agent + the buildpack cannot continue" + }, + error, + ) + .print(), RubyBuildpackError::BundleInstallDigestError(error) => ErrorInfo::header_body_details( "Could not generate digest", formatdoc! {" @@ -95,7 +106,7 @@ fn log_our_error(error: RubyBuildpackError) { RubyBuildpackError::RakeAssetsPrecompileFailed(error) => ErrorInfo::header_body_details( "Asset compilation failed", formatdoc! {" - An error occured while compiling assets via rake command. + An error occurred while compiling assets via rake command. "}, error, ) diff --git a/buildpacks/ruby/tests/fixtures/barnes_app/Gemfile b/buildpacks/ruby/tests/fixtures/barnes_app/Gemfile new file mode 100644 index 00000000..45bc623e --- /dev/null +++ b/buildpacks/ruby/tests/fixtures/barnes_app/Gemfile @@ -0,0 +1,4 @@ +source "https://rubygems.org" + +gem "barnes" +gem "puma" diff --git a/buildpacks/ruby/tests/fixtures/barnes_app/Gemfile.lock b/buildpacks/ruby/tests/fixtures/barnes_app/Gemfile.lock new file mode 100644 index 00000000..502408fa --- /dev/null +++ b/buildpacks/ruby/tests/fixtures/barnes_app/Gemfile.lock @@ -0,0 +1,23 @@ +GEM + remote: https://rubygems.org/ + specs: + barnes (0.0.9) + multi_json (~> 1) + statsd-ruby (~> 1.1) + multi_json (1.15.0) + nio4r (2.5.9) + puma (6.3.0) + nio4r (~> 2.0) + statsd-ruby (1.5.0) + +PLATFORMS + ruby + x86_64-darwin-22 + x86_64-linux + +DEPENDENCIES + barnes + puma + +BUNDLED WITH + 2.4.17 diff --git a/buildpacks/ruby/tests/fixtures/barnes_app/config.ru b/buildpacks/ruby/tests/fixtures/barnes_app/config.ru new file mode 100644 index 00000000..041a6deb --- /dev/null +++ b/buildpacks/ruby/tests/fixtures/barnes_app/config.ru @@ -0,0 +1 @@ +run Proc.new { |env| ['200', {'Content-Type' => 'text/html'}, ['Hello World']] } diff --git a/buildpacks/ruby/tests/fixtures/barnes_app/config/puma.rb b/buildpacks/ruby/tests/fixtures/barnes_app/config/puma.rb new file mode 100644 index 00000000..e9e8332e --- /dev/null +++ b/buildpacks/ruby/tests/fixtures/barnes_app/config/puma.rb @@ -0,0 +1,9 @@ +require 'barnes' + +before_fork do + # worker specific setup + + Barnes.start # Must have enabled worker mode for this to block to be called +end + +workers 2 diff --git a/buildpacks/ruby/tests/integration_test.rs b/buildpacks/ruby/tests/integration_test.rs index ab25139d..d6d9dc85 100644 --- a/buildpacks/ruby/tests/integration_test.rs +++ b/buildpacks/ruby/tests/integration_test.rs @@ -5,7 +5,7 @@ use libcnb_test::{ ContainerContext, TestRunner, }; use std::thread; -use std::time::Duration; +use std::time::{Duration, Instant}; use thiserror::__private::DisplayAsDisplay; use ureq::Response; @@ -13,8 +13,7 @@ use ureq::Response; #[ignore = "integration test"] fn test_default_app() { TestRunner::default().build( - BuildConfig::new("heroku/builder:22", "tests/fixtures/default_ruby") - .buildpacks(vec![BuildpackReference::Crate]), + BuildConfig::new("heroku/builder:22", "tests/fixtures/default_ruby"), |context| { assert_contains!(context.pack_stdout, "# Heroku Ruby Buildpack"); assert_contains!( @@ -112,6 +111,51 @@ fn test_ruby_app_with_yarn_app() { ); } +#[test] +#[ignore = "integration test"] +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"); + + context.start_container( + ContainerConfig::new() + .entrypoint("launcher") + .envs(vec![ + ("DYNO", "web.1"), + ("PORT", "1234"), + ("AGENTMON_DEBUG", "1"), + ("HEROKU_METRICS_URL", "example.com"), + ]) + .command(["while true; do sleep 1; done"]), + |container| { + let boot_message = "Booting agentmon_loop"; + let mut agentmon_log = String::new(); + + let started = Instant::now(); + while started.elapsed() < Duration::from_secs(20) { + if agentmon_log.contains(boot_message) { + break; + } + + std::thread::sleep(frac_seconds(0.1)); + agentmon_log = container + .shell_exec("cat /layers/heroku_ruby/metrics_agent/output.log") + .stdout; + } + + let log_output = container.logs_now(); + println!("{}", log_output.stdout); + println!("{}", log_output.stderr); + + assert_contains!(agentmon_log, boot_message); + }, + ); + }, + ); +} + fn request_container( container: &ContainerContext, port: u16,