diff --git a/.github/workflows/testing.yaml b/.github/workflows/testing.yaml index 02dbb1804..5deabd74a 100644 --- a/.github/workflows/testing.yaml +++ b/.github/workflows/testing.yaml @@ -110,3 +110,32 @@ jobs: - id: test name: Run Unit Tests run: cargo test --tests --benches --examples --workspace --all-targets --all-features + + e2e: + name: E2E + runs-on: ubuntu-latest + needs: unit + + strategy: + matrix: + toolchain: [nightly] + + steps: + - id: setup + name: Setup Toolchain + uses: dtolnay/rust-toolchain@stable + with: + toolchain: ${{ matrix.toolchain }} + components: llvm-tools-preview + + - id: cache + name: Enable Job Cache + uses: Swatinem/rust-cache@v2 + + - id: checkout + name: Checkout Repository + uses: actions/checkout@v4 + + - id: test + name: Run E2E Tests + run: cargo run --bin e2e_tests_runner ./share/default/config/tracker.e2e.container.sqlite3.toml diff --git a/Cargo.lock b/Cargo.lock index 63dfab1c7..ab270d0cc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3467,6 +3467,7 @@ dependencies = [ "serde_with", "tdyne-peer-id", "tdyne-peer-id-registry", + "tempfile", "thiserror", "tokio", "torrust-tracker-configuration", diff --git a/Cargo.toml b/Cargo.toml index 6fd542c2f..3a11786f5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -71,6 +71,7 @@ tower-http = { version = "0", features = ["compression-full"] } uuid = { version = "1", features = ["v4"] } colored = "2.1.0" url = "2.5.0" +tempfile = "3.9.0" [dev-dependencies] criterion = { version = "0.5.1", features = ["async_tokio"] } diff --git a/cSpell.json b/cSpell.json index acd46284c..0a3f78fad 100644 --- a/cSpell.json +++ b/cSpell.json @@ -116,6 +116,7 @@ "Swatinem", "Swiftbit", "taiki", + "tempfile", "thiserror", "tlsv", "Torrentstorm", diff --git a/share/default/config/tracker.e2e.container.sqlite3.toml b/share/default/config/tracker.e2e.container.sqlite3.toml new file mode 100644 index 000000000..86ffb3ffd --- /dev/null +++ b/share/default/config/tracker.e2e.container.sqlite3.toml @@ -0,0 +1,41 @@ +announce_interval = 120 +db_driver = "Sqlite3" +db_path = "/var/lib/torrust/tracker/database/sqlite3.db" +external_ip = "0.0.0.0" +inactive_peer_cleanup_interval = 600 +log_level = "info" +max_peer_timeout = 900 +min_announce_interval = 120 +mode = "public" +on_reverse_proxy = false +persistent_torrent_completed_stat = false +remove_peerless_torrents = true +tracker_usage_statistics = true + +[[udp_trackers]] +bind_address = "0.0.0.0:6969" +enabled = true + +[[http_trackers]] +bind_address = "0.0.0.0:7070" +enabled = true +ssl_cert_path = "/var/lib/torrust/tracker/tls/localhost.crt" +ssl_enabled = false +ssl_key_path = "/var/lib/torrust/tracker/tls/localhost.key" + +[http_api] +bind_address = "0.0.0.0:1212" +enabled = true +ssl_cert_path = "/var/lib/torrust/tracker/tls/localhost.crt" +ssl_enabled = false +ssl_key_path = "/var/lib/torrust/tracker/tls/localhost.key" + +# Please override the admin token setting the +# `TORRUST_TRACKER_API_ADMIN_TOKEN` +# environmental variable! + +[http_api.access_tokens] +admin = "MyAccessToken" + +[health_check_api] +bind_address = "0.0.0.0:1313" diff --git a/src/bin/e2e_tests_runner.rs b/src/bin/e2e_tests_runner.rs new file mode 100644 index 000000000..35368b612 --- /dev/null +++ b/src/bin/e2e_tests_runner.rs @@ -0,0 +1,10 @@ +//! Program to run E2E tests. +//! +//! ```text +//! cargo run --bin e2e_tests_runner share/default/config/tracker.e2e.container.sqlite3.toml +//! ``` +use torrust_tracker::e2e; + +fn main() { + e2e::runner::run(); +} diff --git a/src/e2e/docker.rs b/src/e2e/docker.rs new file mode 100644 index 000000000..419e6138a --- /dev/null +++ b/src/e2e/docker.rs @@ -0,0 +1,177 @@ +//! Docker command wrapper. +use std::io; +use std::process::{Command, Output}; +use std::thread::sleep; +use std::time::{Duration, Instant}; + +use log::debug; + +/// Docker command wrapper. +pub struct Docker {} + +pub struct RunningContainer { + pub name: String, + pub output: Output, +} + +impl Drop for RunningContainer { + /// Ensures that the temporary container is stopped and removed when the + /// struct goes out of scope. + fn drop(&mut self) { + let _unused = Docker::stop(self); + let _unused = Docker::remove(&self.name); + } +} + +impl Docker { + /// Builds a Docker image from a given Dockerfile. + /// + /// # Errors + /// + /// Will fail if the docker build command fails. + pub fn build(dockerfile: &str, tag: &str) -> io::Result<()> { + let status = Command::new("docker") + .args(["build", "-f", dockerfile, "-t", tag, "."]) + .status()?; + + if status.success() { + Ok(()) + } else { + Err(io::Error::new( + io::ErrorKind::Other, + format!("Failed to build Docker image from dockerfile {dockerfile}"), + )) + } + } + + /// Runs a Docker container from a given image with multiple environment variables. + /// + /// # Arguments + /// + /// * `image` - The Docker image to run. + /// * `container` - The name for the Docker container. + /// * `env_vars` - A slice of tuples, each representing an environment variable as ("KEY", "value"). + /// + /// # Errors + /// + /// Will fail if the docker run command fails. + pub fn run(image: &str, container: &str, env_vars: &[(String, String)], ports: &[String]) -> io::Result { + let initial_args = vec![ + "run".to_string(), + "--detach".to_string(), + "--name".to_string(), + container.to_string(), + ]; + + // Add environment variables + let mut env_var_args: Vec = vec![]; + for (key, value) in env_vars { + env_var_args.push("--env".to_string()); + env_var_args.push(format!("{key}={value}")); + } + + // Add port mappings + let mut port_args: Vec = vec![]; + for port in ports { + port_args.push("--publish".to_string()); + port_args.push(port.to_string()); + } + + let args = [initial_args, env_var_args, port_args, [image.to_string()].to_vec()].concat(); + + debug!("Docker run args: {:?}", args); + + let output = Command::new("docker").args(args).output()?; + + if output.status.success() { + Ok(RunningContainer { + name: container.to_owned(), + output, + }) + } else { + Err(io::Error::new( + io::ErrorKind::Other, + format!("Failed to run Docker image {image}"), + )) + } + } + + /// Stops a Docker container. + /// + /// # Errors + /// + /// Will fail if the docker stop command fails. + pub fn stop(container: &RunningContainer) -> io::Result<()> { + let status = Command::new("docker").args(["stop", &container.name]).status()?; + + if status.success() { + Ok(()) + } else { + Err(io::Error::new( + io::ErrorKind::Other, + format!("Failed to stop Docker container {}", container.name), + )) + } + } + + /// Removes a Docker container. + /// + /// # Errors + /// + /// Will fail if the docker rm command fails. + pub fn remove(container: &str) -> io::Result<()> { + let status = Command::new("docker").args(["rm", "-f", container]).status()?; + + if status.success() { + Ok(()) + } else { + Err(io::Error::new( + io::ErrorKind::Other, + format!("Failed to remove Docker container {container}"), + )) + } + } + + /// Fetches logs from a Docker container. + /// + /// # Errors + /// + /// Will fail if the docker logs command fails. + pub fn logs(container: &str) -> io::Result { + let output = Command::new("docker").args(["logs", container]).output()?; + + if output.status.success() { + Ok(String::from_utf8_lossy(&output.stdout).to_string()) + } else { + Err(io::Error::new( + io::ErrorKind::Other, + format!("Failed to fetch logs from Docker container {container}"), + )) + } + } + + /// Checks if a Docker container is healthy. + #[must_use] + pub fn wait_until_is_healthy(name: &str, timeout: Duration) -> bool { + let start = Instant::now(); + + while start.elapsed() < timeout { + let Ok(output) = Command::new("docker") + .args(["ps", "-f", &format!("name={name}"), "--format", "{{.Status}}"]) + .output() + else { + return false; + }; + + let output_str = String::from_utf8_lossy(&output.stdout); + + if output_str.contains("(healthy)") { + return true; + } + + sleep(Duration::from_secs(1)); + } + + false + } +} diff --git a/src/e2e/logs_parser.rs b/src/e2e/logs_parser.rs new file mode 100644 index 000000000..1d6baa23e --- /dev/null +++ b/src/e2e/logs_parser.rs @@ -0,0 +1,114 @@ +//! Utilities to parse Torrust Tracker logs. +use serde::{Deserialize, Serialize}; + +const UDP_TRACKER_PATTERN: &str = "[UDP Tracker][INFO] Starting on: udp://"; +const HTTP_TRACKER_PATTERN: &str = "[HTTP Tracker][INFO] Starting on: "; +const HEALTH_CHECK_PATTERN: &str = "[Health Check API][INFO] Starting on: "; + +#[derive(Serialize, Deserialize, Debug, Default)] +pub struct RunningServices { + pub udp_trackers: Vec, + pub http_trackers: Vec, + pub health_checks: Vec, +} + +impl RunningServices { + /// It parses the tracker logs to extract the running services. + /// + /// For example, from this logs: + /// + /// ```text + /// Loading default configuration file: `./share/default/config/tracker.development.sqlite3.toml` ... + /// 2024-01-24T16:36:14.614898789+00:00 [torrust_tracker::bootstrap::logging][INFO] logging initialized. + /// 2024-01-24T16:36:14.615586025+00:00 [UDP Tracker][INFO] Starting on: udp://0.0.0.0:6969 + /// 2024-01-24T16:36:14.615623705+00:00 [torrust_tracker::bootstrap::jobs][INFO] TLS not enabled + /// 2024-01-24T16:36:14.615694484+00:00 [HTTP Tracker][INFO] Starting on: http://0.0.0.0:7070 + /// 2024-01-24T16:36:14.615710534+00:00 [HTTP Tracker][INFO] Started on: http://0.0.0.0:7070 + /// 2024-01-24T16:36:14.615716574+00:00 [torrust_tracker::bootstrap::jobs][INFO] TLS not enabled + /// 2024-01-24T16:36:14.615764904+00:00 [API][INFO] Starting on http://127.0.0.1:1212 + /// 2024-01-24T16:36:14.615767264+00:00 [API][INFO] Started on http://127.0.0.1:1212 + /// 2024-01-24T16:36:14.615777574+00:00 [Health Check API][INFO] Starting on: http://127.0.0.1:1313 + /// 2024-01-24T16:36:14.615791124+00:00 [Health Check API][INFO] Started on: http://127.0.0.1:1313 + /// ``` + /// + /// It would extract these services: + /// + /// ```json + /// { + /// "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" + /// ] + /// } + /// ``` + #[must_use] + pub fn parse_from_logs(logs: &str) -> Self { + let mut udp_trackers: Vec = Vec::new(); + let mut http_trackers: Vec = Vec::new(); + let mut health_checks: Vec = Vec::new(); + + for line in logs.lines() { + if let Some(address) = Self::extract_address_if_matches(line, UDP_TRACKER_PATTERN) { + udp_trackers.push(address); + } else if let Some(address) = Self::extract_address_if_matches(line, HTTP_TRACKER_PATTERN) { + http_trackers.push(address); + } else if let Some(address) = Self::extract_address_if_matches(line, HEALTH_CHECK_PATTERN) { + health_checks.push(format!("{address}/health_check")); + } + } + + Self { + udp_trackers, + http_trackers, + health_checks, + } + } + + fn extract_address_if_matches(line: &str, pattern: &str) -> Option { + line.find(pattern) + .map(|start| Self::replace_wildcard_ip_with_localhost(line[start + pattern.len()..].trim())) + } + + fn replace_wildcard_ip_with_localhost(address: &str) -> String { + address.replace("0.0.0.0", "127.0.0.1") + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn it_should_parse_from_logs_with_valid_logs() { + let logs = "\ + [UDP Tracker][INFO] Starting on: udp://0.0.0.0:8080\n\ + [HTTP Tracker][INFO] Starting on: 0.0.0.0:9090\n\ + [Health Check API][INFO] Starting on: 0.0.0.0:10010"; + let running_services = RunningServices::parse_from_logs(logs); + + assert_eq!(running_services.udp_trackers, vec!["127.0.0.1:8080"]); + assert_eq!(running_services.http_trackers, vec!["127.0.0.1:9090"]); + assert_eq!(running_services.health_checks, vec!["127.0.0.1:10010/health_check"]); + } + + #[test] + fn it_should_ignore_logs_with_no_matching_lines() { + let logs = "[Other Service][INFO] Starting on: 0.0.0.0:7070"; + let running_services = RunningServices::parse_from_logs(logs); + + assert!(running_services.udp_trackers.is_empty()); + assert!(running_services.http_trackers.is_empty()); + assert!(running_services.health_checks.is_empty()); + } + + #[test] + fn it_should_replace_wildcard_ip_with_localhost() { + let address = "0.0.0.0:8080"; + assert_eq!(RunningServices::replace_wildcard_ip_with_localhost(address), "127.0.0.1:8080"); + } +} diff --git a/src/e2e/mod.rs b/src/e2e/mod.rs new file mode 100644 index 000000000..6745d49cd --- /dev/null +++ b/src/e2e/mod.rs @@ -0,0 +1,4 @@ +pub mod docker; +pub mod logs_parser; +pub mod runner; +pub mod temp_dir; diff --git a/src/e2e/runner.rs b/src/e2e/runner.rs new file mode 100644 index 000000000..eee2805a6 --- /dev/null +++ b/src/e2e/runner.rs @@ -0,0 +1,214 @@ +use std::fs::File; +use std::io::Write; +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::time::Duration; +use std::{env, io}; + +use log::{debug, info, LevelFilter}; +use rand::distributions::Alphanumeric; +use rand::Rng; + +use super::docker::RunningContainer; +use crate::e2e::docker::Docker; +use crate::e2e::logs_parser::RunningServices; +use crate::e2e::temp_dir::Handler; + +pub const NUMBER_OF_ARGUMENTS: usize = 2; +const CONTAINER_TAG: &str = "torrust-tracker:local"; +const TRACKER_CHECKER_CONFIG_FILE: &str = "tracker_checker.json"; + +pub struct Arguments { + pub tracker_config_path: String, +} + +/// Script to run E2E tests. +/// +/// # Panics +/// +/// Will panic if it can't not perform any of the operations. +pub fn run() { + setup_runner_logging(LevelFilter::Info); + + let args = parse_arguments(); + + let tracker_config = load_tracker_configuration(&args.tracker_config_path); + + build_tracker_container_image(CONTAINER_TAG); + + let temp_dir = create_temp_dir(); + + let container_name = generate_random_container_name("tracker_"); + + // code-review: if we want to use port 0 we don't know which ports we have to open. + // Besides, if we don't use port 0 we should get the port numbers from the tracker configuration. + // We could not use docker, but the intention was to create E2E tests including containerization. + let env_vars = [("TORRUST_TRACKER_CONFIG".to_string(), tracker_config.to_string())]; + let ports = [ + "6969:6969/udp".to_string(), + "7070:7070/tcp".to_string(), + "1212:1212/tcp".to_string(), + "1313:1313/tcp".to_string(), + ]; + + let container = run_tracker_container(&container_name, &env_vars, &ports); + + let running_services = parse_running_services_from_logs(&container); + + let tracker_checker_config = + serde_json::to_string_pretty(&running_services).expect("Running services should be serialized into JSON"); + + let mut tracker_checker_config_path = PathBuf::from(&temp_dir.temp_dir.path()); + tracker_checker_config_path.push(TRACKER_CHECKER_CONFIG_FILE); + + write_tracker_checker_config_file(&tracker_checker_config_path, &tracker_checker_config); + + run_tracker_checker(&tracker_checker_config_path).expect("Tracker checker should check running services"); + + // More E2E tests could be executed here in the future. For example: `cargo test ...`. + + info!("Running container `{}` will be automatically removed", container.name); +} + +fn setup_runner_logging(level: LevelFilter) { + if let Err(_err) = fern::Dispatch::new() + .format(|out, message, record| { + out.finish(format_args!( + "{} [{}][{}] {}", + chrono::Local::now().format("%+"), + record.target(), + record.level(), + message + )); + }) + .level(level) + .chain(std::io::stdout()) + .apply() + { + panic!("Failed to initialize logging.") + } + + debug!("logging initialized."); +} + +fn parse_arguments() -> Arguments { + let args: Vec = std::env::args().collect(); + + if args.len() < NUMBER_OF_ARGUMENTS { + eprintln!("Usage: cargo run --bin e2e_tests_runner "); + eprintln!("For example: cargo run --bin e2e_tests_runner ./share/default/config/tracker.e2e.container.sqlite3.toml"); + std::process::exit(1); + } + + let config_path = &args[1]; + + Arguments { + tracker_config_path: config_path.to_string(), + } +} + +fn load_tracker_configuration(tracker_config_path: &str) -> String { + info!("Reading tracker configuration from file: {} ...", tracker_config_path); + read_file(tracker_config_path) +} + +fn read_file(path: &str) -> String { + std::fs::read_to_string(path).unwrap_or_else(|_| panic!("Can't read file {path}")) +} + +fn build_tracker_container_image(tag: &str) { + info!("Building tracker container image with tag: {} ...", tag); + Docker::build("./Containerfile", tag).expect("A tracker local docker image should be built"); +} + +fn create_temp_dir() -> Handler { + debug!( + "Current dir: {:?}", + env::current_dir().expect("It should return the current dir") + ); + + let temp_dir_handler = Handler::new().expect("A temp dir should be created"); + + info!("Temp dir created: {:?}", temp_dir_handler.temp_dir); + + temp_dir_handler +} + +fn generate_random_container_name(prefix: &str) -> String { + let rand_string: String = rand::thread_rng() + .sample_iter(&Alphanumeric) + .take(20) + .map(char::from) + .collect(); + + format!("{prefix}{rand_string}") +} + +fn run_tracker_container(container_name: &str, env_vars: &[(String, String)], ports: &[String]) -> RunningContainer { + info!("Running docker tracker image: {container_name} ..."); + + let container = + Docker::run(CONTAINER_TAG, container_name, env_vars, ports).expect("A tracker local docker image should be running"); + + info!("Waiting for the container {container_name} to be healthy ..."); + + let is_healthy = Docker::wait_until_is_healthy(container_name, Duration::from_secs(10)); + + assert!(is_healthy, "Unhealthy tracker container: {container_name}"); + + debug!("Container {container_name} is healthy ..."); + + container +} + +fn parse_running_services_from_logs(container: &RunningContainer) -> RunningServices { + let logs = Docker::logs(&container.name).expect("Logs should be captured from running container"); + + debug!("Logs after starting the container:\n{logs}"); + + RunningServices::parse_from_logs(&logs) +} + +fn write_tracker_checker_config_file(config_file_path: &Path, config: &str) { + let mut file = File::create(config_file_path).expect("Tracker checker config file to be created"); + + file.write_all(config.as_bytes()) + .expect("Tracker checker config file to be written"); + + info!("Tracker checker configuration file: {:?} \n{config}", config_file_path); +} + +/// Runs the tracker checker +/// +/// ```text +/// cargo run --bin tracker_checker "./share/default/config/tracker_checker.json" +/// ``` +/// +/// # Errors +/// +/// Will return an error if the tracker checker fails. +/// +/// # Panics +/// +/// Will panic if the config path is not a valid string. +pub fn run_tracker_checker(config_path: &Path) -> io::Result<()> { + info!( + "Running tacker checker: cargo --bin tracker_checker {}", + config_path.display() + ); + + let path = config_path.to_str().expect("The path should be a valid string"); + + let status = Command::new("cargo") + .args(["run", "--bin", "tracker_checker", path]) + .status()?; + + if status.success() { + Ok(()) + } else { + Err(io::Error::new( + io::ErrorKind::Other, + format!("Failed to run tracker checker with config file {path}"), + )) + } +} diff --git a/src/e2e/temp_dir.rs b/src/e2e/temp_dir.rs new file mode 100644 index 000000000..8433e3059 --- /dev/null +++ b/src/e2e/temp_dir.rs @@ -0,0 +1,53 @@ +//! Temp dir which is automatically removed when it goes out of scope. +use std::path::PathBuf; +use std::{env, io}; + +use tempfile::TempDir; + +pub struct Handler { + pub temp_dir: TempDir, + pub original_dir: PathBuf, +} + +impl Handler { + /// Creates a new temporary directory and remembers the current working directory. + /// + /// # Errors + /// + /// Will error if: + /// + /// - It can't create the temp dir. + /// - It can't get the current dir. + pub fn new() -> io::Result { + let temp_dir = TempDir::new()?; + let original_dir = env::current_dir()?; + + Ok(Handler { temp_dir, original_dir }) + } + + /// Changes the current working directory to the temporary directory. + /// + /// # Errors + /// + /// Will error if it can't change the current di to the temp dir. + pub fn change_to_temp_dir(&self) -> io::Result<()> { + env::set_current_dir(self.temp_dir.path()) + } + + /// Changes the current working directory back to the original directory. + /// + /// # Errors + /// + /// Will error if it can't revert the current dir to the original one. + pub fn revert_to_original_dir(&self) -> io::Result<()> { + env::set_current_dir(&self.original_dir) + } +} + +impl Drop for Handler { + /// Ensures that the temporary directory is deleted when the struct goes out of scope. + fn drop(&mut self) { + // The temporary directory is automatically deleted when `TempDir` is dropped. + // We can add additional cleanup here if necessary. + } +} diff --git a/src/lib.rs b/src/lib.rs index 7b5d453a4..f239039bd 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -473,6 +473,7 @@ pub mod app; pub mod bootstrap; pub mod checker; pub mod core; +pub mod e2e; pub mod servers; pub mod shared;