From f1c7cccf4fc55aac84b5b735bde1ded8178b4d22 Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Tue, 21 Nov 2023 14:57:59 +0000 Subject: [PATCH 1/9] feat: add cargo dependency reqwest It'll be used by a litle script (health check) that needs to make HTTP request. --- Cargo.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/Cargo.toml b/Cargo.toml index fd6230f80..51a1ac00f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -50,6 +50,7 @@ r2d2 = "0" r2d2_mysql = "24" r2d2_sqlite = { version = "0", features = ["bundled"] } rand = "0" +reqwest = "0" serde = { version = "1", features = ["derive"] } serde_bencode = "0" serde_json = "1" From 0ef4e34254ca175ba861d45f58c065a55bd554c3 Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Tue, 21 Nov 2023 14:59:58 +0000 Subject: [PATCH 2/9] feat: [#508] add new binary HTTP health check It makes a request to an HTTP endpoint to check that the service is healthy. --- Cargo.toml | 1 + src/bin/http_health_check.rs | 37 ++++++++++++++++++++++++++++++++++++ 2 files changed, 38 insertions(+) create mode 100644 src/bin/http_health_check.rs diff --git a/Cargo.toml b/Cargo.toml index 51a1ac00f..2316a1edf 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,4 +1,5 @@ [package] +default-run = "torrust-tracker" name = "torrust-tracker" readme = "README.md" diff --git a/src/bin/http_health_check.rs b/src/bin/http_health_check.rs new file mode 100644 index 000000000..2c39f2a79 --- /dev/null +++ b/src/bin/http_health_check.rs @@ -0,0 +1,37 @@ +//! Minimal `curl` or `wget` to be used for container health checks. +//! +//! It's convenient to avoid using third-party libraries because: +//! +//! - They are harder to maintain. +//! - They introduce new attack vectors. +use std::{env, process}; + +#[tokio::main] +async fn main() { + let args: Vec = env::args().collect(); + if args.len() != 2 { + eprintln!("Usage: cargo run --bin health_check "); + eprintln!("Example: cargo run --bin http_health_check http://localhost:1212/api/v1/stats?token=MyAccessToken"); + std::process::exit(1); + } + + println!("Health check ..."); + + let url = &args[1].clone(); + + match reqwest::get(url).await { + Ok(response) => { + if response.status().is_success() { + println!("STATUS: {}", response.status()); + process::exit(0); + } else { + println!("Non-success status received."); + process::exit(1); + } + } + Err(err) => { + println!("ERROR: {err}"); + process::exit(1); + } + } +} \ No newline at end of file From 48ac64fff9ccc1e258d4adf257dd5223fa050796 Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Tue, 21 Nov 2023 17:30:53 +0000 Subject: [PATCH 3/9] feat: [#508] add container healthcheck for API --- Containerfile | 6 ++-- src/bin/http_health_check.rs | 6 ++-- src/servers/apis/routes.rs | 7 ++-- .../apis/v1/context/health_check/handlers.rs | 11 ++++++ .../apis/v1/context/health_check/mod.rs | 34 +++++++++++++++++++ .../apis/v1/context/health_check/resources.rs | 14 ++++++++ src/servers/apis/v1/context/mod.rs | 1 + tests/servers/api/v1/client.rs | 2 +- .../api/v1/contract/context/health_check.rs | 20 +++++++++++ tests/servers/api/v1/contract/context/mod.rs | 1 + 10 files changed, 94 insertions(+), 8 deletions(-) create mode 100644 src/servers/apis/v1/context/health_check/handlers.rs create mode 100644 src/servers/apis/v1/context/health_check/mod.rs create mode 100644 src/servers/apis/v1/context/health_check/resources.rs create mode 100644 tests/servers/api/v1/contract/context/health_check.rs diff --git a/Containerfile b/Containerfile index be71017db..244f7bdfc 100644 --- a/Containerfile +++ b/Containerfile @@ -85,7 +85,7 @@ COPY --from=build \ RUN cargo nextest run --workspace-remap /test/src/ --extract-to /test/src/ --no-run --archive-file /test/torrust-tracker.tar.zst RUN cargo nextest run --workspace-remap /test/src/ --target-dir-remap /test/src/target/ --cargo-metadata /test/src/target/nextest/cargo-metadata.json --binaries-metadata /test/src/target/nextest/binaries-metadata.json -RUN mkdir -p /app/bin/; cp -l /test/src/target/release/torrust-tracker /app/bin/torrust-tracker +RUN mkdir -p /app/bin/; cp -l /test/src/target/release/torrust-tracker /app/bin/torrust-tracker; cp -l /test/src/target/release/http_health_check /app/bin/http_health_check RUN mkdir -p /app/lib/; cp -l $(realpath $(ldd /app/bin/torrust-tracker | grep "libz\.so\.1" | awk '{print $3}')) /app/lib/libz.so.1 RUN chown -R root:root /app; chmod -R u=rw,go=r,a+X /app; chmod -R a+x /app/bin @@ -136,5 +136,7 @@ CMD ["sh"] FROM runtime as release ENV RUNTIME="release" COPY --from=test /app/ /usr/ -# HEALTHCHECK CMD ["/usr/bin/wget", "--no-verbose", "--tries=1", "--spider", "localhost:${API_PORT}/version"] +HEALTHCHECK --interval=5s --timeout=5s --start-period=3s --retries=3 \ + CMD /usr/bin/http_health_check http://localhost:${API_PORT}/health_check \ + || exit 1 CMD ["/usr/bin/torrust-tracker"] diff --git a/src/bin/http_health_check.rs b/src/bin/http_health_check.rs index 2c39f2a79..313f44045 100644 --- a/src/bin/http_health_check.rs +++ b/src/bin/http_health_check.rs @@ -10,8 +10,8 @@ use std::{env, process}; async fn main() { let args: Vec = env::args().collect(); if args.len() != 2 { - eprintln!("Usage: cargo run --bin health_check "); - eprintln!("Example: cargo run --bin http_health_check http://localhost:1212/api/v1/stats?token=MyAccessToken"); + eprintln!("Usage: cargo run --bin http_health_check "); + eprintln!("Example: cargo run --bin http_health_check http://127.0.0.1:1212/health_check"); std::process::exit(1); } @@ -34,4 +34,4 @@ async fn main() { process::exit(1); } } -} \ No newline at end of file +} diff --git a/src/servers/apis/routes.rs b/src/servers/apis/routes.rs index 7801389f3..0740e1f6a 100644 --- a/src/servers/apis/routes.rs +++ b/src/servers/apis/routes.rs @@ -7,10 +7,12 @@ //! first path segment. For example: `/api/v1/torrents`. use std::sync::Arc; +use axum::routing::get; use axum::{middleware, Router}; use tower_http::compression::CompressionLayer; use super::v1; +use super::v1::context::health_check::handlers::health_check_handler; use crate::tracker::Tracker; /// Add all API routes to the router. @@ -18,14 +20,15 @@ use crate::tracker::Tracker; pub fn router(tracker: Arc) -> Router { let router = Router::new(); - let prefix = "/api"; + let api_url_prefix = "/api"; - let router = v1::routes::add(prefix, router, tracker.clone()); + let router = v1::routes::add(api_url_prefix, router, tracker.clone()); router .layer(middleware::from_fn_with_state( tracker.config.clone(), v1::middlewares::auth::auth, )) + .route("/health_check", get(health_check_handler)) .layer(CompressionLayer::new()) } diff --git a/src/servers/apis/v1/context/health_check/handlers.rs b/src/servers/apis/v1/context/health_check/handlers.rs new file mode 100644 index 000000000..bfbeab549 --- /dev/null +++ b/src/servers/apis/v1/context/health_check/handlers.rs @@ -0,0 +1,11 @@ +//! API handlers for the [`stats`](crate::servers::apis::v1::context::health_check) +//! API context. + +use axum::Json; + +use super::resources::{Report, Status}; + +/// Endpoint for container health check. +pub async fn health_check_handler() -> Json { + Json(Report { status: Status::Ok }) +} diff --git a/src/servers/apis/v1/context/health_check/mod.rs b/src/servers/apis/v1/context/health_check/mod.rs new file mode 100644 index 000000000..c62c5e97b --- /dev/null +++ b/src/servers/apis/v1/context/health_check/mod.rs @@ -0,0 +1,34 @@ +//! API health check endpoint. +//! +//! It is used to check is the service is running. Especially for containers. +//! +//! # Endpoints +//! +//! - [Health Check](#health-check) +//! +//! # Health Check +//! +//! `GET /health_check` +//! +//! Returns the API status. +//! +//! **Example request** +//! +//! ```bash +//! curl "http://127.0.0.1:1212/health_check" +//! ``` +//! +//! **Example response** `200` +//! +//! ```json +//! { +//! "status": "Ok", +//! } +//! ``` +//! +//! **Resource** +//! +//! Refer to the API [`Stats`](crate::servers::apis::v1::context::health_check::resources::Report) +//! resource for more information about the response attributes. +pub mod handlers; +pub mod resources; diff --git a/src/servers/apis/v1/context/health_check/resources.rs b/src/servers/apis/v1/context/health_check/resources.rs new file mode 100644 index 000000000..9830e643c --- /dev/null +++ b/src/servers/apis/v1/context/health_check/resources.rs @@ -0,0 +1,14 @@ +//! API resources for the [`stats`](crate::servers::apis::v1::context::health_check) +//! API context. +use serde::{Deserialize, Serialize}; + +#[derive(Serialize, Deserialize, Debug, PartialEq, Eq)] +pub enum Status { + Ok, + Error, +} + +#[derive(Serialize, Deserialize, Debug, PartialEq, Eq)] +pub struct Report { + pub status: Status, +} diff --git a/src/servers/apis/v1/context/mod.rs b/src/servers/apis/v1/context/mod.rs index 5e268a429..be67cd96a 100644 --- a/src/servers/apis/v1/context/mod.rs +++ b/src/servers/apis/v1/context/mod.rs @@ -3,6 +3,7 @@ //! Each context is a module that contains the API endpoints related to a //! specific resource group. pub mod auth_key; +pub mod health_check; pub mod stats; pub mod torrent; pub mod whitelist; diff --git a/tests/servers/api/v1/client.rs b/tests/servers/api/v1/client.rs index 2b6db2e77..61e98e742 100644 --- a/tests/servers/api/v1/client.rs +++ b/tests/servers/api/v1/client.rs @@ -101,7 +101,7 @@ impl Client { } } -async fn get(path: &str, query: Option) -> Response { +pub async fn get(path: &str, query: Option) -> Response { match query { Some(params) => reqwest::Client::builder() .build() diff --git a/tests/servers/api/v1/contract/context/health_check.rs b/tests/servers/api/v1/contract/context/health_check.rs new file mode 100644 index 000000000..3b6c98374 --- /dev/null +++ b/tests/servers/api/v1/contract/context/health_check.rs @@ -0,0 +1,20 @@ +use torrust_tracker::servers::apis::v1::context::health_check::resources::{Report, Status}; +use torrust_tracker_test_helpers::configuration; + +use crate::servers::api::test_environment::running_test_environment; +use crate::servers::api::v1::client::get; + +#[tokio::test] +async fn health_check_endpoint_should_return_status_ok_if_api_is_running() { + let test_env = running_test_environment(configuration::ephemeral()).await; + + let url = format!("http://{}/health_check", test_env.get_connection_info().bind_address); + + let response = get(&url, None).await; + + assert_eq!(response.status(), 200); + assert_eq!(response.headers().get("content-type").unwrap(), "application/json"); + assert_eq!(response.json::().await.unwrap(), Report { status: Status::Ok }); + + test_env.stop().await; +} diff --git a/tests/servers/api/v1/contract/context/mod.rs b/tests/servers/api/v1/contract/context/mod.rs index 6d3fb7566..032e13b0b 100644 --- a/tests/servers/api/v1/contract/context/mod.rs +++ b/tests/servers/api/v1/contract/context/mod.rs @@ -1,4 +1,5 @@ pub mod auth_key; +pub mod health_check; pub mod stats; pub mod torrent; pub mod whitelist; From e1a45a2561a78c83fc3c6b44063a0aae073ac227 Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Fri, 24 Nov 2023 10:30:45 +0000 Subject: [PATCH 4/9] feat: [#508] Health Check API but no checks yet New Health CHeck API, but it is not checking anything yet. You can call it at: http://localhost:1313/health_check --- Containerfile | 5 +- docs/containers.md | 1 + packages/configuration/src/lib.rs | 50 +++++++++---- packages/test-helpers/src/configuration.rs | 4 + .../config/tracker.container.mysql.toml | 3 + .../config/tracker.container.sqlite3.toml | 3 + .../config/tracker.development.sqlite3.toml | 3 + src/app.rs | 18 ++++- src/bootstrap/jobs/health_check_api.rs | 74 +++++++++++++++++++ src/bootstrap/jobs/mod.rs | 1 + src/lib.rs | 29 ++++---- src/servers/health_check_api/mod.rs | 1 + src/servers/health_check_api/server.rs | 52 +++++++++++++ src/servers/mod.rs | 1 + tests/servers/health_check_api/client.rs | 5 ++ tests/servers/health_check_api/contract.rs | 22 ++++++ tests/servers/health_check_api/mod.rs | 3 + .../health_check_api/test_environment.rs | 32 ++++++++ tests/servers/mod.rs | 1 + 19 files changed, 275 insertions(+), 33 deletions(-) create mode 100644 src/bootstrap/jobs/health_check_api.rs create mode 100644 src/servers/health_check_api/mod.rs create mode 100644 src/servers/health_check_api/server.rs create mode 100644 tests/servers/health_check_api/client.rs create mode 100644 tests/servers/health_check_api/contract.rs create mode 100644 tests/servers/health_check_api/mod.rs create mode 100644 tests/servers/health_check_api/test_environment.rs diff --git a/Containerfile b/Containerfile index 244f7bdfc..8ea555c2d 100644 --- a/Containerfile +++ b/Containerfile @@ -101,6 +101,7 @@ ARG USER_ID=1000 ARG UDP_PORT=6969 ARG HTTP_PORT=7070 ARG API_PORT=1212 +ARG HEALTH_CHECK_API_PORT=1313 ENV TORRUST_TRACKER_PATH_CONFIG=${TORRUST_TRACKER_PATH_CONFIG} ENV TORRUST_TRACKER_DATABASE_DRIVER=${TORRUST_TRACKER_DATABASE_DRIVER} @@ -108,11 +109,13 @@ ENV USER_ID=${USER_ID} ENV UDP_PORT=${UDP_PORT} ENV HTTP_PORT=${HTTP_PORT} ENV API_PORT=${API_PORT} +ENV HEALTH_CHECK_API_PORT=${HEALTH_CHECK_API_PORT} ENV TZ=Etc/UTC EXPOSE ${UDP_PORT}/udp EXPOSE ${HTTP_PORT}/tcp EXPOSE ${API_PORT}/tcp +EXPOSE ${HEALTH_CHECK_API_PORT}/tcp RUN mkdir -p /var/lib/torrust/tracker /var/log/torrust/tracker /etc/torrust/tracker @@ -137,6 +140,6 @@ FROM runtime as release ENV RUNTIME="release" COPY --from=test /app/ /usr/ HEALTHCHECK --interval=5s --timeout=5s --start-period=3s --retries=3 \ - CMD /usr/bin/http_health_check http://localhost:${API_PORT}/health_check \ + CMD /usr/bin/http_health_check http://localhost:${HEALTH_CHECK_API_PORT}/health_check \ || exit 1 CMD ["/usr/bin/torrust-tracker"] diff --git a/docs/containers.md b/docs/containers.md index 737ce40a0..2b06c0f76 100644 --- a/docs/containers.md +++ b/docs/containers.md @@ -146,6 +146,7 @@ The following environmental variables can be set: - `UDP_PORT` - The port for the UDP tracker. This should match the port used in the configuration, (default `6969`). - `HTTP_PORT` - The port for the HTTP tracker. This should match the port used in the configuration, (default `7070`). - `API_PORT` - The port for the tracker API. This should match the port used in the configuration, (default `1212`). +- `HEALTH_CHECK_API_PORT` - The port for the Health Check API. This should match the port used in the configuration, (default `1313`). ### Sockets diff --git a/packages/configuration/src/lib.rs b/packages/configuration/src/lib.rs index 059316a26..217f8a8be 100644 --- a/packages/configuration/src/lib.rs +++ b/packages/configuration/src/lib.rs @@ -191,40 +191,43 @@ //! The default configuration is: //! //! ```toml -//! log_level = "info" -//! mode = "public" +//! announce_interval = 120 //! db_driver = "Sqlite3" //! db_path = "./storage/tracker/lib/database/sqlite3.db" -//! announce_interval = 120 -//! min_announce_interval = 120 +//! 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 -//! external_ip = "0.0.0.0" -//! tracker_usage_statistics = true //! persistent_torrent_completed_stat = false -//! inactive_peer_cleanup_interval = 600 //! remove_peerless_torrents = true +//! tracker_usage_statistics = true //! //! [[udp_trackers]] -//! enabled = false //! bind_address = "0.0.0.0:6969" +//! enabled = false //! //! [[http_trackers]] -//! enabled = false //! bind_address = "0.0.0.0:7070" -//! ssl_enabled = false +//! enabled = false //! ssl_cert_path = "" +//! ssl_enabled = false //! ssl_key_path = "" //! //! [http_api] -//! enabled = true //! bind_address = "127.0.0.1:1212" -//! ssl_enabled = false +//! enabled = true //! ssl_cert_path = "" +//! ssl_enabled = false //! ssl_key_path = "" //! //! [http_api.access_tokens] //! admin = "MyAccessToken" +//! +//! [health_check_api] +//! bind_address = "127.0.0.1:1313" //!``` use std::collections::{HashMap, HashSet}; use std::net::IpAddr; @@ -342,7 +345,7 @@ pub struct HttpApi { /// The address the tracker will bind to. /// The format is `ip:port`, for example `0.0.0.0:6969`. If you want to /// listen to all interfaces, use `0.0.0.0`. If you want the operating - /// system to choose a random port, use port `0`. + /// system to choose a random port, use port `0`. pub bind_address: String, /// Weather the HTTP API will use SSL or not. pub ssl_enabled: bool, @@ -363,9 +366,7 @@ impl HttpApi { fn override_admin_token(&mut self, api_admin_token: &str) { self.access_tokens.insert("admin".to_string(), api_admin_token.to_string()); } -} -impl HttpApi { /// Checks if the given token is one of the token in the configuration. #[must_use] pub fn contains_token(&self, token: &str) -> bool { @@ -375,6 +376,17 @@ impl HttpApi { } } +/// Configuration for the Health Check API. +#[serde_as] +#[derive(Serialize, Deserialize, PartialEq, Eq, Debug, Clone)] +pub struct HealthCheckApi { + /// The address the API will bind to. + /// The format is `ip:port`, for example `127.0.0.1:1313`. If you want to + /// listen to all interfaces, use `0.0.0.0`. If you want the operating + /// system to choose a random port, use port `0`. + pub bind_address: String, +} + /// Core configuration for the tracker. #[allow(clippy::struct_excessive_bools)] #[derive(Serialize, Deserialize, PartialEq, Eq, Debug)] @@ -465,6 +477,8 @@ pub struct Configuration { pub http_trackers: Vec, /// The HTTP API configuration. pub http_api: HttpApi, + /// The Health Check API configuration. + pub health_check_api: HealthCheckApi, } /// Errors that can occur when loading the configuration. @@ -529,6 +543,9 @@ impl Default for Configuration { .cloned() .collect(), }, + health_check_api: HealthCheckApi { + bind_address: String::from("127.0.0.1:1313"), + }, }; configuration.udp_trackers.push(UdpTracker { enabled: false, @@ -676,6 +693,9 @@ mod tests { [http_api.access_tokens] admin = "MyAccessToken" + + [health_check_api] + bind_address = "127.0.0.1:1313" "# .lines() .map(str::trim_start) diff --git a/packages/test-helpers/src/configuration.rs b/packages/test-helpers/src/configuration.rs index 437475ee2..b41f435ec 100644 --- a/packages/test-helpers/src/configuration.rs +++ b/packages/test-helpers/src/configuration.rs @@ -37,6 +37,10 @@ pub fn ephemeral() -> Configuration { config.http_api.enabled = true; config.http_api.bind_address = format!("127.0.0.1:{}", &api_port); + // Ephemeral socket address for Health Check API + let health_check_api_port = 0u16; + config.health_check_api.bind_address = format!("127.0.0.1:{}", &health_check_api_port); + // Ephemeral socket address for UDP tracker let udp_port = 0u16; config.udp_trackers[0].enabled = true; diff --git a/share/default/config/tracker.container.mysql.toml b/share/default/config/tracker.container.mysql.toml index fb9cbf789..e7714c229 100644 --- a/share/default/config/tracker.container.mysql.toml +++ b/share/default/config/tracker.container.mysql.toml @@ -36,3 +36,6 @@ ssl_key_path = "/var/lib/torrust/tracker/tls/localhost.key" [http_api.access_tokens] admin = "MyAccessToken" + +[health_check_api] +bind_address = "127.0.0.1:1313" diff --git a/share/default/config/tracker.container.sqlite3.toml b/share/default/config/tracker.container.sqlite3.toml index 54cfd4023..4ec055c56 100644 --- a/share/default/config/tracker.container.sqlite3.toml +++ b/share/default/config/tracker.container.sqlite3.toml @@ -36,3 +36,6 @@ ssl_key_path = "/var/lib/torrust/tracker/tls/localhost.key" [http_api.access_tokens] admin = "MyAccessToken" + +[health_check_api] +bind_address = "127.0.0.1:1313" diff --git a/share/default/config/tracker.development.sqlite3.toml b/share/default/config/tracker.development.sqlite3.toml index 20f95ac5d..04934dd8a 100644 --- a/share/default/config/tracker.development.sqlite3.toml +++ b/share/default/config/tracker.development.sqlite3.toml @@ -32,3 +32,6 @@ ssl_key_path = "" [http_api.access_tokens] admin = "MyAccessToken" + +[health_check_api] +bind_address = "127.0.0.1:1313" diff --git a/src/app.rs b/src/app.rs index 3fc790a23..6478cffb8 100644 --- a/src/app.rs +++ b/src/app.rs @@ -11,7 +11,11 @@ //! - Loading data from the database when it's needed. //! - Starting some jobs depending on the configuration. //! -//! The started jobs may be: +//! Jobs executed always: +//! +//! - Health Check API +//! +//! Optional jobs: //! //! - Torrent cleaner: it removes inactive peers and (optionally) peerless torrents. //! - UDP trackers: the user can enable multiple UDP tracker on several ports. @@ -23,13 +27,16 @@ use log::warn; use tokio::task::JoinHandle; use torrust_tracker_configuration::Configuration; -use crate::bootstrap::jobs::{http_tracker, torrent_cleanup, tracker_apis, udp_tracker}; +use crate::bootstrap::jobs::{health_check_api, http_tracker, torrent_cleanup, tracker_apis, udp_tracker}; use crate::servers::http::Version; use crate::tracker; /// # Panics /// -/// Will panic if the socket address for API can't be parsed. +/// Will panic if: +/// +/// - Can't retrieve tracker keys from database. +/// - Can't load whitelist from database. pub async fn start(config: Arc, tracker: Arc) -> Vec> { let mut jobs: Vec> = Vec::new(); @@ -78,10 +85,13 @@ pub async fn start(config: Arc, tracker: Arc) - jobs.push(tracker_apis::start_job(&config.http_api, tracker.clone()).await); } - // Remove torrents without peers, every interval + // Start runners to remove torrents without peers, every interval if config.inactive_peer_cleanup_interval > 0 { jobs.push(torrent_cleanup::start_job(&config, &tracker)); } + // Start Health Check API + jobs.push(health_check_api::start_job(&config.health_check_api).await); + jobs } diff --git a/src/bootstrap/jobs/health_check_api.rs b/src/bootstrap/jobs/health_check_api.rs new file mode 100644 index 000000000..29c4ce144 --- /dev/null +++ b/src/bootstrap/jobs/health_check_api.rs @@ -0,0 +1,74 @@ +//! Health Check API job starter. +//! +//! The [`health_check_api::start_job`](crate::bootstrap::jobs::health_check_api::start_job) +//! function starts the Health Check REST API. +//! +//! The [`health_check_api::start_job`](crate::bootstrap::jobs::health_check_api::start_job) +//! function spawns a new asynchronous task, that tasks is the "**launcher**". +//! The "**launcher**" starts the actual server and sends a message back +//! to the main application. The main application waits until receives +//! the message [`ApiServerJobStarted`] +//! from the "**launcher**". +//! +//! The "**launcher**" is an intermediary thread that decouples the Health Check +//! API server from the process that handles it. +//! +//! Refer to the [configuration documentation](https://docs.rs/torrust-tracker-configuration) +//! for the API configuration options. +use std::net::SocketAddr; + +use log::info; +use tokio::sync::oneshot; +use tokio::task::JoinHandle; +use torrust_tracker_configuration::HealthCheckApi; + +use crate::servers::health_check_api::server; + +/// This is the message that the "launcher" spawned task sends to the main +/// application process to notify the API server was successfully started. +/// +/// > **NOTICE**: it does not mean the API server is ready to receive requests. +/// It only means the new server started. It might take some time to the server +/// to be ready to accept request. +#[derive(Debug)] +pub struct ApiServerJobStarted { + pub bound_addr: SocketAddr, +} + +/// This function starts a new Health Check API server with the provided +/// configuration. +/// +/// The functions starts a new concurrent task that will run the API server. +/// This task will send a message to the main application process to notify +/// that the API server was successfully started. +/// +/// # Panics +/// +/// It would panic if unable to send the `ApiServerJobStarted` notice. +pub async fn start_job(config: &HealthCheckApi) -> JoinHandle<()> { + let bind_addr = config + .bind_address + .parse::() + .expect("Health Check API bind_address invalid."); + + let (tx, rx) = oneshot::channel::(); + + // Run the API server + let join_handle = tokio::spawn(async move { + info!("Starting Health Check API server: http://{}", bind_addr); + + let handle = server::start(bind_addr, tx); + + if let Ok(()) = handle.await { + info!("Health Check API server on http://{} stopped", bind_addr); + } + }); + + // Wait until the API server job is running + match rx.await { + Ok(_msg) => info!("Torrust Health Check API server started"), + Err(e) => panic!("the Health Check API server was dropped: {e}"), + } + + join_handle +} diff --git a/src/bootstrap/jobs/mod.rs b/src/bootstrap/jobs/mod.rs index c519a9f4b..8c85ba45b 100644 --- a/src/bootstrap/jobs/mod.rs +++ b/src/bootstrap/jobs/mod.rs @@ -6,6 +6,7 @@ //! 2. Launch all the application services as concurrent jobs. //! //! This modules contains all the functions needed to start those jobs. +pub mod health_check_api; pub mod http_tracker; pub mod torrent_cleanup; pub mod tracker_apis; diff --git a/src/lib.rs b/src/lib.rs index c2e70a8b1..8d453f177 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -148,41 +148,44 @@ //! The default configuration is: //! //! ```toml -//! log_level = "info" -//! mode = "public" +//! announce_interval = 120 //! db_driver = "Sqlite3" //! db_path = "./storage/tracker/lib/database/sqlite3.db" -//! announce_interval = 120 -//! min_announce_interval = 120 +//! 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 -//! external_ip = "0.0.0.0" -//! tracker_usage_statistics = true //! persistent_torrent_completed_stat = false -//! inactive_peer_cleanup_interval = 600 //! remove_peerless_torrents = true +//! tracker_usage_statistics = true //! //! [[udp_trackers]] -//! enabled = false //! bind_address = "0.0.0.0:6969" +//! enabled = false //! //! [[http_trackers]] -//! enabled = false //! bind_address = "0.0.0.0:7070" -//! ssl_enabled = false +//! enabled = false //! ssl_cert_path = "" +//! ssl_enabled = false //! ssl_key_path = "" //! //! [http_api] -//! enabled = true //! bind_address = "127.0.0.1:1212" -//! ssl_enabled = false +//! enabled = true //! ssl_cert_path = "" +//! ssl_enabled = false //! ssl_key_path = "" //! //! [http_api.access_tokens] //! admin = "MyAccessToken" -//! ``` +//! +//! [health_check_api] +//! bind_address = "127.0.0.1:1313" +//!``` //! //! The default configuration includes one disabled UDP server, one disabled HTTP server and the enabled API. //! diff --git a/src/servers/health_check_api/mod.rs b/src/servers/health_check_api/mod.rs new file mode 100644 index 000000000..74f47ad34 --- /dev/null +++ b/src/servers/health_check_api/mod.rs @@ -0,0 +1 @@ +pub mod server; diff --git a/src/servers/health_check_api/server.rs b/src/servers/health_check_api/server.rs new file mode 100644 index 000000000..cbd1b8703 --- /dev/null +++ b/src/servers/health_check_api/server.rs @@ -0,0 +1,52 @@ +//! Logic to run the Health Check HTTP API server. +//! +//! This API is intended to be used by the container infrastructure to check if +//! the whole application is healthy. +use std::net::SocketAddr; + +use axum::routing::get; +use axum::{Json, Router}; +use futures::Future; +use log::info; +use serde_json::{json, Value}; +use tokio::sync::oneshot::Sender; + +use crate::bootstrap::jobs::health_check_api::ApiServerJobStarted; + +/// Starts Health Check API server. +/// +/// # Panics +/// +/// Will panic if binding to the socket address fails. +pub fn start(socket_addr: SocketAddr, tx: Sender) -> impl Future> { + let app = Router::new() + .route("/", get(|| async { Json(json!({})) })) + .route("/health_check", get(health_check_handler)); + + let server = axum::Server::bind(&socket_addr).serve(app.into_make_service()); + + let bound_addr = server.local_addr(); + + info!("Health Check API server listening on http://{}", bound_addr); + + let running = server.with_graceful_shutdown(async move { + tokio::signal::ctrl_c().await.expect("Failed to listen to shutdown signal."); + info!("Stopping Torrust Health Check API server o http://{} ...", socket_addr); + }); + + tx.send(ApiServerJobStarted { bound_addr }) + .expect("the Health Check API server should not be dropped"); + + running +} + +/// Endpoint for container health check. +async fn health_check_handler() -> Json { + // todo: if enabled, check if the Tracker API is healthy + + // todo: if enabled, check if the HTTP Tracker is healthy + + // todo: if enabled, check if the UDP Tracker is healthy + + Json(json!({ "status": "Ok" })) +} diff --git a/src/servers/mod.rs b/src/servers/mod.rs index 38b4b70cd..077109f35 100644 --- a/src/servers/mod.rs +++ b/src/servers/mod.rs @@ -1,5 +1,6 @@ //! Servers. Services that can be started and stopped. pub mod apis; +pub mod health_check_api; pub mod http; pub mod signals; pub mod udp; diff --git a/tests/servers/health_check_api/client.rs b/tests/servers/health_check_api/client.rs new file mode 100644 index 000000000..3d8bdc7d6 --- /dev/null +++ b/tests/servers/health_check_api/client.rs @@ -0,0 +1,5 @@ +use reqwest::Response; + +pub async fn get(path: &str) -> Response { + reqwest::Client::builder().build().unwrap().get(path).send().await.unwrap() +} diff --git a/tests/servers/health_check_api/contract.rs b/tests/servers/health_check_api/contract.rs new file mode 100644 index 000000000..575e10665 --- /dev/null +++ b/tests/servers/health_check_api/contract.rs @@ -0,0 +1,22 @@ +use torrust_tracker::servers::apis::v1::context::health_check::resources::{Report, Status}; +use torrust_tracker_test_helpers::configuration; + +use crate::servers::health_check_api::client::get; +use crate::servers::health_check_api::test_environment; + +#[tokio::test] +async fn health_check_endpoint_should_return_status_ok() { + let configuration = configuration::ephemeral(); + + let (bound_addr, test_env) = test_environment::start(&configuration.health_check_api).await; + + let url = format!("http://{bound_addr}/health_check"); + + let response = get(&url).await; + + assert_eq!(response.status(), 200); + assert_eq!(response.headers().get("content-type").unwrap(), "application/json"); + assert_eq!(response.json::().await.unwrap(), Report { status: Status::Ok }); + + test_env.abort(); +} diff --git a/tests/servers/health_check_api/mod.rs b/tests/servers/health_check_api/mod.rs new file mode 100644 index 000000000..89f19a334 --- /dev/null +++ b/tests/servers/health_check_api/mod.rs @@ -0,0 +1,3 @@ +pub mod client; +pub mod contract; +pub mod test_environment; diff --git a/tests/servers/health_check_api/test_environment.rs b/tests/servers/health_check_api/test_environment.rs new file mode 100644 index 000000000..6ad90eac7 --- /dev/null +++ b/tests/servers/health_check_api/test_environment.rs @@ -0,0 +1,32 @@ +use std::net::SocketAddr; + +use tokio::sync::oneshot; +use tokio::task::JoinHandle; +use torrust_tracker::bootstrap::jobs::health_check_api::ApiServerJobStarted; +use torrust_tracker::servers::health_check_api::server; +use torrust_tracker_configuration::HealthCheckApi; + +/// Start the test environment for the Health Check API. +/// It runs the API server. +pub async fn start(config: &HealthCheckApi) -> (SocketAddr, JoinHandle<()>) { + let bind_addr = config + .bind_address + .parse::() + .expect("Health Check API bind_address invalid."); + + let (tx, rx) = oneshot::channel::(); + + let join_handle = tokio::spawn(async move { + let handle = server::start(bind_addr, tx); + if let Ok(()) = handle.await { + panic!("Health Check API server on http://{bind_addr} stopped"); + } + }); + + let bound_addr = match rx.await { + Ok(msg) => msg.bound_addr, + Err(e) => panic!("the Health Check API server was dropped: {e}"), + }; + + (bound_addr, join_handle) +} diff --git a/tests/servers/mod.rs b/tests/servers/mod.rs index 7c30b6f40..65e9a665b 100644 --- a/tests/servers/mod.rs +++ b/tests/servers/mod.rs @@ -1,3 +1,4 @@ mod api; +pub mod health_check_api; mod http; mod udp; From ef296f76bf3160e219f4b842fe1d055d4c9dc308 Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Fri, 24 Nov 2023 16:13:04 +0000 Subject: [PATCH 5/9] feat: [#508] app health check endpoint checks API The app health check endpoint checks is the API is running healthy when is enabled. --- packages/test-helpers/src/configuration.rs | 12 +++++++ src/app.rs | 2 +- src/bootstrap/jobs/health_check_api.rs | 8 +++-- src/servers/health_check_api/handlers.rs | 31 +++++++++++++++++++ src/servers/health_check_api/mod.rs | 3 ++ src/servers/health_check_api/resources.rs | 31 +++++++++++++++++++ src/servers/health_check_api/responses.rs | 11 +++++++ src/servers/health_check_api/server.rs | 25 +++++++-------- tests/servers/health_check_api/contract.rs | 10 +++--- .../health_check_api/test_environment.rs | 8 +++-- 10 files changed, 115 insertions(+), 26 deletions(-) create mode 100644 src/servers/health_check_api/handlers.rs create mode 100644 src/servers/health_check_api/resources.rs create mode 100644 src/servers/health_check_api/responses.rs diff --git a/packages/test-helpers/src/configuration.rs b/packages/test-helpers/src/configuration.rs index b41f435ec..388d0151f 100644 --- a/packages/test-helpers/src/configuration.rs +++ b/packages/test-helpers/src/configuration.rs @@ -144,3 +144,15 @@ pub fn ephemeral_ipv6() -> Configuration { cfg } + +/// Ephemeral without running any services. +#[must_use] +pub fn ephemeral_with_no_services() -> Configuration { + let mut cfg = ephemeral(); + + cfg.http_api.enabled = false; + cfg.http_trackers[0].enabled = false; + cfg.udp_trackers[0].enabled = false; + + cfg +} diff --git a/src/app.rs b/src/app.rs index 6478cffb8..e749f9c64 100644 --- a/src/app.rs +++ b/src/app.rs @@ -91,7 +91,7 @@ pub async fn start(config: Arc, tracker: Arc) - } // Start Health Check API - jobs.push(health_check_api::start_job(&config.health_check_api).await); + jobs.push(health_check_api::start_job(config).await); jobs } diff --git a/src/bootstrap/jobs/health_check_api.rs b/src/bootstrap/jobs/health_check_api.rs index 29c4ce144..96a703afc 100644 --- a/src/bootstrap/jobs/health_check_api.rs +++ b/src/bootstrap/jobs/health_check_api.rs @@ -16,11 +16,12 @@ //! Refer to the [configuration documentation](https://docs.rs/torrust-tracker-configuration) //! for the API configuration options. use std::net::SocketAddr; +use std::sync::Arc; use log::info; use tokio::sync::oneshot; use tokio::task::JoinHandle; -use torrust_tracker_configuration::HealthCheckApi; +use torrust_tracker_configuration::Configuration; use crate::servers::health_check_api::server; @@ -45,8 +46,9 @@ pub struct ApiServerJobStarted { /// # Panics /// /// It would panic if unable to send the `ApiServerJobStarted` notice. -pub async fn start_job(config: &HealthCheckApi) -> JoinHandle<()> { +pub async fn start_job(config: Arc) -> JoinHandle<()> { let bind_addr = config + .health_check_api .bind_address .parse::() .expect("Health Check API bind_address invalid."); @@ -57,7 +59,7 @@ pub async fn start_job(config: &HealthCheckApi) -> JoinHandle<()> { let join_handle = tokio::spawn(async move { info!("Starting Health Check API server: http://{}", bind_addr); - let handle = server::start(bind_addr, tx); + let handle = server::start(bind_addr, tx, config.clone()); if let Ok(()) = handle.await { info!("Health Check API server on http://{} stopped", bind_addr); diff --git a/src/servers/health_check_api/handlers.rs b/src/servers/health_check_api/handlers.rs new file mode 100644 index 000000000..347106d6e --- /dev/null +++ b/src/servers/health_check_api/handlers.rs @@ -0,0 +1,31 @@ +use std::sync::Arc; + +use axum::extract::State; +use axum::Json; +use torrust_tracker_configuration::Configuration; + +use super::resources::Report; +use super::responses; + +/// Endpoint for container health check. +pub(crate) async fn health_check_handler(State(config): State>) -> Json { + if config.http_api.enabled { + let health_check_url = format!("http://{}/health_check", config.http_api.bind_address); + if !get_req_is_ok(&health_check_url).await { + return responses::error(format!("API is not healthy. Health check endpoint: {health_check_url}")); + } + } + + // todo: for all HTTP Trackers, if enabled, check if is healthy + + // todo: for all UDP Trackers, if enabled, check if is healthy + + responses::ok() +} + +async fn get_req_is_ok(url: &str) -> bool { + match reqwest::get(url).await { + Ok(response) => response.status().is_success(), + Err(_err) => false, + } +} diff --git a/src/servers/health_check_api/mod.rs b/src/servers/health_check_api/mod.rs index 74f47ad34..ec608387d 100644 --- a/src/servers/health_check_api/mod.rs +++ b/src/servers/health_check_api/mod.rs @@ -1 +1,4 @@ +pub mod handlers; +pub mod resources; +pub mod responses; pub mod server; diff --git a/src/servers/health_check_api/resources.rs b/src/servers/health_check_api/resources.rs new file mode 100644 index 000000000..3fadcf456 --- /dev/null +++ b/src/servers/health_check_api/resources.rs @@ -0,0 +1,31 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Serialize, Deserialize, Debug, PartialEq, Eq)] +pub enum Status { + Ok, + Error, +} + +#[derive(Serialize, Deserialize, Debug, PartialEq, Eq)] +pub struct Report { + pub status: Status, + pub message: String, +} + +impl Report { + #[must_use] + pub fn ok() -> Report { + Self { + status: Status::Ok, + message: String::new(), + } + } + + #[must_use] + pub fn error(message: String) -> Report { + Self { + status: Status::Error, + message, + } + } +} diff --git a/src/servers/health_check_api/responses.rs b/src/servers/health_check_api/responses.rs new file mode 100644 index 000000000..043e271db --- /dev/null +++ b/src/servers/health_check_api/responses.rs @@ -0,0 +1,11 @@ +use axum::Json; + +use super::resources::Report; + +pub fn ok() -> Json { + Json(Report::ok()) +} + +pub fn error(message: String) -> Json { + Json(Report::error(message)) +} diff --git a/src/servers/health_check_api/server.rs b/src/servers/health_check_api/server.rs index cbd1b8703..562772a87 100644 --- a/src/servers/health_check_api/server.rs +++ b/src/servers/health_check_api/server.rs @@ -3,25 +3,33 @@ //! This API is intended to be used by the container infrastructure to check if //! the whole application is healthy. use std::net::SocketAddr; +use std::sync::Arc; use axum::routing::get; use axum::{Json, Router}; use futures::Future; use log::info; -use serde_json::{json, Value}; +use serde_json::json; use tokio::sync::oneshot::Sender; +use torrust_tracker_configuration::Configuration; use crate::bootstrap::jobs::health_check_api::ApiServerJobStarted; +use crate::servers::health_check_api::handlers::health_check_handler; /// Starts Health Check API server. /// /// # Panics /// /// Will panic if binding to the socket address fails. -pub fn start(socket_addr: SocketAddr, tx: Sender) -> impl Future> { +pub fn start( + socket_addr: SocketAddr, + tx: Sender, + config: Arc, +) -> impl Future> { let app = Router::new() .route("/", get(|| async { Json(json!({})) })) - .route("/health_check", get(health_check_handler)); + .route("/health_check", get(health_check_handler)) + .with_state(config); let server = axum::Server::bind(&socket_addr).serve(app.into_make_service()); @@ -39,14 +47,3 @@ pub fn start(socket_addr: SocketAddr, tx: Sender) -> impl F running } - -/// Endpoint for container health check. -async fn health_check_handler() -> Json { - // todo: if enabled, check if the Tracker API is healthy - - // todo: if enabled, check if the HTTP Tracker is healthy - - // todo: if enabled, check if the UDP Tracker is healthy - - Json(json!({ "status": "Ok" })) -} diff --git a/tests/servers/health_check_api/contract.rs b/tests/servers/health_check_api/contract.rs index 575e10665..6b816b85f 100644 --- a/tests/servers/health_check_api/contract.rs +++ b/tests/servers/health_check_api/contract.rs @@ -1,14 +1,14 @@ -use torrust_tracker::servers::apis::v1::context::health_check::resources::{Report, Status}; +use torrust_tracker::servers::health_check_api::resources::Report; use torrust_tracker_test_helpers::configuration; use crate::servers::health_check_api::client::get; use crate::servers::health_check_api::test_environment; #[tokio::test] -async fn health_check_endpoint_should_return_status_ok() { - let configuration = configuration::ephemeral(); +async fn health_check_endpoint_should_return_status_ok_when_no_service_is_running() { + let configuration = configuration::ephemeral_with_no_services(); - let (bound_addr, test_env) = test_environment::start(&configuration.health_check_api).await; + let (bound_addr, test_env) = test_environment::start(configuration.into()).await; let url = format!("http://{bound_addr}/health_check"); @@ -16,7 +16,7 @@ async fn health_check_endpoint_should_return_status_ok() { assert_eq!(response.status(), 200); assert_eq!(response.headers().get("content-type").unwrap(), "application/json"); - assert_eq!(response.json::().await.unwrap(), Report { status: Status::Ok }); + assert_eq!(response.json::().await.unwrap(), Report::ok()); test_env.abort(); } diff --git a/tests/servers/health_check_api/test_environment.rs b/tests/servers/health_check_api/test_environment.rs index 6ad90eac7..46e54dc47 100644 --- a/tests/servers/health_check_api/test_environment.rs +++ b/tests/servers/health_check_api/test_environment.rs @@ -1,15 +1,17 @@ use std::net::SocketAddr; +use std::sync::Arc; use tokio::sync::oneshot; use tokio::task::JoinHandle; use torrust_tracker::bootstrap::jobs::health_check_api::ApiServerJobStarted; use torrust_tracker::servers::health_check_api::server; -use torrust_tracker_configuration::HealthCheckApi; +use torrust_tracker_configuration::Configuration; /// Start the test environment for the Health Check API. /// It runs the API server. -pub async fn start(config: &HealthCheckApi) -> (SocketAddr, JoinHandle<()>) { +pub async fn start(config: Arc) -> (SocketAddr, JoinHandle<()>) { let bind_addr = config + .health_check_api .bind_address .parse::() .expect("Health Check API bind_address invalid."); @@ -17,7 +19,7 @@ pub async fn start(config: &HealthCheckApi) -> (SocketAddr, JoinHandle<()>) { let (tx, rx) = oneshot::channel::(); let join_handle = tokio::spawn(async move { - let handle = server::start(bind_addr, tx); + let handle = server::start(bind_addr, tx, config.clone()); if let Ok(()) = handle.await { panic!("Health Check API server on http://{bind_addr} stopped"); } From 742130657bc3c04b3497400207e16250433de966 Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Fri, 24 Nov 2023 17:01:05 +0000 Subject: [PATCH 6/9] feat: [#508] add health check enpoint to HTTP tracker http://localhost:7070/health_check And call the endpoint from the general application health check endpoint: http://localhost:1313/health_check Do not call the endpoint if: - The tracker is disabled. - The tracker configuration uses port 0 only knwon after starting the socket. todo: call the enpoint also when the port is 0 in the configuration. THe service can return back to the main app the port assiged by the OS. And the app can pass that port to the global app health check handler. --- src/servers/health_check_api/handlers.rs | 46 ++++++++++++++++++-- src/servers/http/v1/handlers/health_check.rs | 18 ++++++++ src/servers/http/v1/handlers/mod.rs | 1 + src/servers/http/v1/routes.rs | 4 +- tests/servers/http/client.rs | 4 ++ tests/servers/http/v1/contract.rs | 20 +++++++++ 6 files changed, 88 insertions(+), 5 deletions(-) create mode 100644 src/servers/http/v1/handlers/health_check.rs diff --git a/src/servers/health_check_api/handlers.rs b/src/servers/health_check_api/handlers.rs index 347106d6e..0a95c3211 100644 --- a/src/servers/health_check_api/handlers.rs +++ b/src/servers/health_check_api/handlers.rs @@ -1,3 +1,4 @@ +use std::net::SocketAddr; use std::sync::Arc; use axum::extract::State; @@ -7,16 +8,53 @@ use torrust_tracker_configuration::Configuration; use super::resources::Report; use super::responses; +/// If port 0 is specified in the configuration the OS will automatically +/// assign a free port. But we do now know in from the configuration. +/// We can only know it after starting the socket. +const UNKNOWN_PORT: u16 = 0; + /// Endpoint for container health check. +/// +/// This endpoint only checks services when we know the port from the +/// configuration. If port 0 is specified in the configuration the health check +/// for that service is skipped. pub(crate) async fn health_check_handler(State(config): State>) -> Json { + // todo: when port 0 is specified in the configuration get the port from the + // running service, after starting it as we do for testing with ephemeral + // configurations. + if config.http_api.enabled { - let health_check_url = format!("http://{}/health_check", config.http_api.bind_address); - if !get_req_is_ok(&health_check_url).await { - return responses::error(format!("API is not healthy. Health check endpoint: {health_check_url}")); + let addr: SocketAddr = config.http_api.bind_address.parse().expect("invalid socket address for API"); + + if addr.port() != UNKNOWN_PORT { + let health_check_url = format!("http://{addr}/health_check"); + + if !get_req_is_ok(&health_check_url).await { + return responses::error(format!("API is not healthy. Health check endpoint: {health_check_url}")); + } } } - // todo: for all HTTP Trackers, if enabled, check if is healthy + for http_tracker_config in &config.http_trackers { + if !http_tracker_config.enabled { + continue; + } + + let addr: SocketAddr = http_tracker_config + .bind_address + .parse() + .expect("invalid socket address for HTTP tracker"); + + if addr.port() != UNKNOWN_PORT { + let health_check_url = format!("http://{addr}/health_check"); + + if !get_req_is_ok(&health_check_url).await { + return responses::error(format!( + "HTTP Tracker is not healthy. Health check endpoint: {health_check_url}" + )); + } + } + } // todo: for all UDP Trackers, if enabled, check if is healthy diff --git a/src/servers/http/v1/handlers/health_check.rs b/src/servers/http/v1/handlers/health_check.rs new file mode 100644 index 000000000..b15af6255 --- /dev/null +++ b/src/servers/http/v1/handlers/health_check.rs @@ -0,0 +1,18 @@ +use axum::Json; +use serde::{Deserialize, Serialize}; + +#[allow(clippy::unused_async)] +pub async fn handler() -> Json { + Json(Report { status: Status::Ok }) +} + +#[derive(Serialize, Deserialize, Debug, PartialEq, Eq)] +pub enum Status { + Ok, + Error, +} + +#[derive(Serialize, Deserialize, Debug, PartialEq, Eq)] +pub struct Report { + pub status: Status, +} diff --git a/src/servers/http/v1/handlers/mod.rs b/src/servers/http/v1/handlers/mod.rs index d78dee7d5..d7fd05838 100644 --- a/src/servers/http/v1/handlers/mod.rs +++ b/src/servers/http/v1/handlers/mod.rs @@ -7,6 +7,7 @@ use crate::tracker::error::Error; pub mod announce; pub mod common; +pub mod health_check; pub mod scrape; impl From for responses::error::Error { diff --git a/src/servers/http/v1/routes.rs b/src/servers/http/v1/routes.rs index 6546dcbb8..0b6b419c1 100644 --- a/src/servers/http/v1/routes.rs +++ b/src/servers/http/v1/routes.rs @@ -6,7 +6,7 @@ use axum::Router; use axum_client_ip::SecureClientIpSource; use tower_http::compression::CompressionLayer; -use super::handlers::{announce, scrape}; +use super::handlers::{announce, health_check, scrape}; use crate::tracker::Tracker; /// It adds the routes to the router. @@ -16,6 +16,8 @@ use crate::tracker::Tracker; #[allow(clippy::needless_pass_by_value)] pub fn router(tracker: Arc) -> Router { Router::new() + // Health check + .route("/health_check", get(health_check::handler)) // Announce request .route("/announce", get(announce::handle_without_key).with_state(tracker.clone())) .route("/announce/:key", get(announce::handle_with_key).with_state(tracker.clone())) diff --git a/tests/servers/http/client.rs b/tests/servers/http/client.rs index 0dbdd9cf6..03ed9aee4 100644 --- a/tests/servers/http/client.rs +++ b/tests/servers/http/client.rs @@ -60,6 +60,10 @@ impl Client { .await } + pub async fn health_check(&self) -> Response { + self.get(&self.build_path("health_check")).await + } + pub async fn get(&self, path: &str) -> Response { self.reqwest.get(self.build_url(path)).send().await.unwrap() } diff --git a/tests/servers/http/v1/contract.rs b/tests/servers/http/v1/contract.rs index 2e24af6b7..b19009454 100644 --- a/tests/servers/http/v1/contract.rs +++ b/tests/servers/http/v1/contract.rs @@ -13,6 +13,26 @@ async fn test_environment_should_be_started_and_stopped() { mod for_all_config_modes { + use torrust_tracker::servers::http::v1::handlers::health_check::{Report, Status}; + use torrust_tracker_test_helpers::configuration; + + use crate::servers::http::client::Client; + use crate::servers::http::test_environment::running_test_environment; + use crate::servers::http::v1::contract::V1; + + #[tokio::test] + async fn health_check_endpoint_should_return_ok_if_the_http_tracker_is_running() { + let test_env = running_test_environment::(configuration::ephemeral_with_reverse_proxy()).await; + + let response = Client::new(*test_env.bind_address()).health_check().await; + + assert_eq!(response.status(), 200); + assert_eq!(response.headers().get("content-type").unwrap(), "application/json"); + assert_eq!(response.json::().await.unwrap(), Report { status: Status::Ok }); + + test_env.stop().await; + } + mod and_running_on_reverse_proxy { use torrust_tracker_test_helpers::configuration; From 2a05590fddf79a550abab6b6c477ddf562c46669 Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Fri, 24 Nov 2023 17:32:44 +0000 Subject: [PATCH 7/9] refactor: [#508] move UDP tracker client to production code We will use the UDP tracker connection request to check if the UDP tracker service is healthy. --- src/servers/udp/mod.rs | 6 ----- src/servers/udp/server.rs | 2 +- src/shared/bit_torrent/mod.rs | 1 + .../shared/bit_torrent}/udp/client.rs | 27 +++++++++++++++++-- src/shared/bit_torrent/udp/mod.rs | 12 +++++++++ tests/servers/udp/contract.rs | 10 +++---- tests/servers/udp/mod.rs | 6 ----- 7 files changed, 44 insertions(+), 20 deletions(-) rename {tests/servers => src/shared/bit_torrent}/udp/client.rs (79%) create mode 100644 src/shared/bit_torrent/udp/mod.rs diff --git a/src/servers/udp/mod.rs b/src/servers/udp/mod.rs index 630867218..a50fffd37 100644 --- a/src/servers/udp/mod.rs +++ b/src/servers/udp/mod.rs @@ -652,9 +652,3 @@ pub type Port = u16; /// The transaction id. A random number generated byt the peer that is used to /// match requests and responses. pub type TransactionId = i64; - -/// The maximum number of bytes in a UDP packet. -pub const MAX_PACKET_SIZE: usize = 1496; -/// A magic 64-bit integer constant defined in the protocol that is used to -/// identify the protocol. -pub const PROTOCOL_ID: i64 = 0x0417_2710_1980; diff --git a/src/servers/udp/server.rs b/src/servers/udp/server.rs index 428b76fa1..31e87481e 100644 --- a/src/servers/udp/server.rs +++ b/src/servers/udp/server.rs @@ -30,7 +30,7 @@ use tokio::task::JoinHandle; use crate::servers::signals::shutdown_signal; use crate::servers::udp::handlers::handle_packet; -use crate::servers::udp::MAX_PACKET_SIZE; +use crate::shared::bit_torrent::udp::MAX_PACKET_SIZE; use crate::tracker::Tracker; /// Error that can occur when starting or stopping the UDP server. diff --git a/src/shared/bit_torrent/mod.rs b/src/shared/bit_torrent/mod.rs index eba90b4ab..872203a1f 100644 --- a/src/shared/bit_torrent/mod.rs +++ b/src/shared/bit_torrent/mod.rs @@ -69,3 +69,4 @@ //!Bencode & bdecode in your browser | pub mod common; pub mod info_hash; +pub mod udp; diff --git a/tests/servers/udp/client.rs b/src/shared/bit_torrent/udp/client.rs similarity index 79% rename from tests/servers/udp/client.rs rename to src/shared/bit_torrent/udp/client.rs index d267adaba..d5c4c9adf 100644 --- a/tests/servers/udp/client.rs +++ b/src/shared/bit_torrent/udp/client.rs @@ -3,9 +3,8 @@ use std::sync::Arc; use aquatic_udp_protocol::{Request, Response}; use tokio::net::UdpSocket; -use torrust_tracker::servers::udp::MAX_PACKET_SIZE; -use crate::servers::udp::source_address; +use crate::shared::bit_torrent::udp::{source_address, MAX_PACKET_SIZE}; #[allow(clippy::module_name_repetitions)] pub struct UdpClient { @@ -13,6 +12,9 @@ pub struct UdpClient { } impl UdpClient { + /// # Panics + /// + /// Will panic if the local address can't be bound. pub async fn bind(local_address: &str) -> Self { let socket = UdpSocket::bind(local_address).await.unwrap(); Self { @@ -20,15 +22,30 @@ impl UdpClient { } } + /// # Panics + /// + /// Will panic if can't connect to the socket. pub async fn connect(&self, remote_address: &str) { self.socket.connect(remote_address).await.unwrap(); } + /// # Panics + /// + /// Will panic if: + /// + /// - Can't write to the socket. + /// - Can't send data. pub async fn send(&self, bytes: &[u8]) -> usize { self.socket.writable().await.unwrap(); self.socket.send(bytes).await.unwrap() } + /// # Panics + /// + /// Will panic if: + /// + /// - Can't read from the socket. + /// - Can't receive data. pub async fn receive(&self, bytes: &mut [u8]) -> usize { self.socket.readable().await.unwrap(); self.socket.recv(bytes).await.unwrap() @@ -49,6 +66,9 @@ pub struct UdpTrackerClient { } impl UdpTrackerClient { + /// # Panics + /// + /// Will panic if can't write request to bytes. pub async fn send(&self, request: Request) -> usize { // Write request into a buffer let request_buffer = vec![0u8; MAX_PACKET_SIZE]; @@ -68,6 +88,9 @@ impl UdpTrackerClient { self.udp_client.send(request_data).await } + /// # Panics + /// + /// Will panic if can't create response from the received payload (bytes buffer). pub async fn receive(&self) -> Response { let mut response_buffer = [0u8; MAX_PACKET_SIZE]; diff --git a/src/shared/bit_torrent/udp/mod.rs b/src/shared/bit_torrent/udp/mod.rs new file mode 100644 index 000000000..9322ef045 --- /dev/null +++ b/src/shared/bit_torrent/udp/mod.rs @@ -0,0 +1,12 @@ +pub mod client; + +/// The maximum number of bytes in a UDP packet. +pub const MAX_PACKET_SIZE: usize = 1496; +/// A magic 64-bit integer constant defined in the protocol that is used to +/// identify the protocol. +pub const PROTOCOL_ID: i64 = 0x0417_2710_1980; + +/// Generates the source address for the UDP client +fn source_address(port: u16) -> String { + format!("127.0.0.1:{port}") +} diff --git a/tests/servers/udp/contract.rs b/tests/servers/udp/contract.rs index 3187d9871..72124fc3f 100644 --- a/tests/servers/udp/contract.rs +++ b/tests/servers/udp/contract.rs @@ -6,11 +6,11 @@ use core::panic; use aquatic_udp_protocol::{ConnectRequest, ConnectionId, Response, TransactionId}; -use torrust_tracker::servers::udp::MAX_PACKET_SIZE; +use torrust_tracker::shared::bit_torrent::udp::client::{new_udp_client_connected, UdpTrackerClient}; +use torrust_tracker::shared::bit_torrent::udp::MAX_PACKET_SIZE; use torrust_tracker_test_helpers::configuration; use crate::servers::udp::asserts::is_error_response; -use crate::servers::udp::client::{new_udp_client_connected, UdpTrackerClient}; use crate::servers::udp::test_environment::running_test_environment; fn empty_udp_request() -> [u8; MAX_PACKET_SIZE] { @@ -51,10 +51,10 @@ async fn should_return_a_bad_request_response_when_the_client_sends_an_empty_req mod receiving_a_connection_request { use aquatic_udp_protocol::{ConnectRequest, TransactionId}; + use torrust_tracker::shared::bit_torrent::udp::client::new_udp_tracker_client_connected; use torrust_tracker_test_helpers::configuration; use crate::servers::udp::asserts::is_connect_response; - use crate::servers::udp::client::new_udp_tracker_client_connected; use crate::servers::udp::test_environment::running_test_environment; #[tokio::test] @@ -82,10 +82,10 @@ mod receiving_an_announce_request { AnnounceEvent, AnnounceRequest, ConnectionId, InfoHash, NumberOfBytes, NumberOfPeers, PeerId, PeerKey, Port, TransactionId, }; + use torrust_tracker::shared::bit_torrent::udp::client::new_udp_tracker_client_connected; use torrust_tracker_test_helpers::configuration; use crate::servers::udp::asserts::is_ipv4_announce_response; - use crate::servers::udp::client::new_udp_tracker_client_connected; use crate::servers::udp::contract::send_connection_request; use crate::servers::udp::test_environment::running_test_environment; @@ -124,10 +124,10 @@ mod receiving_an_announce_request { mod receiving_an_scrape_request { use aquatic_udp_protocol::{ConnectionId, InfoHash, ScrapeRequest, TransactionId}; + use torrust_tracker::shared::bit_torrent::udp::client::new_udp_tracker_client_connected; use torrust_tracker_test_helpers::configuration; use crate::servers::udp::asserts::is_scrape_response; - use crate::servers::udp::client::new_udp_tracker_client_connected; use crate::servers::udp::contract::send_connection_request; use crate::servers::udp::test_environment::running_test_environment; diff --git a/tests/servers/udp/mod.rs b/tests/servers/udp/mod.rs index d39c37153..4759350dc 100644 --- a/tests/servers/udp/mod.rs +++ b/tests/servers/udp/mod.rs @@ -1,9 +1,3 @@ pub mod asserts; -pub mod client; pub mod contract; pub mod test_environment; - -/// Generates the source address for the UDP client -fn source_address(port: u16) -> String { - format!("127.0.0.1:{port}") -} From bf23479debf17b7a396ea6b0d5ac6560e31144d7 Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Fri, 24 Nov 2023 17:45:42 +0000 Subject: [PATCH 8/9] feat: [#508] add health check for UDP tracker Using the `connect` UDP request. If the UDP trackers replies to a connection request we assume is healthy. --- src/servers/health_check_api/handlers.rs | 38 +++++++++++++++++++++++- 1 file changed, 37 insertions(+), 1 deletion(-) diff --git a/src/servers/health_check_api/handlers.rs b/src/servers/health_check_api/handlers.rs index 0a95c3211..4e8f2b928 100644 --- a/src/servers/health_check_api/handlers.rs +++ b/src/servers/health_check_api/handlers.rs @@ -1,12 +1,14 @@ use std::net::SocketAddr; use std::sync::Arc; +use aquatic_udp_protocol::{ConnectRequest, Response, TransactionId}; use axum::extract::State; use axum::Json; use torrust_tracker_configuration::Configuration; use super::resources::Report; use super::responses; +use crate::shared::bit_torrent::udp::client::new_udp_tracker_client_connected; /// If port 0 is specified in the configuration the OS will automatically /// assign a free port. But we do now know in from the configuration. @@ -23,6 +25,8 @@ pub(crate) async fn health_check_handler(State(config): State // running service, after starting it as we do for testing with ephemeral // configurations. + // Health check for API + if config.http_api.enabled { let addr: SocketAddr = config.http_api.bind_address.parse().expect("invalid socket address for API"); @@ -35,6 +39,8 @@ pub(crate) async fn health_check_handler(State(config): State } } + // Health check for HTTP Trackers + for http_tracker_config in &config.http_trackers { if !http_tracker_config.enabled { continue; @@ -56,7 +62,22 @@ pub(crate) async fn health_check_handler(State(config): State } } - // todo: for all UDP Trackers, if enabled, check if is healthy + // Health check for UDP Trackers + + for udp_tracker_config in &config.udp_trackers { + if !udp_tracker_config.enabled { + continue; + } + + let addr: SocketAddr = udp_tracker_config + .bind_address + .parse() + .expect("invalid socket address for UDP tracker"); + + if addr.port() != UNKNOWN_PORT && !can_connect_to_udp_tracker(&addr.to_string()).await { + return responses::error(format!("UDP Tracker is not healthy. Can't connect to: {addr}")); + } + } responses::ok() } @@ -67,3 +88,18 @@ async fn get_req_is_ok(url: &str) -> bool { Err(_err) => false, } } + +/// Tries to connect to an UDP tracker. It returns true if it succeeded. +async fn can_connect_to_udp_tracker(url: &str) -> bool { + let client = new_udp_tracker_client_connected(url).await; + + let connect_request = ConnectRequest { + transaction_id: TransactionId(123), + }; + + client.send(connect_request.into()).await; + + let response = client.receive().await; + + matches!(response, Response::Connect(_connect_response)) +} From 5e0a686023862f22e24ea9ffea6530f5d9b83efb Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Fri, 24 Nov 2023 17:57:01 +0000 Subject: [PATCH 9/9] refactor: [#508] extract health check methods --- src/servers/health_check_api/handlers.rs | 58 ++++++++++++++++++------ 1 file changed, 44 insertions(+), 14 deletions(-) diff --git a/src/servers/health_check_api/handlers.rs b/src/servers/health_check_api/handlers.rs index 4e8f2b928..109b89bb4 100644 --- a/src/servers/health_check_api/handlers.rs +++ b/src/servers/health_check_api/handlers.rs @@ -4,7 +4,7 @@ use std::sync::Arc; use aquatic_udp_protocol::{ConnectRequest, Response, TransactionId}; use axum::extract::State; use axum::Json; -use torrust_tracker_configuration::Configuration; +use torrust_tracker_configuration::{Configuration, HttpApi, HttpTracker, UdpTracker}; use super::resources::Report; use super::responses; @@ -21,27 +21,49 @@ const UNKNOWN_PORT: u16 = 0; /// configuration. If port 0 is specified in the configuration the health check /// for that service is skipped. pub(crate) async fn health_check_handler(State(config): State>) -> Json { + if let Some(err_response) = api_health_check(&config.http_api).await { + return err_response; + } + + if let Some(err_response) = http_trackers_health_check(&config.http_trackers).await { + return err_response; + } + + if let Some(err_response) = udp_trackers_health_check(&config.udp_trackers).await { + return err_response; + } + + responses::ok() +} + +async fn api_health_check(config: &HttpApi) -> Option> { // todo: when port 0 is specified in the configuration get the port from the // running service, after starting it as we do for testing with ephemeral // configurations. - // Health check for API - - if config.http_api.enabled { - let addr: SocketAddr = config.http_api.bind_address.parse().expect("invalid socket address for API"); + if config.enabled { + let addr: SocketAddr = config.bind_address.parse().expect("invalid socket address for API"); if addr.port() != UNKNOWN_PORT { let health_check_url = format!("http://{addr}/health_check"); if !get_req_is_ok(&health_check_url).await { - return responses::error(format!("API is not healthy. Health check endpoint: {health_check_url}")); + return Some(responses::error(format!( + "API is not healthy. Health check endpoint: {health_check_url}" + ))); } } } - // Health check for HTTP Trackers + None +} - for http_tracker_config in &config.http_trackers { +async fn http_trackers_health_check(http_trackers: &Vec) -> Option> { + // todo: when port 0 is specified in the configuration get the port from the + // running service, after starting it as we do for testing with ephemeral + // configurations. + + for http_tracker_config in http_trackers { if !http_tracker_config.enabled { continue; } @@ -55,16 +77,22 @@ pub(crate) async fn health_check_handler(State(config): State let health_check_url = format!("http://{addr}/health_check"); if !get_req_is_ok(&health_check_url).await { - return responses::error(format!( + return Some(responses::error(format!( "HTTP Tracker is not healthy. Health check endpoint: {health_check_url}" - )); + ))); } } } - // Health check for UDP Trackers + None +} + +async fn udp_trackers_health_check(udp_trackers: &Vec) -> Option> { + // todo: when port 0 is specified in the configuration get the port from the + // running service, after starting it as we do for testing with ephemeral + // configurations. - for udp_tracker_config in &config.udp_trackers { + for udp_tracker_config in udp_trackers { if !udp_tracker_config.enabled { continue; } @@ -75,11 +103,13 @@ pub(crate) async fn health_check_handler(State(config): State .expect("invalid socket address for UDP tracker"); if addr.port() != UNKNOWN_PORT && !can_connect_to_udp_tracker(&addr.to_string()).await { - return responses::error(format!("UDP Tracker is not healthy. Can't connect to: {addr}")); + return Some(responses::error(format!( + "UDP Tracker is not healthy. Can't connect to: {addr}" + ))); } } - responses::ok() + None } async fn get_req_is_ok(url: &str) -> bool {