Skip to main content

torrust_tracker_deployer_lib/application/command_handlers/validate/
handler.rs

1//! Validate Command Handler
2//!
3//! This module contains the application layer handler for the validate command.
4//! It validates environment configuration files without creating deployments.
5
6use std::convert::TryInto;
7use std::fs;
8use std::path::Path;
9
10use crate::application::command_handlers::create::config::EnvironmentCreationConfig;
11use crate::domain::environment::EnvironmentParams;
12
13use super::errors::ValidateCommandHandlerError;
14
15/// Application layer handler for validate command
16///
17/// This handler validates environment configuration files by:
18/// 1. Parsing the JSON structure
19/// 2. Validating field types and values
20/// 3. Verifying referenced files exist (SSH keys)
21/// 4. Checking domain constraints
22pub struct ValidateCommandHandler;
23
24impl ValidateCommandHandler {
25    /// Create a new validate command handler
26    #[must_use]
27    pub fn new() -> Self {
28        Self
29    }
30
31    /// Validate an environment configuration file
32    ///
33    /// This method performs comprehensive validation:
34    /// - JSON syntax and structure
35    /// - Field types and constraints
36    /// - SSH key file existence
37    /// - Domain business rules
38    ///
39    /// # Arguments
40    ///
41    /// * `config_path` - Path to the configuration file to validate
42    ///
43    /// # Returns
44    ///
45    /// * `Ok(ValidationResult)` - Configuration is valid with details
46    /// * `Err(ValidateCommandHandlerError)` - Validation failed with specific reason
47    ///
48    /// # Errors
49    ///
50    /// Returns an error if:
51    /// - File cannot be read
52    /// - JSON syntax is invalid
53    /// - Required fields are missing
54    /// - Field values violate constraints
55    /// - Referenced SSH keys don't exist
56    /// - Domain rules are violated
57    ///
58    /// # Examples
59    ///
60    /// ```rust,no_run
61    /// use std::path::Path;
62    /// use torrust_tracker_deployer_lib::application::command_handlers::validate::ValidateCommandHandler;
63    ///
64    /// let handler = ValidateCommandHandler::new();
65    /// let result = handler.validate(Path::new("envs/my-env.json"))?;
66    ///
67    /// println!("Configuration is valid!");
68    /// println!("Environment name: {}", result.environment_name);
69    /// # Ok::<(), Box<dyn std::error::Error>>(())
70    /// ```
71    pub fn validate(
72        &self,
73        config_path: &Path,
74    ) -> Result<ValidationResult, ValidateCommandHandlerError> {
75        // Step 1: Read file contents
76        let content = fs::read_to_string(config_path).map_err(|source| {
77            ValidateCommandHandlerError::FileReadFailed {
78                path: config_path.to_path_buf(),
79                source,
80            }
81        })?;
82
83        // Step 2: Parse JSON to EnvironmentCreationConfig
84        // This validates JSON syntax and maps to our structure
85        let config: EnvironmentCreationConfig =
86            serde_json::from_str(&content).map_err(|source| {
87                ValidateCommandHandlerError::JsonParsingFailed {
88                    path: config_path.to_path_buf(),
89                    source,
90                }
91            })?;
92
93        // Step 3: Convert to domain types (validates all constraints)
94        // This includes:
95        // - SSH key paths must be absolute (file existence checked at runtime)
96        // - Port numbers must be valid
97        // - Domain names must be well-formed
98        // - All business rules must pass
99        let _validated_params: EnvironmentParams = config
100            .clone()
101            .try_into()
102            .map_err(ValidateCommandHandlerError::DomainValidationFailed)?;
103
104        // All validation passed!
105        Ok(ValidationResult {
106            environment_name: config.environment.name.clone(),
107            provider: config.provider.provider().to_string(),
108            has_prometheus: config.prometheus.is_some(),
109            has_grafana: config.grafana.is_some(),
110            has_https: config.https.is_some(),
111            has_backup: config.backup.is_some(),
112        })
113    }
114}
115
116impl Default for ValidateCommandHandler {
117    fn default() -> Self {
118        Self::new()
119    }
120}
121
122/// Result of successful validation
123///
124/// Contains key information about the validated configuration
125/// for user feedback.
126#[allow(clippy::struct_excessive_bools)] // Intentional: presentation data with feature flags
127#[derive(Debug, Clone)]
128pub struct ValidationResult {
129    /// Name of the environment
130    pub environment_name: String,
131
132    /// Provider type (lxd or hetzner)
133    pub provider: String,
134
135    /// Whether Prometheus is configured
136    pub has_prometheus: bool,
137
138    /// Whether Grafana is configured
139    pub has_grafana: bool,
140
141    /// Whether HTTPS is configured
142    pub has_https: bool,
143
144    /// Whether backups are configured
145    pub has_backup: bool,
146}
147
148#[cfg(test)]
149mod tests {
150    use super::*;
151    use std::env;
152    use std::fs;
153    use tempfile::TempDir;
154
155    #[test]
156    fn it_should_validate_valid_configuration_when_all_fields_are_correct() {
157        let handler = ValidateCommandHandler::new();
158
159        // Create temp directory for test config
160        let temp_dir = TempDir::new().expect("Failed to create temp directory");
161        let config_path = temp_dir.path().join("test-config.json");
162
163        // Get absolute paths to test fixtures
164        let project_root = env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR not set");
165        let private_key_path = format!("{project_root}/fixtures/testing_rsa");
166        let public_key_path = format!("{project_root}/fixtures/testing_rsa.pub");
167
168        // Create test config with absolute paths
169        let config_json = format!(
170            r#"{{
171    "environment": {{
172        "name": "test-validation"
173    }},
174    "ssh_credentials": {{
175        "private_key_path": "{private_key_path}",
176        "public_key_path": "{public_key_path}",
177        "username": "torrust",
178        "port": 22
179    }},
180    "provider": {{
181        "provider": "lxd",
182        "profile_name": "test-profile"
183    }},
184    "tracker": {{
185        "core": {{
186            "database": {{
187                "driver": "sqlite3",
188                "database_name": "tracker.db"
189            }},
190            "private": false
191        }},
192        "udp_trackers": [
193            {{
194                "bind_address": "0.0.0.0:6969",
195                "domain": "udp.tracker.local"
196            }}
197        ],
198        "http_trackers": [
199            {{
200                "bind_address": "0.0.0.0:7070",
201                "domain": "http.tracker.local"
202            }}
203        ],
204        "http_api": {{
205            "bind_address": "0.0.0.0:1212",
206            "admin_token": "MyAccessToken",
207            "domain": "api.tracker.local"
208        }},
209        "health_check_api": {{
210            "bind_address": "0.0.0.0:1313",
211            "domain": "health.tracker.local"
212        }}
213    }},
214    "grafana": {{
215        "admin_user": "admin",
216        "admin_password": "admin-password",
217        "domain": "grafana.tracker.local"
218    }},
219    "prometheus": {{
220        "scrape_interval_in_secs": 15
221    }}
222}}"#
223        );
224
225        fs::write(&config_path, config_json).expect("Failed to write test config");
226
227        // Run validation
228        let result = handler.validate(&config_path);
229
230        assert!(result.is_ok(), "Valid configuration should pass validation");
231    }
232
233    #[test]
234    fn it_should_return_error_when_file_does_not_exist() {
235        let handler = ValidateCommandHandler::new();
236
237        let result = handler.validate(Path::new("/tmp/nonexistent.json"));
238
239        assert!(
240            matches!(
241                result,
242                Err(ValidateCommandHandlerError::FileReadFailed { .. })
243            ),
244            "Non-existent file should return FileReadFailed error"
245        );
246    }
247
248    #[test]
249    fn it_should_return_error_when_json_is_malformed() {
250        let handler = ValidateCommandHandler::new();
251
252        // Create a temporary file with invalid JSON
253        let temp_file = std::env::temp_dir().join("invalid.json");
254        fs::write(&temp_file, "{ invalid json }").unwrap();
255
256        let result = handler.validate(&temp_file);
257
258        // Cleanup
259        drop(fs::remove_file(temp_file));
260
261        assert!(
262            matches!(
263                result,
264                Err(ValidateCommandHandlerError::JsonParsingFailed { .. })
265            ),
266            "Malformed JSON should return JsonParsingFailed error"
267        );
268    }
269}