diff --git a/Cargo.lock b/Cargo.lock index de630b49..1f49aa98 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -627,6 +627,16 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "acbf1af155f9b9ef647e42cdc158db4b64a1b61f743629225fde6f3e0be2a7c7" +[[package]] +name = "colored" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cbf2150cce219b664a8a70df7a1f933836724b503f8a413af9365b4dcc4d90b8" +dependencies = [ + "lazy_static", + "windows-sys 0.48.0", +] + [[package]] name = "config" version = "0.13.4" @@ -3418,6 +3428,7 @@ dependencies = [ "axum-server", "binascii", "chrono", + "colored", "config", "criterion", "derive_more", @@ -3454,6 +3465,7 @@ dependencies = [ "torrust-tracker-primitives", "torrust-tracker-test-helpers", "tower-http", + "url", "uuid", ] diff --git a/Cargo.toml b/Cargo.toml index 671d66e9..9b7e7190 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -68,6 +68,8 @@ torrust-tracker-located-error = { version = "3.0.0-alpha.12-develop", path = "pa torrust-tracker-primitives = { version = "3.0.0-alpha.12-develop", path = "packages/primitives" } tower-http = { version = "0", features = ["compression-full"] } uuid = { version = "1", features = ["v4"] } +colored = "2.1.0" +url = "2.5.0" [dev-dependencies] criterion = { version = "0.5.1", features = ["async_tokio"] } diff --git a/share/default/config/tracker_checker.json b/share/default/config/tracker_checker.json new file mode 100644 index 00000000..7d1453bf --- /dev/null +++ b/share/default/config/tracker_checker.json @@ -0,0 +1,11 @@ +{ + "udp_trackers": [ + "127.0.0.1:6969" + ], + "http_trackers": [ + "http://127.0.0.1:7070" + ], + "health_checks": [ + "http://127.0.0.1:1313/health_check" + ] +} \ No newline at end of file diff --git a/src/bin/tracker_checker.rs b/src/bin/tracker_checker.rs new file mode 100644 index 00000000..3a0e0ee8 --- /dev/null +++ b/src/bin/tracker_checker.rs @@ -0,0 +1,11 @@ +//! Program to run checks against running trackers. +//! +//! ```text +//! cargo run --bin tracker_checker "./share/default/config/tracker_checker.json" +//! ``` +use torrust_tracker::checker::app; + +#[tokio::main] +async fn main() { + app::run().await; +} diff --git a/src/checker/app.rs b/src/checker/app.rs new file mode 100644 index 00000000..e9237349 --- /dev/null +++ b/src/checker/app.rs @@ -0,0 +1,53 @@ +use std::sync::Arc; + +use super::config::Configuration; +use super::console::Console; +use crate::checker::config::parse_from_json; +use crate::checker::service::Service; + +pub const NUMBER_OF_ARGUMENTS: usize = 2; + +/// # Panics +/// +/// Will panic if: +/// +/// - It can't read the json configuration file. +/// - The configuration file is invalid. +pub async fn run() { + let args = parse_arguments(); + let config = setup_config(&args); + let console_printer = Console {}; + let service = Service { + config: Arc::new(config), + console: console_printer, + }; + + service.run_checks().await; +} + +pub struct Arguments { + pub config_path: String, +} + +fn parse_arguments() -> Arguments { + let args: Vec = std::env::args().collect(); + + if args.len() < NUMBER_OF_ARGUMENTS { + eprintln!("Usage: cargo run --bin tracker_checker "); + eprintln!("For example: cargo run --bin tracker_checker ./share/default/config/tracker_checker.json"); + std::process::exit(1); + } + + let config_path = &args[1]; + + Arguments { + config_path: config_path.to_string(), + } +} + +fn setup_config(args: &Arguments) -> Configuration { + let file_content = std::fs::read_to_string(args.config_path.clone()) + .unwrap_or_else(|_| panic!("Can't read config file {}", args.config_path)); + + parse_from_json(&file_content).expect("Invalid config format") +} diff --git a/src/checker/config.rs b/src/checker/config.rs new file mode 100644 index 00000000..aaf611bb --- /dev/null +++ b/src/checker/config.rs @@ -0,0 +1,152 @@ +use std::fmt; +use std::net::SocketAddr; + +use reqwest::Url as ServiceUrl; +use serde::Deserialize; +use url; + +/// It parses the configuration from a JSON format. +/// +/// # Errors +/// +/// Will return an error if the configuration is not valid. +/// +/// # Panics +/// +/// Will panic if unable to read the configuration file. +pub fn parse_from_json(json: &str) -> Result { + let plain_config: PlainConfiguration = serde_json::from_str(json).map_err(ConfigurationError::JsonParseError)?; + Configuration::try_from(plain_config) +} + +/// DTO for the configuration to serialize/deserialize configuration. +/// +/// Configuration does not need to be valid. +#[derive(Deserialize)] +struct PlainConfiguration { + pub udp_trackers: Vec, + pub http_trackers: Vec, + pub health_checks: Vec, +} + +/// Validated configuration +pub struct Configuration { + pub udp_trackers: Vec, + pub http_trackers: Vec, + pub health_checks: Vec, +} + +#[derive(Debug)] +pub enum ConfigurationError { + JsonParseError(serde_json::Error), + InvalidUdpAddress(std::net::AddrParseError), + InvalidUrl(url::ParseError), +} + +impl fmt::Display for ConfigurationError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + ConfigurationError::JsonParseError(e) => write!(f, "JSON parse error: {e}"), + ConfigurationError::InvalidUdpAddress(e) => write!(f, "Invalid UDP address: {e}"), + ConfigurationError::InvalidUrl(e) => write!(f, "Invalid URL: {e}"), + } + } +} + +impl TryFrom for Configuration { + type Error = ConfigurationError; + + fn try_from(plain_config: PlainConfiguration) -> Result { + let udp_trackers = plain_config + .udp_trackers + .into_iter() + .map(|s| s.parse::().map_err(ConfigurationError::InvalidUdpAddress)) + .collect::, _>>()?; + + let http_trackers = plain_config + .http_trackers + .into_iter() + .map(|s| s.parse::().map_err(ConfigurationError::InvalidUrl)) + .collect::, _>>()?; + + let health_checks = plain_config + .health_checks + .into_iter() + .map(|s| s.parse::().map_err(ConfigurationError::InvalidUrl)) + .collect::, _>>()?; + + Ok(Configuration { + udp_trackers, + http_trackers, + health_checks, + }) + } +} + +#[cfg(test)] +mod tests { + use std::net::{IpAddr, Ipv4Addr, SocketAddr}; + + use super::*; + + #[test] + fn configuration_should_be_build_from_plain_serializable_configuration() { + let dto = PlainConfiguration { + udp_trackers: vec!["127.0.0.1:8080".to_string()], + http_trackers: vec!["http://127.0.0.1:8080".to_string()], + health_checks: vec!["http://127.0.0.1:8080/health".to_string()], + }; + + let config = Configuration::try_from(dto).expect("A valid configuration"); + + assert_eq!( + config.udp_trackers, + vec![SocketAddr::new(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)), 8080)] + ); + assert_eq!( + config.http_trackers, + vec![ServiceUrl::parse("http://127.0.0.1:8080").unwrap()] + ); + assert_eq!( + config.health_checks, + vec![ServiceUrl::parse("http://127.0.0.1:8080/health").unwrap()] + ); + } + + mod building_configuration_from_plan_configuration { + use crate::checker::config::{Configuration, PlainConfiguration}; + + #[test] + fn it_should_fail_when_a_tracker_udp_address_is_invalid() { + let plain_config = PlainConfiguration { + udp_trackers: vec!["invalid_address".to_string()], + http_trackers: vec![], + health_checks: vec![], + }; + + assert!(Configuration::try_from(plain_config).is_err()); + } + + #[test] + fn it_should_fail_when_a_tracker_http_address_is_invalid() { + let plain_config = PlainConfiguration { + udp_trackers: vec![], + http_trackers: vec!["not_a_url".to_string()], + health_checks: vec![], + }; + + assert!(Configuration::try_from(plain_config).is_err()); + } + + #[test] + fn it_should_fail_when_a_health_check_http_address_is_invalid() { + let plain_config = PlainConfiguration { + udp_trackers: vec![], + http_trackers: vec![], + health_checks: vec!["not_a_url".to_string()], + }; + + assert!(Configuration::try_from(plain_config).is_err()); + } + } +} diff --git a/src/checker/console.rs b/src/checker/console.rs new file mode 100644 index 00000000..b55c559f --- /dev/null +++ b/src/checker/console.rs @@ -0,0 +1,38 @@ +use super::printer::{Printer, CLEAR_SCREEN}; + +pub struct Console {} + +impl Default for Console { + fn default() -> Self { + Self::new() + } +} + +impl Console { + #[must_use] + pub fn new() -> Self { + Self {} + } +} + +impl Printer for Console { + fn clear(&self) { + self.print(CLEAR_SCREEN); + } + + fn print(&self, output: &str) { + print!("{}", &output); + } + + fn eprint(&self, output: &str) { + eprint!("{}", &output); + } + + fn println(&self, output: &str) { + println!("{}", &output); + } + + fn eprintln(&self, output: &str) { + eprintln!("{}", &output); + } +} diff --git a/src/checker/logger.rs b/src/checker/logger.rs new file mode 100644 index 00000000..3d1074e7 --- /dev/null +++ b/src/checker/logger.rs @@ -0,0 +1,72 @@ +use std::cell::RefCell; + +use super::printer::{Printer, CLEAR_SCREEN}; + +pub struct Logger { + output: RefCell, +} + +impl Default for Logger { + fn default() -> Self { + Self::new() + } +} + +impl Logger { + #[must_use] + pub fn new() -> Self { + Self { + output: RefCell::new(String::new()), + } + } + + pub fn log(&self) -> String { + self.output.borrow().clone() + } +} + +impl Printer for Logger { + fn clear(&self) { + self.print(CLEAR_SCREEN); + } + + fn print(&self, output: &str) { + *self.output.borrow_mut() = format!("{}{}", self.output.borrow(), &output); + } + + fn eprint(&self, output: &str) { + *self.output.borrow_mut() = format!("{}{}", self.output.borrow(), &output); + } + + fn println(&self, output: &str) { + self.print(&format!("{}/n", &output)); + } + + fn eprintln(&self, output: &str) { + self.eprint(&format!("{}/n", &output)); + } +} + +#[cfg(test)] +mod tests { + use crate::checker::logger::Logger; + use crate::checker::printer::{Printer, CLEAR_SCREEN}; + + #[test] + fn should_capture_the_clear_screen_command() { + let console_logger = Logger::new(); + + console_logger.clear(); + + assert_eq!(CLEAR_SCREEN, console_logger.log()); + } + + #[test] + fn should_capture_the_print_command_output() { + let console_logger = Logger::new(); + + console_logger.print("OUTPUT"); + + assert_eq!("OUTPUT", console_logger.log()); + } +} diff --git a/src/checker/mod.rs b/src/checker/mod.rs new file mode 100644 index 00000000..6a55141d --- /dev/null +++ b/src/checker/mod.rs @@ -0,0 +1,6 @@ +pub mod app; +pub mod config; +pub mod console; +pub mod logger; +pub mod printer; +pub mod service; diff --git a/src/checker/printer.rs b/src/checker/printer.rs new file mode 100644 index 00000000..d590dfed --- /dev/null +++ b/src/checker/printer.rs @@ -0,0 +1,9 @@ +pub const CLEAR_SCREEN: &str = "\x1B[2J\x1B[1;1H"; + +pub trait Printer { + fn clear(&self); + fn print(&self, output: &str); + fn eprint(&self, output: &str); + fn println(&self, output: &str); + fn eprintln(&self, output: &str); +} diff --git a/src/checker/service.rs b/src/checker/service.rs new file mode 100644 index 00000000..92902deb --- /dev/null +++ b/src/checker/service.rs @@ -0,0 +1,84 @@ +use std::net::SocketAddr; +use std::sync::Arc; +use std::time::Duration; + +use colored::Colorize; +use reqwest::{Client, Url}; + +use super::config::Configuration; +use super::console::Console; +use crate::checker::printer::Printer; + +pub struct Service { + pub(crate) config: Arc, + pub(crate) console: Console, +} + +impl Service { + pub async fn run_checks(&self) { + self.console.println("Running checks for trackers ..."); + self.check_udp_trackers(); + self.check_http_trackers(); + self.run_health_checks().await; + } + + fn check_udp_trackers(&self) { + self.console.println("UDP trackers ..."); + + for udp_tracker in &self.config.udp_trackers { + self.check_udp_tracker(udp_tracker); + } + } + + fn check_http_trackers(&self) { + self.console.println("HTTP trackers ..."); + + for http_tracker in &self.config.http_trackers { + self.check_http_tracker(http_tracker); + } + } + + async fn run_health_checks(&self) { + self.console.println("Health checks ..."); + + for health_check_url in &self.config.health_checks { + self.run_health_check(health_check_url.clone()).await; + } + } + + fn check_udp_tracker(&self, address: &SocketAddr) { + // todo: + // - Make announce request + // - Make scrape request + self.console + .println(&format!("{} - UDP tracker at {:?} is OK (TODO)", "✓".green(), address)); + } + + fn check_http_tracker(&self, url: &Url) { + // todo: + // - Make announce request + // - Make scrape request + self.console + .println(&format!("{} - HTTP tracker at {} is OK (TODO)", "✓".green(), url)); + } + + async fn run_health_check(&self, url: Url) { + let client = Client::builder().timeout(Duration::from_secs(5)).build().unwrap(); + + match client.get(url.clone()).send().await { + Ok(response) => { + if response.status().is_success() { + self.console + .println(&format!("{} - Health API at {} is OK", "✓".green(), url)); + } else { + self.console + .eprintln(&format!("{} - Health API at {} failing: {:?}", "✗".red(), url, response)); + } + } + Err(err) => { + self.console + .eprintln(&format!("{} - Health API at {} failing: {:?}", "✗".red(), url, err)); + } + } + } +} diff --git a/src/lib.rs b/src/lib.rs index c5f77564..7b5d453a 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -471,6 +471,7 @@ //! examples on the integration and unit tests. pub mod app; pub mod bootstrap; +pub mod checker; pub mod core; pub mod servers; pub mod shared;