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}