Skip to main content

torrust_tracker_deployer_lib/application/command_handlers/test/
handler.rs

1//! Test command handler implementation
2//!
3//! **Purpose**: Smoke test for running Torrust Tracker services
4//!
5//! This handler validates that a deployed Tracker application is running and accessible
6//! from external clients. The command performs comprehensive end-to-end verification
7//! including service status, health checks, and external accessibility validation.
8//!
9//! ## Validation Strategy
10//!
11//! The test command validates deployed services through:
12//!
13//! 1. **External Health Checks** - Tests service accessibility from outside the VM:
14//!    - Tracker API health endpoint (required)
15//!    - HTTP Tracker health endpoint (required)
16//!
17//! ## HTTPS Support
18//!
19//! When services have TLS enabled via Caddy reverse proxy:
20//! - Uses HTTPS URLs with the configured domain
21//! - Resolves domains locally to the VM IP (no DNS dependency for testing)
22//! - Accepts self-signed certificates for `.local` domains
23//!
24//! This approach allows testing to work without DNS configuration while still
25//! being realistic (Caddy receives the correct SNI/Host header).
26//!
27//! ## Why External-Only Validation?
28//!
29//! We perform external accessibility checks (from test runner to VM) rather than
30//! internal checks (via SSH to localhost) because:
31//! - External checks are a superset of internal checks
32//! - If services are accessible externally, they must be running internally
33//! - External checks validate firewall configuration automatically
34//! - Simpler test implementation reduces maintenance burden
35//!
36//! ## Port Configuration
37//!
38//! The test command extracts tracker ports from the environment's tracker configuration:
39//! - HTTP API port from `environment.context.user_inputs.tracker.http_api.bind_address`
40//! - HTTP Tracker port from `environment.context.user_inputs.tracker.http_trackers[0].bind_address`
41//!
42//! For rationale and alternatives, see:
43//! - `docs/decisions/test-command-as-smoke-test.md` - Architectural decision record
44
45use std::net::IpAddr;
46use std::sync::Arc;
47
48use tracing::{info, instrument};
49
50use super::errors::TestCommandHandlerError;
51use super::result::{DnsIssue, DnsWarning, TestResult};
52use crate::application::command_handlers::common::endpoint_builder;
53use crate::domain::environment::repository::{EnvironmentRepository, TypedEnvironmentRepository};
54use crate::domain::environment::state::AnyEnvironmentState;
55use crate::domain::EnvironmentName;
56use crate::infrastructure::dns::{DnsResolutionError, DnsResolver};
57use crate::infrastructure::external_validators::RunningServicesValidator;
58use crate::infrastructure::remote_actions::RemoteAction;
59use crate::shared::domain_name::DomainName;
60
61/// `TestCommandHandler` orchestrates smoke testing for running Torrust Tracker services
62///
63/// **Purpose**: Post-deployment smoke test to verify the application is running and accessible
64///
65/// This handler validates that deployed services are operational and accessible from
66/// external clients by performing comprehensive health checks on the Tracker API and
67/// HTTP Tracker endpoints.
68///
69/// ## Validation Steps
70///
71/// 1. **Service Status** - Verifies Docker Compose services are running via SSH
72/// 2. **Tracker API Health** (required) - Tests external accessibility of HTTP API
73/// 3. **HTTP Tracker Health** (optional) - Tests external accessibility of HTTP tracker
74///
75/// ## Port Discovery
76///
77/// The handler extracts tracker ports from the environment's tracker configuration:
78/// - HTTP API port from `tracker.http_api.bind_address`
79/// - HTTP Tracker port from `tracker.http_trackers[0].bind_address`
80///
81/// ## Design Rationale
82///
83/// This command accepts an `EnvironmentName` in its `execute` method to align with other
84/// command handlers (`ProvisionCommandHandler`, `ConfigureCommandHandler`). This design:
85///
86/// - Loads environment from repository (consistent pattern across all handlers)
87/// - Allows testing environments regardless of compile-time state (runtime validation)
88/// - Requires the environment to have an instance IP set (checked at runtime)
89/// - Enables repository integration for future enhancements (e.g., tracking test history)
90pub struct TestCommandHandler {
91    repository: TypedEnvironmentRepository,
92}
93
94impl TestCommandHandler {
95    /// Create a new `TestCommandHandler`
96    #[must_use]
97    pub fn new(repository: Arc<dyn EnvironmentRepository>) -> Self {
98        Self {
99            repository: TypedEnvironmentRepository::new(repository),
100        }
101    }
102
103    /// Execute the complete testing and validation workflow
104    ///
105    /// Validates that the Torrust Tracker services are running and accessible by
106    /// performing external health checks on the deployed services. Also performs
107    /// advisory DNS resolution checks for configured domains.
108    ///
109    /// Returns a structured `TestResult` containing any DNS warnings found.
110    /// The presentation layer is responsible for rendering these warnings.
111    ///
112    /// # Arguments
113    ///
114    /// * `env_name` - The name of the environment to test
115    ///
116    /// # Returns
117    ///
118    /// * `Ok(TestResult)` - Test passed, may contain advisory DNS warnings
119    ///
120    /// # Errors
121    ///
122    /// Returns an error if:
123    /// * Environment not found
124    /// * Environment does not have an instance IP set
125    /// * Tracker configuration is invalid or missing required ports
126    /// * Running services validation fails:
127    ///   - Services are not running
128    ///   - Health check endpoints are not accessible
129    ///   - Firewall rules block external access
130    #[instrument(
131        name = "test_command",
132        skip_all,
133        fields(
134            command_type = "test",
135            environment = %env_name
136        )
137    )]
138    pub async fn execute(
139        &self,
140        env_name: &EnvironmentName,
141    ) -> Result<TestResult, TestCommandHandlerError> {
142        let any_env = self.load_environment(env_name)?;
143
144        let instance_ip =
145            any_env
146                .instance_ip()
147                .ok_or_else(|| TestCommandHandlerError::MissingInstanceIp {
148                    environment_name: env_name.to_string(),
149                })?;
150
151        // Extract tracker config
152        let tracker_config = any_env.tracker_config();
153
154        // Build service endpoints from configuration (with server IP)
155        let (tracker_api_endpoint, http_tracker_endpoints) =
156            endpoint_builder::build_all_tracker_endpoints(instance_ip, tracker_config);
157
158        // Log endpoint information
159        info!(
160            command = "test",
161            environment = %env_name,
162            instance_ip = ?instance_ip,
163            api_endpoint_tls = tracker_api_endpoint.uses_tls(),
164            api_endpoint_domain = ?tracker_api_endpoint.domain(),
165            http_tracker_count = http_tracker_endpoints.len(),
166            "Starting service health checks"
167        );
168
169        // Validate running services with external accessibility checks
170        let services_validator =
171            RunningServicesValidator::new(tracker_api_endpoint, http_tracker_endpoints);
172
173        services_validator.execute(&instance_ip).await?;
174
175        // Perform advisory DNS checks
176        let dns_warnings = Self::check_dns_resolution(&any_env, instance_ip);
177
178        info!(
179            command = "test",
180            environment = %env_name,
181            instance_ip = ?instance_ip,
182            dns_warnings = dns_warnings.len(),
183            "Service testing workflow completed successfully"
184        );
185
186        Ok(TestResult::with_dns_warnings(instance_ip, dns_warnings))
187    }
188
189    /// Perform advisory DNS checks for configured domains
190    ///
191    /// Checks DNS resolution for all configured service domains (API, HTTP
192    /// trackers, health check API, Grafana) and returns structured warnings
193    /// for any domains that don't resolve or resolve to unexpected IPs.
194    ///
195    /// **Advisory Only**: DNS check failures are returned as warnings and
196    /// do NOT affect the test result. This is because:
197    /// - DNS propagation can take time
198    /// - Local `.local` domains use `/etc/hosts` which may not be configured
199    /// - Users may intentionally test without DNS
200    fn check_dns_resolution(any_env: &AnyEnvironmentState, instance_ip: IpAddr) -> Vec<DnsWarning> {
201        let domains_to_check = any_env.collect_tls_domains();
202
203        if domains_to_check.is_empty() {
204            return Vec::new();
205        }
206
207        domains_to_check
208            .iter()
209            .filter_map(|domain| Self::check_single_domain(domain, instance_ip))
210            .collect()
211    }
212
213    /// Check a single domain and return a warning if resolution fails or mismatches
214    fn check_single_domain(domain: &DomainName, expected_ip: IpAddr) -> Option<DnsWarning> {
215        let resolver = DnsResolver::new();
216
217        match resolver.resolve_and_verify(domain, expected_ip) {
218            Ok(()) => None,
219            Err(DnsResolutionError::ResolutionFailed { source, .. }) => Some(DnsWarning {
220                domain: domain.clone(),
221                expected_ip,
222                issue: DnsIssue::ResolutionFailed(source.to_string()),
223            }),
224            Err(DnsResolutionError::IpMismatch { resolved_ip, .. }) => Some(DnsWarning {
225                domain: domain.clone(),
226                expected_ip,
227                issue: DnsIssue::IpMismatch {
228                    resolved_ips: vec![resolved_ip],
229                },
230            }),
231        }
232    }
233
234    /// Load environment from storage
235    ///
236    /// # Errors
237    ///
238    /// Returns an error if:
239    /// * Persistence error occurs during load
240    /// * Environment does not exist
241    fn load_environment(
242        &self,
243        env_name: &EnvironmentName,
244    ) -> Result<AnyEnvironmentState, TestCommandHandlerError> {
245        let any_env = self
246            .repository
247            .inner()
248            .load(env_name)
249            .map_err(|e| TestCommandHandlerError::StatePersistence(e.into()))?;
250
251        any_env.ok_or_else(|| TestCommandHandlerError::EnvironmentNotFound {
252            name: env_name.to_string(),
253        })
254    }
255}