Issue: #105 Parent Epic: #19 - Refactor Ansible Templates to Variables Pattern Related: Parent Epic, Template System Architecture
Create the centralized variables.yml.tera template and supporting Rust infrastructure (context, wrapper, renderer) to consolidate Ansible playbook variables into a single file. This establishes the foundation for the variables pattern that will simplify future playbook additions.
- Variables Template: Create
templates/ansible/variables.yml.terawith system configuration variables - Context Layer: Implement
AnsibleVariablesContextwith validation - Wrapper Layer: Implement
AnsibleVariablesTemplatefor rendering - Renderer Layer: Implement
VariablesTemplateRendererfor orchestration - Integration: Hook into
AnsibleTemplateRenderer::render()workflow - Test Coverage: Comprehensive unit tests for all components
DDD Layer: Infrastructure
Module Path: src/infrastructure/external_tools/ansible/template/
Pattern: Template Wrapper + Context + Renderer (existing pattern)
- Follow existing template architecture pattern (see
inventory/andfirewall_playbook/modules) - Separate concerns: context validation, template rendering, file operations
- Use Arc for template access
- Context must validate SSH port range (reuse existing validation)
- Template wrapper uses domain TemplateEngine for rendering
- Renderer orchestrates context → wrapper → file I/O
- Error types use thiserror with proper context
- ❌ Mixing variable validation with template rendering
- ❌ Direct file I/O in context or wrapper (use renderer)
- ❌ Hardcoding template paths (use TemplateManager)
File: templates/ansible/variables.yml.tera
---
# 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:
# mysql_port: {{ mysql_port }}
# tracker_port: {{ tracker_port }}
# prometheus_port: {{ prometheus_port }}
# grafana_port: {{ grafana_port }}Why Not Include Connection Variables?
ansible_host,ansible_port,ansible_ssh_private_key_filebelong in inventory- Ansible inventories don't support
vars_files - These must stay in
inventory.yml.tera
File: src/infrastructure/external_tools/ansible/template/wrappers/variables/context.rs
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
pub fn new(ssh_port: u16) -> Result<Self, AnsibleVariablesContextError> {
// 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
}
}File: src/infrastructure/external_tools/ansible/template/wrappers/variables/mod.rs
//! 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
pub fn new(
template_file: &File,
context: AnsibleVariablesContext,
) -> Result<Self, TemplateEngineError> {
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
pub fn render(&self, output_path: &Path) -> Result<(), FileOperationError> {
write_file_with_dir_creation(output_path, &self.content)
}
}File: src/infrastructure/external_tools/ansible/template/renderer/variables.rs
//! Variables template renderer
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 for variables template rendering
#[derive(Error, Debug)]
pub enum VariablesTemplateError {
#[error("Failed to get template path for '{file_name}': {source}")]
TemplatePathFailed {
file_name: String,
#[source]
source: TemplateManagerError,
},
#[error("Failed to read template file '{file_name}': {source}")]
TemplateReadFailed {
file_name: String,
#[source]
source: std::io::Error,
},
#[error("Failed to create File object for '{file_name}': {source}")]
FileCreationFailed {
file_name: String,
#[source]
source: crate::domain::template::file::Error,
},
#[error("Failed to create variables template: {source}")]
TemplateCreationFailed {
#[source]
source: crate::domain::template::TemplateEngineError,
},
#[error("Failed to render variables template: {source}")]
TemplateRenderFailed {
#[source]
source: FileOperationError,
},
}
/// Renders the variables.yml.tera template
pub struct VariablesTemplateRenderer {
template_manager: Arc<TemplateManager>,
}
impl VariablesTemplateRenderer {
const VARIABLES_TEMPLATE_FILE: &'static str = "variables.yml.tera";
const VARIABLES_OUTPUT_FILE: &'static str = "variables.yml";
#[must_use]
pub fn new(template_manager: Arc<TemplateManager>) -> Self {
Self { template_manager }
}
pub fn render(
&self,
context: &AnsibleVariablesContext,
output_dir: &Path,
) -> Result<(), VariablesTemplateError> {
tracing::debug!("Rendering variables template with system configuration");
// Get template path
let 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 template_content = std::fs::read_to_string(&template_path)
.map_err(|source| VariablesTemplateError::TemplateReadFailed {
file_name: Self::VARIABLES_TEMPLATE_FILE.to_string(),
source,
})?;
// Create File object
let template_file = File::new(Self::VARIABLES_TEMPLATE_FILE, template_content)
.map_err(|source| VariablesTemplateError::FileCreationFailed {
file_name: Self::VARIABLES_TEMPLATE_FILE.to_string(),
source,
})?;
// Create and render template
let variables_template = AnsibleVariablesTemplate::new(&template_file, context.clone())
.map_err(|source| VariablesTemplateError::TemplateCreationFailed { source })?;
let output_path = output_dir.join(Self::VARIABLES_OUTPUT_FILE);
variables_template
.render(&output_path)
.map_err(|source| VariablesTemplateError::TemplateRenderFailed { source })?;
tracing::debug!(
"Successfully rendered variables template to {}",
output_path.display()
);
Ok(())
}
fn build_template_path() -> String {
format!("ansible/{}", Self::VARIABLES_TEMPLATE_FILE)
}
}This task establishes the foundation without breaking existing functionality. Each step keeps tests green.
Safe Steps:
- Create
templates/ansible/variables.yml.terawith SSH port variable - Run linters to validate YAML syntax:
cargo run --bin linter yaml - Commit: "feat: [#19.1] add centralized variables template file"
Green Check: Linters pass, no code changes yet.
Safe Steps:
- Create directory:
src/infrastructure/external_tools/ansible/template/wrappers/variables/ - Create
context.rswithAnsibleVariablesContextand validation - Create
mod.rsin variables directory - Write unit tests for context creation and validation
- Run tests:
cargo test - Commit: "feat: [#19.1] add AnsibleVariablesContext with validation"
Green Check: All tests pass, context fully tested.
Safe Steps:
- Update
wrappers/variables/mod.rswithAnsibleVariablesTemplate - Write unit tests for template rendering
- Run tests:
cargo test - Commit: "feat: [#19.1] add AnsibleVariablesTemplate wrapper"
Green Check: All tests pass, wrapper fully tested.
Safe Steps:
- Create
src/infrastructure/external_tools/ansible/template/renderer/variables.rs - Implement
VariablesTemplateRenderer - Write unit tests for renderer
- Run tests:
cargo test - Commit: "feat: [#19.1] add VariablesTemplateRenderer"
Green Check: All tests pass, renderer fully tested in isolation.
Safe Steps:
- Update
wrappers/mod.rsto exportvariablesmodule - Update
renderer/mod.rsto exportvariablesmodule - Build project:
cargo build - Run tests:
cargo test - Commit: "feat: [#19.1] export variables module from wrappers and renderer"
Green Check: Project compiles, all tests pass, new components accessible.
Safe Steps:
- Add
variables_rendererfield toAnsibleTemplateRendererstruct - Update constructor to initialize
variables_renderer - Add
VariablesRenderingFailederror variant toConfigurationTemplateError - Add helper method
create_variables_context()(private, doesn't break anything) - Build project:
cargo build - Run tests:
cargo test - Commit: "feat: [#19.1] add variables renderer to AnsibleTemplateRenderer"
Green Check: Builds successfully, existing tests still pass.
Safe Steps:
- Add variables rendering call to
AnsibleTemplateRenderer::render()method - Place AFTER firewall rendering (existing functionality unaffected)
- Run unit tests:
cargo test - Run config tests:
cargo run --bin e2e-config-tests - Commit: "feat: [#19.1] integrate variables rendering into main workflow"
Green Check: Config tests pass, variables.yml generated in build directory.
Safe Steps:
- Run all linters:
cargo run --bin linter all - Run unit tests:
cargo test - Run config tests:
cargo run --bin e2e-config-tests - Manually verify
build/e2e-config/ansible/variables.ymlexists with correct content - Check file permissions and format
- Commit: "feat: [#19.1] finalize variables template implementation"
Green Check: All tests pass, linters happy, variables file generated correctly.
- Pre-commit checks pass:
./scripts/pre-commit.sh
-
templates/ansible/variables.yml.teraexists with SSH port variable - Template has correct Tera syntax:
{{ ssh_port }} - Template includes comments explaining usage
- YAML linting passes
-
AnsibleVariablesContextcreated with SSH port validation - Reuses existing
AnsiblePortvalidation from inventory context - Unit tests for valid and invalid port numbers
- Error type uses thiserror with proper context
-
AnsibleVariablesTemplaterenders template with context - Uses domain
TemplateEnginefor rendering - Unit tests for successful rendering
- Unit tests for rendering errors
-
VariablesTemplateRendererorchestrates full rendering process - Uses
TemplateManagerfor template access - Proper error handling with context preservation
- Unit tests for all error paths
- Variables renderer integrated into
AnsibleTemplateRenderer -
variables.ymlgenerated in build directory during rendering - Config tests pass:
cargo run --bin e2e-config-tests - Generated file has correct SSH port value
- All unit tests pass:
cargo test - All linters pass:
cargo run --bin linter all - No compilation warnings
- Module exports updated correctly
- Error handling follows project conventions
- Rustdoc comments added for all public types and methods
- Template file includes descriptive header comments
- Code examples in documentation are valid
- Inline comments explain non-obvious logic
- Error types have clear descriptions
- SSH Port Validation: Reuse existing validation from inventory context to avoid duplication
- Context Scope: Only system configuration variables (not connection details) - connection variables stay in inventory
- Integration Point: Render after firewall playbook to avoid disrupting existing workflow
- Error Handling: Use thiserror for structured errors with proper source chaining
- No existing code modified until Phase 6
- Each phase builds incrementally with test coverage
- Existing template rendering continues to work
- Variables template is additive (doesn't break anything)
This foundation enables:
- Easy addition of new service variables (MySQL, Prometheus, etc.)
- Consistent pattern for all future playbooks
- Reduced Rust boilerplate per service