diff --git a/docs/contributing/templates.md b/docs/contributing/templates.md index f7c8f72e..dd2e8ed7 100644 --- a/docs/contributing/templates.md +++ b/docs/contributing/templates.md @@ -274,6 +274,72 @@ When adding a static Ansible playbook: - [ ] Create application step to execute the playbook - [ ] Verify playbook appears in `build/` directory during execution +## 🎯 Using Centralized Variables in Ansible Playbooks + +When creating new Ansible playbooks that need dynamic variables (ports, paths, etc.), use the **centralized variables pattern** instead of creating new Tera templates. + +### DO ✅ + +**Add variables to `templates/ansible/variables.yml.tera`:** + +```yaml +# System Configuration +ssh_port: {{ ssh_port }} +my_service_port: {{ my_service_port }} # ← Add your new variable +``` + +**Reference variables in static playbook using `vars_files`:** + +```yaml +# templates/ansible/my-new-service.yml (static playbook, no .tera extension) +--- +- name: Configure My Service + hosts: all + vars_files: + - variables.yml # Load centralized variables + + tasks: + - name: Configure service port + ansible.builtin.lineinfile: + path: /etc/myservice/config + line: "port={{ my_service_port }}" +``` + +**Register playbook in `copy_static_templates()` method:** + +```rust +for playbook in &[ + "update-apt-cache.yml", + "install-docker.yml", + "my-new-service.yml", // ← Add here +] { + // ... +} +``` + +### DON'T ❌ + +- ❌ Create a new `.tera` template for the playbook +- ❌ Create a new renderer/wrapper/context for each playbook +- ❌ Add variables directly in `inventory.yml.tera` (unless inventory-specific) + +### Benefits + +1. **Minimal Code**: No Rust boilerplate (renderer, wrapper, context) needed +2. **Centralized Management**: All variables in one place +3. **Runtime Resolution**: Variables resolved by Ansible, not at template rendering +4. **Easy Maintenance**: Adding new variables requires minimal changes + +### When to Create a New Tera Template + +Only create a new `.tera` template if: + +1. The file **cannot** use Ansible's `vars_files` directive (e.g., inventory files) +2. The file requires **complex logic** that Tera provides but Ansible doesn't +3. The file needs **different variable scopes** than what centralized variables provide + +Otherwise, use the centralized variables pattern for simplicity. + ### Related Documentation - **Architecture**: [`docs/technical/template-system-architecture.md`](../technical/template-system-architecture.md) - Understanding the two-phase template system diff --git a/docs/technical/template-system-architecture.md b/docs/technical/template-system-architecture.md index 75725dd1..7ef06388 100644 --- a/docs/technical/template-system-architecture.md +++ b/docs/technical/template-system-architecture.md @@ -52,6 +52,50 @@ The system operates through two levels of indirection to balance portability wit - **Use Case**: Configuration files requiring runtime parameters (IPs, usernames, paths) - **Registration**: Automatically discovered by `.tera` extension +## 🎨 Ansible Variables Pattern + +For Ansible templates, the system uses a **hybrid approach** combining static playbooks with centralized variables: + +### Tera Templates (2 templates) + +1. `inventory.yml.tera` - Inventory requires direct variable substitution (Ansible inventories don't support vars_files) +2. `variables.yml.tera` - Centralized variables for all playbooks + +### Static Playbooks + +- All playbooks are static YAML files (no `.tera` extension) +- Playbooks reference variables via `vars_files: [variables.yml]` +- Variables are resolved at Ansible runtime, not at template rendering time + +### Benefits + +- **Reduced Rust Boilerplate**: No per-playbook renderer/wrapper/context needed +- **Centralized Variable Management**: All playbook variables in one place +- **Consistency**: Follows the same pattern as OpenTofu's `variables.tfvars.tera` +- **Maintainability**: Adding new playbooks requires minimal code changes + +### Example + +```yaml +# templates/ansible/configure-firewall.yml (static playbook) +--- +- name: Configure UFW firewall + hosts: all + vars_files: + - variables.yml # Load centralized variables + + tasks: + - name: Allow SSH access + community.general.ufw: + port: "{{ ssh_port }}" # Variable from variables.yml +``` + +```yaml +# templates/ansible/variables.yml.tera (rendered once) +--- +ssh_port: {{ ssh_port }} +``` + ## 🔧 Key Components ### Template Manager diff --git a/src/adapters/ansible/mod.rs b/src/adapters/ansible/mod.rs index 94f99fff..4e2f1c6b 100644 --- a/src/adapters/ansible/mod.rs +++ b/src/adapters/ansible/mod.rs @@ -45,11 +45,12 @@ impl AnsibleClient { } } - /// Run an Ansible playbook + /// Run an Ansible playbook with optional extra arguments /// /// # Arguments /// /// * `playbook` - Name of the playbook file (without .yml extension) + /// * `extra_args` - Optional extra arguments to pass to ansible-playbook /// /// # Returns /// @@ -62,7 +63,23 @@ impl AnsibleClient { /// * The Ansible playbook execution fails /// * The playbook file does not exist in the working directory /// * There are issues with the inventory or configuration - pub fn run_playbook(&self, playbook: &str) -> Result { + /// + /// # Examples + /// + /// ```rust,no_run + /// # use torrust_tracker_deployer_lib::adapters::ansible::AnsibleClient; + /// # let client = AnsibleClient::new("/path/to/config"); + /// // Run without extra args (backward compatible) + /// client.run_playbook("install-docker", &[]).unwrap(); + /// + /// // Run with extra variables + /// client.run_playbook("configure-firewall", &["-e", "@variables.yml"]).unwrap(); + /// ``` + pub fn run_playbook( + &self, + playbook: &str, + extra_args: &[&str], + ) -> Result { info!( "Running Ansible playbook '{}' in directory: {}", playbook, @@ -71,14 +88,14 @@ impl AnsibleClient { let playbook_file = format!("{playbook}.yml"); + // Build command arguments: -v flag + playbook + extra args + let mut args = vec!["-v", &playbook_file]; + args.extend_from_slice(extra_args); + // Use -v flag for verbose output showing task progress // This helps track progress during long-running operations like Docker installation self.command_executor - .run_command( - "ansible-playbook", - &["-v", &playbook_file], - Some(&self.working_dir), - ) + .run_command("ansible-playbook", &args, Some(&self.working_dir)) .map(|result| result.stdout) } @@ -140,7 +157,7 @@ mod tests { // This tests the structure - we expect the method to exist and accept a &str // The actual execution would fail without Ansible, but we're testing the interface - let result = client.run_playbook("install-docker"); + let result = client.run_playbook("install-docker", &[]); // We expect it to fail because ansible-playbook is not available in test environment // But this confirms the method signature and basic functionality works diff --git a/src/application/steps/software/docker.rs b/src/application/steps/software/docker.rs index aeddd678..217c6200 100644 --- a/src/application/steps/software/docker.rs +++ b/src/application/steps/software/docker.rs @@ -66,7 +66,7 @@ impl InstallDockerStep { "Installing Docker via Ansible" ); - self.ansible_client.run_playbook("install-docker")?; + self.ansible_client.run_playbook("install-docker", &[])?; info!( step = "install_docker", diff --git a/src/application/steps/software/docker_compose.rs b/src/application/steps/software/docker_compose.rs index 6dbd8439..31efb136 100644 --- a/src/application/steps/software/docker_compose.rs +++ b/src/application/steps/software/docker_compose.rs @@ -65,7 +65,8 @@ impl InstallDockerComposeStep { "Installing Docker Compose via Ansible" ); - self.ansible_client.run_playbook("install-docker-compose")?; + self.ansible_client + .run_playbook("install-docker-compose", &[])?; info!( step = "install_docker_compose", diff --git a/src/application/steps/system/configure_firewall.rs b/src/application/steps/system/configure_firewall.rs index 65afd2a0..5fd94661 100644 --- a/src/application/steps/system/configure_firewall.rs +++ b/src/application/steps/system/configure_firewall.rs @@ -95,11 +95,16 @@ impl ConfigureFirewallStep { warn!( step = "configure_firewall", action = "configure_ufw", - "Configuring UFW firewall - CRITICAL: SSH access will be restricted to configured port" + "Configuring UFW firewall with variables from variables.yml" ); - // Run Ansible playbook (SSH port already resolved during template rendering) - match self.ansible_client.run_playbook("configure-firewall") { + // Run Ansible playbook with variables file + // Note: The @ symbol in Ansible means "load variables from this file" + // Equivalent to: ansible-playbook -e @variables.yml configure-firewall.yml + match self + .ansible_client + .run_playbook("configure-firewall", &["-e", "@variables.yml"]) + { Ok(_) => { info!( step = "configure_firewall", diff --git a/src/application/steps/system/configure_security_updates.rs b/src/application/steps/system/configure_security_updates.rs index 9329a5c8..b605299c 100644 --- a/src/application/steps/system/configure_security_updates.rs +++ b/src/application/steps/system/configure_security_updates.rs @@ -71,7 +71,7 @@ impl ConfigureSecurityUpdatesStep { ); self.ansible_client - .run_playbook("configure-security-updates")?; + .run_playbook("configure-security-updates", &[])?; info!( step = "configure_security_updates", diff --git a/src/application/steps/system/wait_cloud_init.rs b/src/application/steps/system/wait_cloud_init.rs index 922fd752..9405ddcd 100644 --- a/src/application/steps/system/wait_cloud_init.rs +++ b/src/application/steps/system/wait_cloud_init.rs @@ -56,7 +56,7 @@ impl WaitForCloudInitStep { "Waiting for cloud-init completion" ); - self.ansible_client.run_playbook("wait-cloud-init")?; + self.ansible_client.run_playbook("wait-cloud-init", &[])?; info!( step = "wait_cloud_init", diff --git a/src/infrastructure/external_tools/ansible/template/renderer/firewall_playbook.rs b/src/infrastructure/external_tools/ansible/template/renderer/firewall_playbook.rs deleted file mode 100644 index 80f4ece2..00000000 --- a/src/infrastructure/external_tools/ansible/template/renderer/firewall_playbook.rs +++ /dev/null @@ -1,347 +0,0 @@ -//! # Firewall Playbook Template Renderer -//! -//! This module handles rendering of the `configure-firewall.yml.tera` template -//! with SSH port configuration. It's responsible for creating the Ansible playbook -//! that configures UFW firewall while preserving SSH access. -//! -//! ## Responsibilities -//! -//! - Load the `configure-firewall.yml.tera` template file -//! - Process template with SSH port configuration -//! - Render final `configure-firewall.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::firewall_playbook::FirewallPlaybookTemplateRenderer; -//! use torrust_tracker_deployer_lib::domain::template::TemplateManager; -//! use torrust_tracker_deployer_lib::infrastructure::external_tools::ansible::template::wrappers::firewall_playbook::FirewallPlaybookContext; -//! use torrust_tracker_deployer_lib::infrastructure::external_tools::ansible::template::wrappers::inventory::context::AnsiblePort; -//! -//! # async fn example() -> Result<(), Box> { -//! let temp_dir = TempDir::new()?; -//! let template_manager = Arc::new(TemplateManager::new("/path/to/templates")); -//! let renderer = FirewallPlaybookTemplateRenderer::new(template_manager); -//! -//! let ssh_port = AnsiblePort::new(22)?; -//! let firewall_context = FirewallPlaybookContext::new(ssh_port)?; -//! renderer.render(&firewall_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::firewall_playbook::{ - FirewallPlaybookContext, FirewallPlaybookTemplate, -}; - -/// Errors that can occur during firewall playbook template rendering -#[derive(Error, Debug)] -pub enum FirewallPlaybookTemplateError { - /// 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 firewall playbook template with provided context - #[error("Failed to create FirewallPlaybookTemplate: {source}")] - FirewallPlaybookTemplateCreationFailed { - #[source] - source: crate::domain::template::TemplateEngineError, - }, - - /// Failed to render firewall playbook template to output file - #[error("Failed to render firewall playbook template to file: {source}")] - FirewallPlaybookTemplateRenderFailed { - #[source] - source: FileOperationError, - }, -} - -/// Handles rendering of the configure-firewall.yml.tera template for Ansible deployments -/// -/// This collaborator is responsible for all firewall playbook template-specific operations: -/// - Loading the configure-firewall.yml.tera template -/// - Processing it with SSH port configuration -/// - Rendering the final configure-firewall.yml file for Ansible consumption -pub struct FirewallPlaybookTemplateRenderer { - template_manager: Arc, -} - -impl FirewallPlaybookTemplateRenderer { - /// Template filename for the firewall playbook Tera template - const FIREWALL_TEMPLATE_FILE: &'static str = "configure-firewall.yml.tera"; - - /// Output filename for the rendered firewall playbook file - const FIREWALL_OUTPUT_FILE: &'static str = "configure-firewall.yml"; - - /// Creates a new firewall playbook 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 configure-firewall.yml.tera template with the provided context - /// - /// This method: - /// 1. Loads the configure-firewall.yml.tera template from the template manager - /// 2. Reads the template content - /// 3. Creates a File object for template processing - /// 4. Creates a `FirewallPlaybookTemplate` with the SSH port context - /// 5. Renders the template to configure-firewall.yml in the output directory - /// - /// # Arguments - /// - /// * `firewall_context` - The context containing SSH port configuration - /// * `output_dir` - The directory where configure-firewall.yml should be written - /// - /// # Returns - /// - /// * `Result<(), FirewallPlaybookTemplateError>` - 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, - firewall_context: &FirewallPlaybookContext, - output_dir: &Path, - ) -> Result<(), FirewallPlaybookTemplateError> { - tracing::debug!("Rendering firewall playbook template with SSH port configuration"); - - // Get the firewall playbook template path - let firewall_template_path = self - .template_manager - .get_template_path(&Self::build_template_path()) - .map_err(|source| FirewallPlaybookTemplateError::TemplatePathFailed { - file_name: Self::FIREWALL_TEMPLATE_FILE.to_string(), - source, - })?; - - // Read template content - let firewall_template_content = - std::fs::read_to_string(&firewall_template_path).map_err(|source| { - FirewallPlaybookTemplateError::TeraTemplateReadFailed { - file_name: Self::FIREWALL_TEMPLATE_FILE.to_string(), - source, - } - })?; - - // Create File object for template processing - let firewall_template_file = - File::new(Self::FIREWALL_TEMPLATE_FILE, firewall_template_content).map_err( - |source| FirewallPlaybookTemplateError::FileCreationFailed { - file_name: Self::FIREWALL_TEMPLATE_FILE.to_string(), - source, - }, - )?; - - // Create FirewallPlaybookTemplate with SSH port context - let firewall_template = - FirewallPlaybookTemplate::new(&firewall_template_file, firewall_context.clone()) - .map_err(|source| { - FirewallPlaybookTemplateError::FirewallPlaybookTemplateCreationFailed { source } - })?; - - // Render to output file - let firewall_output_path = output_dir.join(Self::FIREWALL_OUTPUT_FILE); - firewall_template - .render(&firewall_output_path) - .map_err(|source| { - FirewallPlaybookTemplateError::FirewallPlaybookTemplateRenderFailed { source } - })?; - - tracing::debug!( - "Successfully rendered firewall playbook template to {}", - firewall_output_path.display() - ); - - Ok(()) - } - - /// Builds the full template path for the firewall playbook template - /// - /// # Returns - /// - /// * `String` - The complete template path for configure-firewall.yml.tera - fn build_template_path() -> String { - format!("ansible/{}", Self::FIREWALL_TEMPLATE_FILE) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::infrastructure::external_tools::ansible::template::wrappers::inventory::context::AnsiblePort; - use std::fs; - use tempfile::TempDir; - - /// Helper function to create a test firewall context - fn create_test_firewall_context() -> FirewallPlaybookContext { - let ssh_port = AnsiblePort::new(22).expect("Failed to create SSH port"); - FirewallPlaybookContext::builder() - .with_ssh_port(ssh_port) - .build() - .expect("Failed to build firewall context") - } - - /// Helper function to create a test template directory with configure-firewall.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#"--- -- name: Configure UFW firewall - hosts: all - become: yes - tasks: - - name: Allow SSH on port {{ssh_port}} - community.general.ufw: - rule: allow - port: "{{ssh_port}}" - proto: tcp -"#; - - fs::write( - ansible_dir.join("configure-firewall.yml.tera"), - template_content, - )?; - - Ok(()) - } - - #[test] - fn it_should_create_firewall_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 = FirewallPlaybookTemplateRenderer::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 = FirewallPlaybookTemplateRenderer::new(template_manager); - - let template_path = FirewallPlaybookTemplateRenderer::build_template_path(); - - assert_eq!(template_path, "ansible/configure-firewall.yml.tera"); - } - - #[test] - fn it_should_render_firewall_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 = FirewallPlaybookTemplateRenderer::new(template_manager); - let firewall_context = create_test_firewall_context(); - - // Render template - let result = renderer.render(&firewall_context, &output_dir); - - assert!(result.is_ok(), "Template rendering should succeed"); - - // Verify output file exists - let output_file = output_dir.join("configure-firewall.yml"); - assert!( - output_file.exists(), - "configure-firewall.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("22"), - "Output should contain the SSH port" - ); - assert!( - output_content.contains("hosts: all"), - "Output should contain hosts: all" - ); - 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 = FirewallPlaybookTemplateRenderer::new(template_manager); - - // Use custom SSH port - let ssh_port = AnsiblePort::new(2222).expect("Failed to create SSH port"); - let firewall_context = - FirewallPlaybookContext::new(ssh_port).expect("Failed to create context"); - - let result = renderer.render(&firewall_context, &output_dir); - - assert!(result.is_ok()); - - let output_file = output_dir.join("configure-firewall.yml"); - let output_content = fs::read_to_string(&output_file).expect("Failed to read output file"); - assert!( - output_content.contains("2222"), - "Output should contain custom SSH port 2222" - ); - } -} diff --git a/src/infrastructure/external_tools/ansible/template/renderer/mod.rs b/src/infrastructure/external_tools/ansible/template/renderer/mod.rs index ea3b4f9f..cd83b83f 100644 --- a/src/infrastructure/external_tools/ansible/template/renderer/mod.rs +++ b/src/infrastructure/external_tools/ansible/template/renderer/mod.rs @@ -53,11 +53,9 @@ use crate::domain::template::{FileOperationError, TemplateManager, TemplateManag use crate::infrastructure::external_tools::ansible::template::renderer::inventory::InventoryTemplateError; use crate::infrastructure::external_tools::ansible::template::wrappers::inventory::InventoryContext; -pub mod firewall_playbook; pub mod inventory; pub mod variables; -pub use firewall_playbook::FirewallPlaybookTemplateRenderer; pub use inventory::InventoryTemplateRenderer; pub use variables::VariablesTemplateRenderer; @@ -125,13 +123,6 @@ pub enum ConfigurationTemplateError { source: InventoryTemplateError, }, - /// Failed to render firewall playbook template using collaborator - #[error("Failed to render firewall playbook template: {source}")] - FirewallPlaybookRenderingFailed { - #[source] - source: firewall_playbook::FirewallPlaybookTemplateError, - }, - /// Failed to render variables template using collaborator #[error("Failed to render variables template: {source}")] VariablesRenderingFailed { @@ -156,7 +147,6 @@ pub struct AnsibleTemplateRenderer { build_dir: PathBuf, template_manager: Arc, inventory_renderer: InventoryTemplateRenderer, - firewall_playbook_renderer: FirewallPlaybookTemplateRenderer, variables_renderer: VariablesTemplateRenderer, } @@ -176,15 +166,12 @@ impl AnsibleTemplateRenderer { #[must_use] pub fn new>(build_dir: P, template_manager: Arc) -> Self { 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, } } @@ -230,14 +217,6 @@ impl AnsibleTemplateRenderer { .render(inventory_context, &build_ansible_dir) .map_err(|source| ConfigurationTemplateError::InventoryRenderingFailed { source })?; - // Render dynamic firewall playbook template with SSH port variable using collaborator - let firewall_context = Self::create_firewall_context(inventory_context)?; - self.firewall_playbook_renderer - .render(&firewall_context, &build_ansible_dir) - .map_err( - |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 @@ -355,6 +334,7 @@ impl AnsibleTemplateRenderer { "install-docker-compose.yml", "wait-cloud-init.yml", "configure-security-updates.yml", + "configure-firewall.yml", ] { self.copy_static_file(template_manager, playbook, destination_dir) .await?; @@ -362,7 +342,7 @@ impl AnsibleTemplateRenderer { tracing::debug!( "Successfully copied {} static template files", - 6 // ansible.cfg + 5 playbooks + 7 // ansible.cfg + 6 playbooks ); Ok(()) @@ -419,48 +399,6 @@ impl AnsibleTemplateRenderer { Ok(()) } - /// Creates a `FirewallPlaybookContext` from an `InventoryContext` - /// - /// Extracts the SSH port from the inventory context to create - /// a firewall-specific context for template rendering. - /// - /// # Arguments - /// - /// * `inventory_context` - The inventory context containing SSH port information - /// - /// # Returns - /// - /// * `Result` - The firewall context or an error - /// - /// # Errors - /// - /// Returns an error if the SSH port cannot be extracted or validated - fn create_firewall_context( - inventory_context: &InventoryContext, - ) -> Result< - crate::infrastructure::external_tools::ansible::template::wrappers::firewall_playbook::FirewallPlaybookContext, - ConfigurationTemplateError, - >{ - use crate::infrastructure::external_tools::ansible::template::wrappers::firewall_playbook::FirewallPlaybookContext; - use crate::infrastructure::external_tools::ansible::template::wrappers::inventory::context::AnsiblePort; - - // Extract SSH port from inventory context - let ssh_port = AnsiblePort::new(inventory_context.ansible_port()).map_err(|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::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 diff --git a/src/infrastructure/external_tools/ansible/template/wrappers/firewall_playbook/context.rs b/src/infrastructure/external_tools/ansible/template/wrappers/firewall_playbook/context.rs deleted file mode 100644 index c218923c..00000000 --- a/src/infrastructure/external_tools/ansible/template/wrappers/firewall_playbook/context.rs +++ /dev/null @@ -1,160 +0,0 @@ -//! Context for firewall playbook template rendering -//! -//! This module provides the type-safe context for rendering the -//! `configure-firewall.yml.tera` template with validated SSH port configuration. - -use serde::Serialize; -use thiserror::Error; - -use crate::infrastructure::external_tools::ansible::template::wrappers::inventory::context::{ - AnsiblePort, AnsiblePortError, -}; - -/// Errors that can occur when creating a `FirewallPlaybookContext` -#[derive(Debug, Error)] -pub enum FirewallPlaybookContextError { - /// Invalid SSH port - #[error("Invalid SSH port: {0}")] - InvalidSshPort(#[from] AnsiblePortError), - - /// Missing SSH port in context - #[error("Missing SSH port - must be set before building")] - MissingSshPort, -} - -/// Context for rendering the firewall playbook template -/// -/// This context contains the SSH port configuration needed to render -/// the `configure-firewall.yml.tera` template with proper SSH access rules. -#[derive(Serialize, Debug, Clone)] -pub struct FirewallPlaybookContext { - /// SSH port to allow through the firewall - ssh_port: AnsiblePort, -} - -/// Builder for `FirewallPlaybookContext` with fluent interface -#[derive(Debug, Default)] -pub struct FirewallPlaybookContextBuilder { - ssh_port: Option, -} - -impl FirewallPlaybookContextBuilder { - /// Creates a new empty builder - #[must_use] - pub fn new() -> Self { - Self::default() - } - - /// Sets the SSH port for the builder - #[must_use] - pub fn with_ssh_port(mut self, ssh_port: AnsiblePort) -> Self { - self.ssh_port = Some(ssh_port); - self - } - - /// Builds the `FirewallPlaybookContext` - /// - /// # Errors - /// - /// Returns an error if the SSH port is missing - pub fn build(self) -> Result { - let ssh_port = self - .ssh_port - .ok_or(FirewallPlaybookContextError::MissingSshPort)?; - - Ok(FirewallPlaybookContext { ssh_port }) - } -} - -impl FirewallPlaybookContext { - /// Creates a new `FirewallPlaybookContext` with the specified SSH port - /// - /// # Errors - /// - /// This method cannot fail with the current implementation since it takes - /// already validated types, but returns Result for consistency with builder pattern - pub fn new(ssh_port: AnsiblePort) -> Result { - Ok(Self { ssh_port }) - } - - /// Creates a new builder for `FirewallPlaybookContext` with fluent interface - #[must_use] - pub fn builder() -> FirewallPlaybookContextBuilder { - FirewallPlaybookContextBuilder::new() - } - - /// Get the SSH port - #[must_use] - pub fn ssh_port(&self) -> u16 { - self.ssh_port.as_u16() - } - - /// Get the SSH port as a string - #[must_use] - pub fn ssh_port_string(&self) -> String { - self.ssh_port.as_str() - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn it_should_create_firewall_context_with_builder() { - let ssh_port = AnsiblePort::new(22).unwrap(); - let context = FirewallPlaybookContext::builder() - .with_ssh_port(ssh_port) - .build() - .unwrap(); - - assert_eq!(context.ssh_port(), 22); - } - - #[test] - fn it_should_create_firewall_context_directly() { - let ssh_port = AnsiblePort::new(2222).unwrap(); - let context = FirewallPlaybookContext::new(ssh_port).unwrap(); - - assert_eq!(context.ssh_port(), 2222); - } - - #[test] - fn it_should_fail_without_ssh_port() { - let result = FirewallPlaybookContext::builder().build(); - - assert!(result.is_err()); - match result { - Err(FirewallPlaybookContextError::MissingSshPort) => {} - _ => panic!("Expected MissingSshPort error"), - } - } - - #[test] - fn it_should_serialize_context_to_json() { - let ssh_port = AnsiblePort::new(22).unwrap(); - let context = FirewallPlaybookContext::new(ssh_port).unwrap(); - - let json = serde_json::to_string(&context).unwrap(); - assert!(json.contains("22")); - assert!(json.contains("ssh_port")); - } - - #[test] - fn it_should_support_custom_ssh_ports() { - let ssh_port = AnsiblePort::new(2222).unwrap(); - let context = FirewallPlaybookContext::new(ssh_port).unwrap(); - - assert_eq!(context.ssh_port(), 2222); - assert_eq!(context.ssh_port_string(), "2222"); - } - - #[test] - fn it_should_clone_context() { - let ssh_port = AnsiblePort::new(22).unwrap(); - let context1 = FirewallPlaybookContext::new(ssh_port).unwrap(); - let context2 = context1.clone(); - - assert_eq!(context1.ssh_port(), context2.ssh_port()); - } -} diff --git a/src/infrastructure/external_tools/ansible/template/wrappers/firewall_playbook/mod.rs b/src/infrastructure/external_tools/ansible/template/wrappers/firewall_playbook/mod.rs deleted file mode 100644 index 0b681430..00000000 --- a/src/infrastructure/external_tools/ansible/template/wrappers/firewall_playbook/mod.rs +++ /dev/null @@ -1,168 +0,0 @@ -//! Template wrapper for templates/ansible/configure-firewall.yml.tera -//! -//! This template configures UFW firewall with SSH access preservation. -//! It requires the SSH port to be provided at construction time. - -pub mod context; - -use crate::domain::template::file::File; -use crate::domain::template::{ - write_file_with_dir_creation, FileOperationError, TemplateEngineError, -}; -use anyhow::Result; -use std::path::Path; - -pub use context::{ - FirewallPlaybookContext, FirewallPlaybookContextBuilder, FirewallPlaybookContextError, -}; - -/// Wrapper for the firewall playbook template -/// -/// This wrapper validates the template syntax at construction time -/// and provides a type-safe way to render the firewall configuration -/// playbook with the correct SSH port. -#[derive(Debug)] -pub struct FirewallPlaybookTemplate { - context: FirewallPlaybookContext, - content: String, -} - -impl FirewallPlaybookTemplate { - /// Creates a new `FirewallPlaybookTemplate`, validating the template content and variable substitution - /// - /// # Errors - /// - /// Returns an error if: - /// - Template syntax is invalid - /// - Required variables cannot be substituted - /// - Template validation fails - /// - /// # Panics - /// - /// This method will panic if cloning the already validated `FirewallPlaybookContext` fails, - /// which should never happen under normal circumstances. - pub fn new( - template_file: &File, - firewall_context: FirewallPlaybookContext, - ) -> Result { - let mut engine = crate::domain::template::TemplateEngine::new(); - - let validated_content = engine.render( - template_file.filename(), - template_file.content(), - &firewall_context, - )?; - - Ok(Self { - context: firewall_context, - content: validated_content, - }) - } - - /// Get the SSH port value - #[must_use] - pub fn ssh_port(&self) -> u16 { - self.context.ssh_port() - } - - /// Render the template to a file at the specified output path - /// - /// # Errors - /// - /// Returns `FileOperationError::DirectoryCreation` if the parent directory cannot be created, - /// or `FileOperationError::FileWrite` if the file cannot be written - pub fn render(&self, output_path: &Path) -> Result<(), FileOperationError> { - write_file_with_dir_creation(output_path, &self.content) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::infrastructure::external_tools::ansible::template::wrappers::inventory::context::AnsiblePort; - - /// Helper function to create a `FirewallPlaybookContext` with the given SSH port - fn create_firewall_context(ssh_port: u16) -> FirewallPlaybookContext { - let port = AnsiblePort::new(ssh_port).unwrap(); - FirewallPlaybookContext::builder() - .with_ssh_port(port) - .build() - .unwrap() - } - - /// Helper function to create a minimal valid firewall template file - fn create_minimal_template() -> File { - let content = r#"--- -- name: Configure UFW firewall - hosts: all - tasks: - - name: Allow SSH on port {{ssh_port}} - community.general.ufw: - rule: allow - port: "{{ssh_port}}" -"#; - File::new("configure-firewall.yml.tera", content.to_string()).unwrap() - } - - #[test] - fn it_should_create_firewall_template_with_context() { - let context = create_firewall_context(22); - let template_file = create_minimal_template(); - - let template = FirewallPlaybookTemplate::new(&template_file, context); - - assert!(template.is_ok()); - let template = template.unwrap(); - assert_eq!(template.ssh_port(), 22); - } - - #[test] - fn it_should_render_template_with_ssh_port() { - let context = create_firewall_context(2222); - let template_file = create_minimal_template(); - let template = FirewallPlaybookTemplate::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_firewall_context(22); - let invalid_template = File::new( - "configure-firewall.yml.tera", - "{{ unclosed_variable".to_string(), - ) - .unwrap(); - - let result = FirewallPlaybookTemplate::new(&invalid_template, context); - - assert!(result.is_err()); - } - - #[test] - fn it_should_fail_with_missing_variable_in_context() { - let context = create_firewall_context(22); - // Template references a variable that doesn't exist in context - let template_with_missing_var = File::new( - "configure-firewall.yml.tera", - "Port: {{ssh_port}} and {{nonexistent_var}}".to_string(), - ) - .unwrap(); - - let result = FirewallPlaybookTemplate::new(&template_with_missing_var, context); - - assert!(result.is_err()); - } - - #[test] - fn it_should_support_custom_ssh_ports() { - let context = create_firewall_context(8022); - let template_file = create_minimal_template(); - let template = FirewallPlaybookTemplate::new(&template_file, context).unwrap(); - - assert_eq!(template.ssh_port(), 8022); - assert!(template.content.contains("8022")); - } -} diff --git a/src/infrastructure/external_tools/ansible/template/wrappers/mod.rs b/src/infrastructure/external_tools/ansible/template/wrappers/mod.rs index 73387044..6f16d75a 100644 --- a/src/infrastructure/external_tools/ansible/template/wrappers/mod.rs +++ b/src/infrastructure/external_tools/ansible/template/wrappers/mod.rs @@ -2,11 +2,9 @@ //! //! Contains wrappers only for template files that actually need variable substitution //! 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/templates/ansible/README.md b/templates/ansible/README.md index c84eb183..7f1f4d57 100644 --- a/templates/ansible/README.md +++ b/templates/ansible/README.md @@ -28,18 +28,22 @@ This directory contains Ansible playbook templates for the Torrust Tracker Deplo - Sets up unattended-upgrades for automatic security patches -- **`configure-firewall.yml.tera`** - Configures UFW (Uncomplicated Firewall) with SSH lockout prevention +- **`configure-firewall.yml`** - Configures UFW (Uncomplicated Firewall) with SSH lockout prevention - ⚠️ **Critical**: This playbook configures restrictive firewall rules - Automatically preserves SSH access on the configured port to prevent lockout + - Uses centralized variables from `variables.yml` (loaded via `vars_files`) - **Container Limitation**: Requires kernel capabilities (CAP_NET_ADMIN, CAP_NET_RAW) not available in unprivileged containers - **Automatic Skip**: Container-based E2E tests automatically skip this step via `TORRUST_TD_SKIP_FIREWALL_IN_CONTAINER` environment variable - Accepted values: `"true"` or `"false"` (case-sensitive, lowercase only) - Example: `TORRUST_TD_SKIP_FIREWALL_IN_CONTAINER=true` - - **VM-only**: This playbook is only executed in VM-based deployments and tests### Configuration Files + - **VM-only**: This playbook is only executed in VM-based deployments and tests -- **`ansible.cfg`** - Ansible configuration +### Configuration Files + +- **`ansible.cfg`** - Ansible configuration (static) - **`inventory.yml.tera`** - Inventory template file (processed by Tera templating engine) +- **`variables.yml.tera`** - Centralized variables template (processed by Tera templating engine) ## Usage Order @@ -50,7 +54,7 @@ For a typical deployment: 3. **`install-docker.yml`** - Install Docker 4. **`install-docker-compose.yml`** - Install Docker Compose (optional) 5. **`configure-security-updates.yml`** - Configure automatic security updates -6. **`configure-firewall.yml.tera`** - Configure UFW firewall (VM-only, skipped in containers) +6. **`configure-firewall.yml`** - Configure UFW firewall (VM-only, skipped in containers) ## CI/Testing Considerations @@ -63,6 +67,23 @@ For a typical deployment: - The deployer sets `TORRUST_TD_SKIP_FIREWALL_IN_CONTAINER=true` for container tests (accepts `"true"` or `"false"` only) - VM-based tests (LXD) have full kernel access and run the firewall playbook normally +## Variables Pattern + +This directory uses a **centralized variables pattern**: + +- **`variables.yml.tera`** - Centralized variables (rendered at runtime with Tera) +- **`inventory.yml.tera`** - Connection variables (rendered at runtime with Tera) +- **`*.yml`** - Static playbooks that load `variables.yml` via `vars_files` directive + +### When Adding New Playbooks + +1. **Add variables** to `variables.yml.tera` +2. **Create static** `.yml` playbook (not `.tera`) +3. **Add `vars_files: [variables.yml]`** to playbook +4. **Register** in `copy_static_templates()` if new static playbook + +This pattern reduces Rust boilerplate (no per-playbook renderer/wrapper/context needed) while providing centralized variable management. + ## Template Processing -These files are processed by the Tera templating engine and written to the `build/ansible/` directory during the build process. +Files with `.tera` extension are processed by the Tera templating engine. All files are written to the `build/ansible/` directory during the build process. diff --git a/templates/ansible/configure-firewall.yml.tera b/templates/ansible/configure-firewall.yml similarity index 88% rename from templates/ansible/configure-firewall.yml.tera rename to templates/ansible/configure-firewall.yml index e3870e11..047430d2 100644 --- a/templates/ansible/configure-firewall.yml.tera +++ b/templates/ansible/configure-firewall.yml @@ -2,11 +2,15 @@ # Configure UFW Firewall with Safe SSH Access # This playbook configures UFW with restrictive policies while preserving SSH access. # CRITICAL: SSH access is allowed BEFORE enabling firewall to prevent lockout. +# +# Variables are loaded from variables.yml for centralized management. - name: Configure UFW firewall safely hosts: all become: yes gather_facts: yes + vars_files: + - variables.yml tasks: - name: Install UFW (should already be present on Ubuntu) @@ -49,9 +53,9 @@ - name: Allow SSH access on configured port (BEFORE enabling firewall) community.general.ufw: rule: allow - port: "{{ssh_port}}" + port: "{{ ssh_port }}" proto: tcp - comment: "SSH access (configured port {{ssh_port}})" + comment: "SSH access (configured port {{ ssh_port }})" tags: - security - firewall @@ -85,7 +89,7 @@ - name: Verify SSH port is allowed ansible.builtin.shell: - cmd: "ufw status | grep -E '{{ssh_port}}/tcp.*ALLOW'" + cmd: "ufw status | grep -E '{{ ssh_port }}/tcp.*ALLOW'" register: ssh_port_check changed_when: false failed_when: ssh_port_check.rc != 0 @@ -99,7 +103,7 @@ ansible.builtin.debug: msg: - "UFW firewall configured successfully" - - "SSH access preserved on port {{ssh_port}}" + - "SSH access preserved on port {{ ssh_port }}" - "Default policy: deny incoming, allow outgoing" - "Active rules protect against unauthorized access" tags: