Skip to content
Merged
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions console/tracker-client/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -37,3 +37,6 @@ url = { version = "2", features = [ "serde" ] }

[package.metadata.cargo-machete]
ignored = [ "serde_bytes" ]

[dev-dependencies]
tempfile = "3"
6 changes: 5 additions & 1 deletion console/tracker-client/src/bin/tracker_checker.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,9 @@ use torrust_tracker_client::console::clients::checker::app;

#[tokio::main]
async fn main() {
app::run().await.expect("Some checks fail");
if let Err(e) = app::run().await {
let (json, exit_code) = e.to_stderr_json_and_exit_code();
eprintln!("{json}");
std::process::exit(exit_code);
}
}
33 changes: 23 additions & 10 deletions console/tracker-client/src/console/clients/checker/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -59,12 +59,12 @@
use std::path::PathBuf;
use std::sync::Arc;

use anyhow::{Context, Result};
use clap::Parser;
use tracing::level_filters::LevelFilter;

use super::config::Configuration;
use super::console::Console;
use super::error::{AppError, ConfigSource};
use super::service::{CheckResult, Service};
use crate::console::clients::checker::config::parse_from_json;

Expand All @@ -82,8 +82,9 @@ struct Args {

/// # Errors
///
/// Will return an error if the configuration was not provided.
pub async fn run() -> Result<Vec<CheckResult>> {
/// Will return an `AppError::InvalidConfig` if the configuration cannot be parsed,
/// or an `AppError::Runtime` if the checks fail to execute.
pub async fn run() -> Result<Vec<CheckResult>, AppError> {
tracing_stdout_init(LevelFilter::INFO);

let args = Args::parse();
Expand All @@ -97,24 +98,36 @@ pub async fn run() -> Result<Vec<CheckResult>> {
console: console_printer,
};

service.run_checks().await.context("it should run the check tasks")
service.run_checks().await.map_err(|e| AppError::Runtime(e.to_string()))
}

fn tracing_stdout_init(filter: LevelFilter) {
tracing_subscriber::fmt().with_max_level(filter).init();
tracing::debug!("Logging initialized");
}

fn setup_config(args: Args) -> Result<Configuration> {
fn setup_config(args: Args) -> Result<Configuration, AppError> {
match (args.config_path, args.config_content) {
(Some(config_path), _) => load_config_from_file(&config_path),
(_, Some(config_content)) => parse_from_json(&config_content).context("invalid config format"),
_ => Err(anyhow::anyhow!("no configuration provided")),
(_, Some(config_content)) => parse_from_json(&config_content).map_err(|e| AppError::InvalidConfig {
source: ConfigSource::EnvVar("TORRUST_CHECKER_CONFIG"),
message: e.to_string(),
}),
_ => Err(AppError::InvalidConfig {
source: ConfigSource::EnvVar("TORRUST_CHECKER_CONFIG"),
message: "no configuration provided".to_string(),
}),
}
}

fn load_config_from_file(path: &PathBuf) -> Result<Configuration> {
let file_content = std::fs::read_to_string(path).with_context(|| format!("can't read config file {}", path.display()))?;
fn load_config_from_file(path: &PathBuf) -> Result<Configuration, AppError> {
let file_content = std::fs::read_to_string(path).map_err(|e| AppError::InvalidConfig {
source: ConfigSource::File(path.clone()),
message: format!("can't read config file {}: {e}", path.display()),
})?;

parse_from_json(&file_content).context("invalid config format")
parse_from_json(&file_content).map_err(|e| AppError::InvalidConfig {
source: ConfigSource::File(path.clone()),
message: e.to_string(),
})
}
68 changes: 68 additions & 0 deletions console/tracker-client/src/console/clients/checker/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -279,4 +279,72 @@ mod tests {
}
}
}

mod parsing_from_json {
use crate::console::clients::checker::config::parse_from_json;

#[test]
fn it_should_succeed_with_valid_json() {
let json = r#"{"udp_trackers":[],"http_trackers":[],"health_checks":[]}"#;
assert!(parse_from_json(json).is_ok());
}

#[test]
fn it_should_fail_with_trailing_comma_and_include_serde_detail_in_error() {
let json = r#"{
"udp_trackers": [],
"http_trackers": [
"http://127.0.0.1:7070",
],
"health_checks": []
}"#;

let err = parse_from_json(json).err().expect("Expected a parse error");
let message = err.to_string();

// The specific serde_json detail must be present, not just "invalid config format"
assert!(
message.contains("trailing comma"),
"Expected 'trailing comma' in error message, got: {message}"
);
}

#[test]
fn it_should_fail_with_missing_field_and_include_serde_detail_in_error() {
// Missing required fields entirely
let json = r#"{"udp_trackers":[]}"#;

let err = parse_from_json(json)
.err()
.expect("Expected a parse error for missing fields");
let message = err.to_string();

assert!(!message.is_empty(), "Expected a non-empty error message, got empty string");
}

#[test]
fn it_should_fail_with_malformed_json_and_include_serde_detail_in_error() {
let json = r"not json at all";

let err = parse_from_json(json)
.err()
.expect("Expected a parse error for malformed JSON");
let message = err.to_string();

assert!(
message.contains("JSON parse error"),
"Expected 'JSON parse error' prefix in error message, got: {message}"
);
}

#[test]
fn it_should_fail_with_invalid_url_and_include_detail_in_error() {
let json = r#"{"udp_trackers":["not a url"],"http_trackers":[],"health_checks":[]}"#;

let err = parse_from_json(json).err().expect("Expected an error for an invalid URL");
let message = err.to_string();

assert!(!message.is_empty(), "Expected a non-empty error message");
}
}
}
186 changes: 186 additions & 0 deletions console/tracker-client/src/console/clients/checker/error.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
//! Application-level errors for the tracker checker binary.
//!
//! This module separates two concerns:
//! - **Delivery mechanism**: how the configuration was provided (env var, file path, …)
//! - **Error presentation**: what structured JSON the binary emits on stderr
//!
//! `ConfigSource` captures the delivery mechanism so that error messages can
//! reference it without coupling the parsing layer to delivery specifics.
//!
//! The JSON envelope emitted to stderr follows the Tracker CLI I/O Contract:
//!
//! ```json
//! { "error": { "kind": "...", "source": "...", "message": "..." } }
//! ```
use std::fmt;
use std::path::PathBuf;

/// Where the configuration content was delivered from.
#[derive(Debug, Clone)]
pub enum ConfigSource {
/// Configuration delivered via an environment variable (stores the variable name).
EnvVar(&'static str),
/// Configuration delivered via a file (stores the file path).
File(PathBuf),
}

impl fmt::Display for ConfigSource {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
ConfigSource::EnvVar(name) => write!(f, "{name}"),
ConfigSource::File(path) => write!(f, "{}", path.display()),
}
}
}

/// Top-level application errors for the tracker checker.
#[derive(Debug)]
pub enum AppError {
/// The provided configuration was invalid (bad JSON, invalid URLs, etc.).
InvalidConfig {
/// How the configuration was delivered (env var or file path).
source: ConfigSource,
/// Human-readable detail from the underlying parse error.
message: String,
},
/// An unexpected runtime failure occurred after configuration was accepted.
Runtime(String),
}

impl AppError {
/// Serializes the error to the contract JSON envelope and returns the
/// appropriate process exit code.
///
/// Exit codes:
/// - `2` — configuration error
/// - `1` — generic runtime failure
#[must_use]
pub fn to_stderr_json_and_exit_code(&self) -> (String, i32) {
match self {
AppError::InvalidConfig { source, message } => {
let json = serde_json::json!({
"error": {
"kind": "invalid_configuration",
"source": source.to_string(),
"message": message,
}
})
.to_string();
(json, 2)
}
AppError::Runtime(message) => {
let json = serde_json::json!({
"error": {
"kind": "runtime_failure",
"source": "runtime",
"message": message,
}
})
.to_string();
(json, 1)
Comment thread
josecelano marked this conversation as resolved.
}
}
}
}

impl fmt::Display for AppError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
AppError::InvalidConfig { source, message } => {
write!(f, "invalid configuration from {source}: {message}")
}
AppError::Runtime(msg) => write!(f, "runtime failure: {msg}"),
}
}
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn config_source_env_var_displays_as_variable_name() {
let source = ConfigSource::EnvVar("TORRUST_CHECKER_CONFIG");
assert_eq!(source.to_string(), "TORRUST_CHECKER_CONFIG");
}

#[test]
fn config_source_file_displays_as_path() {
let source = ConfigSource::File(PathBuf::from("/etc/tracker/config.json"));
assert_eq!(source.to_string(), "/etc/tracker/config.json");
}

#[test]
fn invalid_config_error_produces_exit_code_2() {
let error = AppError::InvalidConfig {
source: ConfigSource::EnvVar("TORRUST_CHECKER_CONFIG"),
message: "JSON parse error: trailing comma at line 7 column 5".to_string(),
};
let (_, exit_code) = error.to_stderr_json_and_exit_code();
assert_eq!(exit_code, 2);
}

#[test]
fn runtime_error_produces_exit_code_1() {
let error = AppError::Runtime("failed to bind socket".to_string());
let (_, exit_code) = error.to_stderr_json_and_exit_code();
assert_eq!(exit_code, 1);
}

#[test]
fn invalid_config_error_json_contains_expected_fields() {
let error = AppError::InvalidConfig {
source: ConfigSource::EnvVar("TORRUST_CHECKER_CONFIG"),
message: "JSON parse error: trailing comma at line 7 column 5".to_string(),
};
let (json, _) = error.to_stderr_json_and_exit_code();
let parsed: serde_json::Value = serde_json::from_str(&json).expect("Error JSON should be valid JSON");

assert_eq!(parsed["error"]["kind"], "invalid_configuration");
assert_eq!(parsed["error"]["source"], "TORRUST_CHECKER_CONFIG");
assert_eq!(
parsed["error"]["message"],
"JSON parse error: trailing comma at line 7 column 5"
);
}
Comment thread
josecelano marked this conversation as resolved.

#[test]
fn runtime_error_json_contains_expected_fields() {
let error = AppError::Runtime("failed to bind socket".to_string());
let (json, _) = error.to_stderr_json_and_exit_code();
let parsed: serde_json::Value = serde_json::from_str(&json).expect("Error JSON should be valid JSON");

assert_eq!(parsed["error"]["kind"], "runtime_failure");
assert_eq!(parsed["error"]["source"], "runtime");
assert_eq!(parsed["error"]["message"], "failed to bind socket");
}

#[test]
fn invalid_config_error_from_file_includes_path_in_json() {
let error = AppError::InvalidConfig {
source: ConfigSource::File(PathBuf::from("/etc/tracker/config.json")),
message: "JSON parse error: trailing comma at line 3 column 1".to_string(),
};
let (json, _) = error.to_stderr_json_and_exit_code();
let parsed: serde_json::Value = serde_json::from_str(&json).expect("Error JSON should be valid JSON");

assert_eq!(parsed["error"]["source"], "/etc/tracker/config.json");
}

#[test]
fn invalid_config_error_json_escapes_special_characters() {
let source_path = r"C:\tracker\config\broken.json";
let message = "JSON parse error: unexpected '\"' on line 2\nCheck C:\\temp\\config.json";

let error = AppError::InvalidConfig {
source: ConfigSource::File(PathBuf::from(source_path)),
message: message.to_string(),
};
let (json, _) = error.to_stderr_json_and_exit_code();
let parsed: serde_json::Value = serde_json::from_str(&json).expect("Error JSON should be valid JSON");

assert_eq!(parsed["error"]["kind"], "invalid_configuration");
assert_eq!(parsed["error"]["source"], source_path);
assert_eq!(parsed["error"]["message"], message);
}
}
1 change: 1 addition & 0 deletions console/tracker-client/src/console/clients/checker/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ pub mod app;
pub mod checks;
pub mod config;
pub mod console;
pub mod error;
pub mod logger;
pub mod printer;
pub mod service;
Loading
Loading