|
| 1 | +//! # Cloud-Init Template Renderer |
| 2 | +//! |
| 3 | +//! This module provides the `CloudInitTemplateRenderer`, a specialized template renderer for cloud-init.yml.tera |
| 4 | +//! rendering within the `OpenTofu` deployment workflow. It extracts all cloud-init specific logic |
| 5 | +//! from the main `TofuTemplateRenderer` to follow the single responsibility principle. |
| 6 | +//! |
| 7 | +//! ## Purpose |
| 8 | +//! |
| 9 | +//! The `CloudInitTemplateRenderer` is responsible for: |
| 10 | +//! - Handling the `cloud-init.yml.tera` template file specifically |
| 11 | +//! - Managing SSH public key injection into cloud-init configuration |
| 12 | +//! - Creating appropriate contexts from SSH credentials |
| 13 | +//! - Rendering the template to the output directory |
| 14 | +//! |
| 15 | +//! This follows the collaborator pattern established in the Ansible template renderer refactoring. |
| 16 | +//! |
| 17 | +//! ## Example |
| 18 | +//! |
| 19 | +//! ```rust |
| 20 | +//! # use std::sync::Arc; |
| 21 | +//! # use std::path::Path; |
| 22 | +//! # use torrust_tracker_deploy::tofu::CloudInitTemplateRenderer; |
| 23 | +//! # use torrust_tracker_deploy::template::TemplateManager; |
| 24 | +//! # use torrust_tracker_deploy::command_wrappers::ssh::credentials::SshCredentials; |
| 25 | +//! # use std::path::PathBuf; |
| 26 | +//! # |
| 27 | +//! # #[tokio::main] |
| 28 | +//! # async fn main() -> Result<(), Box<dyn std::error::Error>> { |
| 29 | +//! let template_manager = Arc::new(TemplateManager::new(std::env::temp_dir())); |
| 30 | +//! let ssh_credentials = SshCredentials::new( |
| 31 | +//! PathBuf::from("fixtures/testing_rsa"), |
| 32 | +//! PathBuf::from("fixtures/testing_rsa.pub"), |
| 33 | +//! "username".to_string() |
| 34 | +//! ); |
| 35 | +//! let renderer = CloudInitTemplateRenderer::new(template_manager); |
| 36 | +//! |
| 37 | +//! // Just demonstrate creating the renderer - actual rendering requires |
| 38 | +//! // a proper template manager setup with cloud-init templates |
| 39 | +//! # Ok(()) |
| 40 | +//! # } |
| 41 | +//! ``` |
| 42 | +
|
| 43 | +use std::path::Path; |
| 44 | +use std::sync::Arc; |
| 45 | +use thiserror::Error; |
| 46 | + |
| 47 | +use crate::command_wrappers::ssh::credentials::SshCredentials; |
| 48 | +use crate::template::file::File; |
| 49 | +use crate::template::wrappers::tofu::lxd::cloud_init::{CloudInitContext, CloudInitTemplate}; |
| 50 | +use crate::template::{TemplateManager, TemplateManagerError}; |
| 51 | + |
| 52 | +/// Errors that can occur during cloud-init template rendering |
| 53 | +#[derive(Error, Debug)] |
| 54 | +pub enum CloudInitTemplateError { |
| 55 | + /// Failed to get cloud-init template path from template manager |
| 56 | + #[error("Failed to get template path for 'cloud-init.yml.tera': {source}")] |
| 57 | + TemplatePathFailed { |
| 58 | + #[source] |
| 59 | + source: TemplateManagerError, |
| 60 | + }, |
| 61 | + |
| 62 | + /// Failed to read cloud-init template content from file |
| 63 | + #[error("Failed to read cloud-init template: {source}")] |
| 64 | + TemplateReadError { |
| 65 | + #[source] |
| 66 | + source: std::io::Error, |
| 67 | + }, |
| 68 | + |
| 69 | + /// Failed to create File object from cloud-init template content |
| 70 | + #[error("Failed to create cloud-init template file: Invalid template content")] |
| 71 | + FileCreationFailed, |
| 72 | + |
| 73 | + /// Failed to read SSH public key file |
| 74 | + #[error("SSH public key file not found or unreadable")] |
| 75 | + SshKeyReadError, |
| 76 | + |
| 77 | + /// Failed to build cloud-init context from SSH credentials |
| 78 | + #[error("Failed to build cloud-init context: Invalid SSH credentials or context data")] |
| 79 | + ContextCreationFailed, |
| 80 | + |
| 81 | + /// Failed to create `CloudInitTemplate` with context |
| 82 | + #[error("Failed to create cloud-init template: Template validation or context binding failed")] |
| 83 | + CloudInitTemplateCreationFailed, |
| 84 | + |
| 85 | + /// Failed to render cloud-init template to output file |
| 86 | + #[error("Failed to render cloud-init template: Template rendering or file write failed")] |
| 87 | + CloudInitTemplateRenderFailed, |
| 88 | +} |
| 89 | + |
| 90 | +/// Specialized renderer for `cloud-init.yml.tera` templates |
| 91 | +/// |
| 92 | +/// This collaborator handles all cloud-init template specific logic, including: |
| 93 | +/// - Template path resolution |
| 94 | +/// - SSH public key reading and context creation |
| 95 | +/// - Template rendering and output file writing |
| 96 | +/// |
| 97 | +/// It follows the Single Responsibility Principle by focusing solely on cloud-init |
| 98 | +/// template operations, making the main `TofuTemplateRenderer` simpler and more focused. |
| 99 | +pub struct CloudInitTemplateRenderer { |
| 100 | + template_manager: Arc<TemplateManager>, |
| 101 | +} |
| 102 | + |
| 103 | +impl CloudInitTemplateRenderer { |
| 104 | + /// Template file name for cloud-init configuration |
| 105 | + const CLOUD_INIT_TEMPLATE_FILE: &'static str = "cloud-init.yml.tera"; |
| 106 | + |
| 107 | + /// Output file name for rendered cloud-init configuration |
| 108 | + const CLOUD_INIT_OUTPUT_FILE: &'static str = "cloud-init.yml"; |
| 109 | + |
| 110 | + /// Default template path prefix for `OpenTofu` templates |
| 111 | + const OPENTOFU_TEMPLATE_PATH: &'static str = "tofu/lxd"; |
| 112 | + |
| 113 | + /// Creates a new cloud-init template renderer |
| 114 | + /// |
| 115 | + /// # Arguments |
| 116 | + /// |
| 117 | + /// * `template_manager` - Arc reference to the template manager for file operations |
| 118 | + /// |
| 119 | + /// # Returns |
| 120 | + /// |
| 121 | + /// A new `CloudInitTemplateRenderer` instance ready to render cloud-init templates |
| 122 | + #[must_use] |
| 123 | + pub fn new(template_manager: Arc<TemplateManager>) -> Self { |
| 124 | + Self { template_manager } |
| 125 | + } |
| 126 | + |
| 127 | + /// Renders the cloud-init.yml.tera template with SSH credentials |
| 128 | + /// |
| 129 | + /// This method performs the complete cloud-init template rendering workflow: |
| 130 | + /// 1. Resolves the template path and reads template content |
| 131 | + /// 2. Creates a cloud-init context from SSH credentials |
| 132 | + /// 3. Renders the template with the context |
| 133 | + /// 4. Writes the rendered output to the destination directory |
| 134 | + /// |
| 135 | + /// # Arguments |
| 136 | + /// |
| 137 | + /// * `ssh_credentials` - SSH credentials containing public key path for cloud-init injection |
| 138 | + /// * `output_dir` - Directory where the rendered `cloud-init.yml` file will be written |
| 139 | + /// |
| 140 | + /// # Returns |
| 141 | + /// |
| 142 | + /// * `Ok(())` on successful template rendering |
| 143 | + /// * `Err(CloudInitTemplateError)` on any failure during the rendering process |
| 144 | + /// |
| 145 | + /// # Errors |
| 146 | + /// |
| 147 | + /// This method can fail with: |
| 148 | + /// - `TemplatePathFailed` if the template manager cannot resolve the template path |
| 149 | + /// - `TemplateReadError` if the template file cannot be read from disk |
| 150 | + /// - `FileCreationFailed` if the template content is invalid for File creation |
| 151 | + /// - `SshKeyReadError` if the SSH public key file cannot be read |
| 152 | + /// - `ContextCreationFailed` if the cloud-init context cannot be built |
| 153 | + /// - `CloudInitTemplateCreationFailed` if template creation fails |
| 154 | + /// - `CloudInitTemplateRenderFailed` if template rendering or file writing fails |
| 155 | + pub async fn render( |
| 156 | + &self, |
| 157 | + ssh_credentials: &SshCredentials, |
| 158 | + output_dir: &Path, |
| 159 | + ) -> Result<(), CloudInitTemplateError> { |
| 160 | + tracing::debug!("Rendering cloud-init template with SSH public key injection"); |
| 161 | + |
| 162 | + // Build template path and get source file |
| 163 | + let template_path = Self::build_template_path(Self::CLOUD_INIT_TEMPLATE_FILE); |
| 164 | + let source_path = self |
| 165 | + .template_manager |
| 166 | + .get_template_path(&template_path) |
| 167 | + .map_err(|source| CloudInitTemplateError::TemplatePathFailed { source })?; |
| 168 | + |
| 169 | + // Read template content from file |
| 170 | + let template_content = tokio::fs::read_to_string(&source_path) |
| 171 | + .await |
| 172 | + .map_err(|source| CloudInitTemplateError::TemplateReadError { source })?; |
| 173 | + |
| 174 | + // Create File object for template processing |
| 175 | + let template_file = File::new(Self::CLOUD_INIT_TEMPLATE_FILE, template_content) |
| 176 | + .map_err(|_| CloudInitTemplateError::FileCreationFailed)?; |
| 177 | + |
| 178 | + // Create cloud-init context with SSH public key |
| 179 | + let cloud_init_context = CloudInitContext::builder() |
| 180 | + .with_ssh_public_key_from_file(&ssh_credentials.ssh_pub_key_path) |
| 181 | + .map_err(|_| CloudInitTemplateError::SshKeyReadError)? |
| 182 | + .build() |
| 183 | + .map_err(|_| CloudInitTemplateError::ContextCreationFailed)?; |
| 184 | + |
| 185 | + // Create CloudInitTemplate with context |
| 186 | + let cloud_init_template = CloudInitTemplate::new(&template_file, cloud_init_context) |
| 187 | + .map_err(|_| CloudInitTemplateError::CloudInitTemplateCreationFailed)?; |
| 188 | + |
| 189 | + // Render template to output file |
| 190 | + let output_path = output_dir.join(Self::CLOUD_INIT_OUTPUT_FILE); |
| 191 | + cloud_init_template |
| 192 | + .render(&output_path) |
| 193 | + .map_err(|_| CloudInitTemplateError::CloudInitTemplateRenderFailed)?; |
| 194 | + |
| 195 | + tracing::debug!( |
| 196 | + "Successfully rendered cloud-init template to {}", |
| 197 | + output_path.display() |
| 198 | + ); |
| 199 | + |
| 200 | + Ok(()) |
| 201 | + } |
| 202 | + |
| 203 | + /// Builds the template path for the cloud-init template file |
| 204 | + /// |
| 205 | + /// # Arguments |
| 206 | + /// |
| 207 | + /// * `file_name` - The template file name |
| 208 | + /// |
| 209 | + /// # Returns |
| 210 | + /// |
| 211 | + /// * `String` - The complete template path for the cloud-init template |
| 212 | + fn build_template_path(file_name: &str) -> String { |
| 213 | + format!("{}/{file_name}", Self::OPENTOFU_TEMPLATE_PATH) |
| 214 | + } |
| 215 | +} |
| 216 | + |
| 217 | +#[cfg(test)] |
| 218 | +mod tests { |
| 219 | + use super::*; |
| 220 | + use std::fs; |
| 221 | + use tempfile::TempDir; |
| 222 | + |
| 223 | + /// Helper function to create mock SSH credentials for testing |
| 224 | + fn create_mock_ssh_credentials(temp_dir: &std::path::Path) -> SshCredentials { |
| 225 | + let ssh_priv_key_path = temp_dir.join("test_key"); |
| 226 | + let ssh_pub_key_path = temp_dir.join("test_key.pub"); |
| 227 | + |
| 228 | + // Create mock key files |
| 229 | + fs::write(&ssh_priv_key_path, "-----BEGIN OPENSSH PRIVATE KEY-----\nmock_private_key\n-----END OPENSSH PRIVATE KEY-----") |
| 230 | + .expect("Failed to write private key"); |
| 231 | + fs::write( |
| 232 | + &ssh_pub_key_path, |
| 233 | + "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQC7... test@example.com", |
| 234 | + ) |
| 235 | + .expect("Failed to write public key"); |
| 236 | + |
| 237 | + SshCredentials::new(ssh_priv_key_path, ssh_pub_key_path, "test_user".to_string()) |
| 238 | + } |
| 239 | + |
| 240 | + /// Helper function to create a mock template manager with cloud-init template |
| 241 | + fn create_mock_template_manager_with_cloud_init() -> Arc<TemplateManager> { |
| 242 | + let temp_dir = TempDir::new().expect("Failed to create temp dir"); |
| 243 | + let template_dir = temp_dir.path().join("templates"); |
| 244 | + fs::create_dir_all(&template_dir).expect("Failed to create template dir"); |
| 245 | + |
| 246 | + // Create tofu/lxd template directory structure |
| 247 | + let tofu_lxd_dir = template_dir.join("tofu").join("lxd"); |
| 248 | + fs::create_dir_all(&tofu_lxd_dir).expect("Failed to create tofu/lxd dir"); |
| 249 | + |
| 250 | + // Create cloud-init.yml.tera template |
| 251 | + let cloud_init_template = r"#cloud-config |
| 252 | +users: |
| 253 | + - name: torrust |
| 254 | + ssh_authorized_keys: |
| 255 | + - {{ ssh_public_key }} |
| 256 | +"; |
| 257 | + fs::write( |
| 258 | + tofu_lxd_dir.join("cloud-init.yml.tera"), |
| 259 | + cloud_init_template, |
| 260 | + ) |
| 261 | + .expect("Failed to write cloud-init template"); |
| 262 | + |
| 263 | + Arc::new(TemplateManager::new(temp_dir.keep())) |
| 264 | + } |
| 265 | + |
| 266 | + #[test] |
| 267 | + fn it_should_create_cloud_init_renderer_with_template_manager() { |
| 268 | + let template_manager = Arc::new(TemplateManager::new(std::env::temp_dir())); |
| 269 | + let renderer = CloudInitTemplateRenderer::new(template_manager); |
| 270 | + |
| 271 | + // Verify the renderer was created successfully |
| 272 | + // Just check that it contains the expected template manager reference |
| 273 | + let renderer_ptr = std::ptr::addr_of!(renderer.template_manager); |
| 274 | + assert!(!renderer_ptr.is_null()); |
| 275 | + } |
| 276 | + |
| 277 | + #[test] |
| 278 | + fn it_should_build_correct_template_path() { |
| 279 | + let template_path = CloudInitTemplateRenderer::build_template_path("cloud-init.yml.tera"); |
| 280 | + assert_eq!(template_path, "tofu/lxd/cloud-init.yml.tera"); |
| 281 | + } |
| 282 | + |
| 283 | + #[tokio::test] |
| 284 | + async fn it_should_render_cloud_init_template_successfully() { |
| 285 | + let template_manager = create_mock_template_manager_with_cloud_init(); |
| 286 | + let renderer = CloudInitTemplateRenderer::new(template_manager); |
| 287 | + |
| 288 | + let temp_dir = TempDir::new().expect("Failed to create temp dir"); |
| 289 | + let ssh_credentials = create_mock_ssh_credentials(temp_dir.path()); |
| 290 | + let output_dir = TempDir::new().expect("Failed to create output dir"); |
| 291 | + |
| 292 | + let result = renderer.render(&ssh_credentials, output_dir.path()).await; |
| 293 | + |
| 294 | + assert!( |
| 295 | + result.is_ok(), |
| 296 | + "Cloud-init template rendering should succeed" |
| 297 | + ); |
| 298 | + |
| 299 | + let output_file = output_dir.path().join("cloud-init.yml"); |
| 300 | + assert!( |
| 301 | + output_file.exists(), |
| 302 | + "Rendered cloud-init.yml file should exist" |
| 303 | + ); |
| 304 | + |
| 305 | + let content = fs::read_to_string(&output_file).expect("Failed to read rendered file"); |
| 306 | + assert!( |
| 307 | + content.contains("ssh_authorized_keys"), |
| 308 | + "Rendered content should contain SSH key configuration" |
| 309 | + ); |
| 310 | + assert!( |
| 311 | + content.contains("ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQC7"), |
| 312 | + "Rendered content should contain the actual SSH public key: {content}" |
| 313 | + ); |
| 314 | + } |
| 315 | + |
| 316 | + // #[tokio::test] |
| 317 | + // async fn it_should_fail_when_template_manager_cannot_find_template() { |
| 318 | + // // This test is disabled for now as template manager behavior may vary |
| 319 | + // // depending on embedded template availability |
| 320 | + // } |
| 321 | + |
| 322 | + #[tokio::test] |
| 323 | + async fn it_should_fail_when_ssh_key_file_missing() { |
| 324 | + let template_manager = create_mock_template_manager_with_cloud_init(); |
| 325 | + let renderer = CloudInitTemplateRenderer::new(template_manager); |
| 326 | + |
| 327 | + // Create SSH credentials with non-existent key file |
| 328 | + let temp_dir = TempDir::new().expect("Failed to create temp dir"); |
| 329 | + let non_existent_key = temp_dir.path().join("non_existent_key"); |
| 330 | + let ssh_credentials = SshCredentials::new( |
| 331 | + non_existent_key.clone(), |
| 332 | + non_existent_key, |
| 333 | + "test_user".to_string(), |
| 334 | + ); |
| 335 | + |
| 336 | + let output_dir = TempDir::new().expect("Failed to create output dir"); |
| 337 | + |
| 338 | + let result = renderer.render(&ssh_credentials, output_dir.path()).await; |
| 339 | + |
| 340 | + assert!(result.is_err(), "Should fail when SSH key file is missing"); |
| 341 | + match result.unwrap_err() { |
| 342 | + CloudInitTemplateError::SshKeyReadError => { |
| 343 | + // Expected error type |
| 344 | + } |
| 345 | + other => panic!("Expected SshKeyReadError, got: {other:?}"), |
| 346 | + } |
| 347 | + } |
| 348 | + |
| 349 | + #[tokio::test] |
| 350 | + async fn it_should_fail_when_output_directory_is_readonly() { |
| 351 | + let template_manager = create_mock_template_manager_with_cloud_init(); |
| 352 | + let renderer = CloudInitTemplateRenderer::new(template_manager); |
| 353 | + |
| 354 | + let temp_dir = TempDir::new().expect("Failed to create temp dir"); |
| 355 | + let ssh_credentials = create_mock_ssh_credentials(temp_dir.path()); |
| 356 | + |
| 357 | + // Create read-only output directory |
| 358 | + let output_dir = TempDir::new().expect("Failed to create output dir"); |
| 359 | + let mut permissions = fs::metadata(output_dir.path()) |
| 360 | + .expect("Failed to get metadata") |
| 361 | + .permissions(); |
| 362 | + permissions.set_readonly(true); |
| 363 | + fs::set_permissions(output_dir.path(), permissions) |
| 364 | + .expect("Failed to set readonly permissions"); |
| 365 | + |
| 366 | + let result = renderer.render(&ssh_credentials, output_dir.path()).await; |
| 367 | + |
| 368 | + assert!( |
| 369 | + result.is_err(), |
| 370 | + "Should fail when output directory is readonly" |
| 371 | + ); |
| 372 | + match result.unwrap_err() { |
| 373 | + CloudInitTemplateError::CloudInitTemplateRenderFailed => { |
| 374 | + // Expected error type |
| 375 | + } |
| 376 | + other => panic!("Expected CloudInitTemplateRenderFailed, got: {other:?}"), |
| 377 | + } |
| 378 | + } |
| 379 | + |
| 380 | + // #[tokio::test] |
| 381 | + // async fn it_should_fail_when_template_content_is_invalid() { |
| 382 | + // // This test is disabled as the template validation behavior |
| 383 | + // // may depend on the specific Tera engine implementation |
| 384 | + // } |
| 385 | + |
| 386 | + // #[tokio::test] |
| 387 | + // async fn it_should_fail_when_context_missing_required_fields() { |
| 388 | + // // This test is disabled as missing template variables may not |
| 389 | + // // always cause failures depending on template engine configuration |
| 390 | + // } |
| 391 | +} |
0 commit comments