Skip to main content

torrust_tracker_deployer_lib/application/command_handlers/render/
handler.rs

1//! Render command handler implementation
2
3use std::convert::TryInto;
4use std::fs;
5use std::net::IpAddr;
6use std::path::{Path, PathBuf};
7use std::sync::Arc;
8
9use tracing::{info, instrument};
10
11use super::errors::RenderCommandHandlerError;
12use crate::application::command_handlers::create::config::{
13    CreateConfigError, EnvironmentCreationConfig,
14};
15use crate::application::services::rendering::{
16    AnsibleTemplateRenderingService, BackupTemplateRenderingService, CaddyTemplateRenderingService,
17    DockerComposeTemplateRenderingService, GrafanaTemplateRenderingService,
18    OpenTofuTemplateRenderingService, PrometheusTemplateRenderingService,
19    TrackerTemplateRenderingService,
20};
21use crate::domain::environment::repository::EnvironmentRepository;
22use crate::domain::environment::{Created, Environment, EnvironmentParams};
23use crate::domain::EnvironmentName;
24use crate::shared::{Clock, SystemClock};
25
26/// Input mode for render command
27///
28/// The render command supports two mutually exclusive input modes:
29/// - From an existing environment (by name)
30/// - From a configuration file (without creating environment)
31#[derive(Debug, Clone)]
32pub enum RenderInputMode {
33    /// Load from existing environment in repository
34    EnvironmentName(EnvironmentName),
35    /// Load from configuration file
36    ConfigFile(PathBuf),
37}
38
39/// Result of artifact generation
40///
41/// Contains paths and metadata about generated artifacts
42#[derive(Debug, Clone)]
43pub struct RenderResult {
44    /// Name of the environment (from env or config)
45    pub environment_name: String,
46    /// IP address used in artifact generation
47    pub target_ip: IpAddr,
48    /// Path to generated artifacts
49    pub output_dir: PathBuf,
50    /// Source of configuration
51    pub config_source: String,
52}
53
54/// `RenderCommandHandler` generates deployment artifacts without deployment
55///
56/// This command handler provides a way to preview or generate deployment
57/// artifacts (docker-compose files, Ansible playbooks, tracker config, etc.)
58/// without executing any infrastructure operations.
59///
60/// # State Management
61///
62/// - **Created State Only**: Command works for environments in "Created" state
63/// - **Already Provisioned**: Returns informational result (not error) with artifacts location
64/// - **No State Modification**: Does not change environment state or execute deployments
65///
66/// # Dual Input Modes
67///
68/// 1. **Environment Name Mode**: Loads existing environment from repository
69/// 2. **Config File Mode**: Parses configuration file directly (no env creation)
70///
71/// # Workflow
72///
73/// 1. Determine input mode (env-name or env-file)
74/// 2. Load/parse configuration
75/// 3. Validate state (Created only for existing environments)
76/// 4. Parse target IP address
77/// 5. Render all deployment templates to build/{env}/ directory
78pub struct RenderCommandHandler {
79    repository: Arc<dyn EnvironmentRepository>,
80}
81
82impl RenderCommandHandler {
83    /// Create a new `RenderCommandHandler`
84    #[must_use]
85    pub fn new(repository: Arc<dyn EnvironmentRepository>) -> Self {
86        Self { repository }
87    }
88
89    /// Execute the render workflow
90    ///
91    /// # Arguments
92    ///
93    /// * `input_mode` - Source of configuration (env-name or env-file)
94    /// * `target_ip` - Target instance IP address (always required)
95    /// * `output_dir` - Output directory for generated artifacts
96    /// * `force` - Whether to overwrite existing output directory
97    /// * `working_dir` - Working directory for resolving relative paths
98    ///
99    /// # Returns
100    ///
101    /// Returns `RenderResult` with paths to generated artifacts
102    ///
103    /// # Errors
104    ///
105    /// Returns an error if:
106    /// * Environment not found (env-name mode)
107    /// * Config file not found or invalid (env-file mode)
108    /// * IP address parsing fails
109    /// * Output directory exists and force is false
110    /// * Template rendering fails
111    #[instrument(
112        name = "render_command",
113        skip_all,
114        fields(
115            command_type = "render",
116            input_mode = ?input_mode,
117            target_ip = %target_ip,
118            output_dir = %output_dir.display()
119        )
120    )]
121    pub async fn execute(
122        &self,
123        input_mode: RenderInputMode,
124        target_ip: &str,
125        output_dir: &Path,
126        force: bool,
127        working_dir: &Path,
128    ) -> Result<RenderResult, RenderCommandHandlerError> {
129        // Parse and validate target IP
130        let ip_addr = Self::parse_ip_address(target_ip)?;
131
132        // Load configuration based on input mode
133        match input_mode {
134            RenderInputMode::EnvironmentName(ref env_name) => {
135                // Validate output directory after environment check (fail-fast: file check before directory creation)
136                Self::validate_output_directory(output_dir, force)?;
137
138                self.render_from_environment(env_name, ip_addr, output_dir, working_dir)
139                    .await
140            }
141            RenderInputMode::ConfigFile(ref config_path) => {
142                // Validate output directory after config file check (fail-fast: file check before directory creation)
143                Self::validate_output_directory(output_dir, force)?;
144
145                self.render_from_config_file(config_path, ip_addr, output_dir, working_dir)
146                    .await
147            }
148        }
149    }
150
151    /// Render artifacts from existing environment
152    ///
153    /// This mode loads an existing environment from the repository.
154    ///
155    /// # Arguments
156    ///
157    /// * `env_name` - Name of the environment to render from
158    /// * `ip_addr` - Target instance IP address
159    /// * `output_dir` - Output directory for generated artifacts
160    /// * `working_dir` - Working directory for path resolution
161    ///
162    /// # Errors
163    ///
164    /// Returns error if environment not found or rendering fails
165    async fn render_from_environment(
166        &self,
167        env_name: &EnvironmentName,
168        ip_addr: IpAddr,
169        output_dir: &Path,
170        _working_dir: &Path,
171    ) -> Result<RenderResult, RenderCommandHandlerError> {
172        info!(
173            environment = %env_name,
174            target_ip = %ip_addr,
175            output_dir = %output_dir.display(),
176            "Rendering artifacts from existing environment"
177        );
178
179        // Load environment (untyped to check state)
180        let environment = self.repository.load(env_name)?.ok_or_else(|| {
181            RenderCommandHandlerError::EnvironmentNotFound {
182                name: env_name.clone(),
183            }
184        })?;
185
186        // Try to convert to Created state
187        // Render command works for Created state (before provision)
188        let current_state = environment.state_name().to_string();
189        let created_env: Environment<Created> = environment.try_into_created().map_err(|_| {
190            RenderCommandHandlerError::EnvironmentAlreadyProvisioned {
191                name: env_name.clone(),
192                current_state,
193            }
194        })?;
195
196        // Render all templates
197        self.render_all_templates(&created_env, ip_addr, output_dir)
198            .await?;
199
200        Ok(RenderResult {
201            environment_name: created_env.name().to_string(),
202            target_ip: ip_addr,
203            output_dir: output_dir.to_path_buf(),
204            config_source: format!("Environment: {}", created_env.name()),
205        })
206    }
207
208    /// Render artifacts from configuration file
209    ///
210    /// This mode parses a configuration file directly without creating or
211    /// loading an environment from the repository.
212    ///
213    /// # Arguments
214    ///
215    /// * `config_path` - Path to the configuration file
216    /// * `ip_addr` - Target instance IP address
217    /// * `output_dir` - Output directory for generated artifacts
218    /// * `working_dir` - Working directory for path resolution
219    ///
220    /// # Errors
221    ///
222    /// Returns error if file not found, parsing fails, or rendering fails
223    async fn render_from_config_file(
224        &self,
225        config_path: &Path,
226        ip_addr: IpAddr,
227        output_dir: &Path,
228        working_dir: &Path,
229    ) -> Result<RenderResult, RenderCommandHandlerError> {
230        info!(
231            config_file = %config_path.display(),
232            target_ip = %ip_addr,
233            output_dir = %output_dir.display(),
234            "Rendering artifacts from configuration file"
235        );
236
237        // Read configuration file
238        let content = fs::read_to_string(config_path).map_err(|_| {
239            RenderCommandHandlerError::ConfigFileNotFound {
240                path: config_path.to_path_buf(),
241            }
242        })?;
243
244        // Parse JSON to EnvironmentCreationConfig
245        let config: EnvironmentCreationConfig =
246            serde_json::from_str(&content).map_err(|source| {
247                RenderCommandHandlerError::ConfigParsingFailed {
248                    path: config_path.to_path_buf(),
249                    source,
250                }
251            })?;
252
253        // Validate configuration by converting to domain types (this moves config)
254        let params: EnvironmentParams = config.try_into().map_err(|e: CreateConfigError| {
255            RenderCommandHandlerError::DomainValidationFailed {
256                reason: e.to_string(),
257            }
258        })?;
259
260        // Create a temporary environment for template rendering (not persisted)
261        let env_name = params.environment_name.clone();
262        let clock: Arc<dyn Clock> = Arc::new(SystemClock);
263        let created_env = Environment::<Created>::create(params, working_dir, clock.now())
264            .map_err(|e| RenderCommandHandlerError::DomainValidationFailed {
265                reason: e.to_string(),
266            })?;
267
268        // Render all templates
269        self.render_all_templates(&created_env, ip_addr, output_dir)
270            .await?;
271
272        Ok(RenderResult {
273            environment_name: env_name.to_string(),
274            target_ip: ip_addr,
275            output_dir: output_dir.to_path_buf(),
276            config_source: format!("Config file: {}", config_path.display()),
277        })
278    }
279
280    /// Render all deployment templates to the specified output directory
281    ///
282    /// This method orchestrates the rendering of all templates required for
283    /// deployment: `OpenTofu`, Ansible, Docker Compose, Tracker, Prometheus,
284    /// Grafana, Caddy, and Backup (conditional on configuration).
285    ///
286    /// # Arguments
287    ///
288    /// * `environment` - The environment in Created state
289    /// * `target_ip` - Target instance IP address
290    /// * `output_dir` - Output directory for generated artifacts
291    ///
292    /// # Errors
293    ///
294    /// Returns error if any template rendering fails
295    async fn render_all_templates(
296        &self,
297        environment: &Environment<Created>,
298        target_ip: IpAddr,
299        output_dir: &Path,
300    ) -> Result<(), RenderCommandHandlerError> {
301        info!(
302            environment = %environment.name(),
303            target_ip = %target_ip,
304            output_dir = %output_dir.display(),
305            "Rendering all deployment templates"
306        );
307
308        let clock: Arc<dyn Clock> = Arc::new(SystemClock);
309        let templates_dir = environment.templates_dir();
310        let build_dir = output_dir.to_path_buf();
311        let user_inputs = &environment.context().user_inputs;
312
313        // 1. Render OpenTofu templates (infrastructure provisioning)
314        OpenTofuTemplateRenderingService::from_params(
315            templates_dir.clone(),
316            build_dir.clone(),
317            environment.ssh_credentials().clone(),
318            environment.ssh_port(),
319            environment.instance_name().clone(),
320            environment.provider_config().clone(),
321            clock.clone(),
322        )
323        .render()
324        .await
325        .map_err(|e| RenderCommandHandlerError::TemplateRenderingFailed {
326            reason: e.to_string(),
327        })?;
328
329        // 2. Render Ansible templates (configuration management)
330        AnsibleTemplateRenderingService::from_paths(
331            templates_dir.clone(),
332            build_dir.clone(),
333            clock.clone(),
334        )
335        .render_templates(user_inputs, target_ip, None)
336        .await
337        .map_err(|e| RenderCommandHandlerError::TemplateRenderingFailed {
338            reason: e.to_string(),
339        })?;
340
341        // 3. Render Docker Compose templates (container orchestration)
342        DockerComposeTemplateRenderingService::from_paths(
343            templates_dir.clone(),
344            build_dir.clone(),
345            clock.clone(),
346        )
347        .render(user_inputs, environment.admin_token())
348        .await
349        .map_err(|e| RenderCommandHandlerError::TemplateRenderingFailed {
350            reason: e.to_string(),
351        })?;
352
353        // 4. Render Tracker configuration templates
354        TrackerTemplateRenderingService::from_paths(
355            templates_dir.clone(),
356            build_dir.clone(),
357            clock.clone(),
358        )
359        .render(user_inputs.tracker())
360        .map_err(|e| RenderCommandHandlerError::TemplateRenderingFailed {
361            reason: e.to_string(),
362        })?;
363
364        // 5. Render Prometheus configuration templates (if configured)
365        PrometheusTemplateRenderingService::from_paths(
366            templates_dir.clone(),
367            build_dir.clone(),
368            clock.clone(),
369        )
370        .render(user_inputs.prometheus(), user_inputs.tracker())
371        .map_err(|e| RenderCommandHandlerError::TemplateRenderingFailed {
372            reason: e.to_string(),
373        })?;
374
375        // 6. Render Grafana provisioning templates (if configured)
376        GrafanaTemplateRenderingService::from_paths(
377            templates_dir.clone(),
378            build_dir.clone(),
379            clock.clone(),
380        )
381        .render(user_inputs.grafana().is_some(), user_inputs.prometheus())
382        .map_err(|e| RenderCommandHandlerError::TemplateRenderingFailed {
383            reason: e.to_string(),
384        })?;
385
386        // 7. Render Caddy TLS proxy templates (if HTTPS configured)
387        CaddyTemplateRenderingService::from_paths(
388            templates_dir.clone(),
389            build_dir.clone(),
390            clock.clone(),
391        )
392        .render(user_inputs)
393        .map_err(|e| RenderCommandHandlerError::TemplateRenderingFailed {
394            reason: e.to_string(),
395        })?;
396
397        // 8. Render Backup configuration templates (if configured)
398        BackupTemplateRenderingService::from_paths(templates_dir.clone(), build_dir.clone())
399            .render(
400                user_inputs.backup(),
401                user_inputs.tracker().core().database(),
402                environment.context().created_at(),
403            )
404            .await
405            .map_err(|e| RenderCommandHandlerError::TemplateRenderingFailed {
406                reason: e.to_string(),
407            })?;
408
409        info!(
410            environment = %environment.name(),
411            "All deployment templates rendered successfully"
412        );
413
414        Ok(())
415    }
416
417    /// Parse and validate IP address
418    ///
419    /// # Arguments
420    ///
421    /// * `ip_str` - IP address string to parse
422    ///
423    /// # Errors
424    ///
425    /// Returns error if IP address format is invalid
426    fn parse_ip_address(ip_str: &str) -> Result<IpAddr, RenderCommandHandlerError> {
427        ip_str
428            .parse::<IpAddr>()
429            .map_err(|_| RenderCommandHandlerError::InvalidIpAddress {
430                value: ip_str.to_string(),
431            })
432    }
433
434    /// Validate output directory
435    ///
436    /// Checks if output directory exists and handles --force flag behavior.
437    ///
438    /// # Arguments
439    ///
440    /// * `output_dir` - Path to output directory
441    /// * `force` - Whether to allow overwriting existing directory
442    ///
443    /// # Errors
444    ///
445    /// Returns error if:
446    /// - Directory exists and force is false
447    /// - Directory creation fails
448    fn validate_output_directory(
449        output_dir: &Path,
450        force: bool,
451    ) -> Result<(), RenderCommandHandlerError> {
452        if output_dir.exists() {
453            if !force {
454                return Err(RenderCommandHandlerError::OutputDirectoryExists {
455                    path: output_dir.to_path_buf(),
456                });
457            }
458            // With force flag, we allow overwriting
459            info!(
460                output_dir = %output_dir.display(),
461                "Output directory exists, overwriting with --force"
462            );
463        } else {
464            // Create output directory if it doesn't exist
465            fs::create_dir_all(output_dir).map_err(|e| {
466                RenderCommandHandlerError::OutputDirectoryCreationFailed {
467                    path: output_dir.to_path_buf(),
468                    reason: e.to_string(),
469                }
470            })?;
471            info!(
472                output_dir = %output_dir.display(),
473                "Created output directory"
474            );
475        }
476        Ok(())
477    }
478}
479
480#[cfg(test)]
481mod tests {
482    use super::*;
483    use std::net::{Ipv4Addr, Ipv6Addr};
484
485    use crate::infrastructure::persistence::file_repository_factory::FileRepositoryFactory;
486
487    fn create_test_repository() -> Arc<dyn EnvironmentRepository> {
488        let file_repository_factory =
489            FileRepositoryFactory::new(std::time::Duration::from_secs(30));
490        file_repository_factory.create(PathBuf::from("."))
491    }
492
493    #[test]
494    fn it_should_create_handler() {
495        let repository = create_test_repository();
496        let _handler = RenderCommandHandler::new(repository);
497    }
498
499    #[test]
500    fn it_should_parse_valid_ipv4_address() {
501        let result = RenderCommandHandler::parse_ip_address("192.168.1.100");
502        assert!(result.is_ok());
503        assert_eq!(result.unwrap(), IpAddr::V4(Ipv4Addr::new(192, 168, 1, 100)));
504    }
505
506    #[test]
507    fn it_should_parse_valid_ipv6_address() {
508        let result = RenderCommandHandler::parse_ip_address("2001:db8::1");
509        assert!(result.is_ok());
510        assert_eq!(
511            result.unwrap(),
512            IpAddr::V6("2001:db8::1".parse::<Ipv6Addr>().unwrap())
513        );
514    }
515
516    #[test]
517    fn it_should_reject_invalid_ip_address() {
518        let result = RenderCommandHandler::parse_ip_address("not-an-ip");
519        assert!(result.is_err());
520        assert!(matches!(
521            result.unwrap_err(),
522            RenderCommandHandlerError::InvalidIpAddress { .. }
523        ));
524    }
525
526    #[tokio::test]
527    async fn it_should_return_error_for_nonexistent_environment() {
528        let repository = create_test_repository();
529        let handler = RenderCommandHandler::new(repository);
530        let working_dir = PathBuf::from(".");
531
532        // Use a non-existent path for output directory (don't create it)
533        let temp_dir = tempfile::tempdir().unwrap();
534        let output_dir = temp_dir.path().join("nonexistent-output");
535
536        let env_name = EnvironmentName::new("nonexistent").unwrap();
537        let result = handler
538            .execute(
539                RenderInputMode::EnvironmentName(env_name.clone()),
540                "10.0.0.1",
541                output_dir.as_path(),
542                false,
543                &working_dir,
544            )
545            .await;
546
547        assert!(result.is_err());
548        assert!(matches!(
549            result.unwrap_err(),
550            RenderCommandHandlerError::EnvironmentNotFound { name } if name == env_name
551        ));
552    }
553
554    #[tokio::test]
555    async fn it_should_return_error_for_nonexistent_config_file() {
556        let repository = create_test_repository();
557        let handler = RenderCommandHandler::new(repository);
558        let working_dir = PathBuf::from(".");
559
560        // Use a non-existent path for output directory (don't create it)
561        let temp_dir = tempfile::tempdir().unwrap();
562        let output_dir = temp_dir.path().join("test-output");
563
564        let config_path = PathBuf::from("/tmp/nonexistent-config.json");
565        let result = handler
566            .execute(
567                RenderInputMode::ConfigFile(config_path.clone()),
568                "10.0.0.1",
569                output_dir.as_path(),
570                false,
571                &working_dir,
572            )
573            .await;
574
575        assert!(result.is_err());
576        assert!(matches!(
577            result.unwrap_err(),
578            RenderCommandHandlerError::ConfigFileNotFound { path } if path == config_path
579        ));
580    }
581
582    #[tokio::test]
583    async fn it_should_validate_ip_before_loading_environment() {
584        // This test ensures fail-fast behavior: IP validation happens first
585        let repository = create_test_repository();
586        let handler = RenderCommandHandler::new(repository);
587        let working_dir = PathBuf::from(".");
588
589        // Use a non-existent path for output directory (don't create it)
590        let temp_dir = tempfile::tempdir().unwrap();
591        let output_dir = temp_dir.path().join("test-output");
592
593        let env_name = EnvironmentName::new("any-env").unwrap();
594
595        // Even if environment exists, invalid IP should fail first
596        let result = handler
597            .execute(
598                RenderInputMode::EnvironmentName(env_name),
599                "invalid-ip",
600                output_dir.as_path(),
601                false,
602                &working_dir,
603            )
604            .await;
605
606        assert!(result.is_err());
607        assert!(matches!(
608            result.unwrap_err(),
609            RenderCommandHandlerError::InvalidIpAddress { .. }
610        ));
611    }
612}