diff --git a/src/infrastructure/external_tools/ansible/template/renderer/mod.rs b/src/infrastructure/external_tools/ansible/template/renderer/mod.rs index 32205362..ea3b4f9f 100644 --- a/src/infrastructure/external_tools/ansible/template/renderer/mod.rs +++ b/src/infrastructure/external_tools/ansible/template/renderer/mod.rs @@ -55,9 +55,11 @@ use crate::infrastructure::external_tools::ansible::template::wrappers::inventor pub mod firewall_playbook; pub mod inventory; +pub mod variables; pub use firewall_playbook::FirewallPlaybookTemplateRenderer; pub use inventory::InventoryTemplateRenderer; +pub use variables::VariablesTemplateRenderer; /// Errors that can occur during configuration template rendering #[derive(Error, Debug)] @@ -129,6 +131,20 @@ pub enum ConfigurationTemplateError { #[source] source: firewall_playbook::FirewallPlaybookTemplateError, }, + + /// Failed to render variables template using collaborator + #[error("Failed to render variables template: {source}")] + VariablesRenderingFailed { + #[source] + source: variables::VariablesTemplateError, + }, + + /// Failed to create context from inventory data + #[error("Failed to create {context_type} context: {message}")] + ContextCreationFailed { + context_type: String, + message: String, + }, } /// Renders `Ansible` configuration templates to a build directory @@ -141,6 +157,7 @@ pub struct AnsibleTemplateRenderer { template_manager: Arc, inventory_renderer: InventoryTemplateRenderer, firewall_playbook_renderer: FirewallPlaybookTemplateRenderer, + variables_renderer: VariablesTemplateRenderer, } impl AnsibleTemplateRenderer { @@ -161,12 +178,14 @@ impl AnsibleTemplateRenderer { let inventory_renderer = InventoryTemplateRenderer::new(template_manager.clone()); let firewall_playbook_renderer = FirewallPlaybookTemplateRenderer::new(template_manager.clone()); + let variables_renderer = VariablesTemplateRenderer::new(template_manager.clone()); Self { build_dir: build_dir.as_ref().to_path_buf(), template_manager, inventory_renderer, firewall_playbook_renderer, + variables_renderer, } } @@ -219,6 +238,12 @@ impl AnsibleTemplateRenderer { |source| ConfigurationTemplateError::FirewallPlaybookRenderingFailed { source }, )?; + // Render dynamic variables template with system configuration using collaborator + let variables_context = Self::create_variables_context(inventory_context)?; + self.variables_renderer + .render(&variables_context, &build_ansible_dir) + .map_err(|source| ConfigurationTemplateError::VariablesRenderingFailed { source })?; + // Copy static Ansible files (config and playbooks) self.copy_static_templates(&self.template_manager, &build_ansible_dir) .await?; @@ -421,21 +446,50 @@ impl AnsibleTemplateRenderer { // Extract SSH port from inventory context let ssh_port = AnsiblePort::new(inventory_context.ansible_port()).map_err(|e| { - ConfigurationTemplateError::TemplatePathFailed { - file_name: "configure-firewall.yml.tera".to_string(), - source: TemplateManagerError::TemplateNotFound { - relative_path: format!("Invalid SSH port: {e}"), - }, + ConfigurationTemplateError::ContextCreationFailed { + context_type: "FirewallPlaybook".to_string(), + message: format!("Invalid SSH port: {e}"), } })?; // Create firewall context FirewallPlaybookContext::new(ssh_port).map_err(|e| { - ConfigurationTemplateError::TemplatePathFailed { - file_name: "configure-firewall.yml.tera".to_string(), - source: TemplateManagerError::TemplateNotFound { - relative_path: format!("Failed to create firewall context: {e}"), - }, + ConfigurationTemplateError::ContextCreationFailed { + context_type: "FirewallPlaybook".to_string(), + message: format!("Failed to create firewall context: {e}"), + } + }) + } + + /// Creates an `AnsibleVariablesContext` from an `InventoryContext` + /// + /// Extracts the SSH port from the inventory context to create + /// a variables context for template rendering. + /// + /// # Arguments + /// + /// * `inventory_context` - The inventory context containing SSH port information + /// + /// # Returns + /// + /// * `Result` - The variables context or an error + /// + /// # Errors + /// + /// Returns an error if the SSH port cannot be extracted or validated + fn create_variables_context( + inventory_context: &InventoryContext, + ) -> Result< + crate::infrastructure::external_tools::ansible::template::wrappers::variables::AnsibleVariablesContext, + ConfigurationTemplateError, + >{ + use crate::infrastructure::external_tools::ansible::template::wrappers::variables::AnsibleVariablesContext; + + // Extract SSH port from inventory context and create variables context + AnsibleVariablesContext::new(inventory_context.ansible_port()).map_err(|e| { + ConfigurationTemplateError::ContextCreationFailed { + context_type: "AnsibleVariables".to_string(), + message: format!("Failed to create variables context: {e}"), } }) } diff --git a/src/infrastructure/external_tools/ansible/template/renderer/variables.rs b/src/infrastructure/external_tools/ansible/template/renderer/variables.rs new file mode 100644 index 00000000..f6cbcef1 --- /dev/null +++ b/src/infrastructure/external_tools/ansible/template/renderer/variables.rs @@ -0,0 +1,317 @@ +//! # Variables Template Renderer +//! +//! This module handles rendering of the `variables.yml.tera` template +//! with system configuration variables. It's responsible for creating the centralized +//! variables file that consolidates Ansible playbook variables. +//! +//! ## Responsibilities +//! +//! - Load the `variables.yml.tera` template file +//! - Process template with system configuration variables +//! - Render final `variables.yml` file for Ansible consumption +//! +//! ## Usage +//! +//! ```rust +//! # use std::sync::Arc; +//! # use tempfile::TempDir; +//! use torrust_tracker_deployer_lib::infrastructure::external_tools::ansible::template::renderer::variables::VariablesTemplateRenderer; +//! use torrust_tracker_deployer_lib::domain::template::TemplateManager; +//! use torrust_tracker_deployer_lib::infrastructure::external_tools::ansible::template::wrappers::variables::AnsibleVariablesContext; +//! +//! # async fn example() -> Result<(), Box> { +//! let temp_dir = TempDir::new()?; +//! let template_manager = Arc::new(TemplateManager::new("/path/to/templates")); +//! let renderer = VariablesTemplateRenderer::new(template_manager); +//! +//! let variables_context = AnsibleVariablesContext::new(22)?; +//! renderer.render(&variables_context, temp_dir.path())?; +//! # Ok(()) +//! # } +//! ``` + +use std::path::Path; +use std::sync::Arc; +use thiserror::Error; + +use crate::domain::template::file::File; +use crate::domain::template::{FileOperationError, TemplateManager, TemplateManagerError}; +use crate::infrastructure::external_tools::ansible::template::wrappers::variables::{ + AnsibleVariablesContext, AnsibleVariablesTemplate, +}; + +/// Errors that can occur during variables template rendering +#[derive(Error, Debug)] +pub enum VariablesTemplateError { + /// Failed to get template path from template manager + #[error("Failed to get template path for '{file_name}': {source}")] + TemplatePathFailed { + file_name: String, + #[source] + source: TemplateManagerError, + }, + + /// Failed to read Tera template file content + #[error("Failed to read Tera template file '{file_name}': {source}")] + TeraTemplateReadFailed { + file_name: String, + #[source] + source: std::io::Error, + }, + + /// Failed to create File object from template content + #[error("Failed to create File object for '{file_name}': {source}")] + FileCreationFailed { + file_name: String, + #[source] + source: crate::domain::template::file::Error, + }, + + /// Failed to create variables template with provided context + #[error("Failed to create AnsibleVariablesTemplate: {source}")] + VariablesTemplateCreationFailed { + #[source] + source: crate::domain::template::TemplateEngineError, + }, + + /// Failed to render variables template to output file + #[error("Failed to render variables template to file: {source}")] + VariablesTemplateRenderFailed { + #[source] + source: FileOperationError, + }, +} + +/// Handles rendering of the variables.yml.tera template for Ansible deployments +/// +/// This collaborator is responsible for all variables template-specific operations: +/// - Loading the variables.yml.tera template +/// - Processing it with system configuration variables +/// - Rendering the final variables.yml file for Ansible consumption +pub struct VariablesTemplateRenderer { + template_manager: Arc, +} + +impl VariablesTemplateRenderer { + /// Template filename for the variables Tera template + const VARIABLES_TEMPLATE_FILE: &'static str = "variables.yml.tera"; + + /// Output filename for the rendered variables file + const VARIABLES_OUTPUT_FILE: &'static str = "variables.yml"; + + /// Creates a new variables template renderer + /// + /// # Arguments + /// + /// * `template_manager` - The template manager to source templates from + #[must_use] + pub fn new(template_manager: Arc) -> Self { + Self { template_manager } + } + + /// Renders the variables.yml.tera template with the provided context + /// + /// This method: + /// 1. Loads the variables.yml.tera template from the template manager + /// 2. Reads the template content + /// 3. Creates a File object for template processing + /// 4. Creates a `AnsibleVariablesTemplate` with the system configuration context + /// 5. Renders the template to variables.yml in the output directory + /// + /// # Arguments + /// + /// * `variables_context` - The context containing system configuration variables + /// * `output_dir` - The directory where variables.yml should be written + /// + /// # Returns + /// + /// * `Result<(), VariablesTemplateError>` - Success or error from the template rendering operation + /// + /// # Errors + /// + /// Returns an error if: + /// - Template file cannot be found or read + /// - Template content is invalid + /// - Variable substitution fails + /// - Output file cannot be written + pub fn render( + &self, + variables_context: &AnsibleVariablesContext, + output_dir: &Path, + ) -> Result<(), VariablesTemplateError> { + tracing::debug!("Rendering variables template with system configuration"); + + // Get the variables template path + let variables_template_path = self + .template_manager + .get_template_path(&Self::build_template_path()) + .map_err(|source| VariablesTemplateError::TemplatePathFailed { + file_name: Self::VARIABLES_TEMPLATE_FILE.to_string(), + source, + })?; + + // Read template content + let variables_template_content = std::fs::read_to_string(&variables_template_path) + .map_err(|source| VariablesTemplateError::TeraTemplateReadFailed { + file_name: Self::VARIABLES_TEMPLATE_FILE.to_string(), + source, + })?; + + // Create File object for template processing + let variables_template_file = + File::new(Self::VARIABLES_TEMPLATE_FILE, variables_template_content).map_err( + |source| VariablesTemplateError::FileCreationFailed { + file_name: Self::VARIABLES_TEMPLATE_FILE.to_string(), + source, + }, + )?; + + // Create AnsibleVariablesTemplate with system configuration context + let variables_template = + AnsibleVariablesTemplate::new(&variables_template_file, variables_context).map_err( + |source| VariablesTemplateError::VariablesTemplateCreationFailed { source }, + )?; + + // Render to output file + let variables_output_path = output_dir.join(Self::VARIABLES_OUTPUT_FILE); + variables_template + .render(&variables_output_path) + .map_err(|source| VariablesTemplateError::VariablesTemplateRenderFailed { source })?; + + tracing::debug!( + "Successfully rendered variables template to {}", + variables_output_path.display() + ); + + Ok(()) + } + + /// Builds the full template path for the variables template + /// + /// # Returns + /// + /// * `String` - The complete template path for variables.yml.tera + fn build_template_path() -> String { + format!("ansible/{}", Self::VARIABLES_TEMPLATE_FILE) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use tempfile::TempDir; + + /// Helper function to create a test variables context + fn create_test_variables_context() -> AnsibleVariablesContext { + AnsibleVariablesContext::new(22).expect("Failed to create variables context") + } + + /// Helper function to create a test template directory with variables.yml.tera + fn create_test_templates(temp_dir: &Path) -> std::io::Result<()> { + let ansible_dir = temp_dir.join("ansible"); + fs::create_dir_all(&ansible_dir)?; + + let template_content = r"--- +# Centralized Ansible Variables +ssh_port: {{ ssh_port }} +"; + + fs::write(ansible_dir.join("variables.yml.tera"), template_content)?; + + Ok(()) + } + + #[test] + fn it_should_create_variables_renderer_with_template_manager() { + let temp_dir = TempDir::new().expect("Failed to create temp directory"); + let template_manager = Arc::new(TemplateManager::new(temp_dir.path())); + + let renderer = VariablesTemplateRenderer::new(template_manager.clone()); + + assert!(Arc::ptr_eq(&renderer.template_manager, &template_manager)); + } + + #[test] + fn it_should_build_correct_template_path() { + let temp_dir = TempDir::new().expect("Failed to create temp directory"); + let template_manager = Arc::new(TemplateManager::new(temp_dir.path())); + let _renderer = VariablesTemplateRenderer::new(template_manager); + + let template_path = VariablesTemplateRenderer::build_template_path(); + + assert_eq!(template_path, "ansible/variables.yml.tera"); + } + + #[test] + fn it_should_render_variables_template_successfully() { + let temp_dir = TempDir::new().expect("Failed to create temp directory"); + let template_dir = temp_dir.path().join("templates"); + let output_dir = temp_dir.path().join("output"); + + // Create template directory and files + create_test_templates(&template_dir).expect("Failed to create test templates"); + fs::create_dir_all(&output_dir).expect("Failed to create output directory"); + + // Setup template manager and renderer + let template_manager = Arc::new(TemplateManager::new(&template_dir)); + template_manager + .ensure_templates_dir() + .expect("Failed to ensure templates directory"); + + let renderer = VariablesTemplateRenderer::new(template_manager); + let variables_context = create_test_variables_context(); + + // Render template + let result = renderer.render(&variables_context, &output_dir); + + assert!(result.is_ok(), "Template rendering should succeed"); + + // Verify output file exists + let output_file = output_dir.join("variables.yml"); + assert!(output_file.exists(), "variables.yml should be created"); + + // Verify output content contains expected values + let output_content = fs::read_to_string(&output_file).expect("Failed to read output file"); + assert!( + output_content.contains("ssh_port: 22"), + "Output should contain the SSH port" + ); + assert!( + !output_content.contains("{{ ssh_port }}"), + "Output should not contain template variables" + ); + } + + #[test] + fn it_should_render_with_custom_ssh_port() { + let temp_dir = TempDir::new().expect("Failed to create temp directory"); + let template_dir = temp_dir.path().join("templates"); + let output_dir = temp_dir.path().join("output"); + + create_test_templates(&template_dir).expect("Failed to create test templates"); + fs::create_dir_all(&output_dir).expect("Failed to create output directory"); + + let template_manager = Arc::new(TemplateManager::new(&template_dir)); + template_manager + .ensure_templates_dir() + .expect("Failed to ensure templates directory"); + + let renderer = VariablesTemplateRenderer::new(template_manager); + + // Use custom SSH port + let variables_context = + AnsibleVariablesContext::new(2222).expect("Failed to create variables context"); + + let result = renderer.render(&variables_context, &output_dir); + + assert!(result.is_ok()); + + let output_file = output_dir.join("variables.yml"); + let output_content = fs::read_to_string(&output_file).expect("Failed to read output file"); + assert!( + output_content.contains("ssh_port: 2222"), + "Output should contain custom SSH port 2222" + ); + } +} diff --git a/src/infrastructure/external_tools/ansible/template/wrappers/mod.rs b/src/infrastructure/external_tools/ansible/template/wrappers/mod.rs index dc4ad6d1..73387044 100644 --- a/src/infrastructure/external_tools/ansible/template/wrappers/mod.rs +++ b/src/infrastructure/external_tools/ansible/template/wrappers/mod.rs @@ -4,7 +4,9 @@ //! and have the `.tera` extension. Static playbooks and config files are copied directly. pub mod firewall_playbook; pub mod inventory; +pub mod variables; // Re-export the main template structs for easier access pub use firewall_playbook::FirewallPlaybookTemplate; pub use inventory::InventoryTemplate; +pub use variables::AnsibleVariablesTemplate; diff --git a/src/infrastructure/external_tools/ansible/template/wrappers/variables/context.rs b/src/infrastructure/external_tools/ansible/template/wrappers/variables/context.rs new file mode 100644 index 00000000..fdcab549 --- /dev/null +++ b/src/infrastructure/external_tools/ansible/template/wrappers/variables/context.rs @@ -0,0 +1,93 @@ +use serde::Serialize; +use thiserror::Error; + +/// Errors that can occur when creating an `AnsibleVariablesContext` +#[derive(Debug, Error)] +pub enum AnsibleVariablesContextError { + /// Invalid SSH port + #[error("Invalid SSH port: {0}")] + InvalidSshPort(#[from] crate::infrastructure::external_tools::ansible::template::wrappers::inventory::context::AnsiblePortError), +} + +/// Context for rendering the variables.yml.tera template +/// +/// This context contains system configuration variables used across +/// Ansible playbooks (but NOT inventory connection variables). +#[derive(Serialize, Debug, Clone)] +pub struct AnsibleVariablesContext { + /// SSH port to configure in firewall and other services + ssh_port: u16, +} + +impl AnsibleVariablesContext { + /// Creates a new context with the specified SSH port + /// + /// # Errors + /// + /// Returns an error if the SSH port is invalid (0 or out of range) + pub fn new(ssh_port: u16) -> Result { + // Validate SSH port using existing validation + crate::infrastructure::external_tools::ansible::template::wrappers::inventory::context::AnsiblePort::new(ssh_port)?; + + Ok(Self { ssh_port }) + } + + /// Get the SSH port + #[must_use] + pub fn ssh_port(&self) -> u16 { + self.ssh_port + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn it_should_create_context_with_valid_ssh_port() { + let context = AnsibleVariablesContext::new(22).unwrap(); + assert_eq!(context.ssh_port(), 22); + } + + #[test] + fn it_should_create_context_with_custom_ssh_port() { + let context = AnsibleVariablesContext::new(2222).unwrap(); + assert_eq!(context.ssh_port(), 2222); + } + + #[test] + fn it_should_create_context_with_high_port() { + let context = AnsibleVariablesContext::new(65535).unwrap(); + assert_eq!(context.ssh_port(), 65535); + } + + #[test] + fn it_should_fail_with_port_zero() { + let result = AnsibleVariablesContext::new(0); + assert!(result.is_err()); + let error_msg = result.unwrap_err().to_string(); + assert!(error_msg.contains("Invalid SSH port")); + } + + #[test] + fn it_should_implement_clone() { + let context1 = AnsibleVariablesContext::new(22).unwrap(); + let context2 = context1.clone(); + assert_eq!(context1.ssh_port(), context2.ssh_port()); + } + + #[test] + fn it_should_serialize_to_json() { + let context = AnsibleVariablesContext::new(8022).unwrap(); + let json = serde_json::to_string(&context).unwrap(); + assert!(json.contains("\"ssh_port\":8022")); + } + + #[test] + fn it_should_display_error_message_correctly() { + let error = AnsibleVariablesContext::new(0).unwrap_err(); + let error_msg = format!("{error}"); + assert!(error_msg.contains("Invalid SSH port")); + assert!(error_msg.contains("Invalid port number: 0")); + } +} diff --git a/src/infrastructure/external_tools/ansible/template/wrappers/variables/mod.rs b/src/infrastructure/external_tools/ansible/template/wrappers/variables/mod.rs new file mode 100644 index 00000000..7424f776 --- /dev/null +++ b/src/infrastructure/external_tools/ansible/template/wrappers/variables/mod.rs @@ -0,0 +1,121 @@ +//! Wrapper for templates/ansible/variables.yml.tera + +pub mod context; + +use crate::domain::template::file::File; +use crate::domain::template::{ + write_file_with_dir_creation, FileOperationError, TemplateEngineError, +}; +use std::path::Path; + +pub use context::{AnsibleVariablesContext, AnsibleVariablesContextError}; + +/// Wrapper for the variables template +#[derive(Debug)] +pub struct AnsibleVariablesTemplate { + content: String, +} + +impl AnsibleVariablesTemplate { + /// Creates a new template with variable substitution + /// + /// # Errors + /// + /// Returns an error if template rendering fails + pub fn new( + template_file: &File, + context: &AnsibleVariablesContext, + ) -> Result { + let mut engine = crate::domain::template::TemplateEngine::new(); + let validated_content = + engine.render(template_file.filename(), template_file.content(), context)?; + + Ok(Self { + content: validated_content, + }) + } + + /// Render the template to a file + /// + /// # Errors + /// + /// Returns an error if file creation or directory creation fails + pub fn render(&self, output_path: &Path) -> Result<(), FileOperationError> { + write_file_with_dir_creation(output_path, &self.content) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Helper function to create a `AnsibleVariablesContext` with the given SSH port + fn create_variables_context(ssh_port: u16) -> AnsibleVariablesContext { + AnsibleVariablesContext::new(ssh_port).unwrap() + } + + /// Helper function to create a minimal valid variables template file + fn create_minimal_template() -> File { + let content = r"--- +# Test template +ssh_port: {{ ssh_port }} +"; + File::new("variables.yml.tera", content.to_string()).unwrap() + } + + #[test] + fn it_should_create_variables_template_with_context() { + let context = create_variables_context(22); + let template_file = create_minimal_template(); + + let template = AnsibleVariablesTemplate::new(&template_file, &context); + + assert!(template.is_ok()); + } + + #[test] + fn it_should_render_template_with_ssh_port() { + let context = create_variables_context(2222); + let template_file = create_minimal_template(); + let template = AnsibleVariablesTemplate::new(&template_file, &context).unwrap(); + + // The rendered content should have the port substituted + assert!(template.content.contains("2222")); + assert!(!template.content.contains("{{ ssh_port }}")); + } + + #[test] + fn it_should_fail_with_invalid_template_syntax() { + let context = create_variables_context(22); + let invalid_template = + File::new("variables.yml.tera", "{{ unclosed_variable".to_string()).unwrap(); + + let result = AnsibleVariablesTemplate::new(&invalid_template, &context); + + assert!(result.is_err()); + } + + #[test] + fn it_should_fail_with_missing_variable_in_context() { + let context = create_variables_context(22); + // Template references a variable that doesn't exist in context + let template_with_missing_var = File::new( + "variables.yml.tera", + "Port: {{ ssh_port }} and {{ nonexistent_var }}".to_string(), + ) + .unwrap(); + + let result = AnsibleVariablesTemplate::new(&template_with_missing_var, &context); + + assert!(result.is_err()); + } + + #[test] + fn it_should_support_custom_ssh_ports() { + let context = create_variables_context(8022); + let template_file = create_minimal_template(); + let template = AnsibleVariablesTemplate::new(&template_file, &context).unwrap(); + + assert!(template.content.contains("8022")); + } +} diff --git a/templates/ansible/variables.yml.tera b/templates/ansible/variables.yml.tera new file mode 100644 index 00000000..39e0242f --- /dev/null +++ b/templates/ansible/variables.yml.tera @@ -0,0 +1,11 @@ +--- +# Centralized Ansible Variables +# This file contains all dynamic variables used across Ansible playbooks. +# It follows the same pattern as OpenTofu's variables.tfvars.tera for consistency. +# +# NOTE: The inventory file (inventory.yml.tera) cannot use this file because +# Ansible inventories don't support vars_files. Only playbooks can use vars_files. + +# System Configuration +ssh_port: {{ ssh_port }} +# Future service variables can be added here when needed