From e6fdeafcba3b196eb5b953969ae7d81292c7aa49 Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Tue, 2 Dec 2025 13:46:15 +0000 Subject: [PATCH 01/14] feat: [#212] parameterize TofuTemplateRenderer by provider - Add Hetzner provider support with OpenTofu templates (main.tf, variables.tfvars.tera) - Move cloud-init template to common folder shared by all providers - Add image field to HetznerConfig for OS image selection - Create Hetzner template wrappers (cloud_init, variables contexts) - Update TofuTemplateRenderer to accept ProviderConfig parameter - Ensure instance_info output format matches JSON parser expectations - All E2E tests pass including full deployment workflow --- project-words.txt | 1 + .../create/config/environment_config.rs | 4 +- .../create/config/provider/hetzner.rs | 10 +- .../create/config/provider/mod.rs | 8 +- .../command_handlers/provision/handler.rs | 1 + src/domain/environment/user_inputs.rs | 2 + src/domain/provider/config.rs | 6 +- src/domain/provider/hetzner.rs | 15 +- .../tofu/template/renderer/cloud_init.rs | 76 +++- .../tofu/template/renderer/mod.rs | 323 ++++++++++++--- .../wrappers/hetzner/cloud_init/context.rs | 289 +++++++++++++ .../wrappers/hetzner/cloud_init/mod.rs | 229 +++++++++++ .../tofu/template/wrappers/hetzner/mod.rs | 17 + .../wrappers/hetzner/variables/context.rs | 388 ++++++++++++++++++ .../wrappers/hetzner/variables/mod.rs | 285 +++++++++++++ .../tofu/template/wrappers/mod.rs | 11 +- src/testing/e2e/container.rs | 3 + src/testing/e2e/context.rs | 1 + .../tofu/{lxd => common}/cloud-init.yml.tera | 13 +- templates/tofu/hetzner/main.tf | 139 +++++++ templates/tofu/hetzner/variables.tfvars.tera | 35 ++ 21 files changed, 1774 insertions(+), 82 deletions(-) create mode 100644 src/infrastructure/external_tools/tofu/template/wrappers/hetzner/cloud_init/context.rs create mode 100644 src/infrastructure/external_tools/tofu/template/wrappers/hetzner/cloud_init/mod.rs create mode 100644 src/infrastructure/external_tools/tofu/template/wrappers/hetzner/mod.rs create mode 100644 src/infrastructure/external_tools/tofu/template/wrappers/hetzner/variables/context.rs create mode 100644 src/infrastructure/external_tools/tofu/template/wrappers/hetzner/variables/mod.rs rename templates/tofu/{lxd => common}/cloud-init.yml.tera (53%) create mode 100644 templates/tofu/hetzner/main.tf create mode 100644 templates/tofu/hetzner/variables.tfvars.tera diff --git a/project-words.txt b/project-words.txt index 229216a4..773f9cef 100644 --- a/project-words.txt +++ b/project-words.txt @@ -103,6 +103,7 @@ impls journalctl jsonlint keepalive +keypair keygen keyrings larstobi diff --git a/src/application/command_handlers/create/config/environment_config.rs b/src/application/command_handlers/create/config/environment_config.rs index c8438720..f78492fa 100644 --- a/src/application/command_handlers/create/config/environment_config.rs +++ b/src/application/command_handlers/create/config/environment_config.rs @@ -277,6 +277,7 @@ impl EnvironmentCreationConfig { api_token: "REPLACE_WITH_HETZNER_API_TOKEN".to_string(), server_type: "cx22".to_string(), // default value - small instance location: "nbg1".to_string(), // default value - Nuremberg + image: "ubuntu-24.04".to_string(), // default value - Ubuntu 24.04 LTS }), }; @@ -454,7 +455,8 @@ mod tests { "provider": "hetzner", "api_token": "test-token", "server_type": "cx22", - "location": "nbg1" + "location": "nbg1", + "image": "ubuntu-24.04" } }"#; diff --git a/src/application/command_handlers/create/config/provider/hetzner.rs b/src/application/command_handlers/create/config/provider/hetzner.rs index fb0c0ef7..a0ef0087 100644 --- a/src/application/command_handlers/create/config/provider/hetzner.rs +++ b/src/application/command_handlers/create/config/provider/hetzner.rs @@ -20,6 +20,7 @@ use serde::{Deserialize, Serialize}; /// api_token: "your-api-token".to_string(), /// server_type: "cx22".to_string(), /// location: "nbg1".to_string(), +/// image: "ubuntu-24.04".to_string(), /// }; /// ``` #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -32,6 +33,9 @@ pub struct HetznerProviderSection { /// Hetzner datacenter location (e.g., "fsn1", "nbg1", "hel1"). pub location: String, + + /// Hetzner server image (e.g., "ubuntu-24.04", "ubuntu-22.04", "debian-12"). + pub image: String, } #[cfg(test)] @@ -43,6 +47,7 @@ mod tests { api_token: "token".to_string(), server_type: "cx22".to_string(), location: "nbg1".to_string(), + image: "ubuntu-24.04".to_string(), } } @@ -53,15 +58,17 @@ mod tests { assert!(json.contains("\"api_token\":\"token\"")); assert!(json.contains("\"server_type\":\"cx22\"")); assert!(json.contains("\"location\":\"nbg1\"")); + assert!(json.contains("\"image\":\"ubuntu-24.04\"")); } #[test] fn it_should_deserialize_from_json() { - let json = r#"{"api_token":"token","server_type":"cx22","location":"nbg1"}"#; + let json = r#"{"api_token":"token","server_type":"cx22","location":"nbg1","image":"ubuntu-24.04"}"#; let section: HetznerProviderSection = serde_json::from_str(json).unwrap(); assert_eq!(section.api_token, "token"); assert_eq!(section.server_type, "cx22"); assert_eq!(section.location, "nbg1"); + assert_eq!(section.image, "ubuntu-24.04"); } #[test] @@ -79,5 +86,6 @@ mod tests { assert!(debug.contains("api_token")); assert!(debug.contains("server_type")); assert!(debug.contains("location")); + assert!(debug.contains("image")); } } diff --git a/src/application/command_handlers/create/config/provider/mod.rs b/src/application/command_handlers/create/config/provider/mod.rs index 56f5bce8..346e3249 100644 --- a/src/application/command_handlers/create/config/provider/mod.rs +++ b/src/application/command_handlers/create/config/provider/mod.rs @@ -149,6 +149,7 @@ impl ProviderSection { api_token: hetzner.api_token, server_type: hetzner.server_type, location: hetzner.location, + image: hetzner.image, })) } } @@ -170,6 +171,7 @@ mod tests { api_token: "test-token".to_string(), server_type: "cx22".to_string(), location: "nbg1".to_string(), + image: "ubuntu-24.04".to_string(), }) } @@ -204,7 +206,8 @@ mod tests { "provider": "hetzner", "api_token": "token123", "server_type": "cx32", - "location": "fsn1" + "location": "fsn1", + "image": "ubuntu-24.04" }"#; let section: ProviderSection = serde_json::from_str(json).unwrap(); @@ -213,6 +216,7 @@ mod tests { assert_eq!(hetzner.api_token, "token123"); assert_eq!(hetzner.server_type, "cx32"); assert_eq!(hetzner.location, "fsn1"); + assert_eq!(hetzner.image, "ubuntu-24.04"); } else { panic!("Expected Hetzner section"); } @@ -236,6 +240,7 @@ mod tests { assert!(json.contains("\"api_token\":\"test-token\"")); assert!(json.contains("\"server_type\":\"cx22\"")); assert!(json.contains("\"location\":\"nbg1\"")); + assert!(json.contains("\"image\":\"ubuntu-24.04\"")); } #[test] @@ -263,6 +268,7 @@ mod tests { assert_eq!(hetzner.api_token, "test-token"); assert_eq!(hetzner.server_type, "cx22"); assert_eq!(hetzner.location, "nbg1"); + assert_eq!(hetzner.image, "ubuntu-24.04"); } #[test] diff --git a/src/application/command_handlers/provision/handler.rs b/src/application/command_handlers/provision/handler.rs index 75d85f65..6ed983f7 100644 --- a/src/application/command_handlers/provision/handler.rs +++ b/src/application/command_handlers/provision/handler.rs @@ -274,6 +274,7 @@ impl ProvisionCommandHandler { environment.ssh_credentials().clone(), environment.instance_name().clone(), environment.profile_name().clone(), + environment.provider_config().clone(), )); (tofu_template_renderer, opentofu_client) diff --git a/src/domain/environment/user_inputs.rs b/src/domain/environment/user_inputs.rs index 5d799fbd..ba384093 100644 --- a/src/domain/environment/user_inputs.rs +++ b/src/domain/environment/user_inputs.rs @@ -270,6 +270,7 @@ mod tests { api_token: "test-token".to_string(), server_type: "cx22".to_string(), location: "nbg1".to_string(), + image: "ubuntu-24.04".to_string(), }); let ssh_credentials = create_test_ssh_credentials(); @@ -282,6 +283,7 @@ mod tests { assert_eq!(hetzner_config.api_token, "test-token"); assert_eq!(hetzner_config.server_type, "cx22"); assert_eq!(hetzner_config.location, "nbg1"); + assert_eq!(hetzner_config.image, "ubuntu-24.04"); } #[test] diff --git a/src/domain/provider/config.rs b/src/domain/provider/config.rs index 25fb9976..a35cf647 100644 --- a/src/domain/provider/config.rs +++ b/src/domain/provider/config.rs @@ -128,6 +128,7 @@ impl ProviderConfig { /// api_token: "token".to_string(), /// server_type: "cx22".to_string(), /// location: "nbg1".to_string(), + /// image: "ubuntu-24.04".to_string(), /// }); /// assert!(hetzner_config.as_lxd().is_none()); /// ``` @@ -170,6 +171,7 @@ mod tests { api_token: "test-token".to_string(), server_type: "cx22".to_string(), location: "nbg1".to_string(), + image: "ubuntu-24.04".to_string(), }) } @@ -235,8 +237,7 @@ mod tests { #[test] fn it_should_deserialize_hetzner_config_from_json_with_provider_tag() { - let json = - r#"{"provider":"hetzner","api_token":"token","server_type":"cx22","location":"nbg1"}"#; + let json = r#"{"provider":"hetzner","api_token":"token","server_type":"cx22","location":"nbg1","image":"ubuntu-24.04"}"#; let config: ProviderConfig = serde_json::from_str(json).unwrap(); assert_eq!(config.provider(), Provider::Hetzner); @@ -244,6 +245,7 @@ mod tests { assert_eq!(hetzner.api_token, "token"); assert_eq!(hetzner.server_type, "cx22"); assert_eq!(hetzner.location, "nbg1"); + assert_eq!(hetzner.image, "ubuntu-24.04"); } #[test] diff --git a/src/domain/provider/hetzner.rs b/src/domain/provider/hetzner.rs index 2208062e..b39c424b 100644 --- a/src/domain/provider/hetzner.rs +++ b/src/domain/provider/hetzner.rs @@ -23,6 +23,7 @@ use serde::{Deserialize, Serialize}; /// api_token: "your-api-token".to_string(), /// server_type: "cx22".to_string(), /// location: "nbg1".to_string(), +/// image: "ubuntu-24.04".to_string(), /// }; /// ``` #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -44,6 +45,12 @@ pub struct HetznerConfig { /// Determines where the VM will be physically located. /// Note: Future improvement could use a validated `Location` type. pub location: String, + + /// Operating system image (e.g., "ubuntu-24.04", "ubuntu-22.04", "debian-12"). + /// + /// Determines the base operating system for the server. + /// Note: Future improvement could use a validated `Image` type. + pub image: String, } #[cfg(test)] @@ -55,6 +62,7 @@ mod tests { api_token: "test-token".to_string(), server_type: "cx22".to_string(), location: "nbg1".to_string(), + image: "ubuntu-24.04".to_string(), } } @@ -64,10 +72,12 @@ mod tests { api_token: "token123".to_string(), server_type: "cx32".to_string(), location: "fsn1".to_string(), + image: "ubuntu-22.04".to_string(), }; assert_eq!(config.api_token, "token123"); assert_eq!(config.server_type, "cx32"); assert_eq!(config.location, "fsn1"); + assert_eq!(config.image, "ubuntu-22.04"); } #[test] @@ -78,16 +88,18 @@ mod tests { assert!(json.contains("\"api_token\":\"test-token\"")); assert!(json.contains("\"server_type\":\"cx22\"")); assert!(json.contains("\"location\":\"nbg1\"")); + assert!(json.contains("\"image\":\"ubuntu-24.04\"")); } #[test] fn it_should_deserialize_from_json_when_valid_json_provided() { - let json = r#"{"api_token":"token","server_type":"cx22","location":"nbg1"}"#; + let json = r#"{"api_token":"token","server_type":"cx22","location":"nbg1","image":"ubuntu-24.04"}"#; let config: HetznerConfig = serde_json::from_str(json).unwrap(); assert_eq!(config.api_token, "token"); assert_eq!(config.server_type, "cx22"); assert_eq!(config.location, "nbg1"); + assert_eq!(config.image, "ubuntu-24.04"); } #[test] @@ -105,5 +117,6 @@ mod tests { assert!(debug.contains("api_token")); assert!(debug.contains("server_type")); assert!(debug.contains("location")); + assert!(debug.contains("image")); } } diff --git a/src/infrastructure/external_tools/tofu/template/renderer/cloud_init.rs b/src/infrastructure/external_tools/tofu/template/renderer/cloud_init.rs index 1f2b4b97..05a46aa4 100644 --- a/src/infrastructure/external_tools/tofu/template/renderer/cloud_init.rs +++ b/src/infrastructure/external_tools/tofu/template/renderer/cloud_init.rs @@ -11,6 +11,7 @@ //! - Managing SSH public key injection into cloud-init configuration //! - Creating appropriate contexts from SSH credentials //! - Rendering the template to the output directory +//! - Using a common cloud-init template shared by all providers (LXD, Hetzner) //! //! This follows the collaborator pattern established in the Ansible template renderer refactoring. //! @@ -21,6 +22,7 @@ //! # use std::path::Path; //! # use torrust_tracker_deployer_lib::infrastructure::external_tools::tofu::template::renderer::cloud_init::CloudInitTemplateRenderer; //! # use torrust_tracker_deployer_lib::domain::template::TemplateManager; +//! # use torrust_tracker_deployer_lib::domain::provider::Provider; //! # use torrust_tracker_deployer_lib::shared::Username; //! use torrust_tracker_deployer_lib::adapters::ssh::SshCredentials; //! # use std::path::PathBuf; @@ -33,7 +35,7 @@ //! PathBuf::from("fixtures/testing_rsa.pub"), //! Username::new("username").unwrap() //! ); -//! let renderer = CloudInitTemplateRenderer::new(template_manager); +//! let renderer = CloudInitTemplateRenderer::new(template_manager, Provider::Lxd); //! //! // Just demonstrate creating the renderer - actual rendering requires //! // a proper template manager setup with cloud-init templates @@ -46,11 +48,9 @@ use std::sync::Arc; use thiserror::Error; use crate::adapters::ssh::credentials::SshCredentials; +use crate::domain::provider::Provider; use crate::domain::template::file::File; use crate::domain::template::{TemplateManager, TemplateManagerError}; -use crate::infrastructure::external_tools::tofu::template::wrappers::lxd::cloud_init::{ - CloudInitContext, CloudInitTemplate, -}; /// Errors that can occur during cloud-init template rendering #[derive(Error, Debug)] @@ -93,14 +93,19 @@ pub enum CloudInitTemplateError { /// Specialized renderer for `cloud-init.yml.tera` templates /// /// This collaborator handles all cloud-init template specific logic, including: -/// - Template path resolution +/// - Template path resolution (using common template shared by all providers) /// - SSH public key reading and context creation /// - Template rendering and output file writing /// /// It follows the Single Responsibility Principle by focusing solely on cloud-init /// template operations, making the main `TofuTemplateRenderer` simpler and more focused. +/// +/// Note: The provider field is kept for potential future provider-specific customization, +/// but currently all providers use the same common cloud-init template. pub struct CloudInitTemplateRenderer { template_manager: Arc, + #[allow(dead_code)] + provider: Provider, } impl CloudInitTemplateRenderer { @@ -115,13 +120,17 @@ impl CloudInitTemplateRenderer { /// # Arguments /// /// * `template_manager` - Arc reference to the template manager for file operations + /// * `provider` - The infrastructure provider (LXD, Hetzner) - kept for future customization /// /// # Returns /// /// A new `CloudInitTemplateRenderer` instance ready to render cloud-init templates #[must_use] - pub fn new(template_manager: Arc) -> Self { - Self { template_manager } + pub fn new(template_manager: Arc, provider: Provider) -> Self { + Self { + template_manager, + provider, + } } /// Renders the cloud-init.yml.tera template with SSH credentials @@ -157,9 +166,12 @@ impl CloudInitTemplateRenderer { ssh_credentials: &SshCredentials, output_dir: &Path, ) -> Result<(), CloudInitTemplateError> { - tracing::debug!("Rendering cloud-init template with SSH public key injection"); + tracing::debug!( + provider = %self.provider, + "Rendering cloud-init template with SSH public key injection" + ); - // Build template path and get source file + // Build template path (uses common template for all providers) let template_path = Self::build_template_path(Self::CLOUD_INIT_TEMPLATE_FILE); let source_path = self .template_manager @@ -175,7 +187,23 @@ impl CloudInitTemplateRenderer { let template_file = File::new(Self::CLOUD_INIT_TEMPLATE_FILE, template_content) .map_err(|_| CloudInitTemplateError::FileCreationFailed)?; + // Render cloud-init template (shared logic for all providers) + self.render_cloud_init(&template_file, ssh_credentials, output_dir) + } + + /// Renders cloud-init template (shared logic for all providers) + fn render_cloud_init( + &self, + template_file: &File, + ssh_credentials: &SshCredentials, + output_dir: &Path, + ) -> Result<(), CloudInitTemplateError> { + use crate::infrastructure::external_tools::tofu::template::wrappers::lxd::cloud_init::{ + CloudInitContext, CloudInitTemplate, + }; + // Create cloud-init context with SSH public key and username + // Note: All providers use the same context structure for cloud-init let cloud_init_context = CloudInitContext::builder() .with_ssh_public_key_from_file(&ssh_credentials.ssh_pub_key_path) .map_err(|_| CloudInitTemplateError::SshKeyReadError)? @@ -185,7 +213,7 @@ impl CloudInitTemplateRenderer { .map_err(|_| CloudInitTemplateError::ContextCreationFailed)?; // Create CloudInitTemplate with context - let cloud_init_template = CloudInitTemplate::new(&template_file, cloud_init_context) + let cloud_init_template = CloudInitTemplate::new(template_file, cloud_init_context) .map_err(|_| CloudInitTemplateError::CloudInitTemplateCreationFailed)?; // Render template to output file @@ -195,6 +223,7 @@ impl CloudInitTemplateRenderer { .map_err(|_| CloudInitTemplateError::CloudInitTemplateRenderFailed)?; tracing::debug!( + provider = %self.provider, "Successfully rendered cloud-init template to {}", output_path.display() ); @@ -204,6 +233,8 @@ impl CloudInitTemplateRenderer { /// Builds the template path for the cloud-init template file /// + /// Uses a common template path shared by all providers. + /// /// # Arguments /// /// * `file_name` - The template file name @@ -212,7 +243,7 @@ impl CloudInitTemplateRenderer { /// /// * `String` - The complete template path for the cloud-init template fn build_template_path(file_name: &str) -> String { - format!("tofu/lxd/{file_name}") + format!("tofu/common/{file_name}") } } @@ -251,9 +282,9 @@ mod tests { let template_dir = temp_dir.path().join("templates"); fs::create_dir_all(&template_dir).expect("Failed to create template dir"); - // Create tofu/lxd template directory structure - let tofu_lxd_dir = template_dir.join("tofu").join("lxd"); - fs::create_dir_all(&tofu_lxd_dir).expect("Failed to create tofu/lxd dir"); + // Create tofu/common template directory structure (common for all providers) + let tofu_common_dir = template_dir.join("tofu").join("common"); + fs::create_dir_all(&tofu_common_dir).expect("Failed to create tofu/common dir"); // Create cloud-init.yml.tera template let cloud_init_template = r"#cloud-config @@ -263,7 +294,7 @@ users: - {{ ssh_public_key }} "; fs::write( - tofu_lxd_dir.join("cloud-init.yml.tera"), + tofu_common_dir.join("cloud-init.yml.tera"), cloud_init_template, ) .expect("Failed to write cloud-init template"); @@ -272,9 +303,9 @@ users: } #[test] - fn it_should_create_cloud_init_renderer_with_template_manager() { + fn it_should_create_cloud_init_renderer_with_template_manager_and_provider() { let template_manager = Arc::new(TemplateManager::new(std::env::temp_dir())); - let renderer = CloudInitTemplateRenderer::new(template_manager); + let renderer = CloudInitTemplateRenderer::new(template_manager, Provider::Lxd); // Verify the renderer was created successfully // Just check that it contains the expected template manager reference @@ -283,15 +314,16 @@ users: } #[test] - fn it_should_build_correct_template_path() { + fn it_should_build_common_template_path() { + // All providers use the common template path let template_path = CloudInitTemplateRenderer::build_template_path("cloud-init.yml.tera"); - assert_eq!(template_path, "tofu/lxd/cloud-init.yml.tera"); + assert_eq!(template_path, "tofu/common/cloud-init.yml.tera"); } #[tokio::test] async fn it_should_render_cloud_init_template_successfully() { let template_manager = create_mock_template_manager_with_cloud_init(); - let renderer = CloudInitTemplateRenderer::new(template_manager); + let renderer = CloudInitTemplateRenderer::new(template_manager, Provider::Lxd); let temp_dir = TempDir::new().expect("Failed to create temp dir"); let ssh_credentials = create_mock_ssh_credentials(temp_dir.path()); @@ -330,7 +362,7 @@ users: #[tokio::test] async fn it_should_fail_when_ssh_key_file_missing() { let template_manager = create_mock_template_manager_with_cloud_init(); - let renderer = CloudInitTemplateRenderer::new(template_manager); + let renderer = CloudInitTemplateRenderer::new(template_manager, Provider::Lxd); // Create SSH credentials with non-existent key file let temp_dir = TempDir::new().expect("Failed to create temp dir"); @@ -357,7 +389,7 @@ users: #[tokio::test] async fn it_should_fail_when_output_directory_is_readonly() { let template_manager = create_mock_template_manager_with_cloud_init(); - let renderer = CloudInitTemplateRenderer::new(template_manager); + let renderer = CloudInitTemplateRenderer::new(template_manager, Provider::Lxd); let temp_dir = TempDir::new().expect("Failed to create temp dir"); let ssh_credentials = create_mock_ssh_credentials(temp_dir.path()); diff --git a/src/infrastructure/external_tools/tofu/template/renderer/mod.rs b/src/infrastructure/external_tools/tofu/template/renderer/mod.rs index a4084878..5c5d31cb 100644 --- a/src/infrastructure/external_tools/tofu/template/renderer/mod.rs +++ b/src/infrastructure/external_tools/tofu/template/renderer/mod.rs @@ -4,6 +4,12 @@ //! It manages the creation of build directories, copying template files, and processing them with //! variable substitution. //! +//! ## Provider Support +//! +//! The renderer supports multiple infrastructure providers (LXD, Hetzner) with independent +//! template sets for each provider. Templates are not shared between providers to allow +//! provider-specific customization. +//! //! ## Future Improvements //! //! The following improvements could enhance this module's functionality and maintainability: @@ -12,7 +18,7 @@ //! creation, file copying, template processing) to improve debugging and monitoring. //! //! 2. **Extract constants for magic strings** - Create constants for hardcoded paths like "tofu", -//! "lxd", and file names to improve maintainability and reduce duplication. +//! and file names to improve maintainability and reduce duplication. //! //! 3. **Add input validation** - Validate template names, check for empty strings, validate paths //! before processing to provide early error detection and better user feedback. @@ -34,9 +40,6 @@ //! //! 9. **Add template caching** - Cache parsed templates to improve performance for repeated //! operations and reduce I/O overhead. -//! -//! 10. **Extract provider-specific logic** - Separate LXD-specific logic to make it more -//! extensible for other providers (Multipass, Docker, etc.) following the strategy pattern. pub mod cloud_init; @@ -45,13 +48,15 @@ use std::sync::Arc; use thiserror::Error; use crate::adapters::ssh::credentials::SshCredentials; +use crate::domain::provider::{Provider, ProviderConfig}; use crate::domain::template::{TemplateManager, TemplateManagerError}; use crate::domain::{InstanceName, ProfileName}; use crate::infrastructure::external_tools::tofu::template::renderer::cloud_init::{ CloudInitTemplateError, CloudInitTemplateRenderer, }; use crate::infrastructure::external_tools::tofu::template::wrappers::lxd::variables::{ - VariablesContextBuilder, VariablesTemplate, VariablesTemplateError, + VariablesContextBuilder as LxdVariablesContextBuilder, + VariablesTemplate as LxdVariablesTemplate, VariablesTemplateError as LxdVariablesTemplateError, }; /// Errors that can occur during provision template rendering @@ -92,8 +97,12 @@ pub enum ProvisionTemplateError { #[error("Failed to render variables template: {source}")] VariablesRenderingFailed { #[source] - source: VariablesTemplateError, + source: LxdVariablesTemplateError, }, + + /// Provider not supported for this operation + #[error("Provider '{provider}' is not yet supported for template rendering")] + UnsupportedProvider { provider: String }, } impl crate::shared::Traceable for ProvisionTemplateError { @@ -114,6 +123,9 @@ impl crate::shared::Traceable for ProvisionTemplateError { Self::VariablesRenderingFailed { .. } => { "ProvisionTemplateError: Variables template rendering failed".to_string() } + Self::UnsupportedProvider { provider } => { + format!("ProvisionTemplateError: Provider '{provider}' is not yet supported") + } } } @@ -130,7 +142,11 @@ impl crate::shared::Traceable for ProvisionTemplateError { /// Renders `OpenTofu` provision templates to a build directory /// /// This collaborator is responsible for preparing `OpenTofu` templates for deployment workflows. -/// It copies static templates and renders Tera templates with runtime variables from the template manager to the specified build directory. +/// It copies static templates and renders Tera templates with runtime variables from the template +/// manager to the specified build directory. +/// +/// The renderer is provider-aware and selects the appropriate template directory based on the +/// provider specified in the environment configuration. pub struct TofuTemplateRenderer { template_manager: Arc, build_dir: PathBuf, @@ -138,15 +154,11 @@ pub struct TofuTemplateRenderer { cloud_init_renderer: CloudInitTemplateRenderer, instance_name: InstanceName, profile_name: ProfileName, + provider: Provider, + provider_config: ProviderConfig, } impl TofuTemplateRenderer { - /// Default relative path for `OpenTofu` configuration files - const OPENTOFU_BUILD_PATH: &'static str = "tofu/lxd"; - - /// Default template path prefix for `OpenTofu` templates - const OPENTOFU_TEMPLATE_PATH: &'static str = "tofu/lxd"; - /// Creates a new provision template renderer /// /// # Arguments @@ -156,14 +168,18 @@ impl TofuTemplateRenderer { /// * `ssh_credentials` - The SSH credentials for injecting public key into cloud-init /// * `instance_name` - The name of the instance to be created (for template rendering) /// * `profile_name` - The name of the LXD profile to be created (for template rendering) + /// * `provider_config` - The provider configuration containing provider type and settings pub fn new>( template_manager: Arc, build_dir: P, ssh_credentials: SshCredentials, instance_name: InstanceName, profile_name: ProfileName, + provider_config: ProviderConfig, ) -> Self { - let cloud_init_renderer = CloudInitTemplateRenderer::new(template_manager.clone()); + let provider = provider_config.provider(); + let cloud_init_renderer = + CloudInitTemplateRenderer::new(template_manager.clone(), provider); Self { template_manager, @@ -172,14 +188,26 @@ impl TofuTemplateRenderer { cloud_init_renderer, instance_name, profile_name, + provider, + provider_config, } } + /// Returns the relative path for `OpenTofu` configuration files based on provider + fn opentofu_build_path(&self) -> String { + format!("tofu/{}", self.provider.as_str()) + } + + /// Returns the template path prefix for `OpenTofu` templates based on provider + fn opentofu_template_path(&self) -> String { + format!("tofu/{}", self.provider.as_str()) + } + /// Renders provision templates (`OpenTofu`) to the build directory /// /// This method: /// 1. Creates the build directory structure for `OpenTofu` - /// 2. Copies static templates (main.tf) from the template manager + /// 2. Copies static templates (main.tf, versions.tf for Hetzner) from the template manager /// 3. Renders Tera templates (cloud-init.yml.tera) with runtime variables /// 4. Provides debug logging via the tracing crate /// @@ -197,15 +225,15 @@ impl TofuTemplateRenderer { pub async fn render(&self) -> Result<(), ProvisionTemplateError> { tracing::info!( template_type = "opentofu", + provider = %self.provider, "Rendering provision templates to build directory" ); // Create build directory structure let build_tofu_dir = self.create_build_directory().await?; - // List of static templates to copy directly - // Note: variables.tfvars is now dynamically rendered via VariablesTemplate - let static_template_files = vec!["main.tf"]; + // Get static template files based on provider + let static_template_files = self.get_static_template_files(); // Copy static template files self.copy_templates(&static_template_files, &build_tofu_dir) @@ -216,25 +244,40 @@ impl TofuTemplateRenderer { tracing::debug!( template_type = "opentofu", + provider = %self.provider, output_dir = %build_tofu_dir.display(), "Provision templates copied and rendered" ); tracing::info!( template_type = "opentofu", + provider = %self.provider, status = "complete", "Provision templates ready" ); Ok(()) } + /// Returns the list of static template files for the current provider + /// + /// Both LXD and Hetzner currently use the same static template file (main.tf). + /// This method exists to allow provider-specific customization in the future + /// if different providers need different static files. + #[allow(clippy::match_same_arms)] + fn get_static_template_files(&self) -> Vec<&'static str> { + match self.provider { + Provider::Lxd => vec!["main.tf"], + Provider::Hetzner => vec!["main.tf"], + } + } + /// Builds the full `OpenTofu` build directory path /// /// # Returns /// /// * `PathBuf` - The complete path to the `OpenTofu` build directory fn build_opentofu_directory(&self) -> PathBuf { - self.build_dir.join(Self::OPENTOFU_BUILD_PATH) + self.build_dir.join(self.opentofu_build_path()) } /// Builds the template path for a specific file in the `OpenTofu` template directory @@ -246,8 +289,8 @@ impl TofuTemplateRenderer { /// # Returns /// /// * `String` - The complete template path for the specified file - fn build_template_path(file_name: &str) -> String { - format!("{}/{file_name}", Self::OPENTOFU_TEMPLATE_PATH) + fn build_template_path(&self, file_name: &str) -> String { + format!("{}/{file_name}", self.opentofu_template_path()) } /// Creates the `OpenTofu` build directory structure @@ -298,7 +341,7 @@ impl TofuTemplateRenderer { ); for file_name in file_names { - let template_path = Self::build_template_path(file_name); + let template_path = self.build_template_path(file_name); let source_path = self .template_manager @@ -380,10 +423,13 @@ impl TofuTemplateRenderer { &self, destination_dir: &Path, ) -> Result<(), ProvisionTemplateError> { - tracing::debug!("Rendering variables.tfvars.tera template with instance name context"); + tracing::debug!( + provider = %self.provider, + "Rendering variables.tfvars.tera template with provider-specific context" + ); // Get the variables.tfvars.tera template from the template manager - let template_path = Self::build_template_path("variables.tfvars.tera"); + let template_path = self.build_template_path("variables.tfvars.tera"); let template_file_path = self .template_manager .get_template_path(&template_path) @@ -408,13 +454,29 @@ impl TofuTemplateRenderer { source: std::io::Error::new(std::io::ErrorKind::InvalidData, err.to_string()), })?; - // Build context for template rendering - let context = VariablesContextBuilder::new() + // Render based on provider + match self.provider { + Provider::Lxd => self.render_lxd_variables_template(&template_file, destination_dir), + Provider::Hetzner => { + self.render_hetzner_variables_template(&template_file, destination_dir) + .await + } + } + } + + /// Renders LXD-specific variables template + fn render_lxd_variables_template( + &self, + template_file: &crate::domain::template::file::File, + destination_dir: &Path, + ) -> Result<(), ProvisionTemplateError> { + // Build LXD context for template rendering + let context = LxdVariablesContextBuilder::new() .with_instance_name(self.instance_name.clone()) .with_profile_name(self.profile_name.clone()) .build() .map_err(|err| ProvisionTemplateError::VariablesRenderingFailed { - source: VariablesTemplateError::TemplateEngineError { + source: LxdVariablesTemplateError::TemplateEngineError { source: crate::domain::template::TemplateEngineError::ContextSerialization { source: tera::Error::msg(err.to_string()), }, @@ -422,7 +484,7 @@ impl TofuTemplateRenderer { })?; // Create and render the variables template - let variables_template = VariablesTemplate::new(&template_file, context) + let variables_template = LxdVariablesTemplate::new(template_file, context) .map_err(|source| ProvisionTemplateError::VariablesRenderingFailed { source })?; // Write the rendered template to the destination directory @@ -431,7 +493,67 @@ impl TofuTemplateRenderer { .render(&output_path) .map_err(|source| ProvisionTemplateError::VariablesRenderingFailed { source })?; - tracing::debug!("Variables template rendered successfully"); + tracing::debug!("LXD variables template rendered successfully"); + Ok(()) + } + + /// Renders Hetzner-specific variables template + async fn render_hetzner_variables_template( + &self, + template_file: &crate::domain::template::file::File, + destination_dir: &Path, + ) -> Result<(), ProvisionTemplateError> { + use crate::infrastructure::external_tools::tofu::template::wrappers::hetzner::variables::{ + VariablesContextBuilder as HetznerVariablesContextBuilder, + VariablesTemplate as HetznerVariablesTemplate, + }; + + // Get Hetzner config + let hetzner_config = self.provider_config.as_hetzner().ok_or_else(|| { + ProvisionTemplateError::UnsupportedProvider { + provider: self.provider.to_string(), + } + })?; + + // Read SSH public key content + let ssh_public_key_content = + tokio::fs::read_to_string(&self.ssh_credentials.ssh_pub_key_path) + .await + .map_err(|source| ProvisionTemplateError::FileCopyFailed { + file_name: "ssh public key".to_string(), + source, + })?; + + // Build Hetzner context for template rendering + let context = HetznerVariablesContextBuilder::new() + .with_instance_name(self.instance_name.clone()) + .with_hcloud_api_token(hetzner_config.api_token.clone()) + .with_server_type(hetzner_config.server_type.clone()) + .with_server_location(hetzner_config.location.clone()) + .with_server_image(hetzner_config.image.clone()) + .with_ssh_public_key_content(ssh_public_key_content.trim().to_string()) + .build() + .map_err(|err| ProvisionTemplateError::UnsupportedProvider { + provider: format!("Hetzner context build failed: {err}"), + })?; + + // Create and render the variables template + let variables_template = + HetznerVariablesTemplate::new(template_file, context).map_err(|err| { + ProvisionTemplateError::UnsupportedProvider { + provider: format!("Hetzner template creation failed: {err}"), + } + })?; + + // Write the rendered template to the destination directory + let output_path = destination_dir.join("variables.tfvars"); + variables_template.render(&output_path).map_err(|err| { + ProvisionTemplateError::UnsupportedProvider { + provider: format!("Hetzner template render failed: {err}"), + } + })?; + + tracing::debug!("Hetzner variables template rendered successfully"); Ok(()) } } @@ -473,6 +595,14 @@ mod tests { ) } + /// Helper function to create a test LXD provider config + fn test_lxd_provider_config() -> ProviderConfig { + use crate::domain::provider::LxdConfig; + ProviderConfig::Lxd(LxdConfig { + profile_name: test_profile_name(), + }) + } + #[tokio::test] async fn it_should_create_renderer_with_build_directory() { let temp_dir = tempfile::TempDir::new().expect("Failed to create temp directory"); @@ -486,6 +616,7 @@ mod tests { ssh_credentials, test_instance_name(), test_profile_name(), + test_lxd_provider_config(), ); assert_eq!(renderer.build_dir, build_path); @@ -505,6 +636,7 @@ mod tests { ssh_credentials, test_instance_name(), test_profile_name(), + test_lxd_provider_config(), ); let actual_path = renderer.build_opentofu_directory(); @@ -513,23 +645,50 @@ mod tests { #[tokio::test] async fn it_should_build_correct_template_path_for_file() { - let template_path = TofuTemplateRenderer::build_template_path("main.tf"); + let temp_dir = tempfile::TempDir::new().expect("Failed to create temp directory"); + let build_path = temp_dir.path().join("build"); + let template_manager = Arc::new(TemplateManager::new(temp_dir.path())); + let ssh_credentials = create_dummy_ssh_credentials(temp_dir.path()); + + let renderer = TofuTemplateRenderer::new( + template_manager, + &build_path, + ssh_credentials, + test_instance_name(), + test_profile_name(), + test_lxd_provider_config(), + ); + let template_path = renderer.build_template_path("main.tf"); assert_eq!(template_path, "tofu/lxd/main.tf"); } #[tokio::test] async fn it_should_build_template_path_with_different_file_names() { + let temp_dir = tempfile::TempDir::new().expect("Failed to create temp directory"); + let build_path = temp_dir.path().join("build"); + let template_manager = Arc::new(TemplateManager::new(temp_dir.path())); + let ssh_credentials = create_dummy_ssh_credentials(temp_dir.path()); + + let renderer = TofuTemplateRenderer::new( + template_manager, + &build_path, + ssh_credentials, + test_instance_name(), + test_profile_name(), + test_lxd_provider_config(), + ); + assert_eq!( - TofuTemplateRenderer::build_template_path("cloud-init.yml"), + renderer.build_template_path("cloud-init.yml"), "tofu/lxd/cloud-init.yml" ); assert_eq!( - TofuTemplateRenderer::build_template_path("variables.tf"), + renderer.build_template_path("variables.tf"), "tofu/lxd/variables.tf" ); assert_eq!( - TofuTemplateRenderer::build_template_path("outputs.tf"), + renderer.build_template_path("outputs.tf"), "tofu/lxd/outputs.tf" ); } @@ -548,6 +707,7 @@ mod tests { ssh_credentials, test_instance_name(), test_profile_name(), + test_lxd_provider_config(), ); let created_path = renderer .create_build_directory() @@ -589,6 +749,7 @@ mod tests { ssh_credentials, test_instance_name(), test_profile_name(), + test_lxd_provider_config(), ); let result = renderer.create_build_directory().await; @@ -626,6 +787,7 @@ mod tests { ssh_credentials, test_instance_name(), test_profile_name(), + test_lxd_provider_config(), ); // Try to copy a non-existent template @@ -687,6 +849,7 @@ mod tests { ssh_credentials, test_instance_name(), test_profile_name(), + test_lxd_provider_config(), ); let result = renderer.copy_templates(&["test.tf"], &build_path).await; @@ -704,47 +867,102 @@ mod tests { } // Input Validation Edge Case Tests - #[test] - fn it_should_handle_empty_file_name() { - let template_path = TofuTemplateRenderer::build_template_path(""); + #[tokio::test] + async fn it_should_handle_empty_file_name() { + let temp_dir = tempfile::TempDir::new().expect("Failed to create temp directory"); + let build_path = temp_dir.path().join("build"); + let template_manager = Arc::new(TemplateManager::new(temp_dir.path())); + let ssh_credentials = create_dummy_ssh_credentials(temp_dir.path()); + + let renderer = TofuTemplateRenderer::new( + template_manager, + &build_path, + ssh_credentials, + test_instance_name(), + test_profile_name(), + test_lxd_provider_config(), + ); + let template_path = renderer.build_template_path(""); assert_eq!(template_path, "tofu/lxd/"); } - #[test] - fn it_should_handle_file_names_with_path_separators() { + #[tokio::test] + async fn it_should_handle_file_names_with_path_separators() { + let temp_dir = tempfile::TempDir::new().expect("Failed to create temp directory"); + let build_path = temp_dir.path().join("build"); + let template_manager = Arc::new(TemplateManager::new(temp_dir.path())); + let ssh_credentials = create_dummy_ssh_credentials(temp_dir.path()); + + let renderer = TofuTemplateRenderer::new( + template_manager, + &build_path, + ssh_credentials, + test_instance_name(), + test_profile_name(), + test_lxd_provider_config(), + ); + // File names with forward slashes should be handled literally - let template_path = TofuTemplateRenderer::build_template_path("sub/dir/file.tf"); + let template_path = renderer.build_template_path("sub/dir/file.tf"); assert_eq!(template_path, "tofu/lxd/sub/dir/file.tf"); // File names with backslashes (Windows-style) - let template_path = TofuTemplateRenderer::build_template_path("sub\\dir\\file.tf"); + let template_path = renderer.build_template_path("sub\\dir\\file.tf"); assert_eq!(template_path, "tofu/lxd/sub\\dir\\file.tf"); // Relative path components - let template_path = TofuTemplateRenderer::build_template_path("../main.tf"); + let template_path = renderer.build_template_path("../main.tf"); assert_eq!(template_path, "tofu/lxd/../main.tf"); } - #[test] - fn it_should_handle_special_characters_in_file_names() { + #[tokio::test] + async fn it_should_handle_special_characters_in_file_names() { + let temp_dir = tempfile::TempDir::new().expect("Failed to create temp directory"); + let build_path = temp_dir.path().join("build"); + let template_manager = Arc::new(TemplateManager::new(temp_dir.path())); + let ssh_credentials = create_dummy_ssh_credentials(temp_dir.path()); + + let renderer = TofuTemplateRenderer::new( + template_manager, + &build_path, + ssh_credentials, + test_instance_name(), + test_profile_name(), + test_lxd_provider_config(), + ); + // File names with spaces - let template_path = TofuTemplateRenderer::build_template_path("main file.tf"); + let template_path = renderer.build_template_path("main file.tf"); assert_eq!(template_path, "tofu/lxd/main file.tf"); // File names with unicode characters - let template_path = TofuTemplateRenderer::build_template_path("файл.tf"); + let template_path = renderer.build_template_path("файл.tf"); assert_eq!(template_path, "tofu/lxd/файл.tf"); // File names with special characters - let template_path = TofuTemplateRenderer::build_template_path("main@#$%.tf"); + let template_path = renderer.build_template_path("main@#$%.tf"); assert_eq!(template_path, "tofu/lxd/main@#$%.tf"); } - #[test] - fn it_should_handle_very_long_file_names() { + #[tokio::test] + async fn it_should_handle_very_long_file_names() { + let temp_dir = tempfile::TempDir::new().expect("Failed to create temp directory"); + let build_path = temp_dir.path().join("build"); + let template_manager = Arc::new(TemplateManager::new(temp_dir.path())); + let ssh_credentials = create_dummy_ssh_credentials(temp_dir.path()); + + let renderer = TofuTemplateRenderer::new( + template_manager, + &build_path, + ssh_credentials, + test_instance_name(), + test_profile_name(), + test_lxd_provider_config(), + ); + // Create a very long file name (300 characters) let long_name = "a".repeat(300) + ".tf"; - let template_path = TofuTemplateRenderer::build_template_path(&long_name); + let template_path = renderer.build_template_path(&long_name); assert_eq!(template_path, format!("tofu/lxd/{long_name}")); } @@ -769,6 +987,7 @@ mod tests { ssh_credentials, test_instance_name(), test_profile_name(), + test_lxd_provider_config(), ); let created_path = renderer .create_build_directory() @@ -793,6 +1012,7 @@ mod tests { ssh_credentials, test_instance_name(), test_profile_name(), + test_lxd_provider_config(), ); // Should succeed with empty array @@ -830,6 +1050,7 @@ mod tests { ssh_credentials, test_instance_name(), test_profile_name(), + test_lxd_provider_config(), ); // Copy the same file twice - should succeed (overwrite) @@ -878,6 +1099,7 @@ mod tests { ssh_credentials1, test_instance_name(), test_profile_name(), + test_lxd_provider_config(), ); let ssh_credentials2 = create_dummy_ssh_credentials(temp_dir.path()); let renderer2 = TofuTemplateRenderer::new( @@ -886,6 +1108,7 @@ mod tests { ssh_credentials2, test_instance_name(), test_profile_name(), + test_lxd_provider_config(), ); tokio::fs::create_dir_all(&build_path1) @@ -945,6 +1168,7 @@ mod tests { ssh_credentials, test_instance_name(), test_profile_name(), + test_lxd_provider_config(), ); // Try to copy both existing and non-existing files @@ -1004,6 +1228,7 @@ mod tests { ssh_credentials, test_instance_name(), test_profile_name(), + test_lxd_provider_config(), ); let file_refs: Vec<&str> = file_names.iter().map(std::string::String::as_str).collect(); diff --git a/src/infrastructure/external_tools/tofu/template/wrappers/hetzner/cloud_init/context.rs b/src/infrastructure/external_tools/tofu/template/wrappers/hetzner/cloud_init/context.rs new file mode 100644 index 00000000..883ab1b6 --- /dev/null +++ b/src/infrastructure/external_tools/tofu/template/wrappers/hetzner/cloud_init/context.rs @@ -0,0 +1,289 @@ +//! Context for Hetzner Cloud Init template rendering +//! +//! This module provides the `CloudInitContext` and builder pattern for creating +//! template contexts with SSH public key information for Hetzner cloud-init configuration. + +use serde::Serialize; +use std::fs; +use std::path::Path; +use thiserror::Error; + +use crate::adapters::ssh::SshPublicKey; +use crate::shared::Username; + +/// Errors that can occur when creating a `CloudInitContext` +#[derive(Error, Debug, Clone)] +pub enum CloudInitContextError { + #[error("SSH public key is required but not provided")] + MissingSshPublicKey, + + #[error("Username is required but not provided")] + MissingUsername, + + #[error("Invalid username: {0}")] + InvalidUsername(String), + + #[error("Failed to read SSH public key from file: {0}")] + SshPublicKeyReadError(String), +} + +/// Template context for Hetzner Cloud Init configuration with SSH public key and username +#[derive(Debug, Clone, Serialize)] +pub struct CloudInitContext { + /// SSH public key content to be injected into cloud-init configuration + pub ssh_public_key: SshPublicKey, + /// Username to be created in the cloud-init configuration + pub username: Username, +} + +/// Builder for `CloudInitContext` with fluent interface +#[derive(Debug, Default)] +pub struct CloudInitContextBuilder { + ssh_public_key: Option, + username: Option, +} + +impl CloudInitContextBuilder { + /// Set the SSH public key content directly + /// + /// # Errors + /// Returns an error if the SSH public key is invalid + pub fn with_ssh_public_key>( + mut self, + ssh_public_key: S, + ) -> Result { + let key = SshPublicKey::new(ssh_public_key) + .map_err(|e| CloudInitContextError::SshPublicKeyReadError(e.to_string()))?; + self.ssh_public_key = Some(key); + Ok(self) + } + + /// Set the username for the cloud-init configuration + /// + /// # Errors + /// Returns an error if the username is invalid according to Linux naming requirements + pub fn with_username>( + mut self, + username: S, + ) -> Result { + let username = Username::new(username) + .map_err(|e| CloudInitContextError::InvalidUsername(e.to_string()))?; + self.username = Some(username); + Ok(self) + } + + /// Set the SSH public key by reading from a file path + /// + /// # Errors + /// Returns an error if the file cannot be read or the SSH public key is invalid + pub fn with_ssh_public_key_from_file>( + mut self, + ssh_public_key_path: P, + ) -> Result { + let content = fs::read_to_string(ssh_public_key_path.as_ref()).map_err(|e| { + CloudInitContextError::SshPublicKeyReadError(format!( + "Failed to read SSH public key from {}: {}", + ssh_public_key_path.as_ref().display(), + e + )) + })?; + + // Trim any trailing newlines or whitespace from the SSH key and create SshPublicKey + let key = SshPublicKey::new(content.trim()) + .map_err(|e| CloudInitContextError::SshPublicKeyReadError(e.to_string()))?; + self.ssh_public_key = Some(key); + Ok(self) + } + + /// Builds the `CloudInitContext` + /// + /// # Errors + /// Returns an error if required fields are missing + pub fn build(self) -> Result { + let ssh_public_key = self + .ssh_public_key + .ok_or(CloudInitContextError::MissingSshPublicKey)?; + + let username = self + .username + .ok_or(CloudInitContextError::MissingUsername)?; + + Ok(CloudInitContext { + ssh_public_key, + username, + }) + } +} + +impl CloudInitContext { + /// Creates a new `CloudInitContext` with SSH public key content and username + /// + /// # Errors + /// Returns an error if the username is invalid according to Linux naming requirements + /// or if the SSH public key is invalid + pub fn new>( + ssh_public_key: S, + username: S, + ) -> Result { + let key = SshPublicKey::new(ssh_public_key) + .map_err(|e| CloudInitContextError::SshPublicKeyReadError(e.to_string()))?; + let username = Username::new(username) + .map_err(|e| CloudInitContextError::InvalidUsername(e.to_string()))?; + Ok(Self { + ssh_public_key: key, + username, + }) + } + + /// Creates a new builder for `CloudInitContext` with fluent interface + #[must_use] + pub fn builder() -> CloudInitContextBuilder { + CloudInitContextBuilder::default() + } + + /// Get the SSH public key content + #[must_use] + pub fn ssh_public_key(&self) -> &str { + self.ssh_public_key.as_str() + } + + /// Get the username + #[must_use] + pub fn username(&self) -> &str { + self.username.as_str() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use tempfile::TempDir; + + #[test] + fn it_should_create_cloud_init_context_with_ssh_key() { + let ssh_key = "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQC... test@example.com"; + let username = "testuser"; + let context = CloudInitContext::new(ssh_key, username).unwrap(); + + assert_eq!(context.ssh_public_key(), ssh_key); + assert_eq!(context.username(), username); + } + + #[test] + fn it_should_build_context_with_builder_pattern() { + let ssh_key = "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQC... test@example.com"; + let username = "testuser"; + let context = CloudInitContext::builder() + .with_ssh_public_key(ssh_key) + .unwrap() + .with_username(username) + .unwrap() + .build() + .unwrap(); + + assert_eq!(context.ssh_public_key(), ssh_key); + assert_eq!(context.username(), username); + } + + #[test] + fn it_should_read_ssh_key_from_file() { + let temp_dir = TempDir::new().unwrap(); + let key_file = temp_dir.path().join("test_key.pub"); + let ssh_key = "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQC... test@example.com\n"; + let username = "testuser"; + + fs::write(&key_file, ssh_key).unwrap(); + + let context = CloudInitContext::builder() + .with_ssh_public_key_from_file(&key_file) + .unwrap() + .with_username(username) + .unwrap() + .build() + .unwrap(); + + // Should trim the trailing newline + assert_eq!(context.ssh_public_key(), ssh_key.trim()); + assert_eq!(context.username(), username); + } + + #[test] + fn it_should_fail_when_ssh_key_is_missing() { + let result = CloudInitContext::builder().build(); + + assert!(result.is_err()); + assert!(matches!( + result.unwrap_err(), + CloudInitContextError::MissingSshPublicKey + )); + } + + #[test] + fn it_should_fail_when_username_is_missing() { + let ssh_key = "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQC... test@example.com"; + let result = CloudInitContext::builder() + .with_ssh_public_key(ssh_key) + .unwrap() + .build(); + + assert!(result.is_err()); + assert!(matches!( + result.unwrap_err(), + CloudInitContextError::MissingUsername + )); + } + + #[test] + fn it_should_fail_when_ssh_key_file_does_not_exist() { + let result = + CloudInitContext::builder().with_ssh_public_key_from_file("/nonexistent/path/key.pub"); + + assert!(result.is_err()); + assert!(matches!( + result.unwrap_err(), + CloudInitContextError::SshPublicKeyReadError(_) + )); + } + + #[test] + fn it_should_serialize_to_json() { + let ssh_key = "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQC... test@example.com"; + let username = "testuser"; + let context = CloudInitContext::new(ssh_key, username).unwrap(); + + let json = serde_json::to_value(&context).unwrap(); + assert_eq!(json["ssh_public_key"], ssh_key); + assert_eq!(json["username"], username); + } + + #[test] + fn it_should_fail_with_invalid_username() { + let ssh_key = "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQC... test@example.com"; + let invalid_username = "123invalid"; // starts with digit + + let result = CloudInitContext::new(ssh_key, invalid_username); + assert!(result.is_err()); + assert!(matches!( + result.unwrap_err(), + CloudInitContextError::InvalidUsername(_) + )); + } + + #[test] + fn it_should_fail_with_builder_when_username_is_invalid() { + let ssh_key = "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQC... test@example.com"; + let invalid_username = "@invalid"; // contains @ symbol + + let result = CloudInitContext::builder() + .with_ssh_public_key(ssh_key) + .unwrap() + .with_username(invalid_username); + + assert!(result.is_err()); + assert!(matches!( + result.unwrap_err(), + CloudInitContextError::InvalidUsername(_) + )); + } +} diff --git a/src/infrastructure/external_tools/tofu/template/wrappers/hetzner/cloud_init/mod.rs b/src/infrastructure/external_tools/tofu/template/wrappers/hetzner/cloud_init/mod.rs new file mode 100644 index 00000000..4876387c --- /dev/null +++ b/src/infrastructure/external_tools/tofu/template/wrappers/hetzner/cloud_init/mod.rs @@ -0,0 +1,229 @@ +//! Template wrapper for templates/tofu/hetzner/cloud-init.yml.tera +//! +//! This template has mandatory variables that must be provided at construction time. +//! Hetzner cloud-init is independent from LXD cloud-init, allowing provider-specific customization. + +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::{CloudInitContext, CloudInitContextBuilder, CloudInitContextError}; + +#[derive(Debug)] +pub struct CloudInitTemplate { + context: CloudInitContext, + content: String, +} + +impl CloudInitTemplate { + /// Creates a new `CloudInitTemplate`, 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 `CloudInitContext` fails, + /// which should never happen under normal circumstances. + pub fn new( + template_file: &File, + cloud_init_context: CloudInitContext, + ) -> Result { + let mut engine = crate::domain::template::TemplateEngine::new(); + + let validated_content = engine.render( + template_file.filename(), + template_file.content(), + &cloud_init_context, + )?; + + Ok(Self { + context: cloud_init_context, + content: validated_content, + }) + } + + /// Get the SSH public key value + #[must_use] + pub fn ssh_public_key(&self) -> &str { + self.context.ssh_public_key() + } + + /// 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::*; + + /// Helper function to create a `CloudInitContext` with given SSH key + fn create_cloud_init_context(ssh_key: &str) -> CloudInitContext { + CloudInitContext::builder() + .with_ssh_public_key(ssh_key) + .unwrap() + .with_username("testuser") + .unwrap() + .build() + .unwrap() + } + + #[test] + fn it_should_create_cloud_init_template_successfully() { + // Use template content directly instead of file + let template_content = + "#cloud-config\nusers:\n - ssh_authorized_keys:\n - {{ ssh_public_key }}\n"; + + let template_file = File::new("cloud-init.yml.tera", template_content.to_string()).unwrap(); + + let ssh_key = "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQC... test@example.com"; + let cloud_init_context = create_cloud_init_context(ssh_key); + let template = CloudInitTemplate::new(&template_file, cloud_init_context).unwrap(); + + assert_eq!(template.ssh_public_key(), ssh_key); + } + + #[test] + fn it_should_generate_cloud_init_template_context() { + // Use template content directly instead of file + let template_content = + "#cloud-config\nusers:\n - ssh_authorized_keys:\n - {{ ssh_public_key }}\n"; + + let template_file = File::new("cloud-init.yml.tera", template_content.to_string()).unwrap(); + + let ssh_key = "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQC... test@example.com"; + let cloud_init_context = create_cloud_init_context(ssh_key); + let template = CloudInitTemplate::new(&template_file, cloud_init_context).unwrap(); + + assert_eq!(template.ssh_public_key(), ssh_key); + } + + #[test] + fn it_should_accept_empty_template_content() { + // Test with empty template content + let template_file = File::new("cloud-init.yml.tera", String::new()).unwrap(); + + let ssh_key = "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQC... test@example.com"; + let cloud_init_context = create_cloud_init_context(ssh_key); + let result = CloudInitTemplate::new(&template_file, cloud_init_context); + + // Empty templates are valid in Tera - they just render as empty strings + assert!(result.is_ok()); + let template = result.unwrap(); + assert_eq!(template.content, ""); + } + + #[test] + fn it_should_work_with_missing_placeholder_variables() { + // Create template content with no placeholder variables + let template_content = "#cloud-config\nusers:\n - name: static_user\n"; + + let template_file = File::new("cloud-init.yml.tera", template_content.to_string()).unwrap(); + + let ssh_key = "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQC... test@example.com"; + let cloud_init_context = create_cloud_init_context(ssh_key); + let result = CloudInitTemplate::new(&template_file, cloud_init_context); + + // This is valid - templates don't need to use all available context variables + assert!(result.is_ok()); + let template = result.unwrap(); + assert!(template.content.contains("static_user")); + } + + #[test] + fn it_should_accept_static_template_with_no_variables() { + // Create template content with no placeholder variables at all + let template_content = "#cloud-config\npackages:\n - curl\n"; + + let template_file = File::new("cloud-init.yml.tera", template_content.to_string()).unwrap(); + + let ssh_key = "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQC... test@example.com"; + let cloud_init_context = create_cloud_init_context(ssh_key); + let result = CloudInitTemplate::new(&template_file, cloud_init_context); + + // Static templates are valid - they just don't use template variables + assert!(result.is_ok()); + let template = result.unwrap(); + assert!(template.content.contains("curl")); + } + + #[test] + fn it_should_fail_when_template_references_undefined_variable() { + // Create template content that references an undefined variable + let template_content = + "#cloud-config\nusers:\n - ssh_authorized_keys:\n - {{ undefined_variable }}\n"; + + let template_file = File::new("cloud-init.yml.tera", template_content.to_string()).unwrap(); + + let ssh_key = "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQC... test@example.com"; + let cloud_init_context = create_cloud_init_context(ssh_key); + let result = CloudInitTemplate::new(&template_file, cloud_init_context); + + // This should fail because the template references an undefined variable + assert!(result.is_err()); + let error_msg = result.unwrap_err().to_string(); + assert!(error_msg.contains("Failed to render template") || error_msg.contains("template")); + } + + #[test] + fn it_should_fail_when_template_validation_fails() { + // Create template content with malformed Tera syntax + let template_content = "#cloud-config\nusers:\n - ssh_authorized_keys:\n - {{ ssh_public_key }}\nmalformed={{unclosed_var\n"; + + let template_file = File::new("cloud-init.yml.tera", template_content.to_string()).unwrap(); + + let ssh_key = "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQC... test@example.com"; + let cloud_init_context = create_cloud_init_context(ssh_key); + let result = CloudInitTemplate::new(&template_file, cloud_init_context); + + // Should fail during template validation + assert!(result.is_err()); + } + + #[test] + fn it_should_fail_when_template_has_malformed_syntax() { + // Test with different malformed template syntax + let template_content = "invalid {{{{ syntax"; + + let template_file = File::new("cloud-init.yml.tera", template_content.to_string()).unwrap(); + + let ssh_key = "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQC... test@example.com"; + let cloud_init_context = create_cloud_init_context(ssh_key); + let result = CloudInitTemplate::new(&template_file, cloud_init_context); + + assert!(result.is_err()); + } + + #[test] + fn it_should_validate_template_at_construction_time() { + // Create valid template content + let template_content = + "#cloud-config\nusers:\n - ssh_authorized_keys:\n - {{ ssh_public_key }}\n"; + + let template_file = File::new("cloud-init.yml.tera", template_content.to_string()).unwrap(); + + // Template validation happens during construction, not during render + let ssh_key = "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQC... test@example.com"; + let cloud_init_context = create_cloud_init_context(ssh_key); + let template = CloudInitTemplate::new(&template_file, cloud_init_context).unwrap(); + + // Verify that the template was pre-validated and contains rendered content + assert_eq!(template.ssh_public_key(), ssh_key); + assert!(template.content.contains(ssh_key)); + } +} diff --git a/src/infrastructure/external_tools/tofu/template/wrappers/hetzner/mod.rs b/src/infrastructure/external_tools/tofu/template/wrappers/hetzner/mod.rs new file mode 100644 index 00000000..9a2196c2 --- /dev/null +++ b/src/infrastructure/external_tools/tofu/template/wrappers/hetzner/mod.rs @@ -0,0 +1,17 @@ +//! `OpenTofu` Hetzner Cloud template wrappers +//! +//! Contains template wrappers for Hetzner Cloud-specific configuration files. +//! +//! - `cloud_init` - templates/tofu/hetzner/cloud-init.yml.tera (with runtime variables: `ssh_public_key`, `username`) +//! - `variables` - templates/tofu/hetzner/variables.tfvars.tera (with runtime variables: `hcloud_api_token`, `instance_name`, etc.) + +pub mod cloud_init; +pub mod variables; + +pub use cloud_init::{ + CloudInitContext, CloudInitContextBuilder, CloudInitContextError, CloudInitTemplate, +}; + +pub use variables::{ + VariablesContext, VariablesContextBuilder, VariablesContextError, VariablesTemplate, +}; diff --git a/src/infrastructure/external_tools/tofu/template/wrappers/hetzner/variables/context.rs b/src/infrastructure/external_tools/tofu/template/wrappers/hetzner/variables/context.rs new file mode 100644 index 00000000..1f41195c --- /dev/null +++ b/src/infrastructure/external_tools/tofu/template/wrappers/hetzner/variables/context.rs @@ -0,0 +1,388 @@ +//! # Hetzner Cloud `OpenTofu` Variables Context +//! +//! Provides context structures for Hetzner Cloud `OpenTofu` variables template rendering. +//! +//! This module contains the context object that holds runtime values for variable template rendering, +//! specifically for the `variables.tfvars.tera` template used in Hetzner Cloud infrastructure provisioning. +//! +//! ## Context Structure +//! +//! The `VariablesContext` holds: +//! - `instance_name` - The dynamic name for the server instance +//! - `hcloud_api_token` - Hetzner Cloud API token for authentication +//! - `server_type` - Hetzner server type (e.g., cx22, cx32) +//! - `server_location` - Datacenter location (e.g., nbg1, fsn1) +//! - `server_image` - OS image (e.g., ubuntu-24.04) +//! - `ssh_public_key_content` - SSH public key content for server access +//! +//! ## Example Usage +//! +//! ```rust +//! use torrust_tracker_deployer_lib::infrastructure::external_tools::tofu::template::wrappers::hetzner::variables::VariablesContext; +//! use torrust_tracker_deployer_lib::adapters::lxd::instance::InstanceName; +//! +//! let context = VariablesContext::builder() +//! .with_instance_name(InstanceName::new("my-test-vm".to_string()).unwrap()) +//! .with_hcloud_api_token("my-api-token".to_string()) +//! .with_server_type("cx22".to_string()) +//! .with_server_location("nbg1".to_string()) +//! .with_server_image("ubuntu-24.04".to_string()) +//! .with_ssh_public_key_content("ssh-rsa AAAA...".to_string()) +//! .build() +//! .unwrap(); +//! ``` + +use serde::Serialize; +use thiserror::Error; + +use crate::domain::InstanceName; + +/// Errors that can occur when building the Hetzner variables context +#[derive(Error, Debug)] +pub enum VariablesContextError { + /// Instance name is required but was not provided + #[error("Instance name is required but was not provided")] + MissingInstanceName, + + /// Hetzner Cloud API token is required but was not provided + #[error("Hetzner Cloud API token is required but was not provided")] + MissingHcloudApiToken, + + /// Server type is required but was not provided + #[error("Server type is required but was not provided")] + MissingServerType, + + /// Server location is required but was not provided + #[error("Server location is required but was not provided")] + MissingServerLocation, + + /// Server image is required but was not provided + #[error("Server image is required but was not provided")] + MissingServerImage, + + /// SSH public key content is required but was not provided + #[error("SSH public key content is required but was not provided")] + MissingSshPublicKeyContent, +} + +/// Context for Hetzner Cloud `OpenTofu` variables template rendering +/// +/// Contains all runtime values needed to render `variables.tfvars.tera` +/// with Hetzner Cloud-specific configuration parameters. +#[derive(Debug, Clone, Serialize)] +pub struct VariablesContext { + /// The name of the server instance to be created + pub instance_name: InstanceName, + /// Hetzner Cloud API token for authentication (sensitive) + pub hcloud_api_token: String, + /// Hetzner server type (e.g., cx22, cx32, cpx11) + pub server_type: String, + /// Datacenter location (e.g., nbg1, fsn1, hel1) + pub server_location: String, + /// Operating system image (e.g., ubuntu-24.04) + pub server_image: String, + /// SSH public key content for server access + pub ssh_public_key_content: String, +} + +/// Builder for creating Hetzner `VariablesContext` instances +/// +/// Provides a fluent interface for constructing the context with validation +/// to ensure all required fields are provided. +#[derive(Debug, Default)] +pub struct VariablesContextBuilder { + instance_name: Option, + hcloud_api_token: Option, + server_type: Option, + server_location: Option, + server_image: Option, + ssh_public_key_content: Option, +} + +impl VariablesContextBuilder { + /// Creates a new builder instance + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// Sets the instance name for the server + /// + /// # Arguments + /// + /// * `instance_name` - The name to assign to the created server + #[must_use] + pub fn with_instance_name(mut self, instance_name: InstanceName) -> Self { + self.instance_name = Some(instance_name); + self + } + + /// Sets the Hetzner Cloud API token + /// + /// # Arguments + /// + /// * `hcloud_api_token` - The API token for Hetzner Cloud authentication + #[must_use] + pub fn with_hcloud_api_token(mut self, hcloud_api_token: String) -> Self { + self.hcloud_api_token = Some(hcloud_api_token); + self + } + + /// Sets the server type + /// + /// # Arguments + /// + /// * `server_type` - The Hetzner server type (e.g., cx22) + #[must_use] + pub fn with_server_type(mut self, server_type: String) -> Self { + self.server_type = Some(server_type); + self + } + + /// Sets the server location + /// + /// # Arguments + /// + /// * `server_location` - The datacenter location (e.g., nbg1) + #[must_use] + pub fn with_server_location(mut self, server_location: String) -> Self { + self.server_location = Some(server_location); + self + } + + /// Sets the server image + /// + /// # Arguments + /// + /// * `server_image` - The OS image (e.g., ubuntu-24.04) + #[must_use] + pub fn with_server_image(mut self, server_image: String) -> Self { + self.server_image = Some(server_image); + self + } + + /// Sets the SSH public key content + /// + /// # Arguments + /// + /// * `ssh_public_key_content` - The content of the SSH public key + #[must_use] + pub fn with_ssh_public_key_content(mut self, ssh_public_key_content: String) -> Self { + self.ssh_public_key_content = Some(ssh_public_key_content); + self + } + + /// Builds the `VariablesContext` with validation + /// + /// # Returns + /// + /// * `Ok(VariablesContext)` if all required fields are present + /// * `Err(VariablesContextError)` if validation fails + /// + /// # Errors + /// + /// Returns appropriate error variant for each missing required field + pub fn build(self) -> Result { + let instance_name = self + .instance_name + .ok_or(VariablesContextError::MissingInstanceName)?; + + let hcloud_api_token = self + .hcloud_api_token + .ok_or(VariablesContextError::MissingHcloudApiToken)?; + + let server_type = self + .server_type + .ok_or(VariablesContextError::MissingServerType)?; + + let server_location = self + .server_location + .ok_or(VariablesContextError::MissingServerLocation)?; + + let server_image = self + .server_image + .ok_or(VariablesContextError::MissingServerImage)?; + + let ssh_public_key_content = self + .ssh_public_key_content + .ok_or(VariablesContextError::MissingSshPublicKeyContent)?; + + Ok(VariablesContext { + instance_name, + hcloud_api_token, + server_type, + server_location, + server_image, + ssh_public_key_content, + }) + } +} + +impl VariablesContext { + /// Creates a new builder for constructing `VariablesContext` + #[must_use] + pub fn builder() -> VariablesContextBuilder { + VariablesContextBuilder::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn create_valid_builder() -> VariablesContextBuilder { + VariablesContext::builder() + .with_instance_name(InstanceName::new("test-vm".to_string()).unwrap()) + .with_hcloud_api_token("test-token".to_string()) + .with_server_type("cx22".to_string()) + .with_server_location("nbg1".to_string()) + .with_server_image("ubuntu-24.04".to_string()) + .with_ssh_public_key_content("ssh-rsa AAAA... test@example.com".to_string()) + } + + #[test] + fn it_should_create_variables_context_with_all_required_fields() { + let context = create_valid_builder().build().unwrap(); + + assert_eq!(context.instance_name.as_str(), "test-vm"); + assert_eq!(context.hcloud_api_token, "test-token"); + assert_eq!(context.server_type, "cx22"); + assert_eq!(context.server_location, "nbg1"); + assert_eq!(context.server_image, "ubuntu-24.04"); + assert_eq!( + context.ssh_public_key_content, + "ssh-rsa AAAA... test@example.com" + ); + } + + #[test] + fn it_should_serialize_to_json() { + let context = create_valid_builder().build().unwrap(); + + let json = serde_json::to_string(&context).unwrap(); + assert!(json.contains("test-vm")); + assert!(json.contains("instance_name")); + assert!(json.contains("hcloud_api_token")); + assert!(json.contains("server_type")); + assert!(json.contains("server_location")); + assert!(json.contains("server_image")); + assert!(json.contains("ssh_public_key_content")); + } + + #[test] + fn it_should_fail_when_instance_name_is_missing() { + let result = VariablesContext::builder() + .with_hcloud_api_token("test-token".to_string()) + .with_server_type("cx22".to_string()) + .with_server_location("nbg1".to_string()) + .with_server_image("ubuntu-24.04".to_string()) + .with_ssh_public_key_content("ssh-rsa AAAA...".to_string()) + .build(); + + assert!(matches!( + result.unwrap_err(), + VariablesContextError::MissingInstanceName + )); + } + + #[test] + fn it_should_fail_when_hcloud_api_token_is_missing() { + let result = VariablesContext::builder() + .with_instance_name(InstanceName::new("test-vm".to_string()).unwrap()) + .with_server_type("cx22".to_string()) + .with_server_location("nbg1".to_string()) + .with_server_image("ubuntu-24.04".to_string()) + .with_ssh_public_key_content("ssh-rsa AAAA...".to_string()) + .build(); + + assert!(matches!( + result.unwrap_err(), + VariablesContextError::MissingHcloudApiToken + )); + } + + #[test] + fn it_should_fail_when_server_type_is_missing() { + let result = VariablesContext::builder() + .with_instance_name(InstanceName::new("test-vm".to_string()).unwrap()) + .with_hcloud_api_token("test-token".to_string()) + .with_server_location("nbg1".to_string()) + .with_server_image("ubuntu-24.04".to_string()) + .with_ssh_public_key_content("ssh-rsa AAAA...".to_string()) + .build(); + + assert!(matches!( + result.unwrap_err(), + VariablesContextError::MissingServerType + )); + } + + #[test] + fn it_should_fail_when_server_location_is_missing() { + let result = VariablesContext::builder() + .with_instance_name(InstanceName::new("test-vm".to_string()).unwrap()) + .with_hcloud_api_token("test-token".to_string()) + .with_server_type("cx22".to_string()) + .with_server_image("ubuntu-24.04".to_string()) + .with_ssh_public_key_content("ssh-rsa AAAA...".to_string()) + .build(); + + assert!(matches!( + result.unwrap_err(), + VariablesContextError::MissingServerLocation + )); + } + + #[test] + fn it_should_fail_when_server_image_is_missing() { + let result = VariablesContext::builder() + .with_instance_name(InstanceName::new("test-vm".to_string()).unwrap()) + .with_hcloud_api_token("test-token".to_string()) + .with_server_type("cx22".to_string()) + .with_server_location("nbg1".to_string()) + .with_ssh_public_key_content("ssh-rsa AAAA...".to_string()) + .build(); + + assert!(matches!( + result.unwrap_err(), + VariablesContextError::MissingServerImage + )); + } + + #[test] + fn it_should_fail_when_ssh_public_key_content_is_missing() { + let result = VariablesContext::builder() + .with_instance_name(InstanceName::new("test-vm".to_string()).unwrap()) + .with_hcloud_api_token("test-token".to_string()) + .with_server_type("cx22".to_string()) + .with_server_location("nbg1".to_string()) + .with_server_image("ubuntu-24.04".to_string()) + .build(); + + assert!(matches!( + result.unwrap_err(), + VariablesContextError::MissingSshPublicKeyContent + )); + } + + #[test] + fn it_should_be_cloneable_when_cloned() { + let context = create_valid_builder().build().unwrap(); + let cloned = context.clone(); + + assert_eq!( + context.instance_name.as_str(), + cloned.instance_name.as_str() + ); + assert_eq!(context.hcloud_api_token, cloned.hcloud_api_token); + } + + #[test] + fn it_should_implement_debug_trait_when_formatted() { + let context = create_valid_builder().build().unwrap(); + let debug = format!("{context:?}"); + + assert!(debug.contains("VariablesContext")); + assert!(debug.contains("instance_name")); + } +} diff --git a/src/infrastructure/external_tools/tofu/template/wrappers/hetzner/variables/mod.rs b/src/infrastructure/external_tools/tofu/template/wrappers/hetzner/variables/mod.rs new file mode 100644 index 00000000..96fa4a11 --- /dev/null +++ b/src/infrastructure/external_tools/tofu/template/wrappers/hetzner/variables/mod.rs @@ -0,0 +1,285 @@ +//! # Hetzner Cloud `OpenTofu` Variables Templates +//! +//! Template wrappers for rendering `variables.tfvars.tera` with Hetzner Cloud-specific configuration. +//! +//! This module provides the `VariablesTemplate` and `VariablesContext` for validating and rendering `OpenTofu` +//! variable files with runtime context injection, specifically for parameterizing +//! Hetzner Cloud infrastructure provisioning. + +pub mod context; + +use std::path::Path; +use thiserror::Error; + +use crate::domain::template::file::File; +use crate::domain::template::{ + write_file_with_dir_creation, FileOperationError, TemplateEngine, TemplateEngineError, +}; + +pub use context::{VariablesContext, VariablesContextBuilder, VariablesContextError}; + +/// Errors that can occur during Hetzner variables template operations +#[derive(Error, Debug)] +pub enum VariablesTemplateError { + /// Template engine error + #[error("Template engine error: {source}")] + TemplateEngineError { + #[from] + source: TemplateEngineError, + }, + + /// File I/O operation failed + #[error("File operation failed: {source}")] + FileOperationError { + #[from] + source: FileOperationError, + }, +} + +/// Template wrapper for Hetzner Cloud `OpenTofu` variables rendering +/// +/// Validates and renders `variables.tfvars.tera` templates with `VariablesContext` +/// to produce dynamic infrastructure variable files for Hetzner Cloud. +#[derive(Debug)] +pub struct VariablesTemplate { + context: context::VariablesContext, + content: String, +} + +impl VariablesTemplate { + /// Creates a new Hetzner variables template with validation + /// + /// # Arguments + /// + /// * `template_file` - The template file containing variables.tfvars.tera content + /// * `context` - The context containing Hetzner-specific runtime values + /// + /// # Returns + /// + /// * `Ok(VariablesTemplate)` if template validation succeeds + /// * `Err(VariablesTemplateError)` if validation fails + /// + /// # Errors + /// + /// Returns `TemplateEngineError` if the template has syntax errors or validation fails + pub fn new( + template_file: &File, + context: VariablesContext, + ) -> Result { + let mut engine = TemplateEngine::new(); + + let validated_content = + engine.render(template_file.filename(), template_file.content(), &context)?; + + Ok(Self { + context, + content: validated_content, + }) + } + + /// Get the instance name value + #[must_use] + pub fn instance_name(&self) -> &str { + self.context.instance_name.as_str() + } + + /// 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<(), VariablesTemplateError> { + write_file_with_dir_creation(output_path, &self.content)?; + Ok(()) + } + + /// Gets the context used by this template + #[must_use] + pub fn context(&self) -> &VariablesContext { + &self.context + } + + /// Gets the rendered content + #[must_use] + pub fn content(&self) -> &str { + &self.content + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::domain::InstanceName; + use tempfile::NamedTempFile; + + fn create_test_context() -> VariablesContext { + VariablesContext::builder() + .with_instance_name(InstanceName::new("test-instance".to_string()).unwrap()) + .with_hcloud_api_token("test-api-token".to_string()) + .with_server_type("cx22".to_string()) + .with_server_location("nbg1".to_string()) + .with_server_image("ubuntu-24.04".to_string()) + .with_ssh_public_key_content("ssh-rsa AAAA... test@example.com".to_string()) + .build() + .unwrap() + } + + #[test] + fn it_should_create_variables_template_successfully() { + let template_content = r#"hcloud_api_token = "{{ hcloud_api_token }}" +server_name = "{{ instance_name }}""#; + + let template_file = + File::new("variables.tfvars.tera", template_content.to_string()).unwrap(); + let context = create_test_context(); + + let result = VariablesTemplate::new(&template_file, context); + assert!(result.is_ok()); + } + + #[test] + fn it_should_fail_when_template_has_malformed_syntax() { + let template_content = r#"hcloud_api_token = "{{ hcloud_api_token +server_name = "{{ instance_name }}""#; // Missing closing }} + + let template_file = + File::new("variables.tfvars.tera", template_content.to_string()).unwrap(); + let context = create_test_context(); + + let result = VariablesTemplate::new(&template_file, context); + assert!(matches!( + result.unwrap_err(), + VariablesTemplateError::TemplateEngineError { .. } + )); + } + + #[test] + fn it_should_accept_static_template_with_no_variables() { + let template_content = r#"hcloud_api_token = "hardcoded-token" +server_type = "cx22""#; + + let template_file = + File::new("variables.tfvars.tera", template_content.to_string()).unwrap(); + let context = create_test_context(); + + let result = VariablesTemplate::new(&template_file, context); + assert!(result.is_ok()); + } + + #[test] + fn it_should_accept_empty_template_content() { + let template_content = ""; + + let template_file = + File::new("variables.tfvars.tera", template_content.to_string()).unwrap(); + let context = create_test_context(); + + let result = VariablesTemplate::new(&template_file, context); + assert!(result.is_ok()); + } + + #[test] + fn it_should_render_variables_template_successfully() { + let template_content = r#"# OpenTofu Variables for Hetzner Cloud +hcloud_api_token = "{{ hcloud_api_token }}" +server_name = "{{ instance_name }}" +server_type = "{{ server_type }}""#; + + let template_file = + File::new("variables.tfvars.tera", template_content.to_string()).unwrap(); + let context = create_test_context(); + let variables_template = VariablesTemplate::new(&template_file, context).unwrap(); + + let temp_file = NamedTempFile::new().unwrap(); + let result = variables_template.render(temp_file.path()); + + assert!(result.is_ok()); + + // Verify rendered content + let rendered_content = std::fs::read_to_string(temp_file.path()).unwrap(); + assert!(rendered_content.contains(r#"hcloud_api_token = "test-api-token""#)); + assert!(rendered_content.contains(r#"server_name = "test-instance""#)); + assert!(rendered_content.contains(r#"server_type = "cx22""#)); + } + + #[test] + fn it_should_provide_access_to_context() { + let template_file = File::new("variables.tfvars.tera", String::new()).unwrap(); + let context = create_test_context(); + let variables_template = VariablesTemplate::new(&template_file, context).unwrap(); + + assert_eq!( + variables_template.context().instance_name.as_str(), + "test-instance" + ); + } + + #[test] + fn it_should_provide_access_to_rendered_content() { + let template_content = r#"server_name = "{{ instance_name }}""#; + let template_file = + File::new("variables.tfvars.tera", template_content.to_string()).unwrap(); + let context = create_test_context(); + let variables_template = VariablesTemplate::new(&template_file, context).unwrap(); + + assert!(variables_template.content().contains("test-instance")); + } + + #[test] + fn it_should_work_with_missing_placeholder_variables() { + // Template has no placeholders but context has values - should work fine + let template_content = r#"hcloud_api_token = "hardcoded" +server_type = "cx22""#; + + let template_file = + File::new("variables.tfvars.tera", template_content.to_string()).unwrap(); + let context = create_test_context(); + let variables_template = VariablesTemplate::new(&template_file, context).unwrap(); + + let temp_file = NamedTempFile::new().unwrap(); + let result = variables_template.render(temp_file.path()); + + assert!(result.is_ok()); + + let rendered_content = std::fs::read_to_string(temp_file.path()).unwrap(); + assert!(rendered_content.contains(r#"hcloud_api_token = "hardcoded""#)); + } + + #[test] + fn it_should_validate_template_at_construction_time() { + let template_content = r#"hcloud_api_token = "{{ undefined_variable }}" +server_type = "cx22""#; + + let template_file = + File::new("variables.tfvars.tera", template_content.to_string()).unwrap(); + let context = create_test_context(); + + // Should fail at construction, not during render + let result = VariablesTemplate::new(&template_file, context); + assert!(matches!( + result.unwrap_err(), + VariablesTemplateError::TemplateEngineError { .. } + )); + } + + #[test] + fn it_should_generate_variables_template_context() { + let template_file = + File::new("variables.tfvars.tera", "{{ instance_name }}".to_string()).unwrap(); + let context = VariablesContext::builder() + .with_instance_name(InstanceName::new("dynamic-vm".to_string()).unwrap()) + .with_hcloud_api_token("dynamic-token".to_string()) + .with_server_type("cx32".to_string()) + .with_server_location("fsn1".to_string()) + .with_server_image("ubuntu-24.04".to_string()) + .with_ssh_public_key_content("ssh-rsa AAAA...".to_string()) + .build() + .unwrap(); + + let variables_template = VariablesTemplate::new(&template_file, context).unwrap(); + assert_eq!( + variables_template.context().instance_name.as_str(), + "dynamic-vm" + ); + } +} diff --git a/src/infrastructure/external_tools/tofu/template/wrappers/mod.rs b/src/infrastructure/external_tools/tofu/template/wrappers/mod.rs index 5cd78ddb..bed70e78 100644 --- a/src/infrastructure/external_tools/tofu/template/wrappers/mod.rs +++ b/src/infrastructure/external_tools/tofu/template/wrappers/mod.rs @@ -1,7 +1,12 @@ //! `OpenTofu` template wrappers //! -//! Organized by provider (e.g., lxd/) +//! Organized by provider (e.g., lxd/, hetzner/) //! -//! Currently empty - all `OpenTofu` config files are static and copied directly. -//! Wrappers will be created when config templates need variable substitution. +//! Each provider has its own independent template wrappers for: +//! - `cloud_init` - Cloud-init configuration templates +//! - `variables` - `OpenTofu` variables templates +//! +//! Templates are not shared between providers to allow provider-specific customization. + +pub mod hetzner; pub mod lxd; diff --git a/src/testing/e2e/container.rs b/src/testing/e2e/container.rs index a57fc61a..ffee7d5d 100644 --- a/src/testing/e2e/container.rs +++ b/src/testing/e2e/container.rs @@ -24,6 +24,7 @@ use crate::adapters::lxd::LxdClient; use crate::adapters::ssh::SshCredentials; use crate::adapters::tofu::OpenTofuClient; use crate::config::Config; +use crate::domain::provider::ProviderConfig; use crate::domain::template::TemplateManager; use crate::domain::{InstanceName, ProfileName}; use crate::infrastructure::external_tools::ansible::AnsibleTemplateRenderer; @@ -71,6 +72,7 @@ impl Services { ssh_credentials: SshCredentials, instance_name: InstanceName, profile_name: ProfileName, + provider_config: ProviderConfig, ) -> Self { // Create template manager let template_manager = TemplateManager::new(config.templates_dir.clone()); @@ -92,6 +94,7 @@ impl Services { ssh_credentials, instance_name, profile_name, + provider_config, ); // Create configuration template renderer diff --git a/src/testing/e2e/context.rs b/src/testing/e2e/context.rs index 2a0b48b3..cb16cae0 100644 --- a/src/testing/e2e/context.rs +++ b/src/testing/e2e/context.rs @@ -133,6 +133,7 @@ impl TestContext { environment.ssh_credentials().clone(), environment.instance_name().clone(), environment.profile_name().clone(), + environment.provider_config().clone(), ); let env = Self { diff --git a/templates/tofu/lxd/cloud-init.yml.tera b/templates/tofu/common/cloud-init.yml.tera similarity index 53% rename from templates/tofu/lxd/cloud-init.yml.tera rename to templates/tofu/common/cloud-init.yml.tera index dbd778b7..1e1fc7e9 100644 --- a/templates/tofu/lxd/cloud-init.yml.tera +++ b/templates/tofu/common/cloud-init.yml.tera @@ -1,6 +1,15 @@ #cloud-config -# cloud-init template for LXD containers with dynamic SSH key injection -# This template uses Tera templating to inject the SSH public key from SshConfig.ssh_pub_key_path +# Common cloud-init template for VM provisioning +# +# This template is shared by all providers (LXD, Hetzner) to ensure consistent +# VM initialization. It creates a user with SSH access and sudo privileges. +# +# Template Variables (Tera syntax): +# - username: The SSH user to create +# - ssh_public_key: The public SSH key content for authentication +# +# Note: Package updates are commented out for faster VM creation during +# development. Uncomment for production deployments. # Commented out for faster VM creation during development # package_update: true diff --git a/templates/tofu/hetzner/main.tf b/templates/tofu/hetzner/main.tf new file mode 100644 index 00000000..b534fff6 --- /dev/null +++ b/templates/tofu/hetzner/main.tf @@ -0,0 +1,139 @@ +# Hetzner Cloud Provider Configuration +# +# This is the main OpenTofu configuration for deploying Torrust Tracker +# environments to Hetzner Cloud. +# +# Resources created: +# - SSH key: Imported from local keypair for secure access +# - Server: Hetzner Cloud server running Ubuntu with cloud-init configuration +# +# Dependencies: +# - variables.tfvars: Runtime variables (API token, server settings, SSH config) +# - cloud-init.yml: Server initialization script (rendered from template) + +terraform { + required_providers { + hcloud = { + source = "hetznercloud/hcloud" + version = "~> 1.47" + } + } + required_version = ">= 1.0" +} + +# Configure the Hetzner Cloud provider with the API token from variables +provider "hcloud" { + token = var.hcloud_api_token +} + +# ============================================================================ +# Variables +# ============================================================================ + +variable "hcloud_api_token" { + description = "Hetzner Cloud API token for authentication" + type = string + sensitive = true +} + +variable "ssh_public_key" { + description = "Public SSH key content for server access" + type = string +} + +variable "ssh_key_name" { + description = "Name for the SSH key resource in Hetzner Cloud" + type = string +} + +variable "server_name" { + description = "Name for the server instance" + type = string +} + +variable "server_type" { + description = "Hetzner Cloud server type (e.g., cx22, cx32)" + type = string +} + +variable "server_image" { + description = "Operating system image for the server" + type = string + default = "ubuntu-24.04" +} + +variable "server_location" { + description = "Hetzner Cloud datacenter location (e.g., nbg1, fsn1, hel1)" + type = string +} + +variable "server_labels" { + description = "Labels to apply to the server for organization" + type = map(string) + default = {} +} + +# ============================================================================ +# Resources +# ============================================================================ + +# Create or import the SSH key for server access +resource "hcloud_ssh_key" "torrust" { + name = var.ssh_key_name + public_key = var.ssh_public_key +} + +# Create the Hetzner Cloud server +resource "hcloud_server" "torrust" { + name = var.server_name + image = var.server_image + server_type = var.server_type + location = var.server_location + labels = var.server_labels + + ssh_keys = [ + hcloud_ssh_key.torrust.id + ] + + # Cloud-init configuration for initial server setup + user_data = file("${path.module}/cloud-init.yml") + + # Ensure SSH key is created before the server + depends_on = [hcloud_ssh_key.torrust] +} + +# ============================================================================ +# Outputs +# ============================================================================ + +# IMPORTANT: This output is parsed by src/adapters/tofu/json_parser.rs +# The output name "instance_info" and all fields (name, image, status, ip_address) +# are required by the parser and must remain present with these exact names. +output "instance_info" { + description = "Information about the created server" + value = { + name = hcloud_server.torrust.name + image = hcloud_server.torrust.image + status = hcloud_server.torrust.status + ip_address = hcloud_server.torrust.ipv4_address + } + depends_on = [hcloud_server.torrust] +} + +output "connection_commands" { + description = "Commands to connect to the server" + value = [ + "ssh ${var.server_name}@${hcloud_server.torrust.ipv4_address}", + "hcloud server ssh ${var.server_name}" + ] +} + +output "test_commands" { + description = "Commands to test the server functionality" + value = [ + "hcloud server describe ${var.server_name}", + "hcloud server list", + "ssh ${var.server_name}@${hcloud_server.torrust.ipv4_address} 'cat /etc/os-release'", + "ssh ${var.server_name}@${hcloud_server.torrust.ipv4_address} 'cloud-init status'" + ] +} diff --git a/templates/tofu/hetzner/variables.tfvars.tera b/templates/tofu/hetzner/variables.tfvars.tera new file mode 100644 index 00000000..bf3a6ac6 --- /dev/null +++ b/templates/tofu/hetzner/variables.tfvars.tera @@ -0,0 +1,35 @@ +# Hetzner Cloud Variables Template +# +# This Tera template generates the variables.tfvars file for Hetzner Cloud +# deployments. The template uses double curly braces for Tera variable +# substitution: {{ variable_name }} +# +# Required template variables: +# - hcloud_api_token: Hetzner Cloud API token (sensitive) +# - ssh_public_key_content: Content of the SSH public key +# - instance_name: Name for the server and SSH key resources +# - server_type: Hetzner server type (e.g., cx22) +# - server_location: Datacenter location (e.g., nbg1) +# +# Optional template variables: +# - server_image: OS image (defaults to ubuntu-24.04) + +# Hetzner Cloud API authentication +hcloud_api_token = "{{ hcloud_api_token }}" + +# SSH key configuration +ssh_public_key = "{{ ssh_public_key_content }}" +ssh_key_name = "{{ instance_name }}-ssh-key" + +# Server configuration +server_name = "{{ instance_name }}" +server_type = "{{ server_type }}" +server_image = "{{ server_image }}" +server_location = "{{ server_location }}" + +# Server labels for organization and filtering +server_labels = { + environment = "torrust" + managed_by = "opentofu" + instance = "{{ instance_name }}" +} From c01d9a7dd88663892635fd91aea8aec803df1128 Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Tue, 2 Dec 2025 16:50:37 +0000 Subject: [PATCH 02/14] fix: [#212] improve Hetzner template error messages - Add HetznerVariablesRenderingFailed error variant for proper error propagation - Remove profile_name() from Environment - provider-specific data accessed via provider_config() - Update TofuTemplateRenderer to extract profile_name from ProviderConfig when needed - Add tera_error_chain() helper to display full Tera error chains including root cause - Update TemplateEngineError to include source error messages in Display The error messages now show the actual root cause: Before: 'Provider ... is not yet supported for template rendering' After: 'Failed to render Hetzner variables template: ... Variable `x` not found' --- docs/user-guide/providers/README.md | 114 +++++ docs/user-guide/providers/hetzner.md | 369 +++++++++++++++ docs/user-guide/providers/lxd.md | 424 ++++++++++++++++++ project-words.txt | 4 +- .../command_handlers/provision/handler.rs | 1 - src/domain/environment/mod.rs | 19 - src/domain/template/engine.rs | 30 +- .../tofu/template/renderer/mod.rs | 102 +++-- src/testing/e2e/container.rs | 4 +- src/testing/e2e/context.rs | 1 - templates/tofu/hetzner/variables.tfvars.tera | 2 +- 11 files changed, 993 insertions(+), 77 deletions(-) create mode 100644 docs/user-guide/providers/README.md create mode 100644 docs/user-guide/providers/hetzner.md create mode 100644 docs/user-guide/providers/lxd.md diff --git a/docs/user-guide/providers/README.md b/docs/user-guide/providers/README.md new file mode 100644 index 00000000..c28cfa4f --- /dev/null +++ b/docs/user-guide/providers/README.md @@ -0,0 +1,114 @@ +# Provider Guides + +This directory contains guides for deploying Torrust Tracker infrastructure to different cloud providers and virtualization platforms. + +## Available Providers + +| Provider | Status | Description | +| --------------------------- | --------- | ------------------------------------------ | +| [LXD](lxd.md) | ✅ Stable | Local development using LXD containers/VMs | +| [Hetzner Cloud](hetzner.md) | 🆕 New | Cost-effective European cloud provider | + +## Choosing a Provider + +### LXD (Local Development) + +**Best for**: + +- Local development and testing +- Learning the deployment workflow +- CI/CD pipelines +- No cloud costs + +**Requirements**: + +- Linux system with LXD installed +- Local storage for VMs + +### Hetzner Cloud (Production) + +**Best for**: + +- Production deployments +- European hosting requirements +- Cost-sensitive projects +- Simple, predictable pricing + +**Requirements**: + +- Hetzner Cloud account +- API token with read/write access +- SSH key pair + +## Provider Configuration + +Each provider requires specific configuration in your environment JSON file: + +### LXD Configuration + +```json +{ + "provider": { + "provider": "lxd", + "profile_name": "my-profile" + } +} +``` + +### Hetzner Configuration + +```json +{ + "provider": { + "provider": "hetzner", + "api_token": "your-api-token", + "server_type": "cx22", + "location": "nbg1", + "image": "ubuntu-24.04" + } +} +``` + +## Adding New Providers + +The deployer is designed to support multiple providers. To add a new provider: + +1. Create OpenTofu templates in `templates/tofu//` +2. Add provider configuration types in `src/domain/provider/` +3. Update the template renderer for provider-specific logic +4. Add documentation in `docs/user-guide/providers/` + +See the [contributing guide](../../contributing/README.md) for more details. + +## Common Configuration + +All providers share common configuration sections: + +### Environment Section + +```json +{ + "environment": { + "name": "unique-environment-name" + } +} +``` + +### SSH Credentials Section + +```json +{ + "ssh_credentials": { + "private_key_path": "/path/to/private/key", + "public_key_path": "/path/to/public/key.pub", + "username": "torrust", + "port": 22 + } +} +``` + +## Related Documentation + +- [Quick Start Guide](../quick-start.md) - Getting started with the deployer +- [Commands Reference](../commands/README.md) - Available commands +- [Template Customization](../template-customization.md) - Customizing deployment templates diff --git a/docs/user-guide/providers/hetzner.md b/docs/user-guide/providers/hetzner.md new file mode 100644 index 00000000..051ee1c0 --- /dev/null +++ b/docs/user-guide/providers/hetzner.md @@ -0,0 +1,369 @@ +# Deploying to Hetzner Cloud + +This guide explains how to deploy Torrust Tracker infrastructure to [Hetzner Cloud](https://www.hetzner.com/cloud), a cost-effective European cloud provider. + +## Overview + +Hetzner Cloud provides affordable virtual servers (VPS) with excellent performance. The deployer uses OpenTofu to provision servers and Ansible to configure them with Docker. + +### Why Hetzner Cloud? + +- **Cost-effective**: Competitive pricing for cloud servers +- **European data centers**: Locations in Germany and Finland +- **Simple pricing**: No hidden costs, predictable billing +- **Good performance**: NVMe storage and modern hardware + +## Prerequisites + +Before deploying to Hetzner Cloud, ensure you have: + +1. **Hetzner Cloud Account**: Sign up at [hetzner.com/cloud](https://www.hetzner.com/cloud) +2. **API Token**: Generated from Hetzner Cloud Console +3. **SSH Key Pair**: For secure server access +4. **Deployer Dependencies**: OpenTofu and Ansible installed + +### Installing Dependencies + +```bash +# Verify all dependencies are installed +cargo run --bin dependency-installer check + +# Install missing dependencies +cargo run --bin dependency-installer install +``` + +## Step 1: Create a Hetzner API Token + +1. Log in to [Hetzner Cloud Console](https://console.hetzner.cloud/) +2. Select your project (or create a new one) +3. Navigate to **Security** → **API Tokens** +4. Click **Generate API Token** +5. Give it a descriptive name (e.g., "torrust-deployer") +6. Select **Read & Write** permissions +7. Click **Generate API Token** +8. **Copy the token immediately** - it won't be shown again! + +> ⚠️ **Security Warning**: Keep your API token secret. Never commit it to version control. Consider using environment variables for production deployments. + +## Step 2: Generate SSH Keys (if needed) + +If you don't have SSH keys, generate them: + +```bash +# Generate a new SSH key pair +ssh-keygen -t ed25519 -C "torrust-hetzner" -f ~/.ssh/torrust_hetzner + +# Set proper permissions +chmod 600 ~/.ssh/torrust_hetzner +chmod 644 ~/.ssh/torrust_hetzner.pub +``` + +## Step 3: Create Environment Configuration + +Create a configuration file for your Hetzner deployment: + +```bash +# Create configuration in the envs directory (git-ignored) +nano envs/my-hetzner-env.json +``` + +**Example configuration**: + +```json +{ + "environment": { + "name": "my-hetzner-env" + }, + "provider": { + "provider": "hetzner", + "api_token": "YOUR_HETZNER_API_TOKEN", + "server_type": "cx22", + "location": "nbg1", + "image": "ubuntu-24.04" + }, + "ssh_credentials": { + "private_key_path": "/home/youruser/.ssh/torrust_hetzner", + "public_key_path": "/home/youruser/.ssh/torrust_hetzner.pub", + "username": "torrust", + "port": 22 + } +} +``` + +### Configuration Fields + +| Field | Description | Example | +| ---------------------------------- | ------------------------------------- | -------------------------------- | +| `environment.name` | Unique identifier for this deployment | `my-hetzner-env` | +| `provider.provider` | Must be `"hetzner"` | `hetzner` | +| `provider.api_token` | Your Hetzner API token | `hcloud_xxx...` | +| `provider.server_type` | Server size/type | `cx22` | +| `provider.location` | Datacenter location | `nbg1` | +| `provider.image` | Operating system image | `ubuntu-24.04` | +| `ssh_credentials.private_key_path` | Path to SSH private key | `/home/user/.ssh/id_ed25519` | +| `ssh_credentials.public_key_path` | Path to SSH public key | `/home/user/.ssh/id_ed25519.pub` | +| `ssh_credentials.username` | SSH user to create | `torrust` | +| `ssh_credentials.port` | SSH port | `22` | + +### Available Server Types + +Common Hetzner Cloud server types: + +| Type | vCPUs | RAM | Storage | Use Case | +| ------- | ----- | ----- | ------- | --------------------------- | +| `cx22` | 2 | 4 GB | 40 GB | Development, small trackers | +| `cx32` | 4 | 8 GB | 80 GB | Production, medium traffic | +| `cx42` | 8 | 16 GB | 160 GB | High-traffic trackers | +| `cpx11` | 2 | 2 GB | 40 GB | Testing (AMD) | +| `cpx21` | 3 | 4 GB | 80 GB | Development (AMD) | + +> **Tip**: Start with `cx22` for development and scale up as needed. + +### Available Locations + +| Location | City | Country | +| -------- | ----------- | ---------- | +| `fsn1` | Falkenstein | Germany | +| `nbg1` | Nuremberg | Germany | +| `hel1` | Helsinki | Finland | +| `ash` | Ashburn | USA (East) | +| `hil` | Hillsboro | USA (West) | + +### Available Images + +| Image | Description | +| -------------- | ------------------------------ | +| `ubuntu-24.04` | Ubuntu 24.04 LTS (recommended) | +| `ubuntu-22.04` | Ubuntu 22.04 LTS | +| `debian-12` | Debian 12 (Bookworm) | +| `debian-11` | Debian 11 (Bullseye) | + +## Step 4: Create the Environment + +```bash +torrust-tracker-deployer create environment --env-file envs/my-hetzner-env.json +``` + +**Expected output**: + +```text +✓ Validating configuration... +✓ Creating environment structure... +✓ Environment created successfully: my-hetzner-env +``` + +## Step 5: Provision Infrastructure + +Create the Hetzner Cloud server: + +```bash +torrust-tracker-deployer provision my-hetzner-env +``` + +**Expected output**: + +```text +✓ Rendering OpenTofu templates... +✓ Initializing infrastructure... +✓ Planning infrastructure changes... +✓ Applying infrastructure... +✓ Retrieving instance information... +✓ Instance IP: 203.0.113.42 +✓ Rendering Ansible templates... +✓ Waiting for SSH connectivity... +✓ Waiting for cloud-init completion... +✓ Environment provisioned successfully +``` + +**What happens**: + +1. OpenTofu creates an SSH key in Hetzner Cloud +2. A new server is provisioned with your specifications +3. Cloud-init configures the server with your SSH key +4. The deployer waits for SSH to become available + +**Duration**: ~1-2 minutes + +## Step 6: Configure Software + +Install Docker and Docker Compose: + +```bash +torrust-tracker-deployer configure my-hetzner-env +``` + +**Expected output**: + +```text +✓ Validating prerequisites... +✓ Running Ansible playbooks... +✓ Installing Docker... +✓ Installing Docker Compose... +✓ Configuring permissions... +✓ Verifying installation... +✓ Environment configured successfully +``` + +**Duration**: ~3-5 minutes (depending on network speed) + +## Step 7: Verify Deployment + +Test that everything works: + +```bash +torrust-tracker-deployer test my-hetzner-env +``` + +**Expected output**: + +```text +✓ Validating environment state... +✓ Checking VM connectivity... +✓ Testing Docker installation... +✓ Testing Docker Compose... +✓ Verifying user permissions... +✓ Running infrastructure tests... +✓ All tests passed +``` + +## Step 8: Connect to Your Server + +SSH into your server: + +```bash +# Get the server IP from the deployment output, or: +ssh -i ~/.ssh/torrust_hetzner torrust@ +``` + +Once connected, verify Docker: + +```bash +docker --version +docker compose version +docker ps +``` + +## Step 9: Clean Up (When Done) + +Destroy the infrastructure to stop billing: + +```bash +torrust-tracker-deployer destroy my-hetzner-env +``` + +**Expected output**: + +```text +✓ Stopping containers... +✓ Destroying infrastructure... +✓ Cleaning up resources... +✓ Environment destroyed successfully +``` + +> ⚠️ **Important**: Remember to destroy resources when not in use to avoid unnecessary charges. + +## Cost Estimation + +Approximate monthly costs (as of 2024): + +| Server Type | Monthly Cost (EUR) | +| ----------- | ------------------ | +| `cx22` | ~€4.35 | +| `cx32` | ~€8.70 | +| `cx42` | ~€17.40 | + +> **Note**: Prices may vary. Check [Hetzner Cloud pricing](https://www.hetzner.com/cloud) for current rates. + +## Troubleshooting + +### API Token Invalid + +**Error**: `Failed to authenticate with Hetzner API` + +**Solution**: + +1. Verify your API token is correct +2. Ensure the token has **Read & Write** permissions +3. Check the token hasn't expired + +### SSH Connection Timeout + +**Error**: `Failed to connect via SSH` + +**Solution**: + +```bash +# Check if server is running in Hetzner Console + +# Verify firewall rules (if using Hetzner Firewall) +# Ensure port 22 is open for inbound SSH + +# Check SSH key permissions +chmod 600 ~/.ssh/your_private_key + +# Test manual SSH connection +ssh -i ~/.ssh/your_private_key -v torrust@ +``` + +### Server Creation Failed + +**Error**: `Failed to create server` + +**Possible causes**: + +1. **Quota exceeded**: Check your Hetzner project limits +2. **Invalid server type**: Verify the server type exists in your location +3. **Image not available**: Some images may not be available in all locations + +### Cloud-init Timeout + +**Error**: `Timeout waiting for cloud-init` + +**Solution**: + +```bash +# SSH into the server manually +ssh -i ~/.ssh/your_key root@ + +# Check cloud-init status +cloud-init status --wait + +# View cloud-init logs +cat /var/log/cloud-init-output.log +``` + +## Security Best Practices + +1. **Never commit API tokens**: Use environment variables or secure vaults +2. **Restrict SSH access**: Consider using Hetzner Firewall +3. **Use strong SSH keys**: Ed25519 or RSA 4096-bit minimum +4. **Regular updates**: Keep server packages updated +5. **Backups**: Enable Hetzner Cloud backups for important data + +### Using Environment Variables for API Token + +Instead of storing the token in the config file: + +```bash +# Set environment variable +export HETZNER_API_TOKEN="your-token-here" + +# In your config, use a placeholder and replace at runtime +# (This feature may be added in future versions) +``` + +## Next Steps + +After successful deployment: + +1. **Deploy Torrust Tracker**: Follow the Torrust Tracker deployment guide +2. **Configure DNS**: Point your domain to the server IP +3. **Set up TLS**: Configure SSL certificates for secure connections +4. **Monitor**: Set up monitoring and alerting + +## Related Documentation + +- [Quick Start Guide](../quick-start.md) - General deployment workflow +- [Command Reference](../commands/README.md) - Detailed command documentation +- [LXD Provider](lxd.md) - Local development with LXD +- [Template Customization](../template-customization.md) - Customize deployment templates diff --git a/docs/user-guide/providers/lxd.md b/docs/user-guide/providers/lxd.md new file mode 100644 index 00000000..1327e973 --- /dev/null +++ b/docs/user-guide/providers/lxd.md @@ -0,0 +1,424 @@ +# Deploying with LXD (Local Development) + +This guide explains how to deploy Torrust Tracker infrastructure locally using [LXD](https://canonical.com/lxd), a system container and virtual machine manager. + +## Overview + +LXD provides lightweight virtual machines that run on your local system. It's ideal for development, testing, and CI/CD pipelines where you want to test deployments without cloud costs. + +### Why LXD? + +- **Zero cloud costs**: Run everything locally +- **Fast iteration**: Quick VM creation and destruction +- **Consistent environments**: Same workflow as cloud deployments +- **CI/CD friendly**: Works in GitHub Actions and other CI systems + +## Prerequisites + +Before deploying with LXD, ensure you have: + +1. **Linux System**: LXD runs on Linux (Ubuntu, Debian, Fedora, etc.) +2. **LXD Installed**: System container manager +3. **OpenTofu**: Infrastructure as Code tool +4. **Ansible**: Configuration management +5. **SSH Key Pair**: For VM access + +### Installing Dependencies + +```bash +# Verify all dependencies are installed +cargo run --bin dependency-installer check + +# Install missing dependencies (includes LXD) +cargo run --bin dependency-installer install +``` + +## Step 1: Initialize LXD + +If LXD isn't initialized yet: + +```bash +# Quick initialization with defaults +sudo lxd init --auto + +# Add your user to the lxd group +sudo usermod -a -G lxd $USER + +# Apply group membership (or log out and back in) +newgrp lxd + +# Verify LXD is working +lxc list +``` + +### Detailed LXD Configuration (Optional) + +For custom configuration: + +```bash +sudo lxd init +``` + +Answer the prompts: + +- **Clustering**: No (for single-machine setup) +- **Storage pool**: dir (simplest) or zfs (better performance) +- **Network**: lxdbr0 (default bridge) +- **HTTPS server**: No (unless remote access needed) + +## Step 2: Generate SSH Keys (if needed) + +If you don't have SSH keys: + +```bash +# Generate a new SSH key pair +ssh-keygen -t ed25519 -C "torrust-lxd" -f ~/.ssh/torrust_lxd + +# Or use the test keys from fixtures (development only) +ls fixtures/testing_rsa* +``` + +## Step 3: Create Environment Configuration + +Create a configuration file: + +```bash +# Create configuration in the envs directory (git-ignored) +nano envs/my-local-env.json +``` + +**Example configuration**: + +```json +{ + "environment": { + "name": "my-local-env" + }, + "provider": { + "provider": "lxd", + "profile_name": "torrust-profile-local" + }, + "ssh_credentials": { + "private_key_path": "/home/youruser/.ssh/torrust_lxd", + "public_key_path": "/home/youruser/.ssh/torrust_lxd.pub", + "username": "torrust", + "port": 22 + } +} +``` + +### Configuration Fields + +| Field | Description | Example | +| ---------------------------------- | ------------------------------------- | -------------------------------- | +| `environment.name` | Unique identifier for this deployment | `my-local-env` | +| `provider.provider` | Must be `"lxd"` | `lxd` | +| `provider.profile_name` | LXD profile name (auto-created) | `torrust-profile-local` | +| `ssh_credentials.private_key_path` | Path to SSH private key | `/home/user/.ssh/id_ed25519` | +| `ssh_credentials.public_key_path` | Path to SSH public key | `/home/user/.ssh/id_ed25519.pub` | +| `ssh_credentials.username` | SSH user to create in VM | `torrust` | +| `ssh_credentials.port` | SSH port | `22` | + +## Step 4: Create the Environment + +```bash +torrust-tracker-deployer create environment --env-file envs/my-local-env.json +``` + +**Expected output**: + +```text +✓ Validating configuration... +✓ Creating environment structure... +✓ Environment created successfully: my-local-env +``` + +## Step 5: Provision Infrastructure + +Create the LXD virtual machine: + +```bash +torrust-tracker-deployer provision my-local-env +``` + +**Expected output**: + +```text +✓ Rendering OpenTofu templates... +✓ Initializing infrastructure... +✓ Planning infrastructure changes... +✓ Applying infrastructure... +✓ Retrieving instance information... +✓ Instance IP: 10.140.190.42 +✓ Rendering Ansible templates... +✓ Waiting for SSH connectivity... +✓ Waiting for cloud-init completion... +✓ Environment provisioned successfully +``` + +**What happens**: + +1. Creates an LXD profile with VM configuration +2. Provisions a virtual machine instance +3. Cloud-init configures the VM with your SSH key +4. Waits for the VM to be fully ready + +**Duration**: ~2-3 minutes + +## Step 6: Configure Software + +Install Docker and Docker Compose: + +```bash +torrust-tracker-deployer configure my-local-env +``` + +**Expected output**: + +```text +✓ Validating prerequisites... +✓ Running Ansible playbooks... +✓ Installing Docker... +✓ Installing Docker Compose... +✓ Configuring permissions... +✓ Verifying installation... +✓ Environment configured successfully +``` + +**Duration**: ~3-5 minutes + +## Step 7: Verify Deployment + +Test that everything works: + +```bash +torrust-tracker-deployer test my-local-env +``` + +**Expected output**: + +```text +✓ Validating environment state... +✓ Checking VM connectivity... +✓ Testing Docker installation... +✓ Testing Docker Compose... +✓ Verifying user permissions... +✓ Running infrastructure tests... +✓ All tests passed +``` + +## Step 8: Connect to Your VM + +### Option 1: SSH (Recommended) + +```bash +# Get the VM IP from the deployment output, or: +lxc list my-local-env + +# Connect via SSH +ssh -i ~/.ssh/torrust_lxd torrust@ +``` + +### Option 2: LXC Console + +```bash +# Direct console access +lxc exec torrust-tracker-vm-my-local-env -- bash +``` + +Once connected, verify Docker: + +```bash +docker --version +docker compose version +docker ps +``` + +## Step 9: Clean Up + +Destroy the infrastructure when done: + +```bash +torrust-tracker-deployer destroy my-local-env +``` + +**Expected output**: + +```text +✓ Stopping containers... +✓ Destroying infrastructure... +✓ Cleaning up resources... +✓ Environment destroyed successfully +``` + +## Managing LXD Resources + +### List Resources + +```bash +# List all instances +lxc list + +# List profiles +lxc profile list + +# List images +lxc image list +``` + +### Manual Cleanup + +If you need to manually clean up: + +```bash +# Delete an instance +lxc delete --force + +# Delete a profile +lxc profile delete +``` + +## Troubleshooting + +### LXD Not Running + +**Error**: `Failed to connect to LXD` + +**Solution**: + +```bash +# Check LXD status +sudo systemctl status snap.lxd.daemon + +# Restart LXD +sudo systemctl restart snap.lxd.daemon + +# Or if installed via apt +sudo systemctl restart lxd +``` + +### Permission Denied + +**Error**: `Permission denied while trying to connect to LXD` + +**Solution**: + +```bash +# Add user to lxd group +sudo usermod -a -G lxd $USER + +# Apply group membership +newgrp lxd + +# Or log out and back in +``` + +### Network Issues + +**Error**: `Instance has no network` + +**Solution**: + +```bash +# Check network bridge +lxc network list + +# Recreate default bridge if needed +lxc network delete lxdbr0 +lxc network create lxdbr0 +``` + +### VM Won't Start + +**Error**: `Failed to start instance` + +**Solution**: + +```bash +# Check for resource limits +lxc config show + +# Check system resources +free -h +df -h + +# Check LXD logs +sudo journalctl -u snap.lxd.daemon -n 100 +``` + +### SSH Connection Timeout + +**Error**: `Failed to connect via SSH` + +**Solution**: + +```bash +# Check if VM is running +lxc list + +# Wait for cloud-init +lxc exec -- cloud-init status --wait + +# Check SSH service +lxc exec -- systemctl status ssh + +# Verify SSH key +lxc exec -- cat /home/torrust/.ssh/authorized_keys +``` + +## Performance Tips + +### Use ZFS Storage + +ZFS provides better performance than directory storage: + +```bash +# Create a ZFS pool (requires ZFS installed) +sudo lxd init +# Choose 'zfs' for storage backend +``` + +### Allocate More Resources + +For better VM performance: + +```bash +# Check current limits +lxc config show + +# Increase CPU (runtime) +lxc config set limits.cpu 4 + +# Increase memory (runtime) +lxc config set limits.memory 4GB +``` + +### Cache Images + +Images are cached after first use, speeding up subsequent deployments. + +## Resource Requirements + +Minimum system requirements for LXD: + +| Resource | Minimum | Recommended | +| -------- | ------------------------ | ------------- | +| RAM | 4 GB | 8+ GB | +| CPU | 2 cores | 4+ cores | +| Storage | 20 GB | 50+ GB | +| OS | Linux (any major distro) | Ubuntu 22.04+ | + +## Next Steps + +After successful deployment: + +1. **Deploy Torrust Tracker**: Follow the Torrust Tracker deployment guide +2. **Test locally**: Develop and test your tracker configuration +3. **Move to production**: Use [Hetzner](hetzner.md) or another cloud provider + +## Related Documentation + +- [Quick Start Guide](../quick-start.md) - General deployment workflow +- [Command Reference](../commands/README.md) - Detailed command documentation +- [Hetzner Provider](hetzner.md) - Cloud deployment option +- [Template Customization](../template-customization.md) - Customize deployment templates diff --git a/project-words.txt b/project-words.txt index 773f9cef..373486a5 100644 --- a/project-words.txt +++ b/project-words.txt @@ -71,6 +71,7 @@ dearmor debootstrap debuginfo derefs +distro distutils doctest doctests @@ -103,8 +104,8 @@ impls journalctl jsonlint keepalive -keypair keygen +keypair keyrings larstobi lifecycles @@ -236,6 +237,7 @@ vbqajnc viewmodel webservers writeln +youruser Émojis значение ключ diff --git a/src/application/command_handlers/provision/handler.rs b/src/application/command_handlers/provision/handler.rs index 6ed983f7..bcf36cbb 100644 --- a/src/application/command_handlers/provision/handler.rs +++ b/src/application/command_handlers/provision/handler.rs @@ -273,7 +273,6 @@ impl ProvisionCommandHandler { environment.build_dir(), environment.ssh_credentials().clone(), environment.instance_name().clone(), - environment.profile_name().clone(), environment.provider_config().clone(), )); diff --git a/src/domain/environment/mod.rs b/src/domain/environment/mod.rs index 62eae802..947c844d 100644 --- a/src/domain/environment/mod.rs +++ b/src/domain/environment/mod.rs @@ -443,25 +443,6 @@ impl Environment { &self.context.user_inputs.instance_name } - /// Returns the LXD profile name for this environment - /// - /// Returns the unique LXD profile name to ensure profile isolation - /// between different test environments. - /// - /// # Panics - /// - /// Panics if called on a non-LXD environment. - #[must_use] - pub fn profile_name(&self) -> &ProfileName { - &self - .context - .user_inputs - .provider_config() - .as_lxd() - .expect("profile_name() called on non-LXD environment") - .profile_name - } - /// Returns the provider configuration for this environment #[must_use] pub fn provider_config(&self) -> &ProviderConfig { diff --git a/src/domain/template/engine.rs b/src/domain/template/engine.rs index 03a7a031..9ab23a76 100644 --- a/src/domain/template/engine.rs +++ b/src/domain/template/engine.rs @@ -3,26 +3,50 @@ //! Provides the `TemplateEngine` struct that handles template validation and rendering with Tera. use serde::Serialize; +use std::error::Error as StdError; use tera::Tera; use thiserror::Error; +/// Extracts the full error chain from a `tera::Error` as a single string. +/// +/// Tera errors have nested sources that are important for debugging (e.g., "Variable 'x' not found"). +/// The standard Display trait only shows the outer message. This function traverses +/// the entire error chain and concatenates all messages. +fn tera_error_chain(err: &tera::Error) -> String { + let mut messages = vec![err.to_string()]; + let mut current: Option<&(dyn StdError + 'static)> = err.source(); + + while let Some(source) = current { + messages.push(source.to_string()); + current = source.source(); + } + + messages.join(" -> ") +} + /// Errors that can occur during template engine operations #[derive(Debug, Error)] pub enum TemplateEngineError { - #[error("Failed to parse template: {template_name}")] + #[error( + "Failed to parse template '{template_name}': {}", + tera_error_chain(source) + )] TemplateParse { template_name: String, #[source] source: tera::Error, }, - #[error("Failed to serialize template context")] + #[error("Failed to serialize template context: {}", tera_error_chain(source))] ContextSerialization { #[source] source: tera::Error, }, - #[error("Failed to render template: {template_name}")] + #[error( + "Failed to render template '{template_name}': {}", + tera_error_chain(source) + )] TemplateRender { template_name: String, #[source] diff --git a/src/infrastructure/external_tools/tofu/template/renderer/mod.rs b/src/infrastructure/external_tools/tofu/template/renderer/mod.rs index 5c5d31cb..4c6988f4 100644 --- a/src/infrastructure/external_tools/tofu/template/renderer/mod.rs +++ b/src/infrastructure/external_tools/tofu/template/renderer/mod.rs @@ -50,10 +50,11 @@ use thiserror::Error; use crate::adapters::ssh::credentials::SshCredentials; use crate::domain::provider::{Provider, ProviderConfig}; use crate::domain::template::{TemplateManager, TemplateManagerError}; -use crate::domain::{InstanceName, ProfileName}; +use crate::domain::InstanceName; use crate::infrastructure::external_tools::tofu::template::renderer::cloud_init::{ CloudInitTemplateError, CloudInitTemplateRenderer, }; +use crate::infrastructure::external_tools::tofu::template::wrappers::hetzner::variables::VariablesTemplateError as HetznerVariablesTemplateError; use crate::infrastructure::external_tools::tofu::template::wrappers::lxd::variables::{ VariablesContextBuilder as LxdVariablesContextBuilder, VariablesTemplate as LxdVariablesTemplate, VariablesTemplateError as LxdVariablesTemplateError, @@ -93,13 +94,28 @@ pub enum ProvisionTemplateError { source: CloudInitTemplateError, }, - /// Failed to render variables template using collaborator - #[error("Failed to render variables template: {source}")] - VariablesRenderingFailed { + /// Failed to render LXD variables template + #[error("Failed to render LXD variables template: {source}")] + LxdVariablesRenderingFailed { #[source] source: LxdVariablesTemplateError, }, + /// Failed to render Hetzner variables template + #[error("Failed to render Hetzner variables template: {source}")] + HetznerVariablesRenderingFailed { + #[source] + source: HetznerVariablesTemplateError, + }, + + /// Failed to build Hetzner template context + #[error("Failed to build Hetzner template context: {message}")] + HetznerContextBuildFailed { message: String }, + + /// Provider configuration mismatch + #[error("Provider configuration mismatch: expected {expected} provider but got different configuration")] + ProviderConfigMismatch { expected: String }, + /// Provider not supported for this operation #[error("Provider '{provider}' is not yet supported for template rendering")] UnsupportedProvider { provider: String }, @@ -120,8 +136,17 @@ impl crate::shared::Traceable for ProvisionTemplateError { Self::CloudInitRenderingFailed { .. } => { "ProvisionTemplateError: Cloud-init template rendering failed".to_string() } - Self::VariablesRenderingFailed { .. } => { - "ProvisionTemplateError: Variables template rendering failed".to_string() + Self::LxdVariablesRenderingFailed { .. } => { + "ProvisionTemplateError: LXD variables template rendering failed".to_string() + } + Self::HetznerVariablesRenderingFailed { .. } => { + "ProvisionTemplateError: Hetzner variables template rendering failed".to_string() + } + Self::HetznerContextBuildFailed { message } => { + format!("ProvisionTemplateError: Hetzner context build failed: {message}") + } + Self::ProviderConfigMismatch { expected } => { + format!("ProvisionTemplateError: Expected {expected} provider configuration") } Self::UnsupportedProvider { provider } => { format!("ProvisionTemplateError: Provider '{provider}' is not yet supported") @@ -153,7 +178,6 @@ pub struct TofuTemplateRenderer { ssh_credentials: SshCredentials, cloud_init_renderer: CloudInitTemplateRenderer, instance_name: InstanceName, - profile_name: ProfileName, provider: Provider, provider_config: ProviderConfig, } @@ -167,14 +191,14 @@ impl TofuTemplateRenderer { /// * `build_dir` - The destination directory where templates will be rendered /// * `ssh_credentials` - The SSH credentials for injecting public key into cloud-init /// * `instance_name` - The name of the instance to be created (for template rendering) - /// * `profile_name` - The name of the LXD profile to be created (for template rendering) /// * `provider_config` - The provider configuration containing provider type and settings + /// + /// Note: For LXD provider, the profile name is extracted from `provider_config`. pub fn new>( template_manager: Arc, build_dir: P, ssh_credentials: SshCredentials, instance_name: InstanceName, - profile_name: ProfileName, provider_config: ProviderConfig, ) -> Self { let provider = provider_config.provider(); @@ -187,7 +211,6 @@ impl TofuTemplateRenderer { ssh_credentials, cloud_init_renderer, instance_name, - profile_name, provider, provider_config, } @@ -470,12 +493,19 @@ impl TofuTemplateRenderer { template_file: &crate::domain::template::file::File, destination_dir: &Path, ) -> Result<(), ProvisionTemplateError> { + // Get LXD config (profile_name is LXD-specific) + let lxd_config = self.provider_config.as_lxd().ok_or_else(|| { + ProvisionTemplateError::ProviderConfigMismatch { + expected: "LXD".to_string(), + } + })?; + // Build LXD context for template rendering let context = LxdVariablesContextBuilder::new() .with_instance_name(self.instance_name.clone()) - .with_profile_name(self.profile_name.clone()) + .with_profile_name(lxd_config.profile_name.clone()) .build() - .map_err(|err| ProvisionTemplateError::VariablesRenderingFailed { + .map_err(|err| ProvisionTemplateError::LxdVariablesRenderingFailed { source: LxdVariablesTemplateError::TemplateEngineError { source: crate::domain::template::TemplateEngineError::ContextSerialization { source: tera::Error::msg(err.to_string()), @@ -485,13 +515,13 @@ impl TofuTemplateRenderer { // Create and render the variables template let variables_template = LxdVariablesTemplate::new(template_file, context) - .map_err(|source| ProvisionTemplateError::VariablesRenderingFailed { source })?; + .map_err(|source| ProvisionTemplateError::LxdVariablesRenderingFailed { source })?; // Write the rendered template to the destination directory let output_path = destination_dir.join("variables.tfvars"); variables_template .render(&output_path) - .map_err(|source| ProvisionTemplateError::VariablesRenderingFailed { source })?; + .map_err(|source| ProvisionTemplateError::LxdVariablesRenderingFailed { source })?; tracing::debug!("LXD variables template rendered successfully"); Ok(()) @@ -510,8 +540,8 @@ impl TofuTemplateRenderer { // Get Hetzner config let hetzner_config = self.provider_config.as_hetzner().ok_or_else(|| { - ProvisionTemplateError::UnsupportedProvider { - provider: self.provider.to_string(), + ProvisionTemplateError::ProviderConfigMismatch { + expected: "Hetzner".to_string(), } })?; @@ -533,25 +563,19 @@ impl TofuTemplateRenderer { .with_server_image(hetzner_config.image.clone()) .with_ssh_public_key_content(ssh_public_key_content.trim().to_string()) .build() - .map_err(|err| ProvisionTemplateError::UnsupportedProvider { - provider: format!("Hetzner context build failed: {err}"), + .map_err(|err| ProvisionTemplateError::HetznerContextBuildFailed { + message: err.to_string(), })?; // Create and render the variables template - let variables_template = - HetznerVariablesTemplate::new(template_file, context).map_err(|err| { - ProvisionTemplateError::UnsupportedProvider { - provider: format!("Hetzner template creation failed: {err}"), - } - })?; + let variables_template = HetznerVariablesTemplate::new(template_file, context) + .map_err(|source| ProvisionTemplateError::HetznerVariablesRenderingFailed { source })?; // Write the rendered template to the destination directory let output_path = destination_dir.join("variables.tfvars"); - variables_template.render(&output_path).map_err(|err| { - ProvisionTemplateError::UnsupportedProvider { - provider: format!("Hetzner template render failed: {err}"), - } - })?; + variables_template + .render(&output_path) + .map_err(|source| ProvisionTemplateError::HetznerVariablesRenderingFailed { source })?; tracing::debug!("Hetzner variables template rendered successfully"); Ok(()) @@ -563,6 +587,7 @@ mod tests { use super::*; use std::fs; + use crate::domain::ProfileName; use crate::shared::Username; /// Test instance name for unit tests @@ -615,7 +640,6 @@ mod tests { &build_path, ssh_credentials, test_instance_name(), - test_profile_name(), test_lxd_provider_config(), ); @@ -635,7 +659,6 @@ mod tests { &build_path, ssh_credentials, test_instance_name(), - test_profile_name(), test_lxd_provider_config(), ); let actual_path = renderer.build_opentofu_directory(); @@ -655,7 +678,6 @@ mod tests { &build_path, ssh_credentials, test_instance_name(), - test_profile_name(), test_lxd_provider_config(), ); let template_path = renderer.build_template_path("main.tf"); @@ -675,7 +697,6 @@ mod tests { &build_path, ssh_credentials, test_instance_name(), - test_profile_name(), test_lxd_provider_config(), ); @@ -706,7 +727,6 @@ mod tests { &build_path, ssh_credentials, test_instance_name(), - test_profile_name(), test_lxd_provider_config(), ); let created_path = renderer @@ -748,7 +768,6 @@ mod tests { &build_path, ssh_credentials, test_instance_name(), - test_profile_name(), test_lxd_provider_config(), ); @@ -786,7 +805,6 @@ mod tests { &build_path, ssh_credentials, test_instance_name(), - test_profile_name(), test_lxd_provider_config(), ); @@ -848,7 +866,6 @@ mod tests { temp_dir.path(), ssh_credentials, test_instance_name(), - test_profile_name(), test_lxd_provider_config(), ); @@ -879,7 +896,6 @@ mod tests { &build_path, ssh_credentials, test_instance_name(), - test_profile_name(), test_lxd_provider_config(), ); let template_path = renderer.build_template_path(""); @@ -898,7 +914,6 @@ mod tests { &build_path, ssh_credentials, test_instance_name(), - test_profile_name(), test_lxd_provider_config(), ); @@ -927,7 +942,6 @@ mod tests { &build_path, ssh_credentials, test_instance_name(), - test_profile_name(), test_lxd_provider_config(), ); @@ -956,7 +970,6 @@ mod tests { &build_path, ssh_credentials, test_instance_name(), - test_profile_name(), test_lxd_provider_config(), ); @@ -986,7 +999,6 @@ mod tests { &build_path, ssh_credentials, test_instance_name(), - test_profile_name(), test_lxd_provider_config(), ); let created_path = renderer @@ -1011,7 +1023,6 @@ mod tests { &build_path, ssh_credentials, test_instance_name(), - test_profile_name(), test_lxd_provider_config(), ); @@ -1049,7 +1060,6 @@ mod tests { temp_dir.path(), ssh_credentials, test_instance_name(), - test_profile_name(), test_lxd_provider_config(), ); @@ -1098,7 +1108,6 @@ mod tests { &build_path1, ssh_credentials1, test_instance_name(), - test_profile_name(), test_lxd_provider_config(), ); let ssh_credentials2 = create_dummy_ssh_credentials(temp_dir.path()); @@ -1107,7 +1116,6 @@ mod tests { &build_path2, ssh_credentials2, test_instance_name(), - test_profile_name(), test_lxd_provider_config(), ); @@ -1167,7 +1175,6 @@ mod tests { temp_dir.path(), ssh_credentials, test_instance_name(), - test_profile_name(), test_lxd_provider_config(), ); @@ -1227,7 +1234,6 @@ mod tests { temp_dir.path(), ssh_credentials, test_instance_name(), - test_profile_name(), test_lxd_provider_config(), ); diff --git a/src/testing/e2e/container.rs b/src/testing/e2e/container.rs index ffee7d5d..ecebd4a3 100644 --- a/src/testing/e2e/container.rs +++ b/src/testing/e2e/container.rs @@ -26,7 +26,7 @@ use crate::adapters::tofu::OpenTofuClient; use crate::config::Config; use crate::domain::provider::ProviderConfig; use crate::domain::template::TemplateManager; -use crate::domain::{InstanceName, ProfileName}; +use crate::domain::InstanceName; use crate::infrastructure::external_tools::ansible::AnsibleTemplateRenderer; use crate::infrastructure::external_tools::ansible::ANSIBLE_SUBFOLDER; use crate::infrastructure::external_tools::tofu::TofuTemplateRenderer; @@ -71,7 +71,6 @@ impl Services { config: &Config, ssh_credentials: SshCredentials, instance_name: InstanceName, - profile_name: ProfileName, provider_config: ProviderConfig, ) -> Self { // Create template manager @@ -93,7 +92,6 @@ impl Services { config.build_dir.clone(), ssh_credentials, instance_name, - profile_name, provider_config, ); diff --git a/src/testing/e2e/context.rs b/src/testing/e2e/context.rs index cb16cae0..dffabd86 100644 --- a/src/testing/e2e/context.rs +++ b/src/testing/e2e/context.rs @@ -132,7 +132,6 @@ impl TestContext { &config, environment.ssh_credentials().clone(), environment.instance_name().clone(), - environment.profile_name().clone(), environment.provider_config().clone(), ); diff --git a/templates/tofu/hetzner/variables.tfvars.tera b/templates/tofu/hetzner/variables.tfvars.tera index bf3a6ac6..7494e639 100644 --- a/templates/tofu/hetzner/variables.tfvars.tera +++ b/templates/tofu/hetzner/variables.tfvars.tera @@ -2,7 +2,7 @@ # # This Tera template generates the variables.tfvars file for Hetzner Cloud # deployments. The template uses double curly braces for Tera variable -# substitution: {{ variable_name }} +# substitution. # # Required template variables: # - hcloud_api_token: Hetzner Cloud API token (sensitive) From 18becd411dedd3253373529eff909fe3319b4956 Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Tue, 2 Dec 2025 17:01:02 +0000 Subject: [PATCH 03/14] refactor: [#212] make error messages provider-agnostic Replace LXD-specific troubleshooting commands with generic provider-neutral instructions across all error help messages. Files updated: - src/application/command_handlers/provision/errors.rs - src/application/command_handlers/destroy/errors.rs - src/application/command_handlers/configure/errors.rs - src/presentation/controllers/provision/errors.rs - src/presentation/controllers/destroy/errors.rs - src/presentation/controllers/configure/errors.rs - src/adapters/ssh/error.rs - src/bootstrap/help.rs Changes: - Remove 'lxc list', 'lxc exec', 'lxc version' commands from generic help - Replace with 'use your provider tools' or 'using provider tools' - Update paths like 'build/' to 'build//tofu/' - Keep provider-specific guidance only where it applies to that provider - Update tests to check for 'provider' instead of 'lxc list' --- src/adapters/ssh/error.rs | 6 ++-- .../command_handlers/configure/errors.rs | 2 +- .../command_handlers/destroy/errors.rs | 16 +++++------ .../command_handlers/provision/errors.rs | 28 +++++++++---------- src/bootstrap/help.rs | 12 ++++---- .../controllers/configure/errors.rs | 8 +++--- .../controllers/destroy/errors.rs | 6 ++-- .../controllers/provision/errors.rs | 15 +++++----- 8 files changed, 46 insertions(+), 47 deletions(-) diff --git a/src/adapters/ssh/error.rs b/src/adapters/ssh/error.rs index 4f46da1a..c8ca479c 100644 --- a/src/adapters/ssh/error.rs +++ b/src/adapters/ssh/error.rs @@ -66,12 +66,12 @@ impl SshError { "SSH Connectivity Timeout - Detailed Troubleshooting: 1. Verify the instance is running: - - Check VM/container status in your provider + - Check VM/server status using your provider tools - Ensure instance has finished booting (may take 30-60s) 2. Check SSH service status: - Unix/Linux/macOS: lxc exec -- systemctl status ssh - Or check console logs for cloud instances + - SSH into the server and run: systemctl status ssh + - Or check console logs for cloud instances 3. Verify network connectivity: - Ping the IP address: ping diff --git a/src/application/command_handlers/configure/errors.rs b/src/application/command_handlers/configure/errors.rs index dad8f28a..8ba2a033 100644 --- a/src/application/command_handlers/configure/errors.rs +++ b/src/application/command_handlers/configure/errors.rs @@ -81,7 +81,7 @@ impl ConfigureCommandHandlerError { 1. Verify Ansible is installed: ansible --version 2. Check instance connectivity: - - Verify instance is running: lxc list + - Verify instance is running using your provider tools - Test SSH access: ssh -i @ - Check Ansible inventory file exists and is correct diff --git a/src/application/command_handlers/destroy/errors.rs b/src/application/command_handlers/destroy/errors.rs index 4f52a02a..de27e058 100644 --- a/src/application/command_handlers/destroy/errors.rs +++ b/src/application/command_handlers/destroy/errors.rs @@ -109,22 +109,22 @@ impl DestroyCommandHandlerError { "OpenTofu Destroy Failed - Troubleshooting: 1. Check OpenTofu is installed: tofu version -2. Verify LXD is running: lxc version -3. Check if instance still exists: lxc list +2. Verify your infrastructure provider is accessible +3. Check if instance/server still exists using provider tools 4. Review OpenTofu error output above for specific issues 5. Try manually running: - cd build/ && tofu destroy + cd build//tofu/ && tofu destroy 6. Common issues: - Instance already deleted: Normal, destroy succeeds - - LXD not running: Start LXD service - - Permission denied: Check LXD group membership + - Provider not running: Start the provider service + - Permission denied: Check provider permissions - State file locked: Wait or remove .terraform.lock.hcl 7. Force removal if needed: - lxc delete --force + Use provider-specific commands to delete the instance -For LXD troubleshooting, see docs/vm-providers.md" +For provider troubleshooting, see docs/vm-providers.md" } Self::Command(_) => { "Command Execution Failed - Troubleshooting: @@ -233,7 +233,7 @@ mod tests { assert!(help.contains("OpenTofu Destroy")); assert!(help.contains("Troubleshooting")); assert!(help.contains("tofu version")); - assert!(help.contains("lxc list")); + assert!(help.contains("provider")); } #[test] diff --git a/src/application/command_handlers/provision/errors.rs b/src/application/command_handlers/provision/errors.rs index a8ab27a2..9f91fad2 100644 --- a/src/application/command_handlers/provision/errors.rs +++ b/src/application/command_handlers/provision/errors.rs @@ -160,18 +160,18 @@ For template syntax issues, see the Tera template documentation." "OpenTofu Command Failed - Troubleshooting: 1. Check OpenTofu is installed: tofu version -2. Verify LXD is running: lxc version -3. Check LXD permissions: lxc list +2. Verify your infrastructure provider is running and accessible +3. Check provider permissions and credentials 4. Review OpenTofu error output above for specific issues 5. Try manually running: - cd build/ && tofu init && tofu plan + cd build//tofu/ && tofu init && tofu plan -6. Common LXD issues: - - LXD not initialized: lxd init - - User not in lxd group: sudo usermod -aG lxd $USER (requires logout) - - LXD network not configured: lxc network list +6. Common issues: + - Provider not initialized or configured + - User permissions not configured correctly + - Network not configured properly -For LXD setup issues, see docs/vm-providers.md" +For provider-specific setup issues, see docs/vm-providers.md" } Self::Command(_) => { "Command Execution Failed - Troubleshooting: @@ -192,19 +192,19 @@ For tool installation, see the setup documentation." Self::SshConnectivity(_) => { "SSH Connectivity Failed - Troubleshooting: -1. Verify the instance is running: lxc list -2. Check instance IP address: lxc list +1. Verify the instance/server is running using your provider tools +2. Check instance IP address is accessible 3. Test SSH connectivity manually: ssh -i @ 4. Common SSH issues: - SSH key permissions: chmod 600 - SSH service not running: Check cloud-init status on instance - - Firewall blocking SSH: Check UFW or iptables rules + - Firewall blocking SSH: Check firewall rules - Wrong SSH user: Verify username in configuration 5. Check cloud-init completion: - lxc exec -- cloud-init status --wait + SSH into the server and run: cloud-init status --wait For SSH troubleshooting, see docs/debugging.md" } @@ -305,7 +305,7 @@ mod tests { assert!(help.contains("OpenTofu Command")); assert!(help.contains("Troubleshooting")); assert!(help.contains("tofu version")); - assert!(help.contains("lxc list")); + assert!(help.contains("provider")); } #[test] @@ -338,7 +338,7 @@ mod tests { let help = error.help(); assert!(help.contains("SSH Connectivity")); assert!(help.contains("Troubleshooting")); - assert!(help.contains("lxc list")); + assert!(help.contains("provider")); assert!(help.contains("cloud-init")); } diff --git a/src/bootstrap/help.rs b/src/bootstrap/help.rs index b657ee1c..200a69a0 100644 --- a/src/bootstrap/help.rs +++ b/src/bootstrap/help.rs @@ -96,17 +96,17 @@ pub fn display_troubleshooting() { println!("1. Dependencies not found:"); println!(" - Ensure OpenTofu is installed and in PATH"); println!(" - Verify Ansible is installed and accessible"); - println!(" - Check that LXD is properly configured"); + println!(" - Check that your infrastructure provider is properly configured"); println!(); println!("2. Permission errors:"); - println!(" - Add your user to the lxd group: sudo usermod -aG lxd $USER"); - println!(" - Log out and log back in to apply group changes"); - println!(" - Verify permissions with: groups"); + println!(" - Ensure you have the necessary permissions for your provider"); + println!(" - For LXD: Add your user to the lxd group"); + println!(" - For Hetzner: Verify your API token has correct permissions"); println!(); println!("3. Network connectivity issues:"); println!(" - Check internet connectivity for image downloads"); - println!(" - Verify LXD daemon is running: lxd --version"); - println!(" - Test basic LXD functionality: lxc list"); + println!(" - Verify your provider is accessible"); + println!(" - Test provider-specific connectivity"); println!(); println!("4. Configuration problems:"); println!(" - Validate YAML/JSON syntax in configuration files"); diff --git a/src/presentation/controllers/configure/errors.rs b/src/presentation/controllers/configure/errors.rs index 5f315bf5..ae96f54b 100644 --- a/src/presentation/controllers/configure/errors.rs +++ b/src/presentation/controllers/configure/errors.rs @@ -285,13 +285,13 @@ For persistent issues, check system logs and file system health." SSH connectivity: - Verify SSH keys exist: ls -la ~/.ssh/ - - Check VM is running: lxc list + - Check VM/server is running using provider tools - Test SSH manually: ssh -i @ Docker installation: - - Check if Docker is already installed: lxc exec -- docker --version - - Verify package manager: lxc exec -- apt-get update - - Check internet connectivity: lxc exec -- ping -c 3 google.com + - SSH into server and check: docker --version + - Verify package manager: apt-get update (or equivalent) + - Check internet connectivity: ping -c 3 google.com Ansible issues: - Check Ansible is installed: ansible --version diff --git a/src/presentation/controllers/destroy/errors.rs b/src/presentation/controllers/destroy/errors.rs index d037c0a7..4b18b987 100644 --- a/src/presentation/controllers/destroy/errors.rs +++ b/src/presentation/controllers/destroy/errors.rs @@ -218,13 +218,13 @@ If the environment should exist, check the logs for more details." - Look for specific error details 3. Check infrastructure state: - - Verify LXD/OpenTofu are accessible - - Check if VMs/containers are running + - Verify OpenTofu and provider tools are accessible + - Check if VMs/servers are running using provider tools - Ensure cleanup tools are available 4. Manual intervention may be needed: - Some resources might need manual cleanup - - Check provider-specific tools (lxc list, tofu state list) + - Check provider-specific tools (tofu state list) - Remove stale infrastructure manually if needed 5. Recovery options: diff --git a/src/presentation/controllers/provision/errors.rs b/src/presentation/controllers/provision/errors.rs index 877b2e57..28205ec5 100644 --- a/src/presentation/controllers/provision/errors.rs +++ b/src/presentation/controllers/provision/errors.rs @@ -278,25 +278,24 @@ For persistent issues, check system logs and file system health." 2. Common failure points: - OpenTofu initialization or apply failures - - LXD/VM provisioning issues + - VM/server provisioning issues - SSH connectivity problems - Cloud-init timeout or failures 3. Infrastructure-specific troubleshooting: - OpenTofu/LXD issues: - - Check LXD status: lxc list - - Verify LXD daemon: systemctl status lxd - - Review OpenTofu state: cd build/tofu/lxd && tofu state list + OpenTofu issues: + - Review OpenTofu state: cd build//tofu/ && tofu state list + - Check OpenTofu logs in the build directory SSH connectivity: - Verify SSH keys exist: ls -la ~/.ssh/ - - Check VM is running: lxc list + - Check VM/server is running using provider tools - Test SSH manually: ssh -i @ Cloud-init: - - Check cloud-init status: lxc exec -- cloud-init status - - View cloud-init logs: lxc exec -- cat /var/log/cloud-init.log + - SSH into the server and check: cloud-init status + - View cloud-init logs: cat /var/log/cloud-init.log 4. Recovery steps: - Review error messages and logs From 23c46e7ab2e18e6a0fb7778a4e5e837cae8ef310 Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Tue, 2 Dec 2025 17:11:54 +0000 Subject: [PATCH 04/14] docs: [#212] refactor provider documentation to avoid duplication - Simplify docs/user-guide/providers/README.md to be just an index - Refactor LXD provider guide to focus only on LXD-specific config - Refactor Hetzner provider guide to focus only on Hetzner-specific config - Create new docs/tech-stack/ssh-keys.md for SSH key documentation - Link to existing tech guides instead of duplicating content - Remove duplicated command steps (already in quick-start.md) - Remove made-up command outputs --- docs/tech-stack/ssh-keys.md | 100 +++++++ docs/user-guide/providers/README.md | 91 +------ docs/user-guide/providers/hetzner.md | 302 +++------------------ docs/user-guide/providers/lxd.md | 380 ++------------------------- 4 files changed, 180 insertions(+), 693 deletions(-) create mode 100644 docs/tech-stack/ssh-keys.md diff --git a/docs/tech-stack/ssh-keys.md b/docs/tech-stack/ssh-keys.md new file mode 100644 index 00000000..ee0d4351 --- /dev/null +++ b/docs/tech-stack/ssh-keys.md @@ -0,0 +1,100 @@ +# SSH Keys + +SSH key pairs are used to securely authenticate with provisioned VMs without passwords. + +## Overview + +The deployer uses SSH keys for: + +- Secure access to provisioned instances +- Running Ansible playbooks for configuration +- Executing remote commands via the test command + +## Generate SSH Keys + +If you don't already have SSH keys: + +```bash +# Generate a new SSH key pair (Ed25519 recommended) +ssh-keygen -t ed25519 -C "torrust-deployer" -f ~/.ssh/torrust_deployer + +# Set proper permissions +chmod 600 ~/.ssh/torrust_deployer +chmod 644 ~/.ssh/torrust_deployer.pub +``` + +For RSA keys (if Ed25519 is not supported): + +```bash +ssh-keygen -t rsa -b 4096 -C "torrust-deployer" -f ~/.ssh/torrust_deployer +``` + +## Key Permissions + +SSH requires strict file permissions: + +```bash +# Private key: owner read/write only +chmod 600 ~/.ssh/your_private_key + +# Public key: owner read/write, others read +chmod 644 ~/.ssh/your_private_key.pub + +# SSH directory +chmod 700 ~/.ssh +``` + +## Configuration + +Reference your keys in the environment configuration: + +```json +{ + "ssh_credentials": { + "private_key_path": "/home/youruser/.ssh/torrust_deployer", + "public_key_path": "/home/youruser/.ssh/torrust_deployer.pub", + "username": "torrust", + "port": 22 + } +} +``` + +## Development Keys + +For local development and testing, the repository includes test keys in `fixtures/`: + +```bash +fixtures/testing_rsa # Private key +fixtures/testing_rsa.pub # Public key +``` + +> ⚠️ **Warning**: Never use test keys for production deployments. + +## Best Practices + +1. **Use unique keys per project** - Don't reuse keys across different projects +2. **Never commit private keys** - Keep private keys out of version control +3. **Use passphrases for production** - Add passphrase protection for production keys +4. **Ed25519 over RSA** - Prefer Ed25519 keys for better security and performance + +## Troubleshooting + +### Permission denied (publickey) + +```bash +# Check key permissions +ls -la ~/.ssh/your_private_key + +# Should show: -rw------- (600) +chmod 600 ~/.ssh/your_private_key +``` + +### Agent forwarding + +If you need SSH agent forwarding: + +```bash +# Add key to SSH agent +eval "$(ssh-agent -s)" +ssh-add ~/.ssh/your_private_key +``` diff --git a/docs/user-guide/providers/README.md b/docs/user-guide/providers/README.md index c28cfa4f..e74fad68 100644 --- a/docs/user-guide/providers/README.md +++ b/docs/user-guide/providers/README.md @@ -1,6 +1,6 @@ # Provider Guides -This directory contains guides for deploying Torrust Tracker infrastructure to different cloud providers and virtualization platforms. +This directory contains provider-specific configuration guides. ## Available Providers @@ -13,102 +13,29 @@ This directory contains guides for deploying Torrust Tracker infrastructure to d ### LXD (Local Development) -**Best for**: +**Best for**: Local development, testing, CI/CD pipelines, zero cloud costs. -- Local development and testing -- Learning the deployment workflow -- CI/CD pipelines -- No cloud costs - -**Requirements**: - -- Linux system with LXD installed -- Local storage for VMs +**Requirements**: Linux system with LXD installed. ### Hetzner Cloud (Production) -**Best for**: - -- Production deployments -- European hosting requirements -- Cost-sensitive projects -- Simple, predictable pricing - -**Requirements**: - -- Hetzner Cloud account -- API token with read/write access -- SSH key pair - -## Provider Configuration - -Each provider requires specific configuration in your environment JSON file: - -### LXD Configuration +**Best for**: Production deployments, European hosting, cost-sensitive projects. -```json -{ - "provider": { - "provider": "lxd", - "profile_name": "my-profile" - } -} -``` - -### Hetzner Configuration - -```json -{ - "provider": { - "provider": "hetzner", - "api_token": "your-api-token", - "server_type": "cx22", - "location": "nbg1", - "image": "ubuntu-24.04" - } -} -``` +**Requirements**: Hetzner Cloud account with API token. ## Adding New Providers -The deployer is designed to support multiple providers. To add a new provider: +To add a new provider: 1. Create OpenTofu templates in `templates/tofu//` 2. Add provider configuration types in `src/domain/provider/` 3. Update the template renderer for provider-specific logic -4. Add documentation in `docs/user-guide/providers/` +4. Add documentation in `docs/user-guide/providers/.md` See the [contributing guide](../../contributing/README.md) for more details. -## Common Configuration - -All providers share common configuration sections: - -### Environment Section - -```json -{ - "environment": { - "name": "unique-environment-name" - } -} -``` - -### SSH Credentials Section - -```json -{ - "ssh_credentials": { - "private_key_path": "/path/to/private/key", - "public_key_path": "/path/to/public/key.pub", - "username": "torrust", - "port": 22 - } -} -``` - ## Related Documentation -- [Quick Start Guide](../quick-start.md) - Getting started with the deployer +- [Quick Start Guide](../quick-start.md) - Complete deployment workflow - [Commands Reference](../commands/README.md) - Available commands -- [Template Customization](../template-customization.md) - Customizing deployment templates +- [SSH Keys](../../tech-stack/ssh-keys.md) - SSH key generation and management diff --git a/docs/user-guide/providers/hetzner.md b/docs/user-guide/providers/hetzner.md index 051ee1c0..295a95d7 100644 --- a/docs/user-guide/providers/hetzner.md +++ b/docs/user-guide/providers/hetzner.md @@ -1,114 +1,59 @@ -# Deploying to Hetzner Cloud +# Hetzner Cloud Provider -This guide explains how to deploy Torrust Tracker infrastructure to [Hetzner Cloud](https://www.hetzner.com/cloud), a cost-effective European cloud provider. +This guide covers Hetzner-specific configuration for cloud deployments. ## Overview -Hetzner Cloud provides affordable virtual servers (VPS) with excellent performance. The deployer uses OpenTofu to provision servers and Ansible to configure them with Docker. +[Hetzner Cloud](https://www.hetzner.com/cloud) provides affordable virtual servers with excellent performance. Ideal for production deployments. -### Why Hetzner Cloud? +**Why Hetzner?** -- **Cost-effective**: Competitive pricing for cloud servers -- **European data centers**: Locations in Germany and Finland -- **Simple pricing**: No hidden costs, predictable billing -- **Good performance**: NVMe storage and modern hardware +- Cost-effective pricing +- European data centers (Germany, Finland) + US locations +- Simple, predictable billing +- NVMe storage and modern hardware ## Prerequisites -Before deploying to Hetzner Cloud, ensure you have: +- Hetzner Cloud account ([sign up](https://www.hetzner.com/cloud)) +- API token with read/write permissions +- SSH key pair (see [SSH keys guide](../../tech-stack/ssh-keys.md)) -1. **Hetzner Cloud Account**: Sign up at [hetzner.com/cloud](https://www.hetzner.com/cloud) -2. **API Token**: Generated from Hetzner Cloud Console -3. **SSH Key Pair**: For secure server access -4. **Deployer Dependencies**: OpenTofu and Ansible installed - -### Installing Dependencies - -```bash -# Verify all dependencies are installed -cargo run --bin dependency-installer check - -# Install missing dependencies -cargo run --bin dependency-installer install -``` - -## Step 1: Create a Hetzner API Token +## Create API Token 1. Log in to [Hetzner Cloud Console](https://console.hetzner.cloud/) 2. Select your project (or create a new one) 3. Navigate to **Security** → **API Tokens** 4. Click **Generate API Token** -5. Give it a descriptive name (e.g., "torrust-deployer") -6. Select **Read & Write** permissions -7. Click **Generate API Token** -8. **Copy the token immediately** - it won't be shown again! - -> ⚠️ **Security Warning**: Keep your API token secret. Never commit it to version control. Consider using environment variables for production deployments. - -## Step 2: Generate SSH Keys (if needed) - -If you don't have SSH keys, generate them: - -```bash -# Generate a new SSH key pair -ssh-keygen -t ed25519 -C "torrust-hetzner" -f ~/.ssh/torrust_hetzner - -# Set proper permissions -chmod 600 ~/.ssh/torrust_hetzner -chmod 644 ~/.ssh/torrust_hetzner.pub -``` - -## Step 3: Create Environment Configuration - -Create a configuration file for your Hetzner deployment: +5. Select **Read & Write** permissions +6. **Copy the token immediately** - it won't be shown again! -```bash -# Create configuration in the envs directory (git-ignored) -nano envs/my-hetzner-env.json -``` +> ⚠️ **Security**: Never commit API tokens to version control. -**Example configuration**: +## Hetzner-Specific Configuration ```json { - "environment": { - "name": "my-hetzner-env" - }, "provider": { "provider": "hetzner", "api_token": "YOUR_HETZNER_API_TOKEN", "server_type": "cx22", "location": "nbg1", "image": "ubuntu-24.04" - }, - "ssh_credentials": { - "private_key_path": "/home/youruser/.ssh/torrust_hetzner", - "public_key_path": "/home/youruser/.ssh/torrust_hetzner.pub", - "username": "torrust", - "port": 22 } } ``` -### Configuration Fields - -| Field | Description | Example | -| ---------------------------------- | ------------------------------------- | -------------------------------- | -| `environment.name` | Unique identifier for this deployment | `my-hetzner-env` | -| `provider.provider` | Must be `"hetzner"` | `hetzner` | -| `provider.api_token` | Your Hetzner API token | `hcloud_xxx...` | -| `provider.server_type` | Server size/type | `cx22` | -| `provider.location` | Datacenter location | `nbg1` | -| `provider.image` | Operating system image | `ubuntu-24.04` | -| `ssh_credentials.private_key_path` | Path to SSH private key | `/home/user/.ssh/id_ed25519` | -| `ssh_credentials.public_key_path` | Path to SSH public key | `/home/user/.ssh/id_ed25519.pub` | -| `ssh_credentials.username` | SSH user to create | `torrust` | -| `ssh_credentials.port` | SSH port | `22` | +| Field | Description | Example | +| ------------- | ---------------------- | -------------- | +| `provider` | Must be `"hetzner"` | `hetzner` | +| `api_token` | Your Hetzner API token | `hcloud_xxx…` | +| `server_type` | Server size/type | `cx22` | +| `location` | Datacenter location | `nbg1` | +| `image` | Operating system image | `ubuntu-24.04` | ### Available Server Types -Common Hetzner Cloud server types: - | Type | vCPUs | RAM | Storage | Use Case | | ------- | ----- | ----- | ------- | --------------------------- | | `cx22` | 2 | 4 GB | 40 GB | Development, small trackers | @@ -117,8 +62,6 @@ Common Hetzner Cloud server types: | `cpx11` | 2 | 2 GB | 40 GB | Testing (AMD) | | `cpx21` | 3 | 4 GB | 80 GB | Development (AMD) | -> **Tip**: Start with `cx22` for development and scale up as needed. - ### Available Locations | Location | City | Country | @@ -138,133 +81,9 @@ Common Hetzner Cloud server types: | `debian-12` | Debian 12 (Bookworm) | | `debian-11` | Debian 11 (Bullseye) | -## Step 4: Create the Environment - -```bash -torrust-tracker-deployer create environment --env-file envs/my-hetzner-env.json -``` - -**Expected output**: - -```text -✓ Validating configuration... -✓ Creating environment structure... -✓ Environment created successfully: my-hetzner-env -``` - -## Step 5: Provision Infrastructure - -Create the Hetzner Cloud server: - -```bash -torrust-tracker-deployer provision my-hetzner-env -``` - -**Expected output**: - -```text -✓ Rendering OpenTofu templates... -✓ Initializing infrastructure... -✓ Planning infrastructure changes... -✓ Applying infrastructure... -✓ Retrieving instance information... -✓ Instance IP: 203.0.113.42 -✓ Rendering Ansible templates... -✓ Waiting for SSH connectivity... -✓ Waiting for cloud-init completion... -✓ Environment provisioned successfully -``` - -**What happens**: - -1. OpenTofu creates an SSH key in Hetzner Cloud -2. A new server is provisioned with your specifications -3. Cloud-init configures the server with your SSH key -4. The deployer waits for SSH to become available - -**Duration**: ~1-2 minutes - -## Step 6: Configure Software - -Install Docker and Docker Compose: - -```bash -torrust-tracker-deployer configure my-hetzner-env -``` - -**Expected output**: - -```text -✓ Validating prerequisites... -✓ Running Ansible playbooks... -✓ Installing Docker... -✓ Installing Docker Compose... -✓ Configuring permissions... -✓ Verifying installation... -✓ Environment configured successfully -``` - -**Duration**: ~3-5 minutes (depending on network speed) - -## Step 7: Verify Deployment - -Test that everything works: - -```bash -torrust-tracker-deployer test my-hetzner-env -``` - -**Expected output**: - -```text -✓ Validating environment state... -✓ Checking VM connectivity... -✓ Testing Docker installation... -✓ Testing Docker Compose... -✓ Verifying user permissions... -✓ Running infrastructure tests... -✓ All tests passed -``` - -## Step 8: Connect to Your Server - -SSH into your server: - -```bash -# Get the server IP from the deployment output, or: -ssh -i ~/.ssh/torrust_hetzner torrust@ -``` - -Once connected, verify Docker: - -```bash -docker --version -docker compose version -docker ps -``` - -## Step 9: Clean Up (When Done) - -Destroy the infrastructure to stop billing: - -```bash -torrust-tracker-deployer destroy my-hetzner-env -``` - -**Expected output**: - -```text -✓ Stopping containers... -✓ Destroying infrastructure... -✓ Cleaning up resources... -✓ Environment destroyed successfully -``` - -> ⚠️ **Important**: Remember to destroy resources when not in use to avoid unnecessary charges. - ## Cost Estimation -Approximate monthly costs (as of 2024): +Approximate monthly costs (check [Hetzner pricing](https://www.hetzner.com/cloud) for current rates): | Server Type | Monthly Cost (EUR) | | ----------- | ------------------ | @@ -272,7 +91,7 @@ Approximate monthly costs (as of 2024): | `cx32` | ~€8.70 | | `cx42` | ~€17.40 | -> **Note**: Prices may vary. Check [Hetzner Cloud pricing](https://www.hetzner.com/cloud) for current rates. +> ⚠️ **Important**: Remember to destroy resources when not in use to avoid charges. ## Troubleshooting @@ -280,23 +99,23 @@ Approximate monthly costs (as of 2024): **Error**: `Failed to authenticate with Hetzner API` -**Solution**: +- Verify your API token is correct +- Ensure the token has **Read & Write** permissions +- Check the token hasn't been revoked -1. Verify your API token is correct -2. Ensure the token has **Read & Write** permissions -3. Check the token hasn't expired +### Server Creation Failed -### SSH Connection Timeout +**Possible causes**: -**Error**: `Failed to connect via SSH` +- **Quota exceeded**: Check your Hetzner project limits +- **Invalid server type**: Verify the server type exists in your location +- **Image not available**: Some images may not be available in all locations -**Solution**: +### SSH Connection Timeout ```bash # Check if server is running in Hetzner Console - -# Verify firewall rules (if using Hetzner Firewall) -# Ensure port 22 is open for inbound SSH +# Verify firewall rules (if using Hetzner Firewall) - port 22 must be open # Check SSH key permissions chmod 600 ~/.ssh/your_private_key @@ -305,22 +124,8 @@ chmod 600 ~/.ssh/your_private_key ssh -i ~/.ssh/your_private_key -v torrust@ ``` -### Server Creation Failed - -**Error**: `Failed to create server` - -**Possible causes**: - -1. **Quota exceeded**: Check your Hetzner project limits -2. **Invalid server type**: Verify the server type exists in your location -3. **Image not available**: Some images may not be available in all locations - ### Cloud-init Timeout -**Error**: `Timeout waiting for cloud-init` - -**Solution**: - ```bash # SSH into the server manually ssh -i ~/.ssh/your_key root@ @@ -334,36 +139,13 @@ cat /var/log/cloud-init-output.log ## Security Best Practices -1. **Never commit API tokens**: Use environment variables or secure vaults -2. **Restrict SSH access**: Consider using Hetzner Firewall -3. **Use strong SSH keys**: Ed25519 or RSA 4096-bit minimum -4. **Regular updates**: Keep server packages updated -5. **Backups**: Enable Hetzner Cloud backups for important data - -### Using Environment Variables for API Token - -Instead of storing the token in the config file: - -```bash -# Set environment variable -export HETZNER_API_TOKEN="your-token-here" - -# In your config, use a placeholder and replace at runtime -# (This feature may be added in future versions) -``` - -## Next Steps - -After successful deployment: - -1. **Deploy Torrust Tracker**: Follow the Torrust Tracker deployment guide -2. **Configure DNS**: Point your domain to the server IP -3. **Set up TLS**: Configure SSL certificates for secure connections -4. **Monitor**: Set up monitoring and alerting +1. **Never commit API tokens** - Use environment variables or secure vaults +2. **Restrict SSH access** - Consider using Hetzner Firewall +3. **Use strong SSH keys** - Ed25519 or RSA 4096-bit minimum +4. **Regular updates** - Keep server packages updated ## Related Documentation -- [Quick Start Guide](../quick-start.md) - General deployment workflow -- [Command Reference](../commands/README.md) - Detailed command documentation -- [LXD Provider](lxd.md) - Local development with LXD -- [Template Customization](../template-customization.md) - Customize deployment templates +- [Quick Start Guide](../quick-start.md) - Complete deployment workflow +- [SSH Keys Guide](../../tech-stack/ssh-keys.md) - SSH key generation +- [LXD Provider](lxd.md) - Local development alternative diff --git a/docs/user-guide/providers/lxd.md b/docs/user-guide/providers/lxd.md index 1327e973..9ff9dfd7 100644 --- a/docs/user-guide/providers/lxd.md +++ b/docs/user-guide/providers/lxd.md @@ -1,269 +1,50 @@ -# Deploying with LXD (Local Development) +# LXD Provider -This guide explains how to deploy Torrust Tracker infrastructure locally using [LXD](https://canonical.com/lxd), a system container and virtual machine manager. +This guide covers LXD-specific configuration for deploying locally. ## Overview -LXD provides lightweight virtual machines that run on your local system. It's ideal for development, testing, and CI/CD pipelines where you want to test deployments without cloud costs. +LXD provides lightweight virtual machines that run on your local system. Ideal for development, testing, and CI/CD pipelines. -### Why LXD? +**Why LXD?** -- **Zero cloud costs**: Run everything locally -- **Fast iteration**: Quick VM creation and destruction -- **Consistent environments**: Same workflow as cloud deployments -- **CI/CD friendly**: Works in GitHub Actions and other CI systems +- Zero cloud costs +- Fast iteration +- CI/CD friendly (works in GitHub Actions) ## Prerequisites -Before deploying with LXD, ensure you have: +- LXD installed and initialized (see [LXD tech guide](../../tech-stack/lxd.md)) +- SSH key pair (see [SSH keys guide](../../tech-stack/ssh-keys.md)) -1. **Linux System**: LXD runs on Linux (Ubuntu, Debian, Fedora, etc.) -2. **LXD Installed**: System container manager -3. **OpenTofu**: Infrastructure as Code tool -4. **Ansible**: Configuration management -5. **SSH Key Pair**: For VM access - -### Installing Dependencies - -```bash -# Verify all dependencies are installed -cargo run --bin dependency-installer check - -# Install missing dependencies (includes LXD) -cargo run --bin dependency-installer install -``` - -## Step 1: Initialize LXD - -If LXD isn't initialized yet: - -```bash -# Quick initialization with defaults -sudo lxd init --auto - -# Add your user to the lxd group -sudo usermod -a -G lxd $USER - -# Apply group membership (or log out and back in) -newgrp lxd - -# Verify LXD is working -lxc list -``` - -### Detailed LXD Configuration (Optional) - -For custom configuration: - -```bash -sudo lxd init -``` - -Answer the prompts: - -- **Clustering**: No (for single-machine setup) -- **Storage pool**: dir (simplest) or zfs (better performance) -- **Network**: lxdbr0 (default bridge) -- **HTTPS server**: No (unless remote access needed) - -## Step 2: Generate SSH Keys (if needed) - -If you don't have SSH keys: - -```bash -# Generate a new SSH key pair -ssh-keygen -t ed25519 -C "torrust-lxd" -f ~/.ssh/torrust_lxd - -# Or use the test keys from fixtures (development only) -ls fixtures/testing_rsa* -``` - -## Step 3: Create Environment Configuration - -Create a configuration file: - -```bash -# Create configuration in the envs directory (git-ignored) -nano envs/my-local-env.json -``` - -**Example configuration**: +## LXD-Specific Configuration ```json { - "environment": { - "name": "my-local-env" - }, "provider": { "provider": "lxd", "profile_name": "torrust-profile-local" - }, - "ssh_credentials": { - "private_key_path": "/home/youruser/.ssh/torrust_lxd", - "public_key_path": "/home/youruser/.ssh/torrust_lxd.pub", - "username": "torrust", - "port": 22 } } ``` -### Configuration Fields - -| Field | Description | Example | -| ---------------------------------- | ------------------------------------- | -------------------------------- | -| `environment.name` | Unique identifier for this deployment | `my-local-env` | -| `provider.provider` | Must be `"lxd"` | `lxd` | -| `provider.profile_name` | LXD profile name (auto-created) | `torrust-profile-local` | -| `ssh_credentials.private_key_path` | Path to SSH private key | `/home/user/.ssh/id_ed25519` | -| `ssh_credentials.public_key_path` | Path to SSH public key | `/home/user/.ssh/id_ed25519.pub` | -| `ssh_credentials.username` | SSH user to create in VM | `torrust` | -| `ssh_credentials.port` | SSH port | `22` | - -## Step 4: Create the Environment - -```bash -torrust-tracker-deployer create environment --env-file envs/my-local-env.json -``` - -**Expected output**: - -```text -✓ Validating configuration... -✓ Creating environment structure... -✓ Environment created successfully: my-local-env -``` - -## Step 5: Provision Infrastructure - -Create the LXD virtual machine: - -```bash -torrust-tracker-deployer provision my-local-env -``` - -**Expected output**: - -```text -✓ Rendering OpenTofu templates... -✓ Initializing infrastructure... -✓ Planning infrastructure changes... -✓ Applying infrastructure... -✓ Retrieving instance information... -✓ Instance IP: 10.140.190.42 -✓ Rendering Ansible templates... -✓ Waiting for SSH connectivity... -✓ Waiting for cloud-init completion... -✓ Environment provisioned successfully -``` - -**What happens**: - -1. Creates an LXD profile with VM configuration -2. Provisions a virtual machine instance -3. Cloud-init configures the VM with your SSH key -4. Waits for the VM to be fully ready - -**Duration**: ~2-3 minutes - -## Step 6: Configure Software - -Install Docker and Docker Compose: - -```bash -torrust-tracker-deployer configure my-local-env -``` - -**Expected output**: - -```text -✓ Validating prerequisites... -✓ Running Ansible playbooks... -✓ Installing Docker... -✓ Installing Docker Compose... -✓ Configuring permissions... -✓ Verifying installation... -✓ Environment configured successfully -``` - -**Duration**: ~3-5 minutes - -## Step 7: Verify Deployment - -Test that everything works: - -```bash -torrust-tracker-deployer test my-local-env -``` - -**Expected output**: - -```text -✓ Validating environment state... -✓ Checking VM connectivity... -✓ Testing Docker installation... -✓ Testing Docker Compose... -✓ Verifying user permissions... -✓ Running infrastructure tests... -✓ All tests passed -``` - -## Step 8: Connect to Your VM +| Field | Description | Example | +| -------------- | ------------------------------- | ----------------------- | +| `provider` | Must be `"lxd"` | `lxd` | +| `profile_name` | LXD profile name (auto-created) | `torrust-profile-local` | -### Option 1: SSH (Recommended) +## LXD-Specific Operations -```bash -# Get the VM IP from the deployment output, or: -lxc list my-local-env - -# Connect via SSH -ssh -i ~/.ssh/torrust_lxd torrust@ -``` - -### Option 2: LXC Console +### Check VM Status ```bash -# Direct console access -lxc exec torrust-tracker-vm-my-local-env -- bash -``` - -Once connected, verify Docker: - -```bash -docker --version -docker compose version -docker ps +lxc list ``` -## Step 9: Clean Up - -Destroy the infrastructure when done: +### Direct Console Access ```bash -torrust-tracker-deployer destroy my-local-env -``` - -**Expected output**: - -```text -✓ Stopping containers... -✓ Destroying infrastructure... -✓ Cleaning up resources... -✓ Environment destroyed successfully -``` - -## Managing LXD Resources - -### List Resources - -```bash -# List all instances -lxc list - -# List profiles -lxc profile list - -# List images -lxc image list +lxc exec torrust-tracker-vm- -- bash ``` ### Manual Cleanup @@ -282,43 +63,20 @@ lxc profile delete ### LXD Not Running -**Error**: `Failed to connect to LXD` - -**Solution**: - ```bash # Check LXD status sudo systemctl status snap.lxd.daemon # Restart LXD sudo systemctl restart snap.lxd.daemon - -# Or if installed via apt -sudo systemctl restart lxd ``` ### Permission Denied -**Error**: `Permission denied while trying to connect to LXD` - -**Solution**: - -```bash -# Add user to lxd group -sudo usermod -a -G lxd $USER - -# Apply group membership -newgrp lxd - -# Or log out and back in -``` +See the [LXD Group Setup](../../tech-stack/lxd.md#proper-lxd-group-setup) section in the LXD tech guide. ### Network Issues -**Error**: `Instance has no network` - -**Solution**: - ```bash # Check network bridge lxc network list @@ -328,97 +86,17 @@ lxc network delete lxdbr0 lxc network create lxdbr0 ``` -### VM Won't Start - -**Error**: `Failed to start instance` - -**Solution**: - -```bash -# Check for resource limits -lxc config show - -# Check system resources -free -h -df -h - -# Check LXD logs -sudo journalctl -u snap.lxd.daemon -n 100 -``` - -### SSH Connection Timeout - -**Error**: `Failed to connect via SSH` - -**Solution**: - -```bash -# Check if VM is running -lxc list - -# Wait for cloud-init -lxc exec -- cloud-init status --wait - -# Check SSH service -lxc exec -- systemctl status ssh - -# Verify SSH key -lxc exec -- cat /home/torrust/.ssh/authorized_keys -``` - -## Performance Tips - -### Use ZFS Storage - -ZFS provides better performance than directory storage: - -```bash -# Create a ZFS pool (requires ZFS installed) -sudo lxd init -# Choose 'zfs' for storage backend -``` - -### Allocate More Resources - -For better VM performance: - -```bash -# Check current limits -lxc config show - -# Increase CPU (runtime) -lxc config set limits.cpu 4 - -# Increase memory (runtime) -lxc config set limits.memory 4GB -``` - -### Cache Images - -Images are cached after first use, speeding up subsequent deployments. - ## Resource Requirements -Minimum system requirements for LXD: - -| Resource | Minimum | Recommended | -| -------- | ------------------------ | ------------- | -| RAM | 4 GB | 8+ GB | -| CPU | 2 cores | 4+ cores | -| Storage | 20 GB | 50+ GB | -| OS | Linux (any major distro) | Ubuntu 22.04+ | - -## Next Steps - -After successful deployment: - -1. **Deploy Torrust Tracker**: Follow the Torrust Tracker deployment guide -2. **Test locally**: Develop and test your tracker configuration -3. **Move to production**: Use [Hetzner](hetzner.md) or another cloud provider +| Resource | Minimum | Recommended | +| -------- | ------- | ------------- | +| RAM | 4 GB | 8+ GB | +| CPU | 2 cores | 4+ cores | +| Storage | 20 GB | 50+ GB | +| OS | Linux | Ubuntu 22.04+ | ## Related Documentation -- [Quick Start Guide](../quick-start.md) - General deployment workflow -- [Command Reference](../commands/README.md) - Detailed command documentation -- [Hetzner Provider](hetzner.md) - Cloud deployment option -- [Template Customization](../template-customization.md) - Customize deployment templates +- [LXD Tech Guide](../../tech-stack/lxd.md) - Installation and detailed LXD operations +- [Quick Start Guide](../quick-start.md) - Complete deployment workflow +- [Hetzner Provider](hetzner.md) - Cloud deployment alternative From 9ecb304daaa5c21236493a037ee8c906c92737e3 Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Tue, 2 Dec 2025 17:27:57 +0000 Subject: [PATCH 05/14] refactor: [#212] reorganize tofu template modules by provider MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Create providers/lxd and providers/hetzner modules - Move wrappers from template/wrappers to providers/{lxd,hetzner}/wrappers - Remove empty template/wrappers directory - Update all import paths to use new providers structure New structure: template/ ├── common/renderer/ (shared template rendering) └── providers/ ├── lxd/wrappers/ (LXD-specific templates) └── hetzner/wrappers/ (Hetzner-specific templates) --- .../external_tools/tofu/template/common/mod.rs | 9 +++++++++ .../tofu/template/{ => common}/renderer/cloud_init.rs | 4 ++-- .../tofu/template/{ => common}/renderer/mod.rs | 8 ++++---- src/infrastructure/external_tools/tofu/template/mod.rs | 8 ++++---- .../tofu/template/providers/hetzner/mod.rs | 8 ++++++++ .../hetzner/wrappers}/cloud_init/context.rs | 0 .../hetzner/wrappers}/cloud_init/mod.rs | 0 .../hetzner => providers/hetzner/wrappers}/mod.rs | 0 .../hetzner/wrappers}/variables/context.rs | 2 +- .../hetzner/wrappers}/variables/mod.rs | 0 .../external_tools/tofu/template/providers/lxd/mod.rs | 8 ++++++++ .../lxd => providers/lxd/wrappers}/cloud_init/context.rs | 0 .../lxd => providers/lxd/wrappers}/cloud_init/mod.rs | 0 .../{wrappers/lxd => providers/lxd/wrappers}/mod.rs | 0 .../lxd => providers/lxd/wrappers}/variables/context.rs | 2 +- .../lxd => providers/lxd/wrappers}/variables/mod.rs | 0 .../tofu/template/{wrappers => providers}/mod.rs | 5 +++-- 17 files changed, 40 insertions(+), 14 deletions(-) create mode 100644 src/infrastructure/external_tools/tofu/template/common/mod.rs rename src/infrastructure/external_tools/tofu/template/{ => common}/renderer/cloud_init.rs (99%) rename src/infrastructure/external_tools/tofu/template/{ => common}/renderer/mod.rs (99%) create mode 100644 src/infrastructure/external_tools/tofu/template/providers/hetzner/mod.rs rename src/infrastructure/external_tools/tofu/template/{wrappers/hetzner => providers/hetzner/wrappers}/cloud_init/context.rs (100%) rename src/infrastructure/external_tools/tofu/template/{wrappers/hetzner => providers/hetzner/wrappers}/cloud_init/mod.rs (100%) rename src/infrastructure/external_tools/tofu/template/{wrappers/hetzner => providers/hetzner/wrappers}/mod.rs (100%) rename src/infrastructure/external_tools/tofu/template/{wrappers/hetzner => providers/hetzner/wrappers}/variables/context.rs (99%) rename src/infrastructure/external_tools/tofu/template/{wrappers/hetzner => providers/hetzner/wrappers}/variables/mod.rs (100%) create mode 100644 src/infrastructure/external_tools/tofu/template/providers/lxd/mod.rs rename src/infrastructure/external_tools/tofu/template/{wrappers/lxd => providers/lxd/wrappers}/cloud_init/context.rs (100%) rename src/infrastructure/external_tools/tofu/template/{wrappers/lxd => providers/lxd/wrappers}/cloud_init/mod.rs (100%) rename src/infrastructure/external_tools/tofu/template/{wrappers/lxd => providers/lxd/wrappers}/mod.rs (100%) rename src/infrastructure/external_tools/tofu/template/{wrappers/lxd => providers/lxd/wrappers}/variables/context.rs (98%) rename src/infrastructure/external_tools/tofu/template/{wrappers/lxd => providers/lxd/wrappers}/variables/mod.rs (100%) rename src/infrastructure/external_tools/tofu/template/{wrappers => providers}/mod.rs (61%) diff --git a/src/infrastructure/external_tools/tofu/template/common/mod.rs b/src/infrastructure/external_tools/tofu/template/common/mod.rs new file mode 100644 index 00000000..61c79dd0 --- /dev/null +++ b/src/infrastructure/external_tools/tofu/template/common/mod.rs @@ -0,0 +1,9 @@ +//! Common `OpenTofu` template functionality shared across providers. +//! +//! This module contains template renderers and utilities that are not +//! specific to any particular infrastructure provider. + +pub mod renderer; + +pub use renderer::cloud_init::{CloudInitTemplateError, CloudInitTemplateRenderer}; +pub use renderer::{ProvisionTemplateError, TofuTemplateRenderer}; diff --git a/src/infrastructure/external_tools/tofu/template/renderer/cloud_init.rs b/src/infrastructure/external_tools/tofu/template/common/renderer/cloud_init.rs similarity index 99% rename from src/infrastructure/external_tools/tofu/template/renderer/cloud_init.rs rename to src/infrastructure/external_tools/tofu/template/common/renderer/cloud_init.rs index 05a46aa4..c45e937f 100644 --- a/src/infrastructure/external_tools/tofu/template/renderer/cloud_init.rs +++ b/src/infrastructure/external_tools/tofu/template/common/renderer/cloud_init.rs @@ -20,7 +20,7 @@ //! ```rust //! # use std::sync::Arc; //! # use std::path::Path; -//! # use torrust_tracker_deployer_lib::infrastructure::external_tools::tofu::template::renderer::cloud_init::CloudInitTemplateRenderer; +//! # use torrust_tracker_deployer_lib::infrastructure::external_tools::tofu::template::common::renderer::cloud_init::CloudInitTemplateRenderer; //! # use torrust_tracker_deployer_lib::domain::template::TemplateManager; //! # use torrust_tracker_deployer_lib::domain::provider::Provider; //! # use torrust_tracker_deployer_lib::shared::Username; @@ -198,7 +198,7 @@ impl CloudInitTemplateRenderer { ssh_credentials: &SshCredentials, output_dir: &Path, ) -> Result<(), CloudInitTemplateError> { - use crate::infrastructure::external_tools::tofu::template::wrappers::lxd::cloud_init::{ + use crate::infrastructure::external_tools::tofu::template::providers::lxd::wrappers::cloud_init::{ CloudInitContext, CloudInitTemplate, }; diff --git a/src/infrastructure/external_tools/tofu/template/renderer/mod.rs b/src/infrastructure/external_tools/tofu/template/common/renderer/mod.rs similarity index 99% rename from src/infrastructure/external_tools/tofu/template/renderer/mod.rs rename to src/infrastructure/external_tools/tofu/template/common/renderer/mod.rs index 4c6988f4..bb89d45d 100644 --- a/src/infrastructure/external_tools/tofu/template/renderer/mod.rs +++ b/src/infrastructure/external_tools/tofu/template/common/renderer/mod.rs @@ -51,11 +51,11 @@ use crate::adapters::ssh::credentials::SshCredentials; use crate::domain::provider::{Provider, ProviderConfig}; use crate::domain::template::{TemplateManager, TemplateManagerError}; use crate::domain::InstanceName; -use crate::infrastructure::external_tools::tofu::template::renderer::cloud_init::{ +use crate::infrastructure::external_tools::tofu::template::common::renderer::cloud_init::{ CloudInitTemplateError, CloudInitTemplateRenderer, }; -use crate::infrastructure::external_tools::tofu::template::wrappers::hetzner::variables::VariablesTemplateError as HetznerVariablesTemplateError; -use crate::infrastructure::external_tools::tofu::template::wrappers::lxd::variables::{ +use crate::infrastructure::external_tools::tofu::template::providers::hetzner::wrappers::variables::VariablesTemplateError as HetznerVariablesTemplateError; +use crate::infrastructure::external_tools::tofu::template::providers::lxd::wrappers::variables::{ VariablesContextBuilder as LxdVariablesContextBuilder, VariablesTemplate as LxdVariablesTemplate, VariablesTemplateError as LxdVariablesTemplateError, }; @@ -533,7 +533,7 @@ impl TofuTemplateRenderer { template_file: &crate::domain::template::file::File, destination_dir: &Path, ) -> Result<(), ProvisionTemplateError> { - use crate::infrastructure::external_tools::tofu::template::wrappers::hetzner::variables::{ + use crate::infrastructure::external_tools::tofu::template::providers::hetzner::wrappers::variables::{ VariablesContextBuilder as HetznerVariablesContextBuilder, VariablesTemplate as HetznerVariablesTemplate, }; diff --git a/src/infrastructure/external_tools/tofu/template/mod.rs b/src/infrastructure/external_tools/tofu/template/mod.rs index b3a090d8..74a57c92 100644 --- a/src/infrastructure/external_tools/tofu/template/mod.rs +++ b/src/infrastructure/external_tools/tofu/template/mod.rs @@ -4,8 +4,8 @@ //! including specialized renderers for different types of configuration files //! and template wrappers for type-safe context management. -pub mod renderer; -pub mod wrappers; +pub mod common; +pub mod providers; -pub use renderer::cloud_init::{CloudInitTemplateError, CloudInitTemplateRenderer}; -pub use renderer::{ProvisionTemplateError, TofuTemplateRenderer}; +pub use common::renderer::cloud_init::{CloudInitTemplateError, CloudInitTemplateRenderer}; +pub use common::renderer::{ProvisionTemplateError, TofuTemplateRenderer}; diff --git a/src/infrastructure/external_tools/tofu/template/providers/hetzner/mod.rs b/src/infrastructure/external_tools/tofu/template/providers/hetzner/mod.rs new file mode 100644 index 00000000..9e13ddf3 --- /dev/null +++ b/src/infrastructure/external_tools/tofu/template/providers/hetzner/mod.rs @@ -0,0 +1,8 @@ +//! Hetzner provider-specific `OpenTofu` template functionality. +//! +//! This module contains template wrappers and utilities specific to the Hetzner Cloud provider. + +pub mod wrappers; + +pub use wrappers::cloud_init; +pub use wrappers::variables; diff --git a/src/infrastructure/external_tools/tofu/template/wrappers/hetzner/cloud_init/context.rs b/src/infrastructure/external_tools/tofu/template/providers/hetzner/wrappers/cloud_init/context.rs similarity index 100% rename from src/infrastructure/external_tools/tofu/template/wrappers/hetzner/cloud_init/context.rs rename to src/infrastructure/external_tools/tofu/template/providers/hetzner/wrappers/cloud_init/context.rs diff --git a/src/infrastructure/external_tools/tofu/template/wrappers/hetzner/cloud_init/mod.rs b/src/infrastructure/external_tools/tofu/template/providers/hetzner/wrappers/cloud_init/mod.rs similarity index 100% rename from src/infrastructure/external_tools/tofu/template/wrappers/hetzner/cloud_init/mod.rs rename to src/infrastructure/external_tools/tofu/template/providers/hetzner/wrappers/cloud_init/mod.rs diff --git a/src/infrastructure/external_tools/tofu/template/wrappers/hetzner/mod.rs b/src/infrastructure/external_tools/tofu/template/providers/hetzner/wrappers/mod.rs similarity index 100% rename from src/infrastructure/external_tools/tofu/template/wrappers/hetzner/mod.rs rename to src/infrastructure/external_tools/tofu/template/providers/hetzner/wrappers/mod.rs diff --git a/src/infrastructure/external_tools/tofu/template/wrappers/hetzner/variables/context.rs b/src/infrastructure/external_tools/tofu/template/providers/hetzner/wrappers/variables/context.rs similarity index 99% rename from src/infrastructure/external_tools/tofu/template/wrappers/hetzner/variables/context.rs rename to src/infrastructure/external_tools/tofu/template/providers/hetzner/wrappers/variables/context.rs index 1f41195c..c1acd27f 100644 --- a/src/infrastructure/external_tools/tofu/template/wrappers/hetzner/variables/context.rs +++ b/src/infrastructure/external_tools/tofu/template/providers/hetzner/wrappers/variables/context.rs @@ -18,7 +18,7 @@ //! ## Example Usage //! //! ```rust -//! use torrust_tracker_deployer_lib::infrastructure::external_tools::tofu::template::wrappers::hetzner::variables::VariablesContext; +//! use torrust_tracker_deployer_lib::infrastructure::external_tools::tofu::template::providers::hetzner::wrappers::variables::VariablesContext; //! use torrust_tracker_deployer_lib::adapters::lxd::instance::InstanceName; //! //! let context = VariablesContext::builder() diff --git a/src/infrastructure/external_tools/tofu/template/wrappers/hetzner/variables/mod.rs b/src/infrastructure/external_tools/tofu/template/providers/hetzner/wrappers/variables/mod.rs similarity index 100% rename from src/infrastructure/external_tools/tofu/template/wrappers/hetzner/variables/mod.rs rename to src/infrastructure/external_tools/tofu/template/providers/hetzner/wrappers/variables/mod.rs diff --git a/src/infrastructure/external_tools/tofu/template/providers/lxd/mod.rs b/src/infrastructure/external_tools/tofu/template/providers/lxd/mod.rs new file mode 100644 index 00000000..73f798ac --- /dev/null +++ b/src/infrastructure/external_tools/tofu/template/providers/lxd/mod.rs @@ -0,0 +1,8 @@ +//! LXD provider-specific `OpenTofu` template functionality. +//! +//! This module contains template wrappers and utilities specific to the LXD provider. + +pub mod wrappers; + +pub use wrappers::cloud_init; +pub use wrappers::variables; diff --git a/src/infrastructure/external_tools/tofu/template/wrappers/lxd/cloud_init/context.rs b/src/infrastructure/external_tools/tofu/template/providers/lxd/wrappers/cloud_init/context.rs similarity index 100% rename from src/infrastructure/external_tools/tofu/template/wrappers/lxd/cloud_init/context.rs rename to src/infrastructure/external_tools/tofu/template/providers/lxd/wrappers/cloud_init/context.rs diff --git a/src/infrastructure/external_tools/tofu/template/wrappers/lxd/cloud_init/mod.rs b/src/infrastructure/external_tools/tofu/template/providers/lxd/wrappers/cloud_init/mod.rs similarity index 100% rename from src/infrastructure/external_tools/tofu/template/wrappers/lxd/cloud_init/mod.rs rename to src/infrastructure/external_tools/tofu/template/providers/lxd/wrappers/cloud_init/mod.rs diff --git a/src/infrastructure/external_tools/tofu/template/wrappers/lxd/mod.rs b/src/infrastructure/external_tools/tofu/template/providers/lxd/wrappers/mod.rs similarity index 100% rename from src/infrastructure/external_tools/tofu/template/wrappers/lxd/mod.rs rename to src/infrastructure/external_tools/tofu/template/providers/lxd/wrappers/mod.rs diff --git a/src/infrastructure/external_tools/tofu/template/wrappers/lxd/variables/context.rs b/src/infrastructure/external_tools/tofu/template/providers/lxd/wrappers/variables/context.rs similarity index 98% rename from src/infrastructure/external_tools/tofu/template/wrappers/lxd/variables/context.rs rename to src/infrastructure/external_tools/tofu/template/providers/lxd/wrappers/variables/context.rs index 8e5916fe..a6570fb1 100644 --- a/src/infrastructure/external_tools/tofu/template/wrappers/lxd/variables/context.rs +++ b/src/infrastructure/external_tools/tofu/template/providers/lxd/wrappers/variables/context.rs @@ -13,7 +13,7 @@ //! ## Example Usage //! //! ```rust -//! use torrust_tracker_deployer_lib::infrastructure::external_tools::tofu::template::wrappers::lxd::variables::VariablesContext; +//! use torrust_tracker_deployer_lib::infrastructure::external_tools::tofu::template::providers::lxd::wrappers::variables::VariablesContext; //! use torrust_tracker_deployer_lib::adapters::lxd::instance::InstanceName; //! use torrust_tracker_deployer_lib::domain::ProfileName; //! diff --git a/src/infrastructure/external_tools/tofu/template/wrappers/lxd/variables/mod.rs b/src/infrastructure/external_tools/tofu/template/providers/lxd/wrappers/variables/mod.rs similarity index 100% rename from src/infrastructure/external_tools/tofu/template/wrappers/lxd/variables/mod.rs rename to src/infrastructure/external_tools/tofu/template/providers/lxd/wrappers/variables/mod.rs diff --git a/src/infrastructure/external_tools/tofu/template/wrappers/mod.rs b/src/infrastructure/external_tools/tofu/template/providers/mod.rs similarity index 61% rename from src/infrastructure/external_tools/tofu/template/wrappers/mod.rs rename to src/infrastructure/external_tools/tofu/template/providers/mod.rs index bed70e78..8eaaf463 100644 --- a/src/infrastructure/external_tools/tofu/template/wrappers/mod.rs +++ b/src/infrastructure/external_tools/tofu/template/providers/mod.rs @@ -1,6 +1,7 @@ -//! `OpenTofu` template wrappers +//! Provider-specific `OpenTofu` template functionality. //! -//! Organized by provider (e.g., lxd/, hetzner/) +//! This module contains template implementations that are specific to +//! individual infrastructure providers (LXD, Hetzner, etc.). //! //! Each provider has its own independent template wrappers for: //! - `cloud_init` - Cloud-init configuration templates From d86a5872396322e5b997d18fd06188aa7faf374f Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Tue, 2 Dec 2025 18:14:22 +0000 Subject: [PATCH 06/14] refactor: [#212] consolidate cloud-init wrapper into common module MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Since we use the same cloud-init template (templates/tofu/common/cloud-init.yml.tera) for all providers, there's no need for duplicate wrappers. - Move cloud_init wrapper to common/wrappers/cloud_init - Remove duplicate wrappers from providers/lxd and providers/hetzner - Update imports in cloud_init renderer to use common wrapper - Add notes in provider wrappers about shared cloud-init location This follows the one-to-one relationship between Tera templates and wrappers: - Common template → common wrapper - Provider-specific template → provider-specific wrapper --- .../tofu/template/common/mod.rs | 4 + .../template/common/renderer/cloud_init.rs | 2 +- .../wrappers/cloud_init/context.rs | 1 + .../lxd => common}/wrappers/cloud_init/mod.rs | 3 +- .../tofu/template/common/wrappers/mod.rs | 11 + .../tofu/template/providers/hetzner/mod.rs | 4 +- .../hetzner/wrappers/cloud_init/context.rs | 289 ------------------ .../hetzner/wrappers/cloud_init/mod.rs | 229 -------------- .../providers/hetzner/wrappers/mod.rs | 9 +- .../tofu/template/providers/lxd/mod.rs | 4 +- .../template/providers/lxd/wrappers/mod.rs | 9 +- 11 files changed, 31 insertions(+), 534 deletions(-) rename src/infrastructure/external_tools/tofu/template/{providers/lxd => common}/wrappers/cloud_init/context.rs (99%) rename src/infrastructure/external_tools/tofu/template/{providers/lxd => common}/wrappers/cloud_init/mod.rs (98%) create mode 100644 src/infrastructure/external_tools/tofu/template/common/wrappers/mod.rs delete mode 100644 src/infrastructure/external_tools/tofu/template/providers/hetzner/wrappers/cloud_init/context.rs delete mode 100644 src/infrastructure/external_tools/tofu/template/providers/hetzner/wrappers/cloud_init/mod.rs diff --git a/src/infrastructure/external_tools/tofu/template/common/mod.rs b/src/infrastructure/external_tools/tofu/template/common/mod.rs index 61c79dd0..50cfe9e0 100644 --- a/src/infrastructure/external_tools/tofu/template/common/mod.rs +++ b/src/infrastructure/external_tools/tofu/template/common/mod.rs @@ -4,6 +4,10 @@ //! specific to any particular infrastructure provider. pub mod renderer; +pub mod wrappers; pub use renderer::cloud_init::{CloudInitTemplateError, CloudInitTemplateRenderer}; pub use renderer::{ProvisionTemplateError, TofuTemplateRenderer}; +pub use wrappers::{ + CloudInitContext, CloudInitContextBuilder, CloudInitContextError, CloudInitTemplate, +}; diff --git a/src/infrastructure/external_tools/tofu/template/common/renderer/cloud_init.rs b/src/infrastructure/external_tools/tofu/template/common/renderer/cloud_init.rs index c45e937f..c6043e21 100644 --- a/src/infrastructure/external_tools/tofu/template/common/renderer/cloud_init.rs +++ b/src/infrastructure/external_tools/tofu/template/common/renderer/cloud_init.rs @@ -198,7 +198,7 @@ impl CloudInitTemplateRenderer { ssh_credentials: &SshCredentials, output_dir: &Path, ) -> Result<(), CloudInitTemplateError> { - use crate::infrastructure::external_tools::tofu::template::providers::lxd::wrappers::cloud_init::{ + use crate::infrastructure::external_tools::tofu::template::common::wrappers::cloud_init::{ CloudInitContext, CloudInitTemplate, }; diff --git a/src/infrastructure/external_tools/tofu/template/providers/lxd/wrappers/cloud_init/context.rs b/src/infrastructure/external_tools/tofu/template/common/wrappers/cloud_init/context.rs similarity index 99% rename from src/infrastructure/external_tools/tofu/template/providers/lxd/wrappers/cloud_init/context.rs rename to src/infrastructure/external_tools/tofu/template/common/wrappers/cloud_init/context.rs index 98251b1b..da2e5d59 100644 --- a/src/infrastructure/external_tools/tofu/template/providers/lxd/wrappers/cloud_init/context.rs +++ b/src/infrastructure/external_tools/tofu/template/common/wrappers/cloud_init/context.rs @@ -2,6 +2,7 @@ //! //! This module provides the `CloudInitContext` and builder pattern for creating //! template contexts with SSH public key information for cloud-init configuration. +//! This context is shared by all providers since the cloud-init template is the same. use serde::Serialize; use std::fs; diff --git a/src/infrastructure/external_tools/tofu/template/providers/lxd/wrappers/cloud_init/mod.rs b/src/infrastructure/external_tools/tofu/template/common/wrappers/cloud_init/mod.rs similarity index 98% rename from src/infrastructure/external_tools/tofu/template/providers/lxd/wrappers/cloud_init/mod.rs rename to src/infrastructure/external_tools/tofu/template/common/wrappers/cloud_init/mod.rs index b3f2cc5f..dde43e4f 100644 --- a/src/infrastructure/external_tools/tofu/template/providers/lxd/wrappers/cloud_init/mod.rs +++ b/src/infrastructure/external_tools/tofu/template/common/wrappers/cloud_init/mod.rs @@ -1,6 +1,7 @@ -//! Template wrapper for templates/tofu/lxd/cloud-init.yml.tera +//! Template wrapper for templates/tofu/common/cloud-init.yml.tera //! //! This template has mandatory variables that must be provided at construction time. +//! This wrapper is shared by all providers since the cloud-init template is the same. pub mod context; diff --git a/src/infrastructure/external_tools/tofu/template/common/wrappers/mod.rs b/src/infrastructure/external_tools/tofu/template/common/wrappers/mod.rs new file mode 100644 index 00000000..b4786584 --- /dev/null +++ b/src/infrastructure/external_tools/tofu/template/common/wrappers/mod.rs @@ -0,0 +1,11 @@ +//! Common `OpenTofu` template wrappers shared across providers. +//! +//! Contains template wrappers that are used by all providers. +//! +//! - `cloud_init` - templates/tofu/common/cloud-init.yml.tera (with runtime variables: `ssh_public_key`, `username`) + +pub mod cloud_init; + +pub use cloud_init::{ + CloudInitContext, CloudInitContextBuilder, CloudInitContextError, CloudInitTemplate, +}; diff --git a/src/infrastructure/external_tools/tofu/template/providers/hetzner/mod.rs b/src/infrastructure/external_tools/tofu/template/providers/hetzner/mod.rs index 9e13ddf3..17414ce2 100644 --- a/src/infrastructure/external_tools/tofu/template/providers/hetzner/mod.rs +++ b/src/infrastructure/external_tools/tofu/template/providers/hetzner/mod.rs @@ -1,8 +1,10 @@ //! Hetzner provider-specific `OpenTofu` template functionality. //! //! This module contains template wrappers and utilities specific to the Hetzner Cloud provider. +//! +//! Note: cloud-init wrapper has been moved to `common::wrappers::cloud_init` since +//! the same cloud-init template is used by all providers. pub mod wrappers; -pub use wrappers::cloud_init; pub use wrappers::variables; diff --git a/src/infrastructure/external_tools/tofu/template/providers/hetzner/wrappers/cloud_init/context.rs b/src/infrastructure/external_tools/tofu/template/providers/hetzner/wrappers/cloud_init/context.rs deleted file mode 100644 index 883ab1b6..00000000 --- a/src/infrastructure/external_tools/tofu/template/providers/hetzner/wrappers/cloud_init/context.rs +++ /dev/null @@ -1,289 +0,0 @@ -//! Context for Hetzner Cloud Init template rendering -//! -//! This module provides the `CloudInitContext` and builder pattern for creating -//! template contexts with SSH public key information for Hetzner cloud-init configuration. - -use serde::Serialize; -use std::fs; -use std::path::Path; -use thiserror::Error; - -use crate::adapters::ssh::SshPublicKey; -use crate::shared::Username; - -/// Errors that can occur when creating a `CloudInitContext` -#[derive(Error, Debug, Clone)] -pub enum CloudInitContextError { - #[error("SSH public key is required but not provided")] - MissingSshPublicKey, - - #[error("Username is required but not provided")] - MissingUsername, - - #[error("Invalid username: {0}")] - InvalidUsername(String), - - #[error("Failed to read SSH public key from file: {0}")] - SshPublicKeyReadError(String), -} - -/// Template context for Hetzner Cloud Init configuration with SSH public key and username -#[derive(Debug, Clone, Serialize)] -pub struct CloudInitContext { - /// SSH public key content to be injected into cloud-init configuration - pub ssh_public_key: SshPublicKey, - /// Username to be created in the cloud-init configuration - pub username: Username, -} - -/// Builder for `CloudInitContext` with fluent interface -#[derive(Debug, Default)] -pub struct CloudInitContextBuilder { - ssh_public_key: Option, - username: Option, -} - -impl CloudInitContextBuilder { - /// Set the SSH public key content directly - /// - /// # Errors - /// Returns an error if the SSH public key is invalid - pub fn with_ssh_public_key>( - mut self, - ssh_public_key: S, - ) -> Result { - let key = SshPublicKey::new(ssh_public_key) - .map_err(|e| CloudInitContextError::SshPublicKeyReadError(e.to_string()))?; - self.ssh_public_key = Some(key); - Ok(self) - } - - /// Set the username for the cloud-init configuration - /// - /// # Errors - /// Returns an error if the username is invalid according to Linux naming requirements - pub fn with_username>( - mut self, - username: S, - ) -> Result { - let username = Username::new(username) - .map_err(|e| CloudInitContextError::InvalidUsername(e.to_string()))?; - self.username = Some(username); - Ok(self) - } - - /// Set the SSH public key by reading from a file path - /// - /// # Errors - /// Returns an error if the file cannot be read or the SSH public key is invalid - pub fn with_ssh_public_key_from_file>( - mut self, - ssh_public_key_path: P, - ) -> Result { - let content = fs::read_to_string(ssh_public_key_path.as_ref()).map_err(|e| { - CloudInitContextError::SshPublicKeyReadError(format!( - "Failed to read SSH public key from {}: {}", - ssh_public_key_path.as_ref().display(), - e - )) - })?; - - // Trim any trailing newlines or whitespace from the SSH key and create SshPublicKey - let key = SshPublicKey::new(content.trim()) - .map_err(|e| CloudInitContextError::SshPublicKeyReadError(e.to_string()))?; - self.ssh_public_key = Some(key); - Ok(self) - } - - /// Builds the `CloudInitContext` - /// - /// # Errors - /// Returns an error if required fields are missing - pub fn build(self) -> Result { - let ssh_public_key = self - .ssh_public_key - .ok_or(CloudInitContextError::MissingSshPublicKey)?; - - let username = self - .username - .ok_or(CloudInitContextError::MissingUsername)?; - - Ok(CloudInitContext { - ssh_public_key, - username, - }) - } -} - -impl CloudInitContext { - /// Creates a new `CloudInitContext` with SSH public key content and username - /// - /// # Errors - /// Returns an error if the username is invalid according to Linux naming requirements - /// or if the SSH public key is invalid - pub fn new>( - ssh_public_key: S, - username: S, - ) -> Result { - let key = SshPublicKey::new(ssh_public_key) - .map_err(|e| CloudInitContextError::SshPublicKeyReadError(e.to_string()))?; - let username = Username::new(username) - .map_err(|e| CloudInitContextError::InvalidUsername(e.to_string()))?; - Ok(Self { - ssh_public_key: key, - username, - }) - } - - /// Creates a new builder for `CloudInitContext` with fluent interface - #[must_use] - pub fn builder() -> CloudInitContextBuilder { - CloudInitContextBuilder::default() - } - - /// Get the SSH public key content - #[must_use] - pub fn ssh_public_key(&self) -> &str { - self.ssh_public_key.as_str() - } - - /// Get the username - #[must_use] - pub fn username(&self) -> &str { - self.username.as_str() - } -} - -#[cfg(test)] -mod tests { - use super::*; - use std::fs; - use tempfile::TempDir; - - #[test] - fn it_should_create_cloud_init_context_with_ssh_key() { - let ssh_key = "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQC... test@example.com"; - let username = "testuser"; - let context = CloudInitContext::new(ssh_key, username).unwrap(); - - assert_eq!(context.ssh_public_key(), ssh_key); - assert_eq!(context.username(), username); - } - - #[test] - fn it_should_build_context_with_builder_pattern() { - let ssh_key = "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQC... test@example.com"; - let username = "testuser"; - let context = CloudInitContext::builder() - .with_ssh_public_key(ssh_key) - .unwrap() - .with_username(username) - .unwrap() - .build() - .unwrap(); - - assert_eq!(context.ssh_public_key(), ssh_key); - assert_eq!(context.username(), username); - } - - #[test] - fn it_should_read_ssh_key_from_file() { - let temp_dir = TempDir::new().unwrap(); - let key_file = temp_dir.path().join("test_key.pub"); - let ssh_key = "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQC... test@example.com\n"; - let username = "testuser"; - - fs::write(&key_file, ssh_key).unwrap(); - - let context = CloudInitContext::builder() - .with_ssh_public_key_from_file(&key_file) - .unwrap() - .with_username(username) - .unwrap() - .build() - .unwrap(); - - // Should trim the trailing newline - assert_eq!(context.ssh_public_key(), ssh_key.trim()); - assert_eq!(context.username(), username); - } - - #[test] - fn it_should_fail_when_ssh_key_is_missing() { - let result = CloudInitContext::builder().build(); - - assert!(result.is_err()); - assert!(matches!( - result.unwrap_err(), - CloudInitContextError::MissingSshPublicKey - )); - } - - #[test] - fn it_should_fail_when_username_is_missing() { - let ssh_key = "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQC... test@example.com"; - let result = CloudInitContext::builder() - .with_ssh_public_key(ssh_key) - .unwrap() - .build(); - - assert!(result.is_err()); - assert!(matches!( - result.unwrap_err(), - CloudInitContextError::MissingUsername - )); - } - - #[test] - fn it_should_fail_when_ssh_key_file_does_not_exist() { - let result = - CloudInitContext::builder().with_ssh_public_key_from_file("/nonexistent/path/key.pub"); - - assert!(result.is_err()); - assert!(matches!( - result.unwrap_err(), - CloudInitContextError::SshPublicKeyReadError(_) - )); - } - - #[test] - fn it_should_serialize_to_json() { - let ssh_key = "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQC... test@example.com"; - let username = "testuser"; - let context = CloudInitContext::new(ssh_key, username).unwrap(); - - let json = serde_json::to_value(&context).unwrap(); - assert_eq!(json["ssh_public_key"], ssh_key); - assert_eq!(json["username"], username); - } - - #[test] - fn it_should_fail_with_invalid_username() { - let ssh_key = "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQC... test@example.com"; - let invalid_username = "123invalid"; // starts with digit - - let result = CloudInitContext::new(ssh_key, invalid_username); - assert!(result.is_err()); - assert!(matches!( - result.unwrap_err(), - CloudInitContextError::InvalidUsername(_) - )); - } - - #[test] - fn it_should_fail_with_builder_when_username_is_invalid() { - let ssh_key = "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQC... test@example.com"; - let invalid_username = "@invalid"; // contains @ symbol - - let result = CloudInitContext::builder() - .with_ssh_public_key(ssh_key) - .unwrap() - .with_username(invalid_username); - - assert!(result.is_err()); - assert!(matches!( - result.unwrap_err(), - CloudInitContextError::InvalidUsername(_) - )); - } -} diff --git a/src/infrastructure/external_tools/tofu/template/providers/hetzner/wrappers/cloud_init/mod.rs b/src/infrastructure/external_tools/tofu/template/providers/hetzner/wrappers/cloud_init/mod.rs deleted file mode 100644 index 4876387c..00000000 --- a/src/infrastructure/external_tools/tofu/template/providers/hetzner/wrappers/cloud_init/mod.rs +++ /dev/null @@ -1,229 +0,0 @@ -//! Template wrapper for templates/tofu/hetzner/cloud-init.yml.tera -//! -//! This template has mandatory variables that must be provided at construction time. -//! Hetzner cloud-init is independent from LXD cloud-init, allowing provider-specific customization. - -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::{CloudInitContext, CloudInitContextBuilder, CloudInitContextError}; - -#[derive(Debug)] -pub struct CloudInitTemplate { - context: CloudInitContext, - content: String, -} - -impl CloudInitTemplate { - /// Creates a new `CloudInitTemplate`, 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 `CloudInitContext` fails, - /// which should never happen under normal circumstances. - pub fn new( - template_file: &File, - cloud_init_context: CloudInitContext, - ) -> Result { - let mut engine = crate::domain::template::TemplateEngine::new(); - - let validated_content = engine.render( - template_file.filename(), - template_file.content(), - &cloud_init_context, - )?; - - Ok(Self { - context: cloud_init_context, - content: validated_content, - }) - } - - /// Get the SSH public key value - #[must_use] - pub fn ssh_public_key(&self) -> &str { - self.context.ssh_public_key() - } - - /// 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::*; - - /// Helper function to create a `CloudInitContext` with given SSH key - fn create_cloud_init_context(ssh_key: &str) -> CloudInitContext { - CloudInitContext::builder() - .with_ssh_public_key(ssh_key) - .unwrap() - .with_username("testuser") - .unwrap() - .build() - .unwrap() - } - - #[test] - fn it_should_create_cloud_init_template_successfully() { - // Use template content directly instead of file - let template_content = - "#cloud-config\nusers:\n - ssh_authorized_keys:\n - {{ ssh_public_key }}\n"; - - let template_file = File::new("cloud-init.yml.tera", template_content.to_string()).unwrap(); - - let ssh_key = "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQC... test@example.com"; - let cloud_init_context = create_cloud_init_context(ssh_key); - let template = CloudInitTemplate::new(&template_file, cloud_init_context).unwrap(); - - assert_eq!(template.ssh_public_key(), ssh_key); - } - - #[test] - fn it_should_generate_cloud_init_template_context() { - // Use template content directly instead of file - let template_content = - "#cloud-config\nusers:\n - ssh_authorized_keys:\n - {{ ssh_public_key }}\n"; - - let template_file = File::new("cloud-init.yml.tera", template_content.to_string()).unwrap(); - - let ssh_key = "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQC... test@example.com"; - let cloud_init_context = create_cloud_init_context(ssh_key); - let template = CloudInitTemplate::new(&template_file, cloud_init_context).unwrap(); - - assert_eq!(template.ssh_public_key(), ssh_key); - } - - #[test] - fn it_should_accept_empty_template_content() { - // Test with empty template content - let template_file = File::new("cloud-init.yml.tera", String::new()).unwrap(); - - let ssh_key = "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQC... test@example.com"; - let cloud_init_context = create_cloud_init_context(ssh_key); - let result = CloudInitTemplate::new(&template_file, cloud_init_context); - - // Empty templates are valid in Tera - they just render as empty strings - assert!(result.is_ok()); - let template = result.unwrap(); - assert_eq!(template.content, ""); - } - - #[test] - fn it_should_work_with_missing_placeholder_variables() { - // Create template content with no placeholder variables - let template_content = "#cloud-config\nusers:\n - name: static_user\n"; - - let template_file = File::new("cloud-init.yml.tera", template_content.to_string()).unwrap(); - - let ssh_key = "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQC... test@example.com"; - let cloud_init_context = create_cloud_init_context(ssh_key); - let result = CloudInitTemplate::new(&template_file, cloud_init_context); - - // This is valid - templates don't need to use all available context variables - assert!(result.is_ok()); - let template = result.unwrap(); - assert!(template.content.contains("static_user")); - } - - #[test] - fn it_should_accept_static_template_with_no_variables() { - // Create template content with no placeholder variables at all - let template_content = "#cloud-config\npackages:\n - curl\n"; - - let template_file = File::new("cloud-init.yml.tera", template_content.to_string()).unwrap(); - - let ssh_key = "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQC... test@example.com"; - let cloud_init_context = create_cloud_init_context(ssh_key); - let result = CloudInitTemplate::new(&template_file, cloud_init_context); - - // Static templates are valid - they just don't use template variables - assert!(result.is_ok()); - let template = result.unwrap(); - assert!(template.content.contains("curl")); - } - - #[test] - fn it_should_fail_when_template_references_undefined_variable() { - // Create template content that references an undefined variable - let template_content = - "#cloud-config\nusers:\n - ssh_authorized_keys:\n - {{ undefined_variable }}\n"; - - let template_file = File::new("cloud-init.yml.tera", template_content.to_string()).unwrap(); - - let ssh_key = "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQC... test@example.com"; - let cloud_init_context = create_cloud_init_context(ssh_key); - let result = CloudInitTemplate::new(&template_file, cloud_init_context); - - // This should fail because the template references an undefined variable - assert!(result.is_err()); - let error_msg = result.unwrap_err().to_string(); - assert!(error_msg.contains("Failed to render template") || error_msg.contains("template")); - } - - #[test] - fn it_should_fail_when_template_validation_fails() { - // Create template content with malformed Tera syntax - let template_content = "#cloud-config\nusers:\n - ssh_authorized_keys:\n - {{ ssh_public_key }}\nmalformed={{unclosed_var\n"; - - let template_file = File::new("cloud-init.yml.tera", template_content.to_string()).unwrap(); - - let ssh_key = "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQC... test@example.com"; - let cloud_init_context = create_cloud_init_context(ssh_key); - let result = CloudInitTemplate::new(&template_file, cloud_init_context); - - // Should fail during template validation - assert!(result.is_err()); - } - - #[test] - fn it_should_fail_when_template_has_malformed_syntax() { - // Test with different malformed template syntax - let template_content = "invalid {{{{ syntax"; - - let template_file = File::new("cloud-init.yml.tera", template_content.to_string()).unwrap(); - - let ssh_key = "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQC... test@example.com"; - let cloud_init_context = create_cloud_init_context(ssh_key); - let result = CloudInitTemplate::new(&template_file, cloud_init_context); - - assert!(result.is_err()); - } - - #[test] - fn it_should_validate_template_at_construction_time() { - // Create valid template content - let template_content = - "#cloud-config\nusers:\n - ssh_authorized_keys:\n - {{ ssh_public_key }}\n"; - - let template_file = File::new("cloud-init.yml.tera", template_content.to_string()).unwrap(); - - // Template validation happens during construction, not during render - let ssh_key = "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQC... test@example.com"; - let cloud_init_context = create_cloud_init_context(ssh_key); - let template = CloudInitTemplate::new(&template_file, cloud_init_context).unwrap(); - - // Verify that the template was pre-validated and contains rendered content - assert_eq!(template.ssh_public_key(), ssh_key); - assert!(template.content.contains(ssh_key)); - } -} diff --git a/src/infrastructure/external_tools/tofu/template/providers/hetzner/wrappers/mod.rs b/src/infrastructure/external_tools/tofu/template/providers/hetzner/wrappers/mod.rs index 9a2196c2..2fd13de9 100644 --- a/src/infrastructure/external_tools/tofu/template/providers/hetzner/wrappers/mod.rs +++ b/src/infrastructure/external_tools/tofu/template/providers/hetzner/wrappers/mod.rs @@ -2,16 +2,13 @@ //! //! Contains template wrappers for Hetzner Cloud-specific configuration files. //! -//! - `cloud_init` - templates/tofu/hetzner/cloud-init.yml.tera (with runtime variables: `ssh_public_key`, `username`) //! - `variables` - templates/tofu/hetzner/variables.tfvars.tera (with runtime variables: `hcloud_api_token`, `instance_name`, etc.) +//! +//! Note: cloud-init wrapper has been moved to `common::wrappers::cloud_init` since +//! the same cloud-init template is used by all providers. -pub mod cloud_init; pub mod variables; -pub use cloud_init::{ - CloudInitContext, CloudInitContextBuilder, CloudInitContextError, CloudInitTemplate, -}; - pub use variables::{ VariablesContext, VariablesContextBuilder, VariablesContextError, VariablesTemplate, }; diff --git a/src/infrastructure/external_tools/tofu/template/providers/lxd/mod.rs b/src/infrastructure/external_tools/tofu/template/providers/lxd/mod.rs index 73f798ac..ac342e75 100644 --- a/src/infrastructure/external_tools/tofu/template/providers/lxd/mod.rs +++ b/src/infrastructure/external_tools/tofu/template/providers/lxd/mod.rs @@ -1,8 +1,10 @@ //! LXD provider-specific `OpenTofu` template functionality. //! //! This module contains template wrappers and utilities specific to the LXD provider. +//! +//! Note: cloud-init wrapper has been moved to `common::wrappers::cloud_init` since +//! the same cloud-init template is used by all providers. pub mod wrappers; -pub use wrappers::cloud_init; pub use wrappers::variables; diff --git a/src/infrastructure/external_tools/tofu/template/providers/lxd/wrappers/mod.rs b/src/infrastructure/external_tools/tofu/template/providers/lxd/wrappers/mod.rs index e4758e3b..f21d9f4d 100644 --- a/src/infrastructure/external_tools/tofu/template/providers/lxd/wrappers/mod.rs +++ b/src/infrastructure/external_tools/tofu/template/providers/lxd/wrappers/mod.rs @@ -2,16 +2,13 @@ //! //! Contains template wrappers for LXD-specific configuration files. //! -//! - `cloud_init` - templates/tofu/lxd/cloud-init.yml.tera (with runtime variables: `ssh_public_key`) //! - `variables` - templates/tofu/lxd/variables.tfvars.tera (with runtime variables: `instance_name`) +//! +//! Note: cloud-init wrapper has been moved to `common::wrappers::cloud_init` since +//! the same cloud-init template is used by all providers. -pub mod cloud_init; pub mod variables; -pub use cloud_init::{ - CloudInitContext, CloudInitContextBuilder, CloudInitContextError, CloudInitTemplate, -}; - pub use variables::{ VariablesContext, VariablesContextBuilder, VariablesContextError, VariablesTemplate, }; From de91cafe1310b416212df020d4ba766fac246a68 Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Tue, 2 Dec 2025 18:34:09 +0000 Subject: [PATCH 07/14] refactor: [#212] extract VariablesTemplateError to common wrappers The VariablesTemplateError type was duplicated in both LXD and Hetzner provider variable wrappers. This extracts it to a shared location. - Create common/wrappers/errors.rs with shared VariablesTemplateError - Re-export VariablesTemplateError from both provider variable modules - Update common/wrappers/mod.rs to export the errors module This reduces code duplication while maintaining backward compatibility with existing imports from provider modules. --- .../tofu/template/common/wrappers/errors.rs | 25 +++++++++++++++++++ .../tofu/template/common/wrappers/mod.rs | 3 +++ .../hetzner/wrappers/variables/mod.rs | 25 ++----------------- .../providers/lxd/wrappers/variables/mod.rs | 25 ++----------------- 4 files changed, 32 insertions(+), 46 deletions(-) create mode 100644 src/infrastructure/external_tools/tofu/template/common/wrappers/errors.rs diff --git a/src/infrastructure/external_tools/tofu/template/common/wrappers/errors.rs b/src/infrastructure/external_tools/tofu/template/common/wrappers/errors.rs new file mode 100644 index 00000000..eff3f032 --- /dev/null +++ b/src/infrastructure/external_tools/tofu/template/common/wrappers/errors.rs @@ -0,0 +1,25 @@ +//! Common error types for `OpenTofu` template wrappers. +//! +//! This module provides shared error types used by template wrappers across providers. + +use thiserror::Error; + +use crate::domain::template::{FileOperationError, TemplateEngineError}; + +/// Errors that can occur during variables template operations +#[derive(Error, Debug)] +pub enum VariablesTemplateError { + /// Template engine error + #[error("Template engine error: {source}")] + TemplateEngineError { + #[from] + source: TemplateEngineError, + }, + + /// File I/O operation failed + #[error("File operation failed: {source}")] + FileOperationError { + #[from] + source: FileOperationError, + }, +} diff --git a/src/infrastructure/external_tools/tofu/template/common/wrappers/mod.rs b/src/infrastructure/external_tools/tofu/template/common/wrappers/mod.rs index b4786584..e6e4a78a 100644 --- a/src/infrastructure/external_tools/tofu/template/common/wrappers/mod.rs +++ b/src/infrastructure/external_tools/tofu/template/common/wrappers/mod.rs @@ -3,9 +3,12 @@ //! Contains template wrappers that are used by all providers. //! //! - `cloud_init` - templates/tofu/common/cloud-init.yml.tera (with runtime variables: `ssh_public_key`, `username`) +//! - `errors` - Shared error types for template wrappers pub mod cloud_init; +pub mod errors; pub use cloud_init::{ CloudInitContext, CloudInitContextBuilder, CloudInitContextError, CloudInitTemplate, }; +pub use errors::VariablesTemplateError; diff --git a/src/infrastructure/external_tools/tofu/template/providers/hetzner/wrappers/variables/mod.rs b/src/infrastructure/external_tools/tofu/template/providers/hetzner/wrappers/variables/mod.rs index 96fa4a11..b5fbb6e5 100644 --- a/src/infrastructure/external_tools/tofu/template/providers/hetzner/wrappers/variables/mod.rs +++ b/src/infrastructure/external_tools/tofu/template/providers/hetzner/wrappers/variables/mod.rs @@ -9,33 +9,12 @@ pub mod context; use std::path::Path; -use thiserror::Error; use crate::domain::template::file::File; -use crate::domain::template::{ - write_file_with_dir_creation, FileOperationError, TemplateEngine, TemplateEngineError, -}; - +use crate::domain::template::{write_file_with_dir_creation, TemplateEngine}; +pub use crate::infrastructure::external_tools::tofu::template::common::wrappers::VariablesTemplateError; pub use context::{VariablesContext, VariablesContextBuilder, VariablesContextError}; -/// Errors that can occur during Hetzner variables template operations -#[derive(Error, Debug)] -pub enum VariablesTemplateError { - /// Template engine error - #[error("Template engine error: {source}")] - TemplateEngineError { - #[from] - source: TemplateEngineError, - }, - - /// File I/O operation failed - #[error("File operation failed: {source}")] - FileOperationError { - #[from] - source: FileOperationError, - }, -} - /// Template wrapper for Hetzner Cloud `OpenTofu` variables rendering /// /// Validates and renders `variables.tfvars.tera` templates with `VariablesContext` diff --git a/src/infrastructure/external_tools/tofu/template/providers/lxd/wrappers/variables/mod.rs b/src/infrastructure/external_tools/tofu/template/providers/lxd/wrappers/variables/mod.rs index 1abc1e6e..d1309e7c 100644 --- a/src/infrastructure/external_tools/tofu/template/providers/lxd/wrappers/variables/mod.rs +++ b/src/infrastructure/external_tools/tofu/template/providers/lxd/wrappers/variables/mod.rs @@ -9,33 +9,12 @@ pub mod context; use std::path::Path; -use thiserror::Error; use crate::domain::template::file::File; -use crate::domain::template::{ - write_file_with_dir_creation, FileOperationError, TemplateEngine, TemplateEngineError, -}; - +use crate::domain::template::{write_file_with_dir_creation, TemplateEngine}; +pub use crate::infrastructure::external_tools::tofu::template::common::wrappers::VariablesTemplateError; pub use context::{VariablesContext, VariablesContextBuilder, VariablesContextError}; -/// Errors that can occur during variables template operations -#[derive(Error, Debug)] -pub enum VariablesTemplateError { - /// Template engine error - #[error("Template engine error: {source}")] - TemplateEngineError { - #[from] - source: TemplateEngineError, - }, - - /// File I/O operation failed - #[error("File operation failed: {source}")] - FileOperationError { - #[from] - source: FileOperationError, - }, -} - /// Template wrapper for `OpenTofu` variables rendering /// /// Validates and renders `variables.tfvars.tera` templates with `VariablesContext` From 1c5e0e3b5d348db6090ce1765abb5f133ace5b0a Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Tue, 2 Dec 2025 18:58:48 +0000 Subject: [PATCH 08/14] refactor: [#212] extract TofuTemplateRenderer to dedicated module Extract TofuTemplateRenderer and rename ProvisionTemplateError to TofuTemplateRendererError for better naming alignment. Changes: - Create tofu_template_renderer.rs with TofuTemplateRenderer struct and TofuTemplateRendererError enum (moved from mod.rs) - Rename ProvisionTemplateError to TofuTemplateRendererError - Add deprecated type alias for backward compatibility - Update all imports to use the new error type name - Move all unit tests to the new module file The rename follows Rust conventions where error types are named after the type that produces them (TofuTemplateRenderer -> TofuTemplateRendererError). --- .../command_handlers/provision/errors.rs | 12 +- .../provision/tests/integration.rs | 4 +- .../steps/rendering/opentofu_templates.rs | 6 +- src/infrastructure/external_tools/tofu/mod.rs | 6 +- .../tofu/template/common/mod.rs | 6 +- .../tofu/template/common/renderer/mod.rs | 1216 +--------------- .../common/renderer/tofu_template_renderer.rs | 1227 +++++++++++++++++ .../external_tools/tofu/template/mod.rs | 6 +- 8 files changed, 1264 insertions(+), 1219 deletions(-) create mode 100644 src/infrastructure/external_tools/tofu/template/common/renderer/tofu_template_renderer.rs diff --git a/src/application/command_handlers/provision/errors.rs b/src/application/command_handlers/provision/errors.rs index 9f91fad2..a29026ed 100644 --- a/src/application/command_handlers/provision/errors.rs +++ b/src/application/command_handlers/provision/errors.rs @@ -5,14 +5,14 @@ use crate::adapters::tofu::client::OpenTofuError; use crate::application::services::AnsibleTemplateServiceError; use crate::application::steps::RenderAnsibleTemplatesError; use crate::domain::environment::state::StateTypeError; -use crate::infrastructure::external_tools::tofu::ProvisionTemplateError; +use crate::infrastructure::external_tools::tofu::TofuTemplateRendererError; use crate::shared::command::CommandError; /// Comprehensive error type for the `ProvisionCommandHandler` #[derive(Debug, thiserror::Error)] pub enum ProvisionCommandHandlerError { #[error("OpenTofu template rendering failed: {0}")] - OpenTofuTemplateRendering(#[from] ProvisionTemplateError), + OpenTofuTemplateRendering(#[from] TofuTemplateRendererError), #[error("Ansible template rendering failed: {0}")] AnsibleTemplateRendering(#[from] RenderAnsibleTemplatesError), @@ -256,10 +256,10 @@ mod tests { #[test] fn it_should_provide_help_for_opentofu_template_rendering() { - use crate::infrastructure::external_tools::tofu::ProvisionTemplateError; + use crate::infrastructure::external_tools::tofu::TofuTemplateRendererError; let error = ProvisionCommandHandlerError::OpenTofuTemplateRendering( - ProvisionTemplateError::DirectoryCreationFailed { + TofuTemplateRendererError::DirectoryCreationFailed { directory: "test".to_string(), source: std::io::Error::new(std::io::ErrorKind::PermissionDenied, "test"), }, @@ -357,12 +357,12 @@ mod tests { use crate::adapters::ssh::SshError; use crate::application::steps::RenderAnsibleTemplatesError; use crate::infrastructure::external_tools::ansible::template::wrappers::inventory::InventoryContextError; - use crate::infrastructure::external_tools::tofu::ProvisionTemplateError; + use crate::infrastructure::external_tools::tofu::TofuTemplateRendererError; use crate::shared::command::CommandError; let errors = vec![ ProvisionCommandHandlerError::OpenTofuTemplateRendering( - ProvisionTemplateError::DirectoryCreationFailed { + TofuTemplateRendererError::DirectoryCreationFailed { directory: "test".to_string(), source: std::io::Error::new(std::io::ErrorKind::PermissionDenied, "test"), }, diff --git a/src/application/command_handlers/provision/tests/integration.rs b/src/application/command_handlers/provision/tests/integration.rs index d4ac30d7..f5878a82 100644 --- a/src/application/command_handlers/provision/tests/integration.rs +++ b/src/application/command_handlers/provision/tests/integration.rs @@ -5,13 +5,13 @@ use crate::adapters::ssh::SshError; use crate::adapters::tofu::client::OpenTofuError; use crate::application::command_handlers::provision::ProvisionCommandHandlerError; -use crate::infrastructure::external_tools::tofu::ProvisionTemplateError; +use crate::infrastructure::external_tools::tofu::TofuTemplateRendererError; use crate::shared::command::CommandError; #[test] fn it_should_have_correct_error_type_conversions() { // Test that all error types can convert to ProvisionCommandHandlerError - let template_error = ProvisionTemplateError::DirectoryCreationFailed { + let template_error = TofuTemplateRendererError::DirectoryCreationFailed { directory: "/test".to_string(), source: std::io::Error::new(std::io::ErrorKind::PermissionDenied, "test"), }; diff --git a/src/application/steps/rendering/opentofu_templates.rs b/src/application/steps/rendering/opentofu_templates.rs index dfd0c5d9..090bdb2c 100644 --- a/src/application/steps/rendering/opentofu_templates.rs +++ b/src/application/steps/rendering/opentofu_templates.rs @@ -21,7 +21,9 @@ use std::sync::Arc; use tracing::{info, instrument}; -use crate::infrastructure::external_tools::tofu::{ProvisionTemplateError, TofuTemplateRenderer}; +use crate::infrastructure::external_tools::tofu::{ + TofuTemplateRenderer, TofuTemplateRendererError, +}; /// Simple step that renders `OpenTofu` templates to the build directory pub struct RenderOpenTofuTemplatesStep { @@ -47,7 +49,7 @@ impl RenderOpenTofuTemplatesStep { skip_all, fields(step_type = "rendering", template_type = "opentofu") )] - pub async fn execute(&self) -> Result<(), ProvisionTemplateError> { + pub async fn execute(&self) -> Result<(), TofuTemplateRendererError> { info!( step = "render_opentofu_templates", "Rendering OpenTofu templates" diff --git a/src/infrastructure/external_tools/tofu/mod.rs b/src/infrastructure/external_tools/tofu/mod.rs index 19bda957..c157833f 100644 --- a/src/infrastructure/external_tools/tofu/mod.rs +++ b/src/infrastructure/external_tools/tofu/mod.rs @@ -13,7 +13,11 @@ pub mod template; -pub use template::{CloudInitTemplateRenderer, ProvisionTemplateError, TofuTemplateRenderer}; +pub use template::{CloudInitTemplateRenderer, TofuTemplateRenderer, TofuTemplateRendererError}; + +/// Type alias for backward compatibility. +#[allow(deprecated)] +pub use template::ProvisionTemplateError; /// Subdirectory name for OpenTofu-related files within the build directory. /// diff --git a/src/infrastructure/external_tools/tofu/template/common/mod.rs b/src/infrastructure/external_tools/tofu/template/common/mod.rs index 50cfe9e0..a8969522 100644 --- a/src/infrastructure/external_tools/tofu/template/common/mod.rs +++ b/src/infrastructure/external_tools/tofu/template/common/mod.rs @@ -7,7 +7,11 @@ pub mod renderer; pub mod wrappers; pub use renderer::cloud_init::{CloudInitTemplateError, CloudInitTemplateRenderer}; -pub use renderer::{ProvisionTemplateError, TofuTemplateRenderer}; +pub use renderer::{TofuTemplateRenderer, TofuTemplateRendererError}; + +/// Type alias for backward compatibility. +#[allow(deprecated)] +pub use renderer::ProvisionTemplateError; pub use wrappers::{ CloudInitContext, CloudInitContextBuilder, CloudInitContextError, CloudInitTemplate, }; diff --git a/src/infrastructure/external_tools/tofu/template/common/renderer/mod.rs b/src/infrastructure/external_tools/tofu/template/common/renderer/mod.rs index bb89d45d..0a3cab43 100644 --- a/src/infrastructure/external_tools/tofu/template/common/renderer/mod.rs +++ b/src/infrastructure/external_tools/tofu/template/common/renderer/mod.rs @@ -42,1212 +42,16 @@ //! operations and reduce I/O overhead. pub mod cloud_init; +mod tofu_template_renderer; -use std::path::{Path, PathBuf}; -use std::sync::Arc; -use thiserror::Error; +pub use tofu_template_renderer::{TofuTemplateRenderer, TofuTemplateRendererError}; -use crate::adapters::ssh::credentials::SshCredentials; -use crate::domain::provider::{Provider, ProviderConfig}; -use crate::domain::template::{TemplateManager, TemplateManagerError}; -use crate::domain::InstanceName; -use crate::infrastructure::external_tools::tofu::template::common::renderer::cloud_init::{ - CloudInitTemplateError, CloudInitTemplateRenderer, -}; -use crate::infrastructure::external_tools::tofu::template::providers::hetzner::wrappers::variables::VariablesTemplateError as HetznerVariablesTemplateError; -use crate::infrastructure::external_tools::tofu::template::providers::lxd::wrappers::variables::{ - VariablesContextBuilder as LxdVariablesContextBuilder, - VariablesTemplate as LxdVariablesTemplate, VariablesTemplateError as LxdVariablesTemplateError, -}; - -/// Errors that can occur during provision template rendering -#[derive(Error, Debug)] -pub enum ProvisionTemplateError { - /// Failed to create the build directory - #[error("Failed to create build directory '{directory}': {source}")] - DirectoryCreationFailed { - directory: String, - #[source] - source: std::io::Error, - }, - - /// 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 copy template file - #[error("Failed to copy template file '{file_name}' to build directory: {source}")] - FileCopyFailed { - file_name: String, - #[source] - source: std::io::Error, - }, - - /// Failed to render cloud-init template using collaborator - #[error("Failed to render cloud-init template: {source}")] - CloudInitRenderingFailed { - #[source] - source: CloudInitTemplateError, - }, - - /// Failed to render LXD variables template - #[error("Failed to render LXD variables template: {source}")] - LxdVariablesRenderingFailed { - #[source] - source: LxdVariablesTemplateError, - }, - - /// Failed to render Hetzner variables template - #[error("Failed to render Hetzner variables template: {source}")] - HetznerVariablesRenderingFailed { - #[source] - source: HetznerVariablesTemplateError, - }, - - /// Failed to build Hetzner template context - #[error("Failed to build Hetzner template context: {message}")] - HetznerContextBuildFailed { message: String }, - - /// Provider configuration mismatch - #[error("Provider configuration mismatch: expected {expected} provider but got different configuration")] - ProviderConfigMismatch { expected: String }, - - /// Provider not supported for this operation - #[error("Provider '{provider}' is not yet supported for template rendering")] - UnsupportedProvider { provider: String }, -} - -impl crate::shared::Traceable for ProvisionTemplateError { - fn trace_format(&self) -> String { - match self { - Self::DirectoryCreationFailed { directory, .. } => { - format!("ProvisionTemplateError: Failed to create build directory '{directory}'") - } - Self::TemplatePathFailed { file_name, .. } => { - format!("ProvisionTemplateError: Failed to get template path for '{file_name}'") - } - Self::FileCopyFailed { file_name, .. } => { - format!("ProvisionTemplateError: Failed to copy template file '{file_name}'") - } - Self::CloudInitRenderingFailed { .. } => { - "ProvisionTemplateError: Cloud-init template rendering failed".to_string() - } - Self::LxdVariablesRenderingFailed { .. } => { - "ProvisionTemplateError: LXD variables template rendering failed".to_string() - } - Self::HetznerVariablesRenderingFailed { .. } => { - "ProvisionTemplateError: Hetzner variables template rendering failed".to_string() - } - Self::HetznerContextBuildFailed { message } => { - format!("ProvisionTemplateError: Hetzner context build failed: {message}") - } - Self::ProviderConfigMismatch { expected } => { - format!("ProvisionTemplateError: Expected {expected} provider configuration") - } - Self::UnsupportedProvider { provider } => { - format!("ProvisionTemplateError: Provider '{provider}' is not yet supported") - } - } - } - - fn trace_source(&self) -> Option<&dyn crate::shared::Traceable> { - // None of the source errors implement Traceable (std::io::Error, TemplateManagerError, etc.) - None - } - - fn error_kind(&self) -> crate::shared::ErrorKind { - crate::shared::ErrorKind::TemplateRendering - } -} - -/// Renders `OpenTofu` provision templates to a build directory +/// Type alias for backward compatibility. /// -/// This collaborator is responsible for preparing `OpenTofu` templates for deployment workflows. -/// It copies static templates and renders Tera templates with runtime variables from the template -/// manager to the specified build directory. -/// -/// The renderer is provider-aware and selects the appropriate template directory based on the -/// provider specified in the environment configuration. -pub struct TofuTemplateRenderer { - template_manager: Arc, - build_dir: PathBuf, - ssh_credentials: SshCredentials, - cloud_init_renderer: CloudInitTemplateRenderer, - instance_name: InstanceName, - provider: Provider, - provider_config: ProviderConfig, -} - -impl TofuTemplateRenderer { - /// Creates a new provision template renderer - /// - /// # Arguments - /// - /// * `template_manager` - The template manager to source templates from - /// * `build_dir` - The destination directory where templates will be rendered - /// * `ssh_credentials` - The SSH credentials for injecting public key into cloud-init - /// * `instance_name` - The name of the instance to be created (for template rendering) - /// * `provider_config` - The provider configuration containing provider type and settings - /// - /// Note: For LXD provider, the profile name is extracted from `provider_config`. - pub fn new>( - template_manager: Arc, - build_dir: P, - ssh_credentials: SshCredentials, - instance_name: InstanceName, - provider_config: ProviderConfig, - ) -> Self { - let provider = provider_config.provider(); - let cloud_init_renderer = - CloudInitTemplateRenderer::new(template_manager.clone(), provider); - - Self { - template_manager, - build_dir: build_dir.as_ref().to_path_buf(), - ssh_credentials, - cloud_init_renderer, - instance_name, - provider, - provider_config, - } - } - - /// Returns the relative path for `OpenTofu` configuration files based on provider - fn opentofu_build_path(&self) -> String { - format!("tofu/{}", self.provider.as_str()) - } - - /// Returns the template path prefix for `OpenTofu` templates based on provider - fn opentofu_template_path(&self) -> String { - format!("tofu/{}", self.provider.as_str()) - } - - /// Renders provision templates (`OpenTofu`) to the build directory - /// - /// This method: - /// 1. Creates the build directory structure for `OpenTofu` - /// 2. Copies static templates (main.tf, versions.tf for Hetzner) from the template manager - /// 3. Renders Tera templates (cloud-init.yml.tera) with runtime variables - /// 4. Provides debug logging via the tracing crate - /// - /// # Returns - /// - /// * `Result<(), ProvisionTemplateError>` - Success or error from the template rendering operation - /// - /// # Errors - /// - /// Returns an error if: - /// - Directory creation fails - /// - Template copying fails - /// - Template manager cannot provide required templates - /// - Tera template rendering fails - pub async fn render(&self) -> Result<(), ProvisionTemplateError> { - tracing::info!( - template_type = "opentofu", - provider = %self.provider, - "Rendering provision templates to build directory" - ); - - // Create build directory structure - let build_tofu_dir = self.create_build_directory().await?; - - // Get static template files based on provider - let static_template_files = self.get_static_template_files(); - - // Copy static template files - self.copy_templates(&static_template_files, &build_tofu_dir) - .await?; - - // Render Tera templates with runtime variables - self.render_tera_templates(&build_tofu_dir).await?; - - tracing::debug!( - template_type = "opentofu", - provider = %self.provider, - output_dir = %build_tofu_dir.display(), - "Provision templates copied and rendered" - ); - - tracing::info!( - template_type = "opentofu", - provider = %self.provider, - status = "complete", - "Provision templates ready" - ); - Ok(()) - } - - /// Returns the list of static template files for the current provider - /// - /// Both LXD and Hetzner currently use the same static template file (main.tf). - /// This method exists to allow provider-specific customization in the future - /// if different providers need different static files. - #[allow(clippy::match_same_arms)] - fn get_static_template_files(&self) -> Vec<&'static str> { - match self.provider { - Provider::Lxd => vec!["main.tf"], - Provider::Hetzner => vec!["main.tf"], - } - } - - /// Builds the full `OpenTofu` build directory path - /// - /// # Returns - /// - /// * `PathBuf` - The complete path to the `OpenTofu` build directory - fn build_opentofu_directory(&self) -> PathBuf { - self.build_dir.join(self.opentofu_build_path()) - } - - /// Builds the template path for a specific file in the `OpenTofu` template directory - /// - /// # Arguments - /// - /// * `file_name` - The name of the template file - /// - /// # Returns - /// - /// * `String` - The complete template path for the specified file - fn build_template_path(&self, file_name: &str) -> String { - format!("{}/{file_name}", self.opentofu_template_path()) - } - - /// Creates the `OpenTofu` build directory structure - /// - /// # Returns - /// - /// * `Result` - The created build directory path or an error - /// - /// # Errors - /// - /// Returns an error if directory creation fails - async fn create_build_directory(&self) -> Result { - let build_tofu_dir = self.build_opentofu_directory(); - tokio::fs::create_dir_all(&build_tofu_dir) - .await - .map_err(|source| ProvisionTemplateError::DirectoryCreationFailed { - directory: build_tofu_dir.display().to_string(), - source, - })?; - Ok(build_tofu_dir) - } - - /// Copies a list of template files from the template manager to the destination directory - /// - /// # Arguments - /// - /// * `file_names` - List of file names to copy (without path prefix) - /// * `destination_dir` - The directory where files will be copied - /// - /// # Returns - /// - /// * `Result<(), ProvisionTemplateError>` - Success or error from the file copying operations - /// - /// # Errors - /// - /// Returns an error if: - /// - Template manager cannot provide required template paths - /// - File copying fails for any of the specified files - async fn copy_templates( - &self, - file_names: &[&str], - destination_dir: &Path, - ) -> Result<(), ProvisionTemplateError> { - tracing::debug!( - "Copying {} template files to {}", - file_names.len(), - destination_dir.display() - ); - - for file_name in file_names { - let template_path = self.build_template_path(file_name); - - let source_path = self - .template_manager - .get_template_path(&template_path) - .map_err(|source| ProvisionTemplateError::TemplatePathFailed { - file_name: (*file_name).to_string(), - source, - })?; - - let dest_path = destination_dir.join(file_name); - - tracing::trace!( - "Copying {} to {}", - source_path.display(), - dest_path.display() - ); - - tokio::fs::copy(&source_path, &dest_path) - .await - .map_err(|source| ProvisionTemplateError::FileCopyFailed { - file_name: (*file_name).to_string(), - source, - })?; - - tracing::debug!("Successfully copied {}", file_name); - } - - tracing::debug!("All template files copied successfully"); - Ok(()) - } - - /// Renders Tera templates with runtime variables using collaborators - /// - /// This method delegates template rendering to specialized collaborators: - /// - cloud-init.yml.tera template rendering to the `CloudInitTemplateRenderer` collaborator - /// - variables.tfvars.tera template rendering using the `VariablesTemplate` - /// - /// # Arguments - /// - /// * `destination_dir` - The directory where rendered templates should be written - /// - /// # Errors - /// - /// Returns an error if: - /// - `CloudInitTemplateRenderer` fails to render the template - /// - `VariablesTemplate` fails to render the variables template - async fn render_tera_templates( - &self, - destination_dir: &Path, - ) -> Result<(), ProvisionTemplateError> { - tracing::debug!("Rendering Tera templates with runtime variables using collaborators"); - - // Use collaborator to render cloud-init.yml.tera template - self.cloud_init_renderer - .render(&self.ssh_credentials, destination_dir) - .await - .map_err(|source| ProvisionTemplateError::CloudInitRenderingFailed { source })?; - - // Render variables.tfvars.tera template with instance name - self.render_variables_template(destination_dir).await?; - - tracing::debug!("All Tera templates rendered successfully"); - Ok(()) - } - - /// Renders the variables.tfvars.tera template with the instance name context - /// - /// # Arguments - /// - /// * `destination_dir` - The directory where the rendered variables.tfvars file will be written - /// - /// # Errors - /// - /// Returns an error if: - /// - Template manager cannot provide the variables.tfvars.tera template - /// - Variables template rendering fails - /// - File writing fails - async fn render_variables_template( - &self, - destination_dir: &Path, - ) -> Result<(), ProvisionTemplateError> { - tracing::debug!( - provider = %self.provider, - "Rendering variables.tfvars.tera template with provider-specific context" - ); - - // Get the variables.tfvars.tera template from the template manager - let template_path = self.build_template_path("variables.tfvars.tera"); - let template_file_path = self - .template_manager - .get_template_path(&template_path) - .map_err(|source| ProvisionTemplateError::TemplatePathFailed { - file_name: "variables.tfvars.tera".to_string(), - source, - })?; - - // Read the template file content - let template_content = tokio::fs::read_to_string(&template_file_path) - .await - .map_err(|source| ProvisionTemplateError::FileCopyFailed { - file_name: "variables.tfvars.tera".to_string(), - source, - })?; - - // Create template file wrapper - let template_file = - crate::domain::template::file::File::new("variables.tfvars.tera", template_content) - .map_err(|err| ProvisionTemplateError::FileCopyFailed { - file_name: "variables.tfvars.tera".to_string(), - source: std::io::Error::new(std::io::ErrorKind::InvalidData, err.to_string()), - })?; - - // Render based on provider - match self.provider { - Provider::Lxd => self.render_lxd_variables_template(&template_file, destination_dir), - Provider::Hetzner => { - self.render_hetzner_variables_template(&template_file, destination_dir) - .await - } - } - } - - /// Renders LXD-specific variables template - fn render_lxd_variables_template( - &self, - template_file: &crate::domain::template::file::File, - destination_dir: &Path, - ) -> Result<(), ProvisionTemplateError> { - // Get LXD config (profile_name is LXD-specific) - let lxd_config = self.provider_config.as_lxd().ok_or_else(|| { - ProvisionTemplateError::ProviderConfigMismatch { - expected: "LXD".to_string(), - } - })?; - - // Build LXD context for template rendering - let context = LxdVariablesContextBuilder::new() - .with_instance_name(self.instance_name.clone()) - .with_profile_name(lxd_config.profile_name.clone()) - .build() - .map_err(|err| ProvisionTemplateError::LxdVariablesRenderingFailed { - source: LxdVariablesTemplateError::TemplateEngineError { - source: crate::domain::template::TemplateEngineError::ContextSerialization { - source: tera::Error::msg(err.to_string()), - }, - }, - })?; - - // Create and render the variables template - let variables_template = LxdVariablesTemplate::new(template_file, context) - .map_err(|source| ProvisionTemplateError::LxdVariablesRenderingFailed { source })?; - - // Write the rendered template to the destination directory - let output_path = destination_dir.join("variables.tfvars"); - variables_template - .render(&output_path) - .map_err(|source| ProvisionTemplateError::LxdVariablesRenderingFailed { source })?; - - tracing::debug!("LXD variables template rendered successfully"); - Ok(()) - } - - /// Renders Hetzner-specific variables template - async fn render_hetzner_variables_template( - &self, - template_file: &crate::domain::template::file::File, - destination_dir: &Path, - ) -> Result<(), ProvisionTemplateError> { - use crate::infrastructure::external_tools::tofu::template::providers::hetzner::wrappers::variables::{ - VariablesContextBuilder as HetznerVariablesContextBuilder, - VariablesTemplate as HetznerVariablesTemplate, - }; - - // Get Hetzner config - let hetzner_config = self.provider_config.as_hetzner().ok_or_else(|| { - ProvisionTemplateError::ProviderConfigMismatch { - expected: "Hetzner".to_string(), - } - })?; - - // Read SSH public key content - let ssh_public_key_content = - tokio::fs::read_to_string(&self.ssh_credentials.ssh_pub_key_path) - .await - .map_err(|source| ProvisionTemplateError::FileCopyFailed { - file_name: "ssh public key".to_string(), - source, - })?; - - // Build Hetzner context for template rendering - let context = HetznerVariablesContextBuilder::new() - .with_instance_name(self.instance_name.clone()) - .with_hcloud_api_token(hetzner_config.api_token.clone()) - .with_server_type(hetzner_config.server_type.clone()) - .with_server_location(hetzner_config.location.clone()) - .with_server_image(hetzner_config.image.clone()) - .with_ssh_public_key_content(ssh_public_key_content.trim().to_string()) - .build() - .map_err(|err| ProvisionTemplateError::HetznerContextBuildFailed { - message: err.to_string(), - })?; - - // Create and render the variables template - let variables_template = HetznerVariablesTemplate::new(template_file, context) - .map_err(|source| ProvisionTemplateError::HetznerVariablesRenderingFailed { source })?; - - // Write the rendered template to the destination directory - let output_path = destination_dir.join("variables.tfvars"); - variables_template - .render(&output_path) - .map_err(|source| ProvisionTemplateError::HetznerVariablesRenderingFailed { source })?; - - tracing::debug!("Hetzner variables template rendered successfully"); - Ok(()) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use std::fs; - - use crate::domain::ProfileName; - use crate::shared::Username; - - /// Test instance name for unit tests - fn test_instance_name() -> InstanceName { - InstanceName::new("test-instance".to_string()).expect("Valid test instance name") - } - - /// Test profile name for unit tests - fn test_profile_name() -> ProfileName { - ProfileName::new("test-profile".to_string()).expect("Valid test profile name") - } - - /// Helper function to create dummy SSH credentials for testing - fn create_dummy_ssh_credentials(temp_dir: &Path) -> SshCredentials { - let ssh_priv_key_path = temp_dir.join("test_key"); - let ssh_pub_key_path = temp_dir.join("test_key.pub"); - - // Create dummy key files - fs::write(&ssh_priv_key_path, "dummy_private_key").expect("Failed to write private key"); - fs::write( - &ssh_pub_key_path, - "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQC... test@example.com", - ) - .expect("Failed to write public key"); - - SshCredentials::new( - ssh_priv_key_path, - ssh_pub_key_path, - Username::new("testuser").unwrap(), - ) - } - - /// Helper function to create a test LXD provider config - fn test_lxd_provider_config() -> ProviderConfig { - use crate::domain::provider::LxdConfig; - ProviderConfig::Lxd(LxdConfig { - profile_name: test_profile_name(), - }) - } - - #[tokio::test] - async fn it_should_create_renderer_with_build_directory() { - let temp_dir = tempfile::TempDir::new().expect("Failed to create temp directory"); - let build_path = temp_dir.path().join("build"); - let template_manager = Arc::new(TemplateManager::new(temp_dir.path())); - let ssh_credentials = create_dummy_ssh_credentials(temp_dir.path()); - - let renderer = TofuTemplateRenderer::new( - template_manager, - &build_path, - ssh_credentials, - test_instance_name(), - test_lxd_provider_config(), - ); - - assert_eq!(renderer.build_dir, build_path); - } - - #[tokio::test] - async fn it_should_build_correct_opentofu_directory_path() { - let temp_dir = tempfile::TempDir::new().expect("Failed to create temp directory"); - let build_path = temp_dir.path().join("build"); - let expected_path = build_path.join("tofu/lxd"); - let template_manager = Arc::new(TemplateManager::new(temp_dir.path())); - let ssh_credentials = create_dummy_ssh_credentials(temp_dir.path()); - - let renderer = TofuTemplateRenderer::new( - template_manager, - &build_path, - ssh_credentials, - test_instance_name(), - test_lxd_provider_config(), - ); - let actual_path = renderer.build_opentofu_directory(); - - assert_eq!(actual_path, expected_path); - } - - #[tokio::test] - async fn it_should_build_correct_template_path_for_file() { - let temp_dir = tempfile::TempDir::new().expect("Failed to create temp directory"); - let build_path = temp_dir.path().join("build"); - let template_manager = Arc::new(TemplateManager::new(temp_dir.path())); - let ssh_credentials = create_dummy_ssh_credentials(temp_dir.path()); - - let renderer = TofuTemplateRenderer::new( - template_manager, - &build_path, - ssh_credentials, - test_instance_name(), - test_lxd_provider_config(), - ); - let template_path = renderer.build_template_path("main.tf"); - - assert_eq!(template_path, "tofu/lxd/main.tf"); - } - - #[tokio::test] - async fn it_should_build_template_path_with_different_file_names() { - let temp_dir = tempfile::TempDir::new().expect("Failed to create temp directory"); - let build_path = temp_dir.path().join("build"); - let template_manager = Arc::new(TemplateManager::new(temp_dir.path())); - let ssh_credentials = create_dummy_ssh_credentials(temp_dir.path()); - - let renderer = TofuTemplateRenderer::new( - template_manager, - &build_path, - ssh_credentials, - test_instance_name(), - test_lxd_provider_config(), - ); - - assert_eq!( - renderer.build_template_path("cloud-init.yml"), - "tofu/lxd/cloud-init.yml" - ); - assert_eq!( - renderer.build_template_path("variables.tf"), - "tofu/lxd/variables.tf" - ); - assert_eq!( - renderer.build_template_path("outputs.tf"), - "tofu/lxd/outputs.tf" - ); - } - - #[tokio::test] - async fn it_should_create_build_directory_successfully() { - let temp_dir = tempfile::TempDir::new().expect("Failed to create temp directory"); - let build_path = temp_dir.path().join("build"); - let expected_path = build_path.join("tofu/lxd"); - let template_manager = Arc::new(TemplateManager::new(temp_dir.path())); - - let ssh_credentials = create_dummy_ssh_credentials(temp_dir.path()); - let renderer = TofuTemplateRenderer::new( - template_manager, - &build_path, - ssh_credentials, - test_instance_name(), - test_lxd_provider_config(), - ); - let created_path = renderer - .create_build_directory() - .await - .expect("Failed to create build directory"); - - assert_eq!(created_path, expected_path); - assert!(created_path.exists(), "Build directory should exist"); - assert!(created_path.is_dir(), "Created path should be a directory"); - } - - // Error Handling Tests - #[cfg(unix)] - #[tokio::test] - async fn it_should_fail_when_directory_creation_denied() { - // Create a read-only directory to simulate permission denied - let temp_dir = tempfile::TempDir::new().expect("Failed to create temp directory"); - let readonly_path = temp_dir.path().join("readonly"); - tokio::fs::create_dir(&readonly_path) - .await - .expect("Failed to create readonly dir"); - - // Make the directory read-only - let mut perms = tokio::fs::metadata(&readonly_path) - .await - .unwrap() - .permissions(); - std::os::unix::fs::PermissionsExt::set_mode(&mut perms, 0o444); // Read-only permissions - tokio::fs::set_permissions(&readonly_path, perms) - .await - .unwrap(); - - let build_path = readonly_path.join("build"); - let template_manager = Arc::new(TemplateManager::new(temp_dir.path())); - let ssh_credentials = create_dummy_ssh_credentials(temp_dir.path()); - let renderer = TofuTemplateRenderer::new( - template_manager, - &build_path, - ssh_credentials, - test_instance_name(), - test_lxd_provider_config(), - ); - - let result = renderer.create_build_directory().await; - - assert!( - result.is_err(), - "Should fail when directory creation is denied" - ); - match result.unwrap_err() { - ProvisionTemplateError::DirectoryCreationFailed { - directory, - source: _, - } => { - assert!( - directory.contains("tofu/lxd"), - "Error should contain the full path" - ); - } - other => panic!("Expected DirectoryCreationFailed, got: {other:?}"), - } - } - - #[tokio::test] - async fn it_should_fail_when_template_manager_cannot_find_template() { - let temp_dir = tempfile::TempDir::new().expect("Failed to create temp directory"); - let build_path = temp_dir.path().join("build"); - - // Create a template manager with empty templates directory - let template_manager = Arc::new(TemplateManager::new(temp_dir.path())); - - let ssh_credentials = create_dummy_ssh_credentials(temp_dir.path()); - let renderer = TofuTemplateRenderer::new( - template_manager, - &build_path, - ssh_credentials, - test_instance_name(), - test_lxd_provider_config(), - ); - - // Try to copy a non-existent template - let result = renderer - .copy_templates(&["nonexistent.tf"], &build_path) - .await; - - assert!(result.is_err(), "Should fail when template is not found"); - match result.unwrap_err() { - ProvisionTemplateError::TemplatePathFailed { - file_name, - source: _, - } => { - assert_eq!(file_name, "nonexistent.tf"); - } - other => panic!("Expected TemplatePathFailed, got: {other:?}"), - } - } - - #[cfg(unix)] - #[tokio::test] - async fn it_should_fail_when_file_copy_permission_denied() { - let temp_dir = tempfile::TempDir::new().expect("Failed to create temp directory"); - let build_path = temp_dir.path().join("build"); - - // Create the destination directory first, then make it read-only - tokio::fs::create_dir_all(&build_path) - .await - .expect("Failed to create build directory"); - - let mut perms = tokio::fs::metadata(&build_path) - .await - .unwrap() - .permissions(); - std::os::unix::fs::PermissionsExt::set_mode(&mut perms, 0o444); // Read-only permissions - tokio::fs::set_permissions(&build_path, perms) - .await - .unwrap(); - - // Create template manager and ensure it has the template we need - let template_manager = Arc::new(TemplateManager::new(temp_dir.path())); - template_manager - .ensure_templates_dir() - .expect("Failed to ensure templates dir"); - - // Create a test template file manually since we can't rely on embedded resources - let template_dir = temp_dir.path().join("tofu/lxd"); - tokio::fs::create_dir_all(&template_dir) - .await - .expect("Failed to create template dir"); - tokio::fs::write(template_dir.join("test.tf"), "# Test template") - .await - .expect("Failed to write test template"); - - let ssh_credentials = create_dummy_ssh_credentials(temp_dir.path()); - let renderer = TofuTemplateRenderer::new( - template_manager, - temp_dir.path(), - ssh_credentials, - test_instance_name(), - test_lxd_provider_config(), - ); - - let result = renderer.copy_templates(&["test.tf"], &build_path).await; - - assert!(result.is_err(), "Should fail when file copy is denied"); - match result.unwrap_err() { - ProvisionTemplateError::FileCopyFailed { - file_name, - source: _, - } => { - assert_eq!(file_name, "test.tf"); - } - other => panic!("Expected FileCopyFailed, got: {other:?}"), - } - } - - // Input Validation Edge Case Tests - #[tokio::test] - async fn it_should_handle_empty_file_name() { - let temp_dir = tempfile::TempDir::new().expect("Failed to create temp directory"); - let build_path = temp_dir.path().join("build"); - let template_manager = Arc::new(TemplateManager::new(temp_dir.path())); - let ssh_credentials = create_dummy_ssh_credentials(temp_dir.path()); - - let renderer = TofuTemplateRenderer::new( - template_manager, - &build_path, - ssh_credentials, - test_instance_name(), - test_lxd_provider_config(), - ); - let template_path = renderer.build_template_path(""); - assert_eq!(template_path, "tofu/lxd/"); - } - - #[tokio::test] - async fn it_should_handle_file_names_with_path_separators() { - let temp_dir = tempfile::TempDir::new().expect("Failed to create temp directory"); - let build_path = temp_dir.path().join("build"); - let template_manager = Arc::new(TemplateManager::new(temp_dir.path())); - let ssh_credentials = create_dummy_ssh_credentials(temp_dir.path()); - - let renderer = TofuTemplateRenderer::new( - template_manager, - &build_path, - ssh_credentials, - test_instance_name(), - test_lxd_provider_config(), - ); - - // File names with forward slashes should be handled literally - let template_path = renderer.build_template_path("sub/dir/file.tf"); - assert_eq!(template_path, "tofu/lxd/sub/dir/file.tf"); - - // File names with backslashes (Windows-style) - let template_path = renderer.build_template_path("sub\\dir\\file.tf"); - assert_eq!(template_path, "tofu/lxd/sub\\dir\\file.tf"); - - // Relative path components - let template_path = renderer.build_template_path("../main.tf"); - assert_eq!(template_path, "tofu/lxd/../main.tf"); - } - - #[tokio::test] - async fn it_should_handle_special_characters_in_file_names() { - let temp_dir = tempfile::TempDir::new().expect("Failed to create temp directory"); - let build_path = temp_dir.path().join("build"); - let template_manager = Arc::new(TemplateManager::new(temp_dir.path())); - let ssh_credentials = create_dummy_ssh_credentials(temp_dir.path()); - - let renderer = TofuTemplateRenderer::new( - template_manager, - &build_path, - ssh_credentials, - test_instance_name(), - test_lxd_provider_config(), - ); - - // File names with spaces - let template_path = renderer.build_template_path("main file.tf"); - assert_eq!(template_path, "tofu/lxd/main file.tf"); - - // File names with unicode characters - let template_path = renderer.build_template_path("файл.tf"); - assert_eq!(template_path, "tofu/lxd/файл.tf"); - - // File names with special characters - let template_path = renderer.build_template_path("main@#$%.tf"); - assert_eq!(template_path, "tofu/lxd/main@#$%.tf"); - } - - #[tokio::test] - async fn it_should_handle_very_long_file_names() { - let temp_dir = tempfile::TempDir::new().expect("Failed to create temp directory"); - let build_path = temp_dir.path().join("build"); - let template_manager = Arc::new(TemplateManager::new(temp_dir.path())); - let ssh_credentials = create_dummy_ssh_credentials(temp_dir.path()); - - let renderer = TofuTemplateRenderer::new( - template_manager, - &build_path, - ssh_credentials, - test_instance_name(), - test_lxd_provider_config(), - ); - - // Create a very long file name (300 characters) - let long_name = "a".repeat(300) + ".tf"; - let template_path = renderer.build_template_path(&long_name); - assert_eq!(template_path, format!("tofu/lxd/{long_name}")); - } - - // File System Edge Case Tests - #[tokio::test] - async fn it_should_handle_existing_build_directory() { - let temp_dir = tempfile::TempDir::new().expect("Failed to create temp directory"); - let build_path = temp_dir.path().join("build"); - let tofu_path = build_path.join("tofu/lxd"); - - // Pre-create the directory structure - tokio::fs::create_dir_all(&tofu_path) - .await - .expect("Failed to create existing directory"); - assert!(tofu_path.exists(), "Directory should already exist"); - - let template_manager = Arc::new(TemplateManager::new(temp_dir.path())); - let ssh_credentials = create_dummy_ssh_credentials(temp_dir.path()); - let renderer = TofuTemplateRenderer::new( - template_manager, - &build_path, - ssh_credentials, - test_instance_name(), - test_lxd_provider_config(), - ); - let created_path = renderer - .create_build_directory() - .await - .expect("Should handle existing directory gracefully"); - - assert_eq!(created_path, tofu_path); - assert!(created_path.exists(), "Directory should still exist"); - } - - #[tokio::test] - async fn it_should_handle_empty_template_files_array() { - let temp_dir = tempfile::TempDir::new().expect("Failed to create temp directory"); - let build_path = temp_dir.path().join("build"); - - let template_manager = Arc::new(TemplateManager::new(temp_dir.path())); - - let ssh_credentials = create_dummy_ssh_credentials(temp_dir.path()); - let renderer = TofuTemplateRenderer::new( - template_manager, - &build_path, - ssh_credentials, - test_instance_name(), - test_lxd_provider_config(), - ); - - // Should succeed with empty array - let result = renderer.copy_templates(&[], &build_path).await; - - assert!(result.is_ok(), "Should handle empty template files array"); - } - - #[tokio::test] - async fn it_should_handle_duplicate_files_in_array() { - let temp_dir = tempfile::TempDir::new().expect("Failed to create temp directory"); - let build_path = temp_dir.path().join("build"); - tokio::fs::create_dir_all(&build_path) - .await - .expect("Failed to create build directory"); - - let template_manager = Arc::new(TemplateManager::new(temp_dir.path())); - template_manager - .ensure_templates_dir() - .expect("Failed to ensure templates dir"); - - // Create a test template file manually - let template_dir = temp_dir.path().join("tofu/lxd"); - tokio::fs::create_dir_all(&template_dir) - .await - .expect("Failed to create template dir"); - tokio::fs::write(template_dir.join("main.tf"), "# Main template") - .await - .expect("Failed to write test template"); - - let ssh_credentials = create_dummy_ssh_credentials(temp_dir.path()); - let renderer = TofuTemplateRenderer::new( - template_manager, - temp_dir.path(), - ssh_credentials, - test_instance_name(), - test_lxd_provider_config(), - ); - - // Copy the same file twice - should succeed (overwrite) - let result = renderer - .copy_templates(&["main.tf", "main.tf"], &build_path) - .await; - - assert!( - result.is_ok(), - "Should handle duplicate files by overwriting" - ); - assert!( - build_path.join("main.tf").exists(), - "File should exist after duplicate copy" - ); - } - - // Async Operation Edge Case Tests - #[tokio::test] - async fn it_should_handle_concurrent_renderer_operations() { - let temp_dir = tempfile::TempDir::new().expect("Failed to create temp directory"); - let build_path1 = temp_dir.path().join("build1"); - let build_path2 = temp_dir.path().join("build2"); - - let template_manager = Arc::new(TemplateManager::new(temp_dir.path())); - template_manager - .ensure_templates_dir() - .expect("Failed to ensure templates dir"); - - // Create test template files - let template_dir = temp_dir.path().join("tofu/lxd"); - tokio::fs::create_dir_all(&template_dir) - .await - .expect("Failed to create template dir"); - tokio::fs::write(template_dir.join("test1.tf"), "# Test template 1") - .await - .expect("Failed to write test template 1"); - tokio::fs::write(template_dir.join("test2.tf"), "# Test template 2") - .await - .expect("Failed to write test template 2"); - - let ssh_credentials1 = create_dummy_ssh_credentials(temp_dir.path()); - let renderer1 = TofuTemplateRenderer::new( - template_manager.clone(), - &build_path1, - ssh_credentials1, - test_instance_name(), - test_lxd_provider_config(), - ); - let ssh_credentials2 = create_dummy_ssh_credentials(temp_dir.path()); - let renderer2 = TofuTemplateRenderer::new( - template_manager, - &build_path2, - ssh_credentials2, - test_instance_name(), - test_lxd_provider_config(), - ); - - tokio::fs::create_dir_all(&build_path1) - .await - .expect("Failed to create build path 1"); - tokio::fs::create_dir_all(&build_path2) - .await - .expect("Failed to create build path 2"); - - // Run both operations concurrently - let (result1, result2) = tokio::join!( - renderer1.copy_templates(&["test1.tf"], &build_path1), - renderer2.copy_templates(&["test2.tf"], &build_path2) - ); - - assert!(result1.is_ok(), "First concurrent operation should succeed"); - assert!( - result2.is_ok(), - "Second concurrent operation should succeed" - ); - assert!( - build_path1.join("test1.tf").exists(), - "First template should exist" - ); - assert!( - build_path2.join("test2.tf").exists(), - "Second template should exist" - ); - } - - #[tokio::test] - async fn it_should_handle_partial_failure_scenarios() { - let temp_dir = tempfile::TempDir::new().expect("Failed to create temp directory"); - let build_path = temp_dir.path().join("build"); - tokio::fs::create_dir_all(&build_path) - .await - .expect("Failed to create build directory"); - - let template_manager = Arc::new(TemplateManager::new(temp_dir.path())); - template_manager - .ensure_templates_dir() - .expect("Failed to ensure templates dir"); - - // Create only one of the two template files we'll try to copy - let template_dir = temp_dir.path().join("tofu/lxd"); - tokio::fs::create_dir_all(&template_dir) - .await - .expect("Failed to create template dir"); - tokio::fs::write(template_dir.join("exists.tf"), "# Existing template") - .await - .expect("Failed to write existing template"); - - let ssh_credentials = create_dummy_ssh_credentials(temp_dir.path()); - let renderer = TofuTemplateRenderer::new( - template_manager, - temp_dir.path(), - ssh_credentials, - test_instance_name(), - test_lxd_provider_config(), - ); - - // Try to copy both existing and non-existing files - let result = renderer - .copy_templates(&["exists.tf", "missing.tf"], &build_path) - .await; - - // Should fail on the missing template - assert!(result.is_err(), "Should fail when one template is missing"); - - // The first file might have been copied before the failure - // This tests the partial failure behavior - match result.unwrap_err() { - ProvisionTemplateError::TemplatePathFailed { - file_name, - source: _, - } => { - assert_eq!(file_name, "missing.tf"); - } - other => panic!("Expected TemplatePathFailed for missing file, got: {other:?}"), - } - } - - #[tokio::test] - async fn it_should_handle_large_number_of_files() { - let temp_dir = tempfile::TempDir::new().expect("Failed to create temp directory"); - let build_path = temp_dir.path().join("build"); - tokio::fs::create_dir_all(&build_path) - .await - .expect("Failed to create build directory"); - - let template_manager = Arc::new(TemplateManager::new(temp_dir.path())); - template_manager - .ensure_templates_dir() - .expect("Failed to ensure templates dir"); - - // Create many template files - let template_dir = temp_dir.path().join("tofu/lxd"); - tokio::fs::create_dir_all(&template_dir) - .await - .expect("Failed to create template dir"); - - let mut file_names = Vec::new(); - for i in 0..50 { - // Create 50 files - let file_name = format!("template_{i}.tf"); - tokio::fs::write(template_dir.join(&file_name), format!("# Template {i}")) - .await - .expect("Failed to write template file"); - file_names.push(file_name); - } - - let ssh_credentials = create_dummy_ssh_credentials(temp_dir.path()); - let renderer = TofuTemplateRenderer::new( - template_manager, - temp_dir.path(), - ssh_credentials, - test_instance_name(), - test_lxd_provider_config(), - ); - - let file_refs: Vec<&str> = file_names.iter().map(std::string::String::as_str).collect(); - let result = renderer.copy_templates(&file_refs, &build_path).await; - - assert!(result.is_ok(), "Should handle large number of files"); - - // Verify all files were copied - for file_name in &file_names { - assert!( - build_path.join(file_name).exists(), - "File {file_name} should exist" - ); - } - } -} +/// This alias allows existing code using `ProvisionTemplateError` to continue working +/// without modification. New code should use `TofuTemplateRendererError` directly. +#[deprecated( + since = "0.1.0", + note = "Use `TofuTemplateRendererError` instead. This alias exists for backward compatibility." +)] +pub type ProvisionTemplateError = TofuTemplateRendererError; diff --git a/src/infrastructure/external_tools/tofu/template/common/renderer/tofu_template_renderer.rs b/src/infrastructure/external_tools/tofu/template/common/renderer/tofu_template_renderer.rs new file mode 100644 index 00000000..aed146b7 --- /dev/null +++ b/src/infrastructure/external_tools/tofu/template/common/renderer/tofu_template_renderer.rs @@ -0,0 +1,1227 @@ +//! `OpenTofu` Template Renderer +//! +//! This module provides the main template renderer for `OpenTofu` deployment workflows. +//! It manages the creation of build directories, copying template files, and processing +//! them with variable substitution. +//! +//! ## Provider Support +//! +//! The renderer supports multiple infrastructure providers (LXD, Hetzner) with independent +//! template sets for each provider. Templates are not shared between providers to allow +//! provider-specific customization. + +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use thiserror::Error; + +use crate::adapters::ssh::credentials::SshCredentials; +use crate::domain::provider::{Provider, ProviderConfig}; +use crate::domain::template::{TemplateManager, TemplateManagerError}; +use crate::domain::InstanceName; +use crate::infrastructure::external_tools::tofu::template::common::renderer::cloud_init::{ + CloudInitTemplateError, CloudInitTemplateRenderer, +}; +use crate::infrastructure::external_tools::tofu::template::providers::hetzner::wrappers::variables::VariablesTemplateError as HetznerVariablesTemplateError; +use crate::infrastructure::external_tools::tofu::template::providers::lxd::wrappers::variables::{ + VariablesContextBuilder as LxdVariablesContextBuilder, + VariablesTemplate as LxdVariablesTemplate, VariablesTemplateError as LxdVariablesTemplateError, +}; + +/// Errors that can occur during `OpenTofu` template rendering +#[derive(Error, Debug)] +pub enum TofuTemplateRendererError { + /// Failed to create the build directory + #[error("Failed to create build directory '{directory}': {source}")] + DirectoryCreationFailed { + directory: String, + #[source] + source: std::io::Error, + }, + + /// 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 copy template file + #[error("Failed to copy template file '{file_name}' to build directory: {source}")] + FileCopyFailed { + file_name: String, + #[source] + source: std::io::Error, + }, + + /// Failed to render cloud-init template using collaborator + #[error("Failed to render cloud-init template: {source}")] + CloudInitRenderingFailed { + #[source] + source: CloudInitTemplateError, + }, + + /// Failed to render LXD variables template + #[error("Failed to render LXD variables template: {source}")] + LxdVariablesRenderingFailed { + #[source] + source: LxdVariablesTemplateError, + }, + + /// Failed to render Hetzner variables template + #[error("Failed to render Hetzner variables template: {source}")] + HetznerVariablesRenderingFailed { + #[source] + source: HetznerVariablesTemplateError, + }, + + /// Failed to build Hetzner template context + #[error("Failed to build Hetzner template context: {message}")] + HetznerContextBuildFailed { message: String }, + + /// Provider configuration mismatch + #[error("Provider configuration mismatch: expected {expected} provider but got different configuration")] + ProviderConfigMismatch { expected: String }, + + /// Provider not supported for this operation + #[error("Provider '{provider}' is not yet supported for template rendering")] + UnsupportedProvider { provider: String }, +} + +impl crate::shared::Traceable for TofuTemplateRendererError { + fn trace_format(&self) -> String { + match self { + Self::DirectoryCreationFailed { directory, .. } => { + format!("TofuTemplateRendererError: Failed to create build directory '{directory}'") + } + Self::TemplatePathFailed { file_name, .. } => { + format!("TofuTemplateRendererError: Failed to get template path for '{file_name}'") + } + Self::FileCopyFailed { file_name, .. } => { + format!("TofuTemplateRendererError: Failed to copy template file '{file_name}'") + } + Self::CloudInitRenderingFailed { .. } => { + "TofuTemplateRendererError: Cloud-init template rendering failed".to_string() + } + Self::LxdVariablesRenderingFailed { .. } => { + "TofuTemplateRendererError: LXD variables template rendering failed".to_string() + } + Self::HetznerVariablesRenderingFailed { .. } => { + "TofuTemplateRendererError: Hetzner variables template rendering failed".to_string() + } + Self::HetznerContextBuildFailed { message } => { + format!("TofuTemplateRendererError: Hetzner context build failed: {message}") + } + Self::ProviderConfigMismatch { expected } => { + format!("TofuTemplateRendererError: Expected {expected} provider configuration") + } + Self::UnsupportedProvider { provider } => { + format!("TofuTemplateRendererError: Provider '{provider}' is not yet supported") + } + } + } + + fn trace_source(&self) -> Option<&dyn crate::shared::Traceable> { + // None of the source errors implement Traceable (std::io::Error, TemplateManagerError, etc.) + None + } + + fn error_kind(&self) -> crate::shared::ErrorKind { + crate::shared::ErrorKind::TemplateRendering + } +} + +/// Renders `OpenTofu` provision templates to a build directory +/// +/// This collaborator is responsible for preparing `OpenTofu` templates for deployment workflows. +/// It copies static templates and renders Tera templates with runtime variables from the template +/// manager to the specified build directory. +/// +/// The renderer is provider-aware and selects the appropriate template directory based on the +/// provider specified in the environment configuration. +pub struct TofuTemplateRenderer { + template_manager: Arc, + build_dir: PathBuf, + ssh_credentials: SshCredentials, + cloud_init_renderer: CloudInitTemplateRenderer, + instance_name: InstanceName, + provider: Provider, + provider_config: ProviderConfig, +} + +impl TofuTemplateRenderer { + /// Creates a new provision template renderer + /// + /// # Arguments + /// + /// * `template_manager` - The template manager to source templates from + /// * `build_dir` - The destination directory where templates will be rendered + /// * `ssh_credentials` - The SSH credentials for injecting public key into cloud-init + /// * `instance_name` - The name of the instance to be created (for template rendering) + /// * `provider_config` - The provider configuration containing provider type and settings + /// + /// Note: For LXD provider, the profile name is extracted from `provider_config`. + pub fn new>( + template_manager: Arc, + build_dir: P, + ssh_credentials: SshCredentials, + instance_name: InstanceName, + provider_config: ProviderConfig, + ) -> Self { + let provider = provider_config.provider(); + let cloud_init_renderer = + CloudInitTemplateRenderer::new(template_manager.clone(), provider); + + Self { + template_manager, + build_dir: build_dir.as_ref().to_path_buf(), + ssh_credentials, + cloud_init_renderer, + instance_name, + provider, + provider_config, + } + } + + /// Returns the relative path for `OpenTofu` configuration files based on provider + fn opentofu_build_path(&self) -> String { + format!("tofu/{}", self.provider.as_str()) + } + + /// Returns the template path prefix for `OpenTofu` templates based on provider + fn opentofu_template_path(&self) -> String { + format!("tofu/{}", self.provider.as_str()) + } + + /// Renders provision templates (`OpenTofu`) to the build directory + /// + /// This method: + /// 1. Creates the build directory structure for `OpenTofu` + /// 2. Copies static templates (main.tf, versions.tf for Hetzner) from the template manager + /// 3. Renders Tera templates (cloud-init.yml.tera) with runtime variables + /// 4. Provides debug logging via the tracing crate + /// + /// # Returns + /// + /// * `Result<(), TofuTemplateRendererError>` - Success or error from the template rendering operation + /// + /// # Errors + /// + /// Returns an error if: + /// - Directory creation fails + /// - Template copying fails + /// - Template manager cannot provide required templates + /// - Tera template rendering fails + pub async fn render(&self) -> Result<(), TofuTemplateRendererError> { + tracing::info!( + template_type = "opentofu", + provider = %self.provider, + "Rendering provision templates to build directory" + ); + + // Create build directory structure + let build_tofu_dir = self.create_build_directory().await?; + + // Get static template files based on provider + let static_template_files = self.get_static_template_files(); + + // Copy static template files + self.copy_templates(&static_template_files, &build_tofu_dir) + .await?; + + // Render Tera templates with runtime variables + self.render_tera_templates(&build_tofu_dir).await?; + + tracing::debug!( + template_type = "opentofu", + provider = %self.provider, + output_dir = %build_tofu_dir.display(), + "Provision templates copied and rendered" + ); + + tracing::info!( + template_type = "opentofu", + provider = %self.provider, + status = "complete", + "Provision templates ready" + ); + Ok(()) + } + + /// Returns the list of static template files for the current provider + /// + /// Both LXD and Hetzner currently use the same static template file (main.tf). + /// This method exists to allow provider-specific customization in the future + /// if different providers need different static files. + #[allow(clippy::match_same_arms)] + fn get_static_template_files(&self) -> Vec<&'static str> { + match self.provider { + Provider::Lxd => vec!["main.tf"], + Provider::Hetzner => vec!["main.tf"], + } + } + + /// Builds the full `OpenTofu` build directory path + /// + /// # Returns + /// + /// * `PathBuf` - The complete path to the `OpenTofu` build directory + fn build_opentofu_directory(&self) -> PathBuf { + self.build_dir.join(self.opentofu_build_path()) + } + + /// Builds the template path for a specific file in the `OpenTofu` template directory + /// + /// # Arguments + /// + /// * `file_name` - The name of the template file + /// + /// # Returns + /// + /// * `String` - The complete template path for the specified file + fn build_template_path(&self, file_name: &str) -> String { + format!("{}/{file_name}", self.opentofu_template_path()) + } + + /// Creates the `OpenTofu` build directory structure + /// + /// # Returns + /// + /// * `Result` - The created build directory path or an error + /// + /// # Errors + /// + /// Returns an error if directory creation fails + async fn create_build_directory(&self) -> Result { + let build_tofu_dir = self.build_opentofu_directory(); + tokio::fs::create_dir_all(&build_tofu_dir) + .await + .map_err( + |source| TofuTemplateRendererError::DirectoryCreationFailed { + directory: build_tofu_dir.display().to_string(), + source, + }, + )?; + Ok(build_tofu_dir) + } + + /// Copies a list of template files from the template manager to the destination directory + /// + /// # Arguments + /// + /// * `file_names` - List of file names to copy (without path prefix) + /// * `destination_dir` - The directory where files will be copied + /// + /// # Returns + /// + /// * `Result<(), TofuTemplateRendererError>` - Success or error from the file copying operations + /// + /// # Errors + /// + /// Returns an error if: + /// - Template manager cannot provide required template paths + /// - File copying fails for any of the specified files + async fn copy_templates( + &self, + file_names: &[&str], + destination_dir: &Path, + ) -> Result<(), TofuTemplateRendererError> { + tracing::debug!( + "Copying {} template files to {}", + file_names.len(), + destination_dir.display() + ); + + for file_name in file_names { + let template_path = self.build_template_path(file_name); + + let source_path = self + .template_manager + .get_template_path(&template_path) + .map_err(|source| TofuTemplateRendererError::TemplatePathFailed { + file_name: (*file_name).to_string(), + source, + })?; + + let dest_path = destination_dir.join(file_name); + + tracing::trace!( + "Copying {} to {}", + source_path.display(), + dest_path.display() + ); + + tokio::fs::copy(&source_path, &dest_path) + .await + .map_err(|source| TofuTemplateRendererError::FileCopyFailed { + file_name: (*file_name).to_string(), + source, + })?; + + tracing::debug!("Successfully copied {}", file_name); + } + + tracing::debug!("All template files copied successfully"); + Ok(()) + } + + /// Renders Tera templates with runtime variables using collaborators + /// + /// This method delegates template rendering to specialized collaborators: + /// - cloud-init.yml.tera template rendering to the `CloudInitTemplateRenderer` collaborator + /// - variables.tfvars.tera template rendering using the `VariablesTemplate` + /// + /// # Arguments + /// + /// * `destination_dir` - The directory where rendered templates should be written + /// + /// # Errors + /// + /// Returns an error if: + /// - `CloudInitTemplateRenderer` fails to render the template + /// - `VariablesTemplate` fails to render the variables template + async fn render_tera_templates( + &self, + destination_dir: &Path, + ) -> Result<(), TofuTemplateRendererError> { + tracing::debug!("Rendering Tera templates with runtime variables using collaborators"); + + // Use collaborator to render cloud-init.yml.tera template + self.cloud_init_renderer + .render(&self.ssh_credentials, destination_dir) + .await + .map_err(|source| TofuTemplateRendererError::CloudInitRenderingFailed { source })?; + + // Render variables.tfvars.tera template with instance name + self.render_variables_template(destination_dir).await?; + + tracing::debug!("All Tera templates rendered successfully"); + Ok(()) + } + + /// Renders the variables.tfvars.tera template with the instance name context + /// + /// # Arguments + /// + /// * `destination_dir` - The directory where the rendered variables.tfvars file will be written + /// + /// # Errors + /// + /// Returns an error if: + /// - Template manager cannot provide the variables.tfvars.tera template + /// - Variables template rendering fails + /// - File writing fails + async fn render_variables_template( + &self, + destination_dir: &Path, + ) -> Result<(), TofuTemplateRendererError> { + tracing::debug!( + provider = %self.provider, + "Rendering variables.tfvars.tera template with provider-specific context" + ); + + // Get the variables.tfvars.tera template from the template manager + let template_path = self.build_template_path("variables.tfvars.tera"); + let template_file_path = self + .template_manager + .get_template_path(&template_path) + .map_err(|source| TofuTemplateRendererError::TemplatePathFailed { + file_name: "variables.tfvars.tera".to_string(), + source, + })?; + + // Read the template file content + let template_content = tokio::fs::read_to_string(&template_file_path) + .await + .map_err(|source| TofuTemplateRendererError::FileCopyFailed { + file_name: "variables.tfvars.tera".to_string(), + source, + })?; + + // Create template file wrapper + let template_file = + crate::domain::template::file::File::new("variables.tfvars.tera", template_content) + .map_err(|err| TofuTemplateRendererError::FileCopyFailed { + file_name: "variables.tfvars.tera".to_string(), + source: std::io::Error::new(std::io::ErrorKind::InvalidData, err.to_string()), + })?; + + // Render based on provider + match self.provider { + Provider::Lxd => self.render_lxd_variables_template(&template_file, destination_dir), + Provider::Hetzner => { + self.render_hetzner_variables_template(&template_file, destination_dir) + .await + } + } + } + + /// Renders LXD-specific variables template + fn render_lxd_variables_template( + &self, + template_file: &crate::domain::template::file::File, + destination_dir: &Path, + ) -> Result<(), TofuTemplateRendererError> { + // Get LXD config (profile_name is LXD-specific) + let lxd_config = self.provider_config.as_lxd().ok_or_else(|| { + TofuTemplateRendererError::ProviderConfigMismatch { + expected: "LXD".to_string(), + } + })?; + + // Build LXD context for template rendering + let context = LxdVariablesContextBuilder::new() + .with_instance_name(self.instance_name.clone()) + .with_profile_name(lxd_config.profile_name.clone()) + .build() + .map_err( + |err| TofuTemplateRendererError::LxdVariablesRenderingFailed { + source: LxdVariablesTemplateError::TemplateEngineError { + source: + crate::domain::template::TemplateEngineError::ContextSerialization { + source: tera::Error::msg(err.to_string()), + }, + }, + }, + )?; + + // Create and render the variables template + let variables_template = LxdVariablesTemplate::new(template_file, context) + .map_err(|source| TofuTemplateRendererError::LxdVariablesRenderingFailed { source })?; + + // Write the rendered template to the destination directory + let output_path = destination_dir.join("variables.tfvars"); + variables_template + .render(&output_path) + .map_err(|source| TofuTemplateRendererError::LxdVariablesRenderingFailed { source })?; + + tracing::debug!("LXD variables template rendered successfully"); + Ok(()) + } + + /// Renders Hetzner-specific variables template + async fn render_hetzner_variables_template( + &self, + template_file: &crate::domain::template::file::File, + destination_dir: &Path, + ) -> Result<(), TofuTemplateRendererError> { + use crate::infrastructure::external_tools::tofu::template::providers::hetzner::wrappers::variables::{ + VariablesContextBuilder as HetznerVariablesContextBuilder, + VariablesTemplate as HetznerVariablesTemplate, + }; + + // Get Hetzner config + let hetzner_config = self.provider_config.as_hetzner().ok_or_else(|| { + TofuTemplateRendererError::ProviderConfigMismatch { + expected: "Hetzner".to_string(), + } + })?; + + // Read SSH public key content + let ssh_public_key_content = + tokio::fs::read_to_string(&self.ssh_credentials.ssh_pub_key_path) + .await + .map_err(|source| TofuTemplateRendererError::FileCopyFailed { + file_name: "ssh public key".to_string(), + source, + })?; + + // Build Hetzner context for template rendering + let context = HetznerVariablesContextBuilder::new() + .with_instance_name(self.instance_name.clone()) + .with_hcloud_api_token(hetzner_config.api_token.clone()) + .with_server_type(hetzner_config.server_type.clone()) + .with_server_location(hetzner_config.location.clone()) + .with_server_image(hetzner_config.image.clone()) + .with_ssh_public_key_content(ssh_public_key_content.trim().to_string()) + .build() + .map_err(|err| TofuTemplateRendererError::HetznerContextBuildFailed { + message: err.to_string(), + })?; + + // Create and render the variables template + let variables_template = + HetznerVariablesTemplate::new(template_file, context).map_err(|source| { + TofuTemplateRendererError::HetznerVariablesRenderingFailed { source } + })?; + + // Write the rendered template to the destination directory + let output_path = destination_dir.join("variables.tfvars"); + variables_template.render(&output_path).map_err(|source| { + TofuTemplateRendererError::HetznerVariablesRenderingFailed { source } + })?; + + tracing::debug!("Hetzner variables template rendered successfully"); + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + + use crate::domain::ProfileName; + use crate::shared::Username; + + /// Test instance name for unit tests + fn test_instance_name() -> InstanceName { + InstanceName::new("test-instance".to_string()).expect("Valid test instance name") + } + + /// Test profile name for unit tests + fn test_profile_name() -> ProfileName { + ProfileName::new("test-profile".to_string()).expect("Valid test profile name") + } + + /// Helper function to create dummy SSH credentials for testing + fn create_dummy_ssh_credentials(temp_dir: &Path) -> SshCredentials { + let ssh_priv_key_path = temp_dir.join("test_key"); + let ssh_pub_key_path = temp_dir.join("test_key.pub"); + + // Create dummy key files + fs::write(&ssh_priv_key_path, "dummy_private_key").expect("Failed to write private key"); + fs::write( + &ssh_pub_key_path, + "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQC... test@example.com", + ) + .expect("Failed to write public key"); + + SshCredentials::new( + ssh_priv_key_path, + ssh_pub_key_path, + Username::new("testuser").unwrap(), + ) + } + + /// Helper function to create a test LXD provider config + fn test_lxd_provider_config() -> ProviderConfig { + use crate::domain::provider::LxdConfig; + ProviderConfig::Lxd(LxdConfig { + profile_name: test_profile_name(), + }) + } + + #[tokio::test] + async fn it_should_create_renderer_with_build_directory() { + let temp_dir = tempfile::TempDir::new().expect("Failed to create temp directory"); + let build_path = temp_dir.path().join("build"); + let template_manager = Arc::new(TemplateManager::new(temp_dir.path())); + let ssh_credentials = create_dummy_ssh_credentials(temp_dir.path()); + + let renderer = TofuTemplateRenderer::new( + template_manager, + &build_path, + ssh_credentials, + test_instance_name(), + test_lxd_provider_config(), + ); + + assert_eq!(renderer.build_dir, build_path); + } + + #[tokio::test] + async fn it_should_build_correct_opentofu_directory_path() { + let temp_dir = tempfile::TempDir::new().expect("Failed to create temp directory"); + let build_path = temp_dir.path().join("build"); + let expected_path = build_path.join("tofu/lxd"); + let template_manager = Arc::new(TemplateManager::new(temp_dir.path())); + let ssh_credentials = create_dummy_ssh_credentials(temp_dir.path()); + + let renderer = TofuTemplateRenderer::new( + template_manager, + &build_path, + ssh_credentials, + test_instance_name(), + test_lxd_provider_config(), + ); + let actual_path = renderer.build_opentofu_directory(); + + assert_eq!(actual_path, expected_path); + } + + #[tokio::test] + async fn it_should_build_correct_template_path_for_file() { + let temp_dir = tempfile::TempDir::new().expect("Failed to create temp directory"); + let build_path = temp_dir.path().join("build"); + let template_manager = Arc::new(TemplateManager::new(temp_dir.path())); + let ssh_credentials = create_dummy_ssh_credentials(temp_dir.path()); + + let renderer = TofuTemplateRenderer::new( + template_manager, + &build_path, + ssh_credentials, + test_instance_name(), + test_lxd_provider_config(), + ); + let template_path = renderer.build_template_path("main.tf"); + + assert_eq!(template_path, "tofu/lxd/main.tf"); + } + + #[tokio::test] + async fn it_should_build_template_path_with_different_file_names() { + let temp_dir = tempfile::TempDir::new().expect("Failed to create temp directory"); + let build_path = temp_dir.path().join("build"); + let template_manager = Arc::new(TemplateManager::new(temp_dir.path())); + let ssh_credentials = create_dummy_ssh_credentials(temp_dir.path()); + + let renderer = TofuTemplateRenderer::new( + template_manager, + &build_path, + ssh_credentials, + test_instance_name(), + test_lxd_provider_config(), + ); + + assert_eq!( + renderer.build_template_path("cloud-init.yml"), + "tofu/lxd/cloud-init.yml" + ); + assert_eq!( + renderer.build_template_path("variables.tf"), + "tofu/lxd/variables.tf" + ); + assert_eq!( + renderer.build_template_path("outputs.tf"), + "tofu/lxd/outputs.tf" + ); + } + + #[tokio::test] + async fn it_should_create_build_directory_successfully() { + let temp_dir = tempfile::TempDir::new().expect("Failed to create temp directory"); + let build_path = temp_dir.path().join("build"); + let expected_path = build_path.join("tofu/lxd"); + let template_manager = Arc::new(TemplateManager::new(temp_dir.path())); + + let ssh_credentials = create_dummy_ssh_credentials(temp_dir.path()); + let renderer = TofuTemplateRenderer::new( + template_manager, + &build_path, + ssh_credentials, + test_instance_name(), + test_lxd_provider_config(), + ); + let created_path = renderer + .create_build_directory() + .await + .expect("Failed to create build directory"); + + assert_eq!(created_path, expected_path); + assert!(created_path.exists(), "Build directory should exist"); + assert!(created_path.is_dir(), "Created path should be a directory"); + } + + // Error Handling Tests + #[cfg(unix)] + #[tokio::test] + async fn it_should_fail_when_directory_creation_denied() { + // Create a read-only directory to simulate permission denied + let temp_dir = tempfile::TempDir::new().expect("Failed to create temp directory"); + let readonly_path = temp_dir.path().join("readonly"); + tokio::fs::create_dir(&readonly_path) + .await + .expect("Failed to create readonly dir"); + + // Make the directory read-only + let mut perms = tokio::fs::metadata(&readonly_path) + .await + .unwrap() + .permissions(); + std::os::unix::fs::PermissionsExt::set_mode(&mut perms, 0o444); // Read-only permissions + tokio::fs::set_permissions(&readonly_path, perms) + .await + .unwrap(); + + let build_path = readonly_path.join("build"); + let template_manager = Arc::new(TemplateManager::new(temp_dir.path())); + let ssh_credentials = create_dummy_ssh_credentials(temp_dir.path()); + let renderer = TofuTemplateRenderer::new( + template_manager, + &build_path, + ssh_credentials, + test_instance_name(), + test_lxd_provider_config(), + ); + + let result = renderer.create_build_directory().await; + + assert!( + result.is_err(), + "Should fail when directory creation is denied" + ); + match result.unwrap_err() { + TofuTemplateRendererError::DirectoryCreationFailed { + directory, + source: _, + } => { + assert!( + directory.contains("tofu/lxd"), + "Error should contain the full path" + ); + } + other => panic!("Expected DirectoryCreationFailed, got: {other:?}"), + } + } + + #[tokio::test] + async fn it_should_fail_when_template_manager_cannot_find_template() { + let temp_dir = tempfile::TempDir::new().expect("Failed to create temp directory"); + let build_path = temp_dir.path().join("build"); + + // Create a template manager with empty templates directory + let template_manager = Arc::new(TemplateManager::new(temp_dir.path())); + + let ssh_credentials = create_dummy_ssh_credentials(temp_dir.path()); + let renderer = TofuTemplateRenderer::new( + template_manager, + &build_path, + ssh_credentials, + test_instance_name(), + test_lxd_provider_config(), + ); + + // Try to copy a non-existent template + let result = renderer + .copy_templates(&["nonexistent.tf"], &build_path) + .await; + + assert!(result.is_err(), "Should fail when template is not found"); + match result.unwrap_err() { + TofuTemplateRendererError::TemplatePathFailed { + file_name, + source: _, + } => { + assert_eq!(file_name, "nonexistent.tf"); + } + other => panic!("Expected TemplatePathFailed, got: {other:?}"), + } + } + + #[cfg(unix)] + #[tokio::test] + async fn it_should_fail_when_file_copy_permission_denied() { + let temp_dir = tempfile::TempDir::new().expect("Failed to create temp directory"); + let build_path = temp_dir.path().join("build"); + + // Create the destination directory first, then make it read-only + tokio::fs::create_dir_all(&build_path) + .await + .expect("Failed to create build directory"); + + let mut perms = tokio::fs::metadata(&build_path) + .await + .unwrap() + .permissions(); + std::os::unix::fs::PermissionsExt::set_mode(&mut perms, 0o444); // Read-only permissions + tokio::fs::set_permissions(&build_path, perms) + .await + .unwrap(); + + // Create template manager and ensure it has the template we need + let template_manager = Arc::new(TemplateManager::new(temp_dir.path())); + template_manager + .ensure_templates_dir() + .expect("Failed to ensure templates dir"); + + // Create a test template file manually since we can't rely on embedded resources + let template_dir = temp_dir.path().join("tofu/lxd"); + tokio::fs::create_dir_all(&template_dir) + .await + .expect("Failed to create template dir"); + tokio::fs::write(template_dir.join("test.tf"), "# Test template") + .await + .expect("Failed to write test template"); + + let ssh_credentials = create_dummy_ssh_credentials(temp_dir.path()); + let renderer = TofuTemplateRenderer::new( + template_manager, + temp_dir.path(), + ssh_credentials, + test_instance_name(), + test_lxd_provider_config(), + ); + + let result = renderer.copy_templates(&["test.tf"], &build_path).await; + + assert!(result.is_err(), "Should fail when file copy is denied"); + match result.unwrap_err() { + TofuTemplateRendererError::FileCopyFailed { + file_name, + source: _, + } => { + assert_eq!(file_name, "test.tf"); + } + other => panic!("Expected FileCopyFailed, got: {other:?}"), + } + } + + // Input Validation Edge Case Tests + #[tokio::test] + async fn it_should_handle_empty_file_name() { + let temp_dir = tempfile::TempDir::new().expect("Failed to create temp directory"); + let build_path = temp_dir.path().join("build"); + let template_manager = Arc::new(TemplateManager::new(temp_dir.path())); + let ssh_credentials = create_dummy_ssh_credentials(temp_dir.path()); + + let renderer = TofuTemplateRenderer::new( + template_manager, + &build_path, + ssh_credentials, + test_instance_name(), + test_lxd_provider_config(), + ); + let template_path = renderer.build_template_path(""); + assert_eq!(template_path, "tofu/lxd/"); + } + + #[tokio::test] + async fn it_should_handle_file_names_with_path_separators() { + let temp_dir = tempfile::TempDir::new().expect("Failed to create temp directory"); + let build_path = temp_dir.path().join("build"); + let template_manager = Arc::new(TemplateManager::new(temp_dir.path())); + let ssh_credentials = create_dummy_ssh_credentials(temp_dir.path()); + + let renderer = TofuTemplateRenderer::new( + template_manager, + &build_path, + ssh_credentials, + test_instance_name(), + test_lxd_provider_config(), + ); + + // File names with forward slashes should be handled literally + let template_path = renderer.build_template_path("sub/dir/file.tf"); + assert_eq!(template_path, "tofu/lxd/sub/dir/file.tf"); + + // File names with backslashes (Windows-style) + let template_path = renderer.build_template_path("sub\\dir\\file.tf"); + assert_eq!(template_path, "tofu/lxd/sub\\dir\\file.tf"); + + // Relative path components + let template_path = renderer.build_template_path("../main.tf"); + assert_eq!(template_path, "tofu/lxd/../main.tf"); + } + + #[tokio::test] + async fn it_should_handle_special_characters_in_file_names() { + let temp_dir = tempfile::TempDir::new().expect("Failed to create temp directory"); + let build_path = temp_dir.path().join("build"); + let template_manager = Arc::new(TemplateManager::new(temp_dir.path())); + let ssh_credentials = create_dummy_ssh_credentials(temp_dir.path()); + + let renderer = TofuTemplateRenderer::new( + template_manager, + &build_path, + ssh_credentials, + test_instance_name(), + test_lxd_provider_config(), + ); + + // File names with spaces + let template_path = renderer.build_template_path("main file.tf"); + assert_eq!(template_path, "tofu/lxd/main file.tf"); + + // File names with unicode characters + let template_path = renderer.build_template_path("файл.tf"); + assert_eq!(template_path, "tofu/lxd/файл.tf"); + + // File names with special characters + let template_path = renderer.build_template_path("main@#$%.tf"); + assert_eq!(template_path, "tofu/lxd/main@#$%.tf"); + } + + #[tokio::test] + async fn it_should_handle_very_long_file_names() { + let temp_dir = tempfile::TempDir::new().expect("Failed to create temp directory"); + let build_path = temp_dir.path().join("build"); + let template_manager = Arc::new(TemplateManager::new(temp_dir.path())); + let ssh_credentials = create_dummy_ssh_credentials(temp_dir.path()); + + let renderer = TofuTemplateRenderer::new( + template_manager, + &build_path, + ssh_credentials, + test_instance_name(), + test_lxd_provider_config(), + ); + + // Create a very long file name (300 characters) + let long_name = "a".repeat(300) + ".tf"; + let template_path = renderer.build_template_path(&long_name); + assert_eq!(template_path, format!("tofu/lxd/{long_name}")); + } + + // File System Edge Case Tests + #[tokio::test] + async fn it_should_handle_existing_build_directory() { + let temp_dir = tempfile::TempDir::new().expect("Failed to create temp directory"); + let build_path = temp_dir.path().join("build"); + let tofu_path = build_path.join("tofu/lxd"); + + // Pre-create the directory structure + tokio::fs::create_dir_all(&tofu_path) + .await + .expect("Failed to create existing directory"); + assert!(tofu_path.exists(), "Directory should already exist"); + + let template_manager = Arc::new(TemplateManager::new(temp_dir.path())); + let ssh_credentials = create_dummy_ssh_credentials(temp_dir.path()); + let renderer = TofuTemplateRenderer::new( + template_manager, + &build_path, + ssh_credentials, + test_instance_name(), + test_lxd_provider_config(), + ); + let created_path = renderer + .create_build_directory() + .await + .expect("Should handle existing directory gracefully"); + + assert_eq!(created_path, tofu_path); + assert!(created_path.exists(), "Directory should still exist"); + } + + #[tokio::test] + async fn it_should_handle_empty_template_files_array() { + let temp_dir = tempfile::TempDir::new().expect("Failed to create temp directory"); + let build_path = temp_dir.path().join("build"); + + let template_manager = Arc::new(TemplateManager::new(temp_dir.path())); + + let ssh_credentials = create_dummy_ssh_credentials(temp_dir.path()); + let renderer = TofuTemplateRenderer::new( + template_manager, + &build_path, + ssh_credentials, + test_instance_name(), + test_lxd_provider_config(), + ); + + // Should succeed with empty array + let result = renderer.copy_templates(&[], &build_path).await; + + assert!(result.is_ok(), "Should handle empty template files array"); + } + + #[tokio::test] + async fn it_should_handle_duplicate_files_in_array() { + let temp_dir = tempfile::TempDir::new().expect("Failed to create temp directory"); + let build_path = temp_dir.path().join("build"); + tokio::fs::create_dir_all(&build_path) + .await + .expect("Failed to create build directory"); + + let template_manager = Arc::new(TemplateManager::new(temp_dir.path())); + template_manager + .ensure_templates_dir() + .expect("Failed to ensure templates dir"); + + // Create a test template file manually + let template_dir = temp_dir.path().join("tofu/lxd"); + tokio::fs::create_dir_all(&template_dir) + .await + .expect("Failed to create template dir"); + tokio::fs::write(template_dir.join("main.tf"), "# Main template") + .await + .expect("Failed to write test template"); + + let ssh_credentials = create_dummy_ssh_credentials(temp_dir.path()); + let renderer = TofuTemplateRenderer::new( + template_manager, + temp_dir.path(), + ssh_credentials, + test_instance_name(), + test_lxd_provider_config(), + ); + + // Copy the same file twice - should succeed (overwrite) + let result = renderer + .copy_templates(&["main.tf", "main.tf"], &build_path) + .await; + + assert!( + result.is_ok(), + "Should handle duplicate files by overwriting" + ); + assert!( + build_path.join("main.tf").exists(), + "File should exist after duplicate copy" + ); + } + + // Async Operation Edge Case Tests + #[tokio::test] + async fn it_should_handle_concurrent_renderer_operations() { + let temp_dir = tempfile::TempDir::new().expect("Failed to create temp directory"); + let build_path1 = temp_dir.path().join("build1"); + let build_path2 = temp_dir.path().join("build2"); + + let template_manager = Arc::new(TemplateManager::new(temp_dir.path())); + template_manager + .ensure_templates_dir() + .expect("Failed to ensure templates dir"); + + // Create test template files + let template_dir = temp_dir.path().join("tofu/lxd"); + tokio::fs::create_dir_all(&template_dir) + .await + .expect("Failed to create template dir"); + tokio::fs::write(template_dir.join("test1.tf"), "# Test template 1") + .await + .expect("Failed to write test template 1"); + tokio::fs::write(template_dir.join("test2.tf"), "# Test template 2") + .await + .expect("Failed to write test template 2"); + + let ssh_credentials1 = create_dummy_ssh_credentials(temp_dir.path()); + let renderer1 = TofuTemplateRenderer::new( + template_manager.clone(), + &build_path1, + ssh_credentials1, + test_instance_name(), + test_lxd_provider_config(), + ); + let ssh_credentials2 = create_dummy_ssh_credentials(temp_dir.path()); + let renderer2 = TofuTemplateRenderer::new( + template_manager, + &build_path2, + ssh_credentials2, + test_instance_name(), + test_lxd_provider_config(), + ); + + tokio::fs::create_dir_all(&build_path1) + .await + .expect("Failed to create build path 1"); + tokio::fs::create_dir_all(&build_path2) + .await + .expect("Failed to create build path 2"); + + // Run both operations concurrently + let (result1, result2) = tokio::join!( + renderer1.copy_templates(&["test1.tf"], &build_path1), + renderer2.copy_templates(&["test2.tf"], &build_path2) + ); + + assert!(result1.is_ok(), "First concurrent operation should succeed"); + assert!( + result2.is_ok(), + "Second concurrent operation should succeed" + ); + assert!( + build_path1.join("test1.tf").exists(), + "First template should exist" + ); + assert!( + build_path2.join("test2.tf").exists(), + "Second template should exist" + ); + } + + #[tokio::test] + async fn it_should_handle_partial_failure_scenarios() { + let temp_dir = tempfile::TempDir::new().expect("Failed to create temp directory"); + let build_path = temp_dir.path().join("build"); + tokio::fs::create_dir_all(&build_path) + .await + .expect("Failed to create build directory"); + + let template_manager = Arc::new(TemplateManager::new(temp_dir.path())); + template_manager + .ensure_templates_dir() + .expect("Failed to ensure templates dir"); + + // Create only one of the two template files we'll try to copy + let template_dir = temp_dir.path().join("tofu/lxd"); + tokio::fs::create_dir_all(&template_dir) + .await + .expect("Failed to create template dir"); + tokio::fs::write(template_dir.join("exists.tf"), "# Existing template") + .await + .expect("Failed to write existing template"); + + let ssh_credentials = create_dummy_ssh_credentials(temp_dir.path()); + let renderer = TofuTemplateRenderer::new( + template_manager, + temp_dir.path(), + ssh_credentials, + test_instance_name(), + test_lxd_provider_config(), + ); + + // Try to copy both existing and non-existing files + let result = renderer + .copy_templates(&["exists.tf", "missing.tf"], &build_path) + .await; + + // Should fail on the missing template + assert!(result.is_err(), "Should fail when one template is missing"); + + // The first file might have been copied before the failure + // This tests the partial failure behavior + match result.unwrap_err() { + TofuTemplateRendererError::TemplatePathFailed { + file_name, + source: _, + } => { + assert_eq!(file_name, "missing.tf"); + } + other => panic!("Expected TemplatePathFailed for missing file, got: {other:?}"), + } + } + + #[tokio::test] + async fn it_should_handle_large_number_of_files() { + let temp_dir = tempfile::TempDir::new().expect("Failed to create temp directory"); + let build_path = temp_dir.path().join("build"); + tokio::fs::create_dir_all(&build_path) + .await + .expect("Failed to create build directory"); + + let template_manager = Arc::new(TemplateManager::new(temp_dir.path())); + template_manager + .ensure_templates_dir() + .expect("Failed to ensure templates dir"); + + // Create many template files + let template_dir = temp_dir.path().join("tofu/lxd"); + tokio::fs::create_dir_all(&template_dir) + .await + .expect("Failed to create template dir"); + + let mut file_names = Vec::new(); + for i in 0..50 { + // Create 50 files + let file_name = format!("template_{i}.tf"); + tokio::fs::write(template_dir.join(&file_name), format!("# Template {i}")) + .await + .expect("Failed to write template file"); + file_names.push(file_name); + } + + let ssh_credentials = create_dummy_ssh_credentials(temp_dir.path()); + let renderer = TofuTemplateRenderer::new( + template_manager, + temp_dir.path(), + ssh_credentials, + test_instance_name(), + test_lxd_provider_config(), + ); + + let file_refs: Vec<&str> = file_names.iter().map(std::string::String::as_str).collect(); + let result = renderer.copy_templates(&file_refs, &build_path).await; + + assert!(result.is_ok(), "Should handle large number of files"); + + // Verify all files were copied + for file_name in &file_names { + assert!( + build_path.join(file_name).exists(), + "File {file_name} should exist" + ); + } + } +} diff --git a/src/infrastructure/external_tools/tofu/template/mod.rs b/src/infrastructure/external_tools/tofu/template/mod.rs index 74a57c92..f28c3d97 100644 --- a/src/infrastructure/external_tools/tofu/template/mod.rs +++ b/src/infrastructure/external_tools/tofu/template/mod.rs @@ -8,4 +8,8 @@ pub mod common; pub mod providers; pub use common::renderer::cloud_init::{CloudInitTemplateError, CloudInitTemplateRenderer}; -pub use common::renderer::{ProvisionTemplateError, TofuTemplateRenderer}; +pub use common::renderer::{TofuTemplateRenderer, TofuTemplateRendererError}; + +/// Type alias for backward compatibility. +#[allow(deprecated)] +pub use common::renderer::ProvisionTemplateError; From d9055742dd1956a1b363611d0c79c051ab9557bc Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Tue, 2 Dec 2025 19:09:26 +0000 Subject: [PATCH 09/14] refactor: [#212] extract template types to dedicated modules Extract CloudInitTemplate and VariablesTemplate types to their own module files for better code organization and separation of concerns. Changes: - Extract CloudInitTemplate from common/wrappers/cloud_init/mod.rs to cloud_init_template.rs - Extract VariablesTemplate from providers/hetzner/wrappers/variables/mod.rs to variables_template.rs - Extract VariablesTemplate from providers/lxd/wrappers/variables/mod.rs to variables_template.rs - Move all associated unit tests with each type Each mod.rs now contains only module declarations and re-exports, following the pattern established in previous commits. --- .../cloud_init/cloud_init_template.rs | 225 +++++++++++++++ .../common/wrappers/cloud_init/mod.rs | 223 +-------------- .../hetzner/wrappers/variables/mod.rs | 254 +---------------- .../wrappers/variables/variables_template.rs | 257 ++++++++++++++++++ .../providers/lxd/wrappers/variables/mod.rs | 246 +---------------- .../wrappers/variables/variables_template.rs | 249 +++++++++++++++++ 6 files changed, 737 insertions(+), 717 deletions(-) create mode 100644 src/infrastructure/external_tools/tofu/template/common/wrappers/cloud_init/cloud_init_template.rs create mode 100644 src/infrastructure/external_tools/tofu/template/providers/hetzner/wrappers/variables/variables_template.rs create mode 100644 src/infrastructure/external_tools/tofu/template/providers/lxd/wrappers/variables/variables_template.rs diff --git a/src/infrastructure/external_tools/tofu/template/common/wrappers/cloud_init/cloud_init_template.rs b/src/infrastructure/external_tools/tofu/template/common/wrappers/cloud_init/cloud_init_template.rs new file mode 100644 index 00000000..a14dfd12 --- /dev/null +++ b/src/infrastructure/external_tools/tofu/template/common/wrappers/cloud_init/cloud_init_template.rs @@ -0,0 +1,225 @@ +//! `CloudInitTemplate` type and implementation. + +use crate::domain::template::file::File; +use crate::domain::template::{ + write_file_with_dir_creation, FileOperationError, TemplateEngineError, +}; +use anyhow::Result; +use std::path::Path; + +use super::CloudInitContext; + +#[derive(Debug)] +pub struct CloudInitTemplate { + context: CloudInitContext, + content: String, +} + +impl CloudInitTemplate { + /// Creates a new `CloudInitTemplate`, 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 `CloudInitContext` fails, + /// which should never happen under normal circumstances. + pub fn new( + template_file: &File, + cloud_init_context: CloudInitContext, + ) -> Result { + let mut engine = crate::domain::template::TemplateEngine::new(); + + let validated_content = engine.render( + template_file.filename(), + template_file.content(), + &cloud_init_context, + )?; + + Ok(Self { + context: cloud_init_context, + content: validated_content, + }) + } + + /// Get the SSH public key value + #[must_use] + pub fn ssh_public_key(&self) -> &str { + self.context.ssh_public_key() + } + + /// 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::tofu::template::common::wrappers::cloud_init::CloudInitContext; + + /// Helper function to create a `CloudInitContext` with given SSH key + fn create_cloud_init_context(ssh_key: &str) -> CloudInitContext { + CloudInitContext::builder() + .with_ssh_public_key(ssh_key) + .unwrap() + .with_username("testuser") + .unwrap() + .build() + .unwrap() + } + + #[test] + fn it_should_create_cloud_init_template_successfully() { + // Use template content directly instead of file + let template_content = + "#cloud-config\nusers:\n - ssh_authorized_keys:\n - {{ ssh_public_key }}\n"; + + let template_file = File::new("cloud-init.yml.tera", template_content.to_string()).unwrap(); + + let ssh_key = "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQC... test@example.com"; + let cloud_init_context = create_cloud_init_context(ssh_key); + let template = CloudInitTemplate::new(&template_file, cloud_init_context).unwrap(); + + assert_eq!(template.ssh_public_key(), ssh_key); + } + + #[test] + fn it_should_generate_cloud_init_template_context() { + // Use template content directly instead of file + let template_content = + "#cloud-config\nusers:\n - ssh_authorized_keys:\n - {{ ssh_public_key }}\n"; + + let template_file = File::new("cloud-init.yml.tera", template_content.to_string()).unwrap(); + + let ssh_key = "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQC... test@example.com"; + let cloud_init_context = create_cloud_init_context(ssh_key); + let template = CloudInitTemplate::new(&template_file, cloud_init_context).unwrap(); + + assert_eq!(template.ssh_public_key(), ssh_key); + } + + #[test] + fn it_should_accept_empty_template_content() { + // Test with empty template content + let template_file = File::new("cloud-init.yml.tera", String::new()).unwrap(); + + let ssh_key = "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQC... test@example.com"; + let cloud_init_context = create_cloud_init_context(ssh_key); + let result = CloudInitTemplate::new(&template_file, cloud_init_context); + + // Empty templates are valid in Tera - they just render as empty strings + assert!(result.is_ok()); + let template = result.unwrap(); + assert_eq!(template.content, ""); + } + + #[test] + fn it_should_work_with_missing_placeholder_variables() { + // Create template content with no placeholder variables + let template_content = "#cloud-config\nusers:\n - name: static_user\n"; + + let template_file = File::new("cloud-init.yml.tera", template_content.to_string()).unwrap(); + + let ssh_key = "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQC... test@example.com"; + let cloud_init_context = create_cloud_init_context(ssh_key); + let result = CloudInitTemplate::new(&template_file, cloud_init_context); + + // This is valid - templates don't need to use all available context variables + assert!(result.is_ok()); + let template = result.unwrap(); + assert!(template.content.contains("static_user")); + } + + #[test] + fn it_should_accept_static_template_with_no_variables() { + // Create template content with no placeholder variables at all + let template_content = "#cloud-config\npackages:\n - curl\n"; + + let template_file = File::new("cloud-init.yml.tera", template_content.to_string()).unwrap(); + + let ssh_key = "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQC... test@example.com"; + let cloud_init_context = create_cloud_init_context(ssh_key); + let result = CloudInitTemplate::new(&template_file, cloud_init_context); + + // Static templates are valid - they just don't use template variables + assert!(result.is_ok()); + let template = result.unwrap(); + assert!(template.content.contains("curl")); + } + + #[test] + fn it_should_fail_when_template_references_undefined_variable() { + // Create template content that references an undefined variable + let template_content = + "#cloud-config\nusers:\n - ssh_authorized_keys:\n - {{ undefined_variable }}\n"; + + let template_file = File::new("cloud-init.yml.tera", template_content.to_string()).unwrap(); + + let ssh_key = "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQC... test@example.com"; + let cloud_init_context = create_cloud_init_context(ssh_key); + let result = CloudInitTemplate::new(&template_file, cloud_init_context); + + // This should fail because the template references an undefined variable + assert!(result.is_err()); + let error_msg = result.unwrap_err().to_string(); + assert!(error_msg.contains("Failed to render template") || error_msg.contains("template")); + } + + #[test] + fn it_should_fail_when_template_validation_fails() { + // Create template content with malformed Tera syntax + let template_content = "#cloud-config\nusers:\n - ssh_authorized_keys:\n - {{ ssh_public_key }}\nmalformed={{unclosed_var\n"; + + let template_file = File::new("cloud-init.yml.tera", template_content.to_string()).unwrap(); + + let ssh_key = "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQC... test@example.com"; + let cloud_init_context = create_cloud_init_context(ssh_key); + let result = CloudInitTemplate::new(&template_file, cloud_init_context); + + // Should fail during template validation + assert!(result.is_err()); + } + + #[test] + fn it_should_fail_when_template_has_malformed_syntax() { + // Test with different malformed template syntax + let template_content = "invalid {{{{ syntax"; + + let template_file = File::new("cloud-init.yml.tera", template_content.to_string()).unwrap(); + + let ssh_key = "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQC... test@example.com"; + let cloud_init_context = create_cloud_init_context(ssh_key); + let result = CloudInitTemplate::new(&template_file, cloud_init_context); + + assert!(result.is_err()); + } + + #[test] + fn it_should_validate_template_at_construction_time() { + // Create valid template content + let template_content = + "#cloud-config\nusers:\n - ssh_authorized_keys:\n - {{ ssh_public_key }}\n"; + + let template_file = File::new("cloud-init.yml.tera", template_content.to_string()).unwrap(); + + // Template validation happens during construction, not during render + let ssh_key = "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQC... test@example.com"; + let cloud_init_context = create_cloud_init_context(ssh_key); + let template = CloudInitTemplate::new(&template_file, cloud_init_context).unwrap(); + + // Verify that the template was pre-validated and contains rendered content + assert_eq!(template.ssh_public_key(), ssh_key); + assert!(template.content.contains(ssh_key)); + } +} diff --git a/src/infrastructure/external_tools/tofu/template/common/wrappers/cloud_init/mod.rs b/src/infrastructure/external_tools/tofu/template/common/wrappers/cloud_init/mod.rs index dde43e4f..efa2eac9 100644 --- a/src/infrastructure/external_tools/tofu/template/common/wrappers/cloud_init/mod.rs +++ b/src/infrastructure/external_tools/tofu/template/common/wrappers/cloud_init/mod.rs @@ -3,227 +3,8 @@ //! This template has mandatory variables that must be provided at construction time. //! This wrapper is shared by all providers since the cloud-init template is the same. +mod cloud_init_template; 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 cloud_init_template::CloudInitTemplate; pub use context::{CloudInitContext, CloudInitContextBuilder, CloudInitContextError}; - -#[derive(Debug)] -pub struct CloudInitTemplate { - context: CloudInitContext, - content: String, -} - -impl CloudInitTemplate { - /// Creates a new `CloudInitTemplate`, 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 `CloudInitContext` fails, - /// which should never happen under normal circumstances. - pub fn new( - template_file: &File, - cloud_init_context: CloudInitContext, - ) -> Result { - let mut engine = crate::domain::template::TemplateEngine::new(); - - let validated_content = engine.render( - template_file.filename(), - template_file.content(), - &cloud_init_context, - )?; - - Ok(Self { - context: cloud_init_context, - content: validated_content, - }) - } - - /// Get the SSH public key value - #[must_use] - pub fn ssh_public_key(&self) -> &str { - self.context.ssh_public_key() - } - - /// 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::*; - - /// Helper function to create a `CloudInitContext` with given SSH key - fn create_cloud_init_context(ssh_key: &str) -> CloudInitContext { - CloudInitContext::builder() - .with_ssh_public_key(ssh_key) - .unwrap() - .with_username("testuser") - .unwrap() - .build() - .unwrap() - } - - #[test] - fn it_should_create_cloud_init_template_successfully() { - // Use template content directly instead of file - let template_content = - "#cloud-config\nusers:\n - ssh_authorized_keys:\n - {{ ssh_public_key }}\n"; - - let template_file = File::new("cloud-init.yml.tera", template_content.to_string()).unwrap(); - - let ssh_key = "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQC... test@example.com"; - let cloud_init_context = create_cloud_init_context(ssh_key); - let template = CloudInitTemplate::new(&template_file, cloud_init_context).unwrap(); - - assert_eq!(template.ssh_public_key(), ssh_key); - } - - #[test] - fn it_should_generate_cloud_init_template_context() { - // Use template content directly instead of file - let template_content = - "#cloud-config\nusers:\n - ssh_authorized_keys:\n - {{ ssh_public_key }}\n"; - - let template_file = File::new("cloud-init.yml.tera", template_content.to_string()).unwrap(); - - let ssh_key = "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQC... test@example.com"; - let cloud_init_context = create_cloud_init_context(ssh_key); - let template = CloudInitTemplate::new(&template_file, cloud_init_context).unwrap(); - - assert_eq!(template.ssh_public_key(), ssh_key); - } - - #[test] - fn it_should_accept_empty_template_content() { - // Test with empty template content - let template_file = File::new("cloud-init.yml.tera", String::new()).unwrap(); - - let ssh_key = "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQC... test@example.com"; - let cloud_init_context = create_cloud_init_context(ssh_key); - let result = CloudInitTemplate::new(&template_file, cloud_init_context); - - // Empty templates are valid in Tera - they just render as empty strings - assert!(result.is_ok()); - let template = result.unwrap(); - assert_eq!(template.content, ""); - } - - #[test] - fn it_should_work_with_missing_placeholder_variables() { - // Create template content with no placeholder variables - let template_content = "#cloud-config\nusers:\n - name: static_user\n"; - - let template_file = File::new("cloud-init.yml.tera", template_content.to_string()).unwrap(); - - let ssh_key = "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQC... test@example.com"; - let cloud_init_context = create_cloud_init_context(ssh_key); - let result = CloudInitTemplate::new(&template_file, cloud_init_context); - - // This is valid - templates don't need to use all available context variables - assert!(result.is_ok()); - let template = result.unwrap(); - assert!(template.content.contains("static_user")); - } - - #[test] - fn it_should_accept_static_template_with_no_variables() { - // Create template content with no placeholder variables at all - let template_content = "#cloud-config\npackages:\n - curl\n"; - - let template_file = File::new("cloud-init.yml.tera", template_content.to_string()).unwrap(); - - let ssh_key = "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQC... test@example.com"; - let cloud_init_context = create_cloud_init_context(ssh_key); - let result = CloudInitTemplate::new(&template_file, cloud_init_context); - - // Static templates are valid - they just don't use template variables - assert!(result.is_ok()); - let template = result.unwrap(); - assert!(template.content.contains("curl")); - } - - #[test] - fn it_should_fail_when_template_references_undefined_variable() { - // Create template content that references an undefined variable - let template_content = - "#cloud-config\nusers:\n - ssh_authorized_keys:\n - {{ undefined_variable }}\n"; - - let template_file = File::new("cloud-init.yml.tera", template_content.to_string()).unwrap(); - - let ssh_key = "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQC... test@example.com"; - let cloud_init_context = create_cloud_init_context(ssh_key); - let result = CloudInitTemplate::new(&template_file, cloud_init_context); - - // This should fail because the template references an undefined variable - assert!(result.is_err()); - let error_msg = result.unwrap_err().to_string(); - assert!(error_msg.contains("Failed to render template") || error_msg.contains("template")); - } - - #[test] - fn it_should_fail_when_template_validation_fails() { - // Create template content with malformed Tera syntax - let template_content = "#cloud-config\nusers:\n - ssh_authorized_keys:\n - {{ ssh_public_key }}\nmalformed={{unclosed_var\n"; - - let template_file = File::new("cloud-init.yml.tera", template_content.to_string()).unwrap(); - - let ssh_key = "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQC... test@example.com"; - let cloud_init_context = create_cloud_init_context(ssh_key); - let result = CloudInitTemplate::new(&template_file, cloud_init_context); - - // Should fail during template validation - assert!(result.is_err()); - } - - #[test] - fn it_should_fail_when_template_has_malformed_syntax() { - // Test with different malformed template syntax - let template_content = "invalid {{{{ syntax"; - - let template_file = File::new("cloud-init.yml.tera", template_content.to_string()).unwrap(); - - let ssh_key = "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQC... test@example.com"; - let cloud_init_context = create_cloud_init_context(ssh_key); - let result = CloudInitTemplate::new(&template_file, cloud_init_context); - - assert!(result.is_err()); - } - - #[test] - fn it_should_validate_template_at_construction_time() { - // Create valid template content - let template_content = - "#cloud-config\nusers:\n - ssh_authorized_keys:\n - {{ ssh_public_key }}\n"; - - let template_file = File::new("cloud-init.yml.tera", template_content.to_string()).unwrap(); - - // Template validation happens during construction, not during render - let ssh_key = "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQC... test@example.com"; - let cloud_init_context = create_cloud_init_context(ssh_key); - let template = CloudInitTemplate::new(&template_file, cloud_init_context).unwrap(); - - // Verify that the template was pre-validated and contains rendered content - assert_eq!(template.ssh_public_key(), ssh_key); - assert!(template.content.contains(ssh_key)); - } -} diff --git a/src/infrastructure/external_tools/tofu/template/providers/hetzner/wrappers/variables/mod.rs b/src/infrastructure/external_tools/tofu/template/providers/hetzner/wrappers/variables/mod.rs index b5fbb6e5..0b0dee9b 100644 --- a/src/infrastructure/external_tools/tofu/template/providers/hetzner/wrappers/variables/mod.rs +++ b/src/infrastructure/external_tools/tofu/template/providers/hetzner/wrappers/variables/mod.rs @@ -7,258 +7,8 @@ //! Hetzner Cloud infrastructure provisioning. pub mod context; +mod variables_template; -use std::path::Path; - -use crate::domain::template::file::File; -use crate::domain::template::{write_file_with_dir_creation, TemplateEngine}; pub use crate::infrastructure::external_tools::tofu::template::common::wrappers::VariablesTemplateError; pub use context::{VariablesContext, VariablesContextBuilder, VariablesContextError}; - -/// Template wrapper for Hetzner Cloud `OpenTofu` variables rendering -/// -/// Validates and renders `variables.tfvars.tera` templates with `VariablesContext` -/// to produce dynamic infrastructure variable files for Hetzner Cloud. -#[derive(Debug)] -pub struct VariablesTemplate { - context: context::VariablesContext, - content: String, -} - -impl VariablesTemplate { - /// Creates a new Hetzner variables template with validation - /// - /// # Arguments - /// - /// * `template_file` - The template file containing variables.tfvars.tera content - /// * `context` - The context containing Hetzner-specific runtime values - /// - /// # Returns - /// - /// * `Ok(VariablesTemplate)` if template validation succeeds - /// * `Err(VariablesTemplateError)` if validation fails - /// - /// # Errors - /// - /// Returns `TemplateEngineError` if the template has syntax errors or validation fails - pub fn new( - template_file: &File, - context: VariablesContext, - ) -> Result { - let mut engine = TemplateEngine::new(); - - let validated_content = - engine.render(template_file.filename(), template_file.content(), &context)?; - - Ok(Self { - context, - content: validated_content, - }) - } - - /// Get the instance name value - #[must_use] - pub fn instance_name(&self) -> &str { - self.context.instance_name.as_str() - } - - /// 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<(), VariablesTemplateError> { - write_file_with_dir_creation(output_path, &self.content)?; - Ok(()) - } - - /// Gets the context used by this template - #[must_use] - pub fn context(&self) -> &VariablesContext { - &self.context - } - - /// Gets the rendered content - #[must_use] - pub fn content(&self) -> &str { - &self.content - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::domain::InstanceName; - use tempfile::NamedTempFile; - - fn create_test_context() -> VariablesContext { - VariablesContext::builder() - .with_instance_name(InstanceName::new("test-instance".to_string()).unwrap()) - .with_hcloud_api_token("test-api-token".to_string()) - .with_server_type("cx22".to_string()) - .with_server_location("nbg1".to_string()) - .with_server_image("ubuntu-24.04".to_string()) - .with_ssh_public_key_content("ssh-rsa AAAA... test@example.com".to_string()) - .build() - .unwrap() - } - - #[test] - fn it_should_create_variables_template_successfully() { - let template_content = r#"hcloud_api_token = "{{ hcloud_api_token }}" -server_name = "{{ instance_name }}""#; - - let template_file = - File::new("variables.tfvars.tera", template_content.to_string()).unwrap(); - let context = create_test_context(); - - let result = VariablesTemplate::new(&template_file, context); - assert!(result.is_ok()); - } - - #[test] - fn it_should_fail_when_template_has_malformed_syntax() { - let template_content = r#"hcloud_api_token = "{{ hcloud_api_token -server_name = "{{ instance_name }}""#; // Missing closing }} - - let template_file = - File::new("variables.tfvars.tera", template_content.to_string()).unwrap(); - let context = create_test_context(); - - let result = VariablesTemplate::new(&template_file, context); - assert!(matches!( - result.unwrap_err(), - VariablesTemplateError::TemplateEngineError { .. } - )); - } - - #[test] - fn it_should_accept_static_template_with_no_variables() { - let template_content = r#"hcloud_api_token = "hardcoded-token" -server_type = "cx22""#; - - let template_file = - File::new("variables.tfvars.tera", template_content.to_string()).unwrap(); - let context = create_test_context(); - - let result = VariablesTemplate::new(&template_file, context); - assert!(result.is_ok()); - } - - #[test] - fn it_should_accept_empty_template_content() { - let template_content = ""; - - let template_file = - File::new("variables.tfvars.tera", template_content.to_string()).unwrap(); - let context = create_test_context(); - - let result = VariablesTemplate::new(&template_file, context); - assert!(result.is_ok()); - } - - #[test] - fn it_should_render_variables_template_successfully() { - let template_content = r#"# OpenTofu Variables for Hetzner Cloud -hcloud_api_token = "{{ hcloud_api_token }}" -server_name = "{{ instance_name }}" -server_type = "{{ server_type }}""#; - - let template_file = - File::new("variables.tfvars.tera", template_content.to_string()).unwrap(); - let context = create_test_context(); - let variables_template = VariablesTemplate::new(&template_file, context).unwrap(); - - let temp_file = NamedTempFile::new().unwrap(); - let result = variables_template.render(temp_file.path()); - - assert!(result.is_ok()); - - // Verify rendered content - let rendered_content = std::fs::read_to_string(temp_file.path()).unwrap(); - assert!(rendered_content.contains(r#"hcloud_api_token = "test-api-token""#)); - assert!(rendered_content.contains(r#"server_name = "test-instance""#)); - assert!(rendered_content.contains(r#"server_type = "cx22""#)); - } - - #[test] - fn it_should_provide_access_to_context() { - let template_file = File::new("variables.tfvars.tera", String::new()).unwrap(); - let context = create_test_context(); - let variables_template = VariablesTemplate::new(&template_file, context).unwrap(); - - assert_eq!( - variables_template.context().instance_name.as_str(), - "test-instance" - ); - } - - #[test] - fn it_should_provide_access_to_rendered_content() { - let template_content = r#"server_name = "{{ instance_name }}""#; - let template_file = - File::new("variables.tfvars.tera", template_content.to_string()).unwrap(); - let context = create_test_context(); - let variables_template = VariablesTemplate::new(&template_file, context).unwrap(); - - assert!(variables_template.content().contains("test-instance")); - } - - #[test] - fn it_should_work_with_missing_placeholder_variables() { - // Template has no placeholders but context has values - should work fine - let template_content = r#"hcloud_api_token = "hardcoded" -server_type = "cx22""#; - - let template_file = - File::new("variables.tfvars.tera", template_content.to_string()).unwrap(); - let context = create_test_context(); - let variables_template = VariablesTemplate::new(&template_file, context).unwrap(); - - let temp_file = NamedTempFile::new().unwrap(); - let result = variables_template.render(temp_file.path()); - - assert!(result.is_ok()); - - let rendered_content = std::fs::read_to_string(temp_file.path()).unwrap(); - assert!(rendered_content.contains(r#"hcloud_api_token = "hardcoded""#)); - } - - #[test] - fn it_should_validate_template_at_construction_time() { - let template_content = r#"hcloud_api_token = "{{ undefined_variable }}" -server_type = "cx22""#; - - let template_file = - File::new("variables.tfvars.tera", template_content.to_string()).unwrap(); - let context = create_test_context(); - - // Should fail at construction, not during render - let result = VariablesTemplate::new(&template_file, context); - assert!(matches!( - result.unwrap_err(), - VariablesTemplateError::TemplateEngineError { .. } - )); - } - - #[test] - fn it_should_generate_variables_template_context() { - let template_file = - File::new("variables.tfvars.tera", "{{ instance_name }}".to_string()).unwrap(); - let context = VariablesContext::builder() - .with_instance_name(InstanceName::new("dynamic-vm".to_string()).unwrap()) - .with_hcloud_api_token("dynamic-token".to_string()) - .with_server_type("cx32".to_string()) - .with_server_location("fsn1".to_string()) - .with_server_image("ubuntu-24.04".to_string()) - .with_ssh_public_key_content("ssh-rsa AAAA...".to_string()) - .build() - .unwrap(); - - let variables_template = VariablesTemplate::new(&template_file, context).unwrap(); - assert_eq!( - variables_template.context().instance_name.as_str(), - "dynamic-vm" - ); - } -} +pub use variables_template::VariablesTemplate; diff --git a/src/infrastructure/external_tools/tofu/template/providers/hetzner/wrappers/variables/variables_template.rs b/src/infrastructure/external_tools/tofu/template/providers/hetzner/wrappers/variables/variables_template.rs new file mode 100644 index 00000000..30e27812 --- /dev/null +++ b/src/infrastructure/external_tools/tofu/template/providers/hetzner/wrappers/variables/variables_template.rs @@ -0,0 +1,257 @@ +//! `VariablesTemplate` type and implementation for Hetzner Cloud. + +use std::path::Path; + +use crate::domain::template::file::File; +use crate::domain::template::{write_file_with_dir_creation, TemplateEngine}; +use crate::infrastructure::external_tools::tofu::template::common::wrappers::VariablesTemplateError; + +use super::context::VariablesContext; + +/// Template wrapper for Hetzner Cloud `OpenTofu` variables rendering +/// +/// Validates and renders `variables.tfvars.tera` templates with `VariablesContext` +/// to produce dynamic infrastructure variable files for Hetzner Cloud. +#[derive(Debug)] +pub struct VariablesTemplate { + context: VariablesContext, + content: String, +} + +impl VariablesTemplate { + /// Creates a new Hetzner variables template with validation + /// + /// # Arguments + /// + /// * `template_file` - The template file containing variables.tfvars.tera content + /// * `context` - The context containing Hetzner-specific runtime values + /// + /// # Returns + /// + /// * `Ok(VariablesTemplate)` if template validation succeeds + /// * `Err(VariablesTemplateError)` if validation fails + /// + /// # Errors + /// + /// Returns `TemplateEngineError` if the template has syntax errors or validation fails + pub fn new( + template_file: &File, + context: VariablesContext, + ) -> Result { + let mut engine = TemplateEngine::new(); + + let validated_content = + engine.render(template_file.filename(), template_file.content(), &context)?; + + Ok(Self { + context, + content: validated_content, + }) + } + + /// Get the instance name value + #[must_use] + pub fn instance_name(&self) -> &str { + self.context.instance_name.as_str() + } + + /// 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<(), VariablesTemplateError> { + write_file_with_dir_creation(output_path, &self.content)?; + Ok(()) + } + + /// Gets the context used by this template + #[must_use] + pub fn context(&self) -> &VariablesContext { + &self.context + } + + /// Gets the rendered content + #[must_use] + pub fn content(&self) -> &str { + &self.content + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::domain::InstanceName; + use tempfile::NamedTempFile; + + fn create_test_context() -> VariablesContext { + VariablesContext::builder() + .with_instance_name(InstanceName::new("test-instance".to_string()).unwrap()) + .with_hcloud_api_token("test-api-token".to_string()) + .with_server_type("cx22".to_string()) + .with_server_location("nbg1".to_string()) + .with_server_image("ubuntu-24.04".to_string()) + .with_ssh_public_key_content("ssh-rsa AAAA... test@example.com".to_string()) + .build() + .unwrap() + } + + #[test] + fn it_should_create_variables_template_successfully() { + let template_content = r#"hcloud_api_token = "{{ hcloud_api_token }}" +server_name = "{{ instance_name }}""#; + + let template_file = + File::new("variables.tfvars.tera", template_content.to_string()).unwrap(); + let context = create_test_context(); + + let result = VariablesTemplate::new(&template_file, context); + assert!(result.is_ok()); + } + + #[test] + fn it_should_fail_when_template_has_malformed_syntax() { + let template_content = r#"hcloud_api_token = "{{ hcloud_api_token +server_name = "{{ instance_name }}""#; // Missing closing }} + + let template_file = + File::new("variables.tfvars.tera", template_content.to_string()).unwrap(); + let context = create_test_context(); + + let result = VariablesTemplate::new(&template_file, context); + assert!(matches!( + result.unwrap_err(), + VariablesTemplateError::TemplateEngineError { .. } + )); + } + + #[test] + fn it_should_accept_static_template_with_no_variables() { + let template_content = r#"hcloud_api_token = "hardcoded-token" +server_type = "cx22""#; + + let template_file = + File::new("variables.tfvars.tera", template_content.to_string()).unwrap(); + let context = create_test_context(); + + let result = VariablesTemplate::new(&template_file, context); + assert!(result.is_ok()); + } + + #[test] + fn it_should_accept_empty_template_content() { + let template_content = ""; + + let template_file = + File::new("variables.tfvars.tera", template_content.to_string()).unwrap(); + let context = create_test_context(); + + let result = VariablesTemplate::new(&template_file, context); + assert!(result.is_ok()); + } + + #[test] + fn it_should_render_variables_template_successfully() { + let template_content = r#"# OpenTofu Variables for Hetzner Cloud +hcloud_api_token = "{{ hcloud_api_token }}" +server_name = "{{ instance_name }}" +server_type = "{{ server_type }}""#; + + let template_file = + File::new("variables.tfvars.tera", template_content.to_string()).unwrap(); + let context = create_test_context(); + let variables_template = VariablesTemplate::new(&template_file, context).unwrap(); + + let temp_file = NamedTempFile::new().unwrap(); + let result = variables_template.render(temp_file.path()); + + assert!(result.is_ok()); + + // Verify rendered content + let rendered_content = std::fs::read_to_string(temp_file.path()).unwrap(); + assert!(rendered_content.contains(r#"hcloud_api_token = "test-api-token""#)); + assert!(rendered_content.contains(r#"server_name = "test-instance""#)); + assert!(rendered_content.contains(r#"server_type = "cx22""#)); + } + + #[test] + fn it_should_provide_access_to_context() { + let template_file = File::new("variables.tfvars.tera", String::new()).unwrap(); + let context = create_test_context(); + let variables_template = VariablesTemplate::new(&template_file, context).unwrap(); + + assert_eq!( + variables_template.context().instance_name.as_str(), + "test-instance" + ); + } + + #[test] + fn it_should_provide_access_to_rendered_content() { + let template_content = r#"server_name = "{{ instance_name }}""#; + let template_file = + File::new("variables.tfvars.tera", template_content.to_string()).unwrap(); + let context = create_test_context(); + let variables_template = VariablesTemplate::new(&template_file, context).unwrap(); + + assert!(variables_template.content().contains("test-instance")); + } + + #[test] + fn it_should_work_with_missing_placeholder_variables() { + // Template has no placeholders but context has values - should work fine + let template_content = r#"hcloud_api_token = "hardcoded" +server_type = "cx22""#; + + let template_file = + File::new("variables.tfvars.tera", template_content.to_string()).unwrap(); + let context = create_test_context(); + let variables_template = VariablesTemplate::new(&template_file, context).unwrap(); + + let temp_file = NamedTempFile::new().unwrap(); + let result = variables_template.render(temp_file.path()); + + assert!(result.is_ok()); + + let rendered_content = std::fs::read_to_string(temp_file.path()).unwrap(); + assert!(rendered_content.contains(r#"hcloud_api_token = "hardcoded""#)); + } + + #[test] + fn it_should_validate_template_at_construction_time() { + let template_content = r#"hcloud_api_token = "{{ undefined_variable }}" +server_type = "cx22""#; + + let template_file = + File::new("variables.tfvars.tera", template_content.to_string()).unwrap(); + let context = create_test_context(); + + // Should fail at construction, not during render + let result = VariablesTemplate::new(&template_file, context); + assert!(matches!( + result.unwrap_err(), + VariablesTemplateError::TemplateEngineError { .. } + )); + } + + #[test] + fn it_should_generate_variables_template_context() { + let template_file = + File::new("variables.tfvars.tera", "{{ instance_name }}".to_string()).unwrap(); + let context = VariablesContext::builder() + .with_instance_name(InstanceName::new("dynamic-vm".to_string()).unwrap()) + .with_hcloud_api_token("dynamic-token".to_string()) + .with_server_type("cx32".to_string()) + .with_server_location("fsn1".to_string()) + .with_server_image("ubuntu-24.04".to_string()) + .with_ssh_public_key_content("ssh-rsa AAAA...".to_string()) + .build() + .unwrap(); + + let variables_template = VariablesTemplate::new(&template_file, context).unwrap(); + assert_eq!( + variables_template.context().instance_name.as_str(), + "dynamic-vm" + ); + } +} diff --git a/src/infrastructure/external_tools/tofu/template/providers/lxd/wrappers/variables/mod.rs b/src/infrastructure/external_tools/tofu/template/providers/lxd/wrappers/variables/mod.rs index d1309e7c..fb823e41 100644 --- a/src/infrastructure/external_tools/tofu/template/providers/lxd/wrappers/variables/mod.rs +++ b/src/infrastructure/external_tools/tofu/template/providers/lxd/wrappers/variables/mod.rs @@ -7,250 +7,8 @@ //! instance names in LXD infrastructure provisioning. pub mod context; +mod variables_template; -use std::path::Path; - -use crate::domain::template::file::File; -use crate::domain::template::{write_file_with_dir_creation, TemplateEngine}; pub use crate::infrastructure::external_tools::tofu::template::common::wrappers::VariablesTemplateError; pub use context::{VariablesContext, VariablesContextBuilder, VariablesContextError}; - -/// Template wrapper for `OpenTofu` variables rendering -/// -/// Validates and renders `variables.tfvars.tera` templates with `VariablesContext` -/// to produce dynamic infrastructure variable files. -#[derive(Debug)] -pub struct VariablesTemplate { - context: context::VariablesContext, - content: String, -} - -impl VariablesTemplate { - /// Creates a new variables template with validation - /// - /// # Arguments - /// - /// * `template_file` - The template file containing variables.tfvars.tera content - /// * `context` - The context containing `instance_name` and other runtime values - /// - /// # Returns - /// - /// * `Ok(VariablesTemplate)` if template validation succeeds - /// * `Err(VariablesTemplateError)` if validation fails - /// - /// # Errors - /// - /// Returns `TemplateEngineError` if the template has syntax errors or validation fails - pub fn new( - template_file: &File, - context: VariablesContext, - ) -> Result { - let mut engine = TemplateEngine::new(); - - let validated_content = - engine.render(template_file.filename(), template_file.content(), &context)?; - - Ok(Self { - context, - content: validated_content, - }) - } - - /// Get the instance name value - #[must_use] - pub fn instance_name(&self) -> &str { - self.context.instance_name.as_str() - } - - /// 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<(), VariablesTemplateError> { - write_file_with_dir_creation(output_path, &self.content)?; - Ok(()) - } - - /// Gets the context used by this template - #[must_use] - pub fn context(&self) -> &VariablesContext { - &self.context - } - - /// Gets the rendered content - #[must_use] - pub fn content(&self) -> &str { - &self.content - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::domain::InstanceName; - use tempfile::NamedTempFile; - - fn create_test_context() -> VariablesContext { - VariablesContext::builder() - .with_instance_name(InstanceName::new("test-instance".to_string()).unwrap()) - .with_profile_name(crate::domain::ProfileName::new("test-profile".to_string()).unwrap()) - .build() - .unwrap() - } - - #[test] - fn it_should_create_variables_template_successfully() { - let template_content = r#"instance_name = "{{ instance_name }}" -image = "ubuntu:24.04""#; - - let template_file = - File::new("variables.tfvars.tera", template_content.to_string()).unwrap(); - let context = create_test_context(); - - let result = VariablesTemplate::new(&template_file, context); - assert!(result.is_ok()); - } - - #[test] - fn it_should_fail_when_template_has_malformed_syntax() { - let template_content = r#"instance_name = "{{ instance_name -image = "ubuntu:24.04""#; // Missing closing }} - - let template_file = - File::new("variables.tfvars.tera", template_content.to_string()).unwrap(); - let context = create_test_context(); - - let result = VariablesTemplate::new(&template_file, context); - assert!(matches!( - result.unwrap_err(), - VariablesTemplateError::TemplateEngineError { .. } - )); - } - - #[test] - fn it_should_accept_static_template_with_no_variables() { - let template_content = r#"instance_name = "hardcoded-name" -image = "ubuntu:24.04""#; - - let template_file = - File::new("variables.tfvars.tera", template_content.to_string()).unwrap(); - let context = create_test_context(); - - let result = VariablesTemplate::new(&template_file, context); - assert!(result.is_ok()); - } - - #[test] - fn it_should_accept_empty_template_content() { - let template_content = ""; - - let template_file = - File::new("variables.tfvars.tera", template_content.to_string()).unwrap(); - let context = create_test_context(); - - let result = VariablesTemplate::new(&template_file, context); - assert!(result.is_ok()); - } - - #[test] - fn it_should_render_variables_template_successfully() { - let template_content = r#"# OpenTofu Variables -instance_name = "{{ instance_name }}" -image = "ubuntu:24.04""#; - - let template_file = - File::new("variables.tfvars.tera", template_content.to_string()).unwrap(); - let context = create_test_context(); - let variables_template = VariablesTemplate::new(&template_file, context).unwrap(); - - let temp_file = NamedTempFile::new().unwrap(); - let result = variables_template.render(temp_file.path()); - - assert!(result.is_ok()); - - // Verify rendered content - let rendered_content = std::fs::read_to_string(temp_file.path()).unwrap(); - assert!(rendered_content.contains("instance_name = \"test-instance\"")); - assert!(rendered_content.contains("image = \"ubuntu:24.04\"")); - } - - #[test] - fn it_should_provide_access_to_context() { - let template_file = File::new("variables.tfvars.tera", String::new()).unwrap(); - let context = create_test_context(); - let variables_template = VariablesTemplate::new(&template_file, context).unwrap(); - - assert_eq!( - variables_template.context().instance_name.as_str(), - "test-instance" - ); - } - - #[test] - fn it_should_provide_access_to_rendered_content() { - let template_content = "instance_name = \"{{ instance_name }}\""; - let template_file = - File::new("variables.tfvars.tera", template_content.to_string()).unwrap(); - let context = create_test_context(); - let variables_template = VariablesTemplate::new(&template_file, context).unwrap(); - - assert!(variables_template.content().contains("test-instance")); - } - - #[test] - fn it_should_work_with_missing_placeholder_variables() { - // Template has no placeholders but context has values - should work fine - let template_content = r#"instance_name = "hardcoded" -image = "ubuntu:24.04""#; - - let template_file = - File::new("variables.tfvars.tera", template_content.to_string()).unwrap(); - let context = create_test_context(); - let variables_template = VariablesTemplate::new(&template_file, context).unwrap(); - - let temp_file = NamedTempFile::new().unwrap(); - let result = variables_template.render(temp_file.path()); - - assert!(result.is_ok()); - - let rendered_content = std::fs::read_to_string(temp_file.path()).unwrap(); - assert!(rendered_content.contains("instance_name = \"hardcoded\"")); - } - - #[test] - fn it_should_validate_template_at_construction_time() { - let template_content = r#"instance_name = "{{ undefined_variable }}" -image = "ubuntu:24.04""#; - - let template_file = - File::new("variables.tfvars.tera", template_content.to_string()).unwrap(); - let context = create_test_context(); - - // Should fail at construction, not during render - let result = VariablesTemplate::new(&template_file, context); - assert!(matches!( - result.unwrap_err(), - VariablesTemplateError::TemplateEngineError { .. } - )); - } - - #[test] - fn it_should_generate_variables_template_context() { - let template_file = - File::new("variables.tfvars.tera", "{{ instance_name }}".to_string()).unwrap(); - let context = VariablesContext::builder() - .with_instance_name(InstanceName::new("dynamic-vm".to_string()).unwrap()) - .with_profile_name( - crate::domain::ProfileName::new("dynamic-profile".to_string()).unwrap(), - ) - .build() - .unwrap(); - - let variables_template = VariablesTemplate::new(&template_file, context).unwrap(); - assert_eq!( - variables_template.context().instance_name.as_str(), - "dynamic-vm" - ); - } -} +pub use variables_template::VariablesTemplate; diff --git a/src/infrastructure/external_tools/tofu/template/providers/lxd/wrappers/variables/variables_template.rs b/src/infrastructure/external_tools/tofu/template/providers/lxd/wrappers/variables/variables_template.rs new file mode 100644 index 00000000..4945e5fc --- /dev/null +++ b/src/infrastructure/external_tools/tofu/template/providers/lxd/wrappers/variables/variables_template.rs @@ -0,0 +1,249 @@ +//! `VariablesTemplate` type and implementation for LXD. + +use std::path::Path; + +use crate::domain::template::file::File; +use crate::domain::template::{write_file_with_dir_creation, TemplateEngine}; +use crate::infrastructure::external_tools::tofu::template::common::wrappers::VariablesTemplateError; + +use super::context::VariablesContext; + +/// Template wrapper for `OpenTofu` variables rendering +/// +/// Validates and renders `variables.tfvars.tera` templates with `VariablesContext` +/// to produce dynamic infrastructure variable files. +#[derive(Debug)] +pub struct VariablesTemplate { + context: VariablesContext, + content: String, +} + +impl VariablesTemplate { + /// Creates a new variables template with validation + /// + /// # Arguments + /// + /// * `template_file` - The template file containing variables.tfvars.tera content + /// * `context` - The context containing `instance_name` and other runtime values + /// + /// # Returns + /// + /// * `Ok(VariablesTemplate)` if template validation succeeds + /// * `Err(VariablesTemplateError)` if validation fails + /// + /// # Errors + /// + /// Returns `TemplateEngineError` if the template has syntax errors or validation fails + pub fn new( + template_file: &File, + context: VariablesContext, + ) -> Result { + let mut engine = TemplateEngine::new(); + + let validated_content = + engine.render(template_file.filename(), template_file.content(), &context)?; + + Ok(Self { + context, + content: validated_content, + }) + } + + /// Get the instance name value + #[must_use] + pub fn instance_name(&self) -> &str { + self.context.instance_name.as_str() + } + + /// 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<(), VariablesTemplateError> { + write_file_with_dir_creation(output_path, &self.content)?; + Ok(()) + } + + /// Gets the context used by this template + #[must_use] + pub fn context(&self) -> &VariablesContext { + &self.context + } + + /// Gets the rendered content + #[must_use] + pub fn content(&self) -> &str { + &self.content + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::domain::InstanceName; + use tempfile::NamedTempFile; + + fn create_test_context() -> VariablesContext { + VariablesContext::builder() + .with_instance_name(InstanceName::new("test-instance".to_string()).unwrap()) + .with_profile_name(crate::domain::ProfileName::new("test-profile".to_string()).unwrap()) + .build() + .unwrap() + } + + #[test] + fn it_should_create_variables_template_successfully() { + let template_content = r#"instance_name = "{{ instance_name }}" +image = "ubuntu:24.04""#; + + let template_file = + File::new("variables.tfvars.tera", template_content.to_string()).unwrap(); + let context = create_test_context(); + + let result = VariablesTemplate::new(&template_file, context); + assert!(result.is_ok()); + } + + #[test] + fn it_should_fail_when_template_has_malformed_syntax() { + let template_content = r#"instance_name = "{{ instance_name +image = "ubuntu:24.04""#; // Missing closing }} + + let template_file = + File::new("variables.tfvars.tera", template_content.to_string()).unwrap(); + let context = create_test_context(); + + let result = VariablesTemplate::new(&template_file, context); + assert!(matches!( + result.unwrap_err(), + VariablesTemplateError::TemplateEngineError { .. } + )); + } + + #[test] + fn it_should_accept_static_template_with_no_variables() { + let template_content = r#"instance_name = "hardcoded-name" +image = "ubuntu:24.04""#; + + let template_file = + File::new("variables.tfvars.tera", template_content.to_string()).unwrap(); + let context = create_test_context(); + + let result = VariablesTemplate::new(&template_file, context); + assert!(result.is_ok()); + } + + #[test] + fn it_should_accept_empty_template_content() { + let template_content = ""; + + let template_file = + File::new("variables.tfvars.tera", template_content.to_string()).unwrap(); + let context = create_test_context(); + + let result = VariablesTemplate::new(&template_file, context); + assert!(result.is_ok()); + } + + #[test] + fn it_should_render_variables_template_successfully() { + let template_content = r#"# OpenTofu Variables +instance_name = "{{ instance_name }}" +image = "ubuntu:24.04""#; + + let template_file = + File::new("variables.tfvars.tera", template_content.to_string()).unwrap(); + let context = create_test_context(); + let variables_template = VariablesTemplate::new(&template_file, context).unwrap(); + + let temp_file = NamedTempFile::new().unwrap(); + let result = variables_template.render(temp_file.path()); + + assert!(result.is_ok()); + + // Verify rendered content + let rendered_content = std::fs::read_to_string(temp_file.path()).unwrap(); + assert!(rendered_content.contains("instance_name = \"test-instance\"")); + assert!(rendered_content.contains("image = \"ubuntu:24.04\"")); + } + + #[test] + fn it_should_provide_access_to_context() { + let template_file = File::new("variables.tfvars.tera", String::new()).unwrap(); + let context = create_test_context(); + let variables_template = VariablesTemplate::new(&template_file, context).unwrap(); + + assert_eq!( + variables_template.context().instance_name.as_str(), + "test-instance" + ); + } + + #[test] + fn it_should_provide_access_to_rendered_content() { + let template_content = "instance_name = \"{{ instance_name }}\""; + let template_file = + File::new("variables.tfvars.tera", template_content.to_string()).unwrap(); + let context = create_test_context(); + let variables_template = VariablesTemplate::new(&template_file, context).unwrap(); + + assert!(variables_template.content().contains("test-instance")); + } + + #[test] + fn it_should_work_with_missing_placeholder_variables() { + // Template has no placeholders but context has values - should work fine + let template_content = r#"instance_name = "hardcoded" +image = "ubuntu:24.04""#; + + let template_file = + File::new("variables.tfvars.tera", template_content.to_string()).unwrap(); + let context = create_test_context(); + let variables_template = VariablesTemplate::new(&template_file, context).unwrap(); + + let temp_file = NamedTempFile::new().unwrap(); + let result = variables_template.render(temp_file.path()); + + assert!(result.is_ok()); + + let rendered_content = std::fs::read_to_string(temp_file.path()).unwrap(); + assert!(rendered_content.contains("instance_name = \"hardcoded\"")); + } + + #[test] + fn it_should_validate_template_at_construction_time() { + let template_content = r#"instance_name = "{{ undefined_variable }}" +image = "ubuntu:24.04""#; + + let template_file = + File::new("variables.tfvars.tera", template_content.to_string()).unwrap(); + let context = create_test_context(); + + // Should fail at construction, not during render + let result = VariablesTemplate::new(&template_file, context); + assert!(matches!( + result.unwrap_err(), + VariablesTemplateError::TemplateEngineError { .. } + )); + } + + #[test] + fn it_should_generate_variables_template_context() { + let template_file = + File::new("variables.tfvars.tera", "{{ instance_name }}".to_string()).unwrap(); + let context = VariablesContext::builder() + .with_instance_name(InstanceName::new("dynamic-vm".to_string()).unwrap()) + .with_profile_name( + crate::domain::ProfileName::new("dynamic-profile".to_string()).unwrap(), + ) + .build() + .unwrap(); + + let variables_template = VariablesTemplate::new(&template_file, context).unwrap(); + assert_eq!( + variables_template.context().instance_name.as_str(), + "dynamic-vm" + ); + } +} From fc3e483097efb6a9a040a3791b16ef8fb747fc8c Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Tue, 2 Dec 2025 19:19:00 +0000 Subject: [PATCH 10/14] refactor: [#212] remove deprecated ProvisionTemplateError alias Remove the deprecated ProvisionTemplateError type alias and all its re-exports throughout the module hierarchy. The alias was introduced for backward compatibility during the rename to TofuTemplateRendererError. Changes: - Remove deprecated type alias from common/renderer/mod.rs - Remove re-exports from template/mod.rs and common/mod.rs - Remove re-export from tofu/mod.rs - Update docs/codebase-architecture.md to use TofuTemplateRendererError All code now uses TofuTemplateRendererError directly. --- docs/codebase-architecture.md | 2 +- src/infrastructure/external_tools/tofu/mod.rs | 4 ---- .../external_tools/tofu/template/common/mod.rs | 4 ---- .../tofu/template/common/renderer/mod.rs | 10 ---------- src/infrastructure/external_tools/tofu/template/mod.rs | 4 ---- 5 files changed, 1 insertion(+), 23 deletions(-) diff --git a/docs/codebase-architecture.md b/docs/codebase-architecture.md index 7f3a3e51..ed07de90 100644 --- a/docs/codebase-architecture.md +++ b/docs/codebase-architecture.md @@ -170,7 +170,7 @@ impl ProvisionCommand { } // Each method delegates to corresponding Step structs - async fn render_opentofu_templates(&self) -> Result<(), ProvisionTemplateError> { + async fn render_opentofu_templates(&self) -> Result<(), TofuTemplateRendererError> { RenderOpenTofuTemplatesStep::new(&self.tofu_renderer, &self.config) .execute().await } diff --git a/src/infrastructure/external_tools/tofu/mod.rs b/src/infrastructure/external_tools/tofu/mod.rs index c157833f..44260b3e 100644 --- a/src/infrastructure/external_tools/tofu/mod.rs +++ b/src/infrastructure/external_tools/tofu/mod.rs @@ -15,10 +15,6 @@ pub mod template; pub use template::{CloudInitTemplateRenderer, TofuTemplateRenderer, TofuTemplateRendererError}; -/// Type alias for backward compatibility. -#[allow(deprecated)] -pub use template::ProvisionTemplateError; - /// Subdirectory name for OpenTofu-related files within the build directory. /// /// OpenTofu/Terraform configuration files and state will be managed diff --git a/src/infrastructure/external_tools/tofu/template/common/mod.rs b/src/infrastructure/external_tools/tofu/template/common/mod.rs index a8969522..c8055d7e 100644 --- a/src/infrastructure/external_tools/tofu/template/common/mod.rs +++ b/src/infrastructure/external_tools/tofu/template/common/mod.rs @@ -8,10 +8,6 @@ pub mod wrappers; pub use renderer::cloud_init::{CloudInitTemplateError, CloudInitTemplateRenderer}; pub use renderer::{TofuTemplateRenderer, TofuTemplateRendererError}; - -/// Type alias for backward compatibility. -#[allow(deprecated)] -pub use renderer::ProvisionTemplateError; pub use wrappers::{ CloudInitContext, CloudInitContextBuilder, CloudInitContextError, CloudInitTemplate, }; diff --git a/src/infrastructure/external_tools/tofu/template/common/renderer/mod.rs b/src/infrastructure/external_tools/tofu/template/common/renderer/mod.rs index 0a3cab43..b6185880 100644 --- a/src/infrastructure/external_tools/tofu/template/common/renderer/mod.rs +++ b/src/infrastructure/external_tools/tofu/template/common/renderer/mod.rs @@ -45,13 +45,3 @@ pub mod cloud_init; mod tofu_template_renderer; pub use tofu_template_renderer::{TofuTemplateRenderer, TofuTemplateRendererError}; - -/// Type alias for backward compatibility. -/// -/// This alias allows existing code using `ProvisionTemplateError` to continue working -/// without modification. New code should use `TofuTemplateRendererError` directly. -#[deprecated( - since = "0.1.0", - note = "Use `TofuTemplateRendererError` instead. This alias exists for backward compatibility." -)] -pub type ProvisionTemplateError = TofuTemplateRendererError; diff --git a/src/infrastructure/external_tools/tofu/template/mod.rs b/src/infrastructure/external_tools/tofu/template/mod.rs index f28c3d97..ddade42d 100644 --- a/src/infrastructure/external_tools/tofu/template/mod.rs +++ b/src/infrastructure/external_tools/tofu/template/mod.rs @@ -9,7 +9,3 @@ pub mod providers; pub use common::renderer::cloud_init::{CloudInitTemplateError, CloudInitTemplateRenderer}; pub use common::renderer::{TofuTemplateRenderer, TofuTemplateRendererError}; - -/// Type alias for backward compatibility. -#[allow(deprecated)] -pub use common::renderer::ProvisionTemplateError; From d8b16f1407ed50f2e172f87cc01569d15e6c9b88 Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Tue, 2 Dec 2025 19:31:54 +0000 Subject: [PATCH 11/14] refactor: [#212] move hardcoded LXD tofu path to testing module The OPENTOFU_SUBFOLDER constant was hardcoded to 'tofu/lxd' in production code, but was only used by LXD-specific E2E tests. This violates the principle that production code should not contain provider-specific constants. Changes: - Moved constant from src/infrastructure/external_tools/tofu/mod.rs to src/testing/e2e/mod.rs - Renamed from OPENTOFU_SUBFOLDER to LXD_OPENTOFU_SUBFOLDER for clarity about its provider-specific purpose - Updated all 3 test files that use it: - container.rs - Services dependency injection - context.rs - TestContext drop cleanup - preflight_cleanup.rs - cleanup_opentofu_infrastructure --- src/infrastructure/external_tools/tofu/mod.rs | 6 ------ src/testing/e2e/container.rs | 4 ++-- src/testing/e2e/context.rs | 4 ++-- src/testing/e2e/mod.rs | 9 +++++++++ .../e2e/tasks/virtual_machine/preflight_cleanup.rs | 4 ++-- 5 files changed, 15 insertions(+), 12 deletions(-) diff --git a/src/infrastructure/external_tools/tofu/mod.rs b/src/infrastructure/external_tools/tofu/mod.rs index 44260b3e..78ced4c5 100644 --- a/src/infrastructure/external_tools/tofu/mod.rs +++ b/src/infrastructure/external_tools/tofu/mod.rs @@ -14,9 +14,3 @@ pub mod template; pub use template::{CloudInitTemplateRenderer, TofuTemplateRenderer, TofuTemplateRendererError}; - -/// Subdirectory name for OpenTofu-related files within the build directory. -/// -/// OpenTofu/Terraform configuration files and state will be managed -/// in `build_dir/{OPENTOFU_SUBFOLDER}/`. Example: "tofu/lxd". -pub const OPENTOFU_SUBFOLDER: &str = "tofu/lxd"; diff --git a/src/testing/e2e/container.rs b/src/testing/e2e/container.rs index ecebd4a3..b42b5f34 100644 --- a/src/testing/e2e/container.rs +++ b/src/testing/e2e/container.rs @@ -30,9 +30,9 @@ use crate::domain::InstanceName; use crate::infrastructure::external_tools::ansible::AnsibleTemplateRenderer; use crate::infrastructure::external_tools::ansible::ANSIBLE_SUBFOLDER; use crate::infrastructure::external_tools::tofu::TofuTemplateRenderer; -use crate::infrastructure::external_tools::tofu::OPENTOFU_SUBFOLDER; use crate::infrastructure::persistence::repository_factory::RepositoryFactory; use crate::shared::Clock; +use crate::testing::e2e::LXD_OPENTOFU_SUBFOLDER; /// Default lock timeout for repository operations /// @@ -78,7 +78,7 @@ impl Services { let template_manager = Arc::new(template_manager); // Create OpenTofu client pointing to build/opentofu_subfolder directory - let opentofu_client = OpenTofuClient::new(config.build_dir.join(OPENTOFU_SUBFOLDER)); + let opentofu_client = OpenTofuClient::new(config.build_dir.join(LXD_OPENTOFU_SUBFOLDER)); // Create LXD client for instance management let lxd_client = LxdClient::new(); diff --git a/src/testing/e2e/context.rs b/src/testing/e2e/context.rs index dffabd86..5da45dd7 100644 --- a/src/testing/e2e/context.rs +++ b/src/testing/e2e/context.rs @@ -27,7 +27,7 @@ use super::container::Services; use crate::config::Config; use crate::domain::environment::state::AnyEnvironmentState; use crate::domain::Environment; -use crate::infrastructure::external_tools::tofu::OPENTOFU_SUBFOLDER; +use crate::testing::e2e::LXD_OPENTOFU_SUBFOLDER; /// Errors that can occur during test context creation and initialization #[derive(Debug, thiserror::Error)] @@ -483,7 +483,7 @@ impl Drop for TestContext { // Try basic cleanup in case async cleanup failed // Using emergency_destroy for consistent OpenTofu handling - let tofu_dir = self.config.build_dir.join(OPENTOFU_SUBFOLDER); + let tofu_dir = self.config.build_dir.join(LXD_OPENTOFU_SUBFOLDER); if let Err(e) = crate::adapters::tofu::emergency_destroy(&tofu_dir) { eprintln!("Warning: Failed to cleanup OpenTofu resources during TestContext drop: {e}"); diff --git a/src/testing/e2e/mod.rs b/src/testing/e2e/mod.rs index 140062a5..8fbc8297 100644 --- a/src/testing/e2e/mod.rs +++ b/src/testing/e2e/mod.rs @@ -37,3 +37,12 @@ pub use containers::{ContainerError, RunningProvisionedContainer, StoppedProvisi // Re-export black-box testing types pub use process_runner::{ProcessResult, ProcessRunner}; + +/// Subdirectory name for LXD `OpenTofu` configuration files within the build directory. +/// +/// This constant defines the path where `OpenTofu`/Terraform configuration files +/// and state for the LXD provider will be managed: `build_dir/tofu/lxd/`. +/// +/// Note: This is specific to LXD provider testing. Other providers (e.g., Hetzner) +/// would use their own paths like `tofu/hetzner`. +pub const LXD_OPENTOFU_SUBFOLDER: &str = "tofu/lxd"; diff --git a/src/testing/e2e/tasks/virtual_machine/preflight_cleanup.rs b/src/testing/e2e/tasks/virtual_machine/preflight_cleanup.rs index fa99fb98..411f6e12 100644 --- a/src/testing/e2e/tasks/virtual_machine/preflight_cleanup.rs +++ b/src/testing/e2e/tasks/virtual_machine/preflight_cleanup.rs @@ -9,8 +9,8 @@ use std::path::PathBuf; use crate::adapters::lxd::client::LxdClient; use crate::adapters::tofu; use crate::domain::{EnvironmentName, InstanceName, ProfileName}; -use crate::infrastructure::external_tools::tofu::OPENTOFU_SUBFOLDER; use crate::testing::e2e::tasks::preflight_cleanup::PreflightCleanupError; +use crate::testing::e2e::LXD_OPENTOFU_SUBFOLDER; use tracing::{info, warn}; /// Minimal context required for preflight cleanup operations @@ -343,7 +343,7 @@ fn cleanup_data_environment( fn cleanup_opentofu_infrastructure( context: &PreflightCleanupContext, ) -> Result<(), PreflightCleanupError> { - let tofu_dir = context.build_dir.join(OPENTOFU_SUBFOLDER); + let tofu_dir = context.build_dir.join(LXD_OPENTOFU_SUBFOLDER); if !tofu_dir.exists() { info!( From 90aff5392f61bbfdf1f67e0c6f30e12f0df19d91 Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Tue, 2 Dec 2025 19:37:31 +0000 Subject: [PATCH 12/14] refactor: [#212] extract common templates path constant The build_template_path function in CloudInitTemplateRenderer used a hardcoded 'tofu/common' string. This commit extracts it to a named constant COMMON_TEMPLATES_PATH for better clarity and maintainability. This follows the pattern of extracting magic strings to named constants for improved code readability and self-documentation. --- .../tofu/template/common/renderer/cloud_init.rs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/infrastructure/external_tools/tofu/template/common/renderer/cloud_init.rs b/src/infrastructure/external_tools/tofu/template/common/renderer/cloud_init.rs index c6043e21..29b226b9 100644 --- a/src/infrastructure/external_tools/tofu/template/common/renderer/cloud_init.rs +++ b/src/infrastructure/external_tools/tofu/template/common/renderer/cloud_init.rs @@ -115,6 +115,11 @@ impl CloudInitTemplateRenderer { /// Output file name for rendered cloud-init configuration const CLOUD_INIT_OUTPUT_FILE: &'static str = "cloud-init.yml"; + /// Base path for common `OpenTofu` templates shared by all providers + /// + /// Templates in this directory are used by all infrastructure providers (LXD, Hetzner). + const COMMON_TEMPLATES_PATH: &'static str = "tofu/common"; + /// Creates a new cloud-init template renderer /// /// # Arguments @@ -243,7 +248,7 @@ impl CloudInitTemplateRenderer { /// /// * `String` - The complete template path for the cloud-init template fn build_template_path(file_name: &str) -> String { - format!("tofu/common/{file_name}") + format!("{}/{file_name}", Self::COMMON_TEMPLATES_PATH) } } From 8745e169bdbf7d9ad84ece297b013533b8040360 Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Tue, 2 Dec 2025 19:50:24 +0000 Subject: [PATCH 13/14] refactor: [#212] add clear error message for missing working directory Previously, when a command's working directory didn't exist, the error was 'No such file or directory (os error 2)' which could be confused with the command binary not being found. Now we check if the working directory exists before running the command and return a clear WorkingDirectoryNotFound error with the path, making it obvious what's missing. --- src/shared/command/error.rs | 23 +++++++++++++++++++++++ src/shared/command/executor.rs | 27 +++++++++++++++++++++++++++ 2 files changed, 50 insertions(+) diff --git a/src/shared/command/error.rs b/src/shared/command/error.rs index 8cc4e9cc..f6744f58 100644 --- a/src/shared/command/error.rs +++ b/src/shared/command/error.rs @@ -3,6 +3,8 @@ //! This module provides error types for command execution failures, //! including startup errors and execution errors with detailed context. +use std::path::PathBuf; + use thiserror::Error; /// Errors that can occur during command execution @@ -16,6 +18,10 @@ pub enum CommandError { source: std::io::Error, }, + /// The working directory does not exist + #[error("Working directory does not exist: '{working_dir}'")] + WorkingDirectoryNotFound { working_dir: PathBuf }, + /// The command was started but exited with a non-zero status code #[error( "Command '{command}' failed with exit code {exit_code}\nStdout: {stdout}\nStderr: {stderr}" @@ -34,6 +40,12 @@ impl crate::shared::Traceable for CommandError { Self::StartupFailed { command, source } => { format!("CommandError: Failed to start '{command}' - {source}") } + Self::WorkingDirectoryNotFound { working_dir } => { + format!( + "CommandError: Working directory does not exist - '{}'", + working_dir.display() + ) + } Self::ExecutionFailed { command, exit_code, @@ -100,4 +112,15 @@ mod tests { assert!(error.source().is_some()); assert_eq!(error.source().unwrap().to_string(), "permission denied"); } + + #[test] + fn it_should_format_working_directory_not_found_error_correctly() { + let error = CommandError::WorkingDirectoryNotFound { + working_dir: PathBuf::from("/nonexistent/path/to/dir"), + }; + + let error_message = error.to_string(); + assert!(error_message.contains("Working directory does not exist")); + assert!(error_message.contains("/nonexistent/path/to/dir")); + } } diff --git a/src/shared/command/executor.rs b/src/shared/command/executor.rs index 3fec6a4c..5a3f5b57 100644 --- a/src/shared/command/executor.rs +++ b/src/shared/command/executor.rs @@ -40,6 +40,7 @@ impl CommandExecutor { /// /// # Errors /// This function will return an error if: + /// * The working directory does not exist - `CommandError::WorkingDirectoryNotFound` /// * The command cannot be started (e.g., command not found) - `CommandError::StartupFailed` /// * The command execution fails with a non-zero exit code - `CommandError::ExecutionFailed` pub fn run_command( @@ -48,6 +49,16 @@ impl CommandExecutor { args: &[&str], working_dir: Option<&Path>, ) -> Result { + // Check if working directory exists before attempting to run the command + // This provides a clearer error message than the generic "No such file or directory" + if let Some(dir) = working_dir { + if !dir.exists() { + return Err(CommandError::WorkingDirectoryNotFound { + working_dir: dir.to_path_buf(), + }); + } + } + let mut command = Command::new(cmd); let command_display = format!("{} {}", cmd, args.join(" ")); @@ -179,4 +190,20 @@ mod tests { assert_eq!(output.stdout_trimmed(), "tracing_test"); assert!(output.is_success()); } + + #[test] + fn it_should_return_clear_error_when_working_directory_does_not_exist() { + let executor = CommandExecutor::new(); + let nonexistent_dir = Path::new("/nonexistent/path/that/does/not/exist"); + let result = executor.run_command("echo", &["hello"], Some(nonexistent_dir)); + + assert!(result.is_err()); + let error = result.unwrap_err(); + match error { + CommandError::WorkingDirectoryNotFound { working_dir } => { + assert_eq!(working_dir, nonexistent_dir); + } + other => panic!("Expected WorkingDirectoryNotFound, got: {other:?}"), + } + } } From 24c8d33d14a045aebfe4981dba59ead9dc3c8418 Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Tue, 2 Dec 2025 20:02:30 +0000 Subject: [PATCH 14/14] refactor: [#212] parameterize tofu_build_dir by provider - tofu_build_dir_for_provider now takes Provider enum instead of &str - Removed hardcoded LXD-specific tofu_build_dir method from InternalConfig - EnvironmentContext::tofu_build_dir() now uses the environment's actual provider to determine the correct build directory path This ensures the correct tofu build directory is used for each provider (e.g., build/{env}/tofu/lxd vs build/{env}/tofu/hetzner). --- src/domain/environment/context.rs | 10 +++++++--- src/domain/environment/internal_config.rs | 13 +++++++++---- 2 files changed, 16 insertions(+), 7 deletions(-) diff --git a/src/domain/environment/context.rs b/src/domain/environment/context.rs index 6d835a2e..7ecdbd81 100644 --- a/src/domain/environment/context.rs +++ b/src/domain/environment/context.rs @@ -286,12 +286,16 @@ impl EnvironmentContext { self.internal_config.ansible_build_dir() } - /// Returns the tofu build directory + /// Returns the tofu build directory for the environment's provider /// - /// Path: `build/{env_name}/tofu` + /// Path: `build/{env_name}/tofu/{provider_name}` + /// + /// The provider is determined from the environment's provider + /// configuration (e.g., LXD, Hetzner). #[must_use] pub fn tofu_build_dir(&self) -> PathBuf { - self.internal_config.tofu_build_dir() + let provider = self.user_inputs.provider_config.provider(); + self.internal_config.tofu_build_dir_for_provider(provider) } /// Returns the ansible templates directory diff --git a/src/domain/environment/internal_config.rs b/src/domain/environment/internal_config.rs index c04c0e30..23911319 100644 --- a/src/domain/environment/internal_config.rs +++ b/src/domain/environment/internal_config.rs @@ -19,6 +19,7 @@ //! Add new fields here when: Need internal paths or derived configuration. use crate::domain::environment::EnvironmentName; +use crate::domain::provider::Provider; use serde::{Deserialize, Serialize}; use std::path::PathBuf; @@ -165,14 +166,18 @@ impl InternalConfig { self.build_dir.join(super::ANSIBLE_DIR_NAME) } - /// Returns the `OpenTofu` build directory for the LXD provider + /// Returns the `OpenTofu` build directory for a specific provider /// - /// Path: `build/{env_name}/tofu/lxd` + /// Path: `build/{env_name}/tofu/{provider_name}` + /// + /// # Arguments + /// + /// * `provider` - The provider type (LXD, Hetzner, etc.) #[must_use] - pub fn tofu_build_dir(&self) -> PathBuf { + pub fn tofu_build_dir_for_provider(&self, provider: Provider) -> PathBuf { self.build_dir .join(super::TOFU_DIR_NAME) - .join(super::LXD_PROVIDER_NAME) + .join(provider.as_str()) } /// Returns the ansible templates directory