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/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 new file mode 100644 index 00000000..e74fad68 --- /dev/null +++ b/docs/user-guide/providers/README.md @@ -0,0 +1,41 @@ +# Provider Guides + +This directory contains provider-specific configuration guides. + +## 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, testing, CI/CD pipelines, zero cloud costs. + +**Requirements**: Linux system with LXD installed. + +### Hetzner Cloud (Production) + +**Best for**: Production deployments, European hosting, cost-sensitive projects. + +**Requirements**: Hetzner Cloud account with API token. + +## Adding New 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/.md` + +See the [contributing guide](../../contributing/README.md) for more details. + +## Related Documentation + +- [Quick Start Guide](../quick-start.md) - Complete deployment workflow +- [Commands Reference](../commands/README.md) - Available commands +- [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 new file mode 100644 index 00000000..295a95d7 --- /dev/null +++ b/docs/user-guide/providers/hetzner.md @@ -0,0 +1,151 @@ +# Hetzner Cloud Provider + +This guide covers Hetzner-specific configuration for cloud deployments. + +## Overview + +[Hetzner Cloud](https://www.hetzner.com/cloud) provides affordable virtual servers with excellent performance. Ideal for production deployments. + +**Why Hetzner?** + +- Cost-effective pricing +- European data centers (Germany, Finland) + US locations +- Simple, predictable billing +- NVMe storage and modern hardware + +## Prerequisites + +- 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)) + +## 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. Select **Read & Write** permissions +6. **Copy the token immediately** - it won't be shown again! + +> ⚠️ **Security**: Never commit API tokens to version control. + +## Hetzner-Specific Configuration + +```json +{ + "provider": { + "provider": "hetzner", + "api_token": "YOUR_HETZNER_API_TOKEN", + "server_type": "cx22", + "location": "nbg1", + "image": "ubuntu-24.04" + } +} +``` + +| 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 + +| 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) | + +### 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) | + +## Cost Estimation + +Approximate monthly costs (check [Hetzner pricing](https://www.hetzner.com/cloud) for current rates): + +| Server Type | Monthly Cost (EUR) | +| ----------- | ------------------ | +| `cx22` | ~€4.35 | +| `cx32` | ~€8.70 | +| `cx42` | ~€17.40 | + +> ⚠️ **Important**: Remember to destroy resources when not in use to avoid charges. + +## Troubleshooting + +### API Token Invalid + +**Error**: `Failed to authenticate with Hetzner API` + +- Verify your API token is correct +- Ensure the token has **Read & Write** permissions +- Check the token hasn't been revoked + +### Server Creation Failed + +**Possible causes**: + +- **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 + +### SSH Connection Timeout + +```bash +# Check if server is running in Hetzner Console +# Verify firewall rules (if using Hetzner Firewall) - port 22 must be open + +# Check SSH key permissions +chmod 600 ~/.ssh/your_private_key + +# Test manual SSH connection +ssh -i ~/.ssh/your_private_key -v torrust@ +``` + +### Cloud-init Timeout + +```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 + +## Related Documentation + +- [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 new file mode 100644 index 00000000..9ff9dfd7 --- /dev/null +++ b/docs/user-guide/providers/lxd.md @@ -0,0 +1,102 @@ +# LXD Provider + +This guide covers LXD-specific configuration for deploying locally. + +## Overview + +LXD provides lightweight virtual machines that run on your local system. Ideal for development, testing, and CI/CD pipelines. + +**Why LXD?** + +- Zero cloud costs +- Fast iteration +- CI/CD friendly (works in GitHub Actions) + +## Prerequisites + +- LXD installed and initialized (see [LXD tech guide](../../tech-stack/lxd.md)) +- SSH key pair (see [SSH keys guide](../../tech-stack/ssh-keys.md)) + +## LXD-Specific Configuration + +```json +{ + "provider": { + "provider": "lxd", + "profile_name": "torrust-profile-local" + } +} +``` + +| Field | Description | Example | +| -------------- | ------------------------------- | ----------------------- | +| `provider` | Must be `"lxd"` | `lxd` | +| `profile_name` | LXD profile name (auto-created) | `torrust-profile-local` | + +## LXD-Specific Operations + +### Check VM Status + +```bash +lxc list +``` + +### Direct Console Access + +```bash +lxc exec torrust-tracker-vm- -- bash +``` + +### 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 + +```bash +# Check LXD status +sudo systemctl status snap.lxd.daemon + +# Restart LXD +sudo systemctl restart snap.lxd.daemon +``` + +### Permission Denied + +See the [LXD Group Setup](../../tech-stack/lxd.md#proper-lxd-group-setup) section in the LXD tech guide. + +### Network Issues + +```bash +# Check network bridge +lxc network list + +# Recreate default bridge if needed +lxc network delete lxdbr0 +lxc network create lxdbr0 +``` + +## Resource Requirements + +| 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 + +- [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 diff --git a/project-words.txt b/project-words.txt index 229216a4..373486a5 100644 --- a/project-words.txt +++ b/project-words.txt @@ -71,6 +71,7 @@ dearmor debootstrap debuginfo derefs +distro distutils doctest doctests @@ -104,6 +105,7 @@ journalctl jsonlint keepalive keygen +keypair keyrings larstobi lifecycles @@ -235,6 +237,7 @@ vbqajnc viewmodel webservers writeln +youruser Émojis значение ключ 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/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/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..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), @@ -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" } @@ -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"), }, @@ -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")); } @@ -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/handler.rs b/src/application/command_handlers/provision/handler.rs index 75d85f65..bcf36cbb 100644 --- a/src/application/command_handlers/provision/handler.rs +++ b/src/application/command_handlers/provision/handler.rs @@ -273,7 +273,7 @@ impl ProvisionCommandHandler { environment.build_dir(), 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/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/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/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 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/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/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/mod.rs b/src/infrastructure/external_tools/tofu/mod.rs index 19bda957..78ced4c5 100644 --- a/src/infrastructure/external_tools/tofu/mod.rs +++ b/src/infrastructure/external_tools/tofu/mod.rs @@ -13,10 +13,4 @@ pub mod template; -pub use template::{CloudInitTemplateRenderer, ProvisionTemplateError, TofuTemplateRenderer}; - -/// 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"; +pub use template::{CloudInitTemplateRenderer, TofuTemplateRenderer, TofuTemplateRendererError}; 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..c8055d7e --- /dev/null +++ b/src/infrastructure/external_tools/tofu/template/common/mod.rs @@ -0,0 +1,13 @@ +//! 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 mod wrappers; + +pub use renderer::cloud_init::{CloudInitTemplateError, CloudInitTemplateRenderer}; +pub use renderer::{TofuTemplateRenderer, TofuTemplateRendererError}; +pub use wrappers::{ + CloudInitContext, CloudInitContextBuilder, CloudInitContextError, CloudInitTemplate, +}; 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 84% 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 1f2b4b97..29b226b9 100644 --- a/src/infrastructure/external_tools/tofu/template/renderer/cloud_init.rs +++ b/src/infrastructure/external_tools/tofu/template/common/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. //! @@ -19,8 +20,9 @@ //! ```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; //! 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 { @@ -110,18 +115,27 @@ 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 /// /// * `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 +171,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 +192,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::common::wrappers::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 +218,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 +228,7 @@ impl CloudInitTemplateRenderer { .map_err(|_| CloudInitTemplateError::CloudInitTemplateRenderFailed)?; tracing::debug!( + provider = %self.provider, "Successfully rendered cloud-init template to {}", output_path.display() ); @@ -204,6 +238,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 +248,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!("{}/{file_name}", Self::COMMON_TEMPLATES_PATH) } } @@ -251,9 +287,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 +299,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 +308,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 +319,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 +367,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 +394,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/common/renderer/mod.rs b/src/infrastructure/external_tools/tofu/template/common/renderer/mod.rs new file mode 100644 index 00000000..b6185880 --- /dev/null +++ b/src/infrastructure/external_tools/tofu/template/common/renderer/mod.rs @@ -0,0 +1,47 @@ +//! # `OpenTofu` Template Renderer +//! +//! This module handles `OpenTofu` template rendering for 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. +//! +//! ## Future Improvements +//! +//! The following improvements could enhance this module's functionality and maintainability: +//! +//! 1. **Add comprehensive logging** - Add debug/trace logs for each operation step (directory +//! creation, file copying, template processing) to improve debugging and monitoring. +//! +//! 2. **Extract constants for magic strings** - Create constants for hardcoded paths like "tofu", +//! 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. +//! +//! 4. **Improve error messages** - Make error messages more user-friendly and actionable with +//! suggestions for resolution, including common troubleshooting steps. +//! +//! 5. **Add configuration validation** - Pre-validate that required template files exist before +//! starting the rendering process to avoid partial failures. +//! +//! 6. **Extract template discovery logic** - Separate the logic for finding and listing available +//! templates to make it reusable and testable independently. +//! +//! 7. **Add progress reporting** - Add callback mechanism or progress indicators for long-running +//! operations to improve user experience during deployment. +//! +//! 8. **Improve file operations** - Add more robust file copying with better error handling and +//! atomic operations to prevent partial state corruption. +//! +//! 9. **Add template caching** - Cache parsed templates to improve performance for repeated +//! operations and reduce I/O overhead. + +pub mod cloud_init; +mod tofu_template_renderer; + +pub use tofu_template_renderer::{TofuTemplateRenderer, TofuTemplateRendererError}; diff --git a/src/infrastructure/external_tools/tofu/template/renderer/mod.rs b/src/infrastructure/external_tools/tofu/template/common/renderer/tofu_template_renderer.rs similarity index 64% rename from src/infrastructure/external_tools/tofu/template/renderer/mod.rs rename to src/infrastructure/external_tools/tofu/template/common/renderer/tofu_template_renderer.rs index a4084878..aed146b7 100644 --- a/src/infrastructure/external_tools/tofu/template/renderer/mod.rs +++ b/src/infrastructure/external_tools/tofu/template/common/renderer/tofu_template_renderer.rs @@ -1,62 +1,35 @@ -//! # `OpenTofu` Template Renderer +//! `OpenTofu` Template Renderer //! -//! This module handles `OpenTofu` template rendering for deployment workflows. -//! It manages the creation of build directories, copying template files, and processing them with -//! variable substitution. +//! 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. //! -//! ## Future Improvements +//! ## Provider Support //! -//! The following improvements could enhance this module's functionality and maintainability: -//! -//! 1. **Add comprehensive logging** - Add debug/trace logs for each operation step (directory -//! 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. -//! -//! 3. **Add input validation** - Validate template names, check for empty strings, validate paths -//! before processing to provide early error detection and better user feedback. -//! -//! 4. **Improve error messages** - Make error messages more user-friendly and actionable with -//! suggestions for resolution, including common troubleshooting steps. -//! -//! 5. **Add configuration validation** - Pre-validate that required template files exist before -//! starting the rendering process to avoid partial failures. -//! -//! 6. **Extract template discovery logic** - Separate the logic for finding and listing available -//! templates to make it reusable and testable independently. -//! -//! 7. **Add progress reporting** - Add callback mechanism or progress indicators for long-running -//! operations to improve user experience during deployment. -//! -//! 8. **Improve file operations** - Add more robust file copying with better error handling and -//! atomic operations to prevent partial state corruption. -//! -//! 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; +//! 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, ProfileName}; -use crate::infrastructure::external_tools::tofu::template::renderer::cloud_init::{ +use crate::domain::InstanceName; +use crate::infrastructure::external_tools::tofu::template::common::renderer::cloud_init::{ CloudInitTemplateError, CloudInitTemplateRenderer, }; -use crate::infrastructure::external_tools::tofu::template::wrappers::lxd::variables::{ - VariablesContextBuilder, VariablesTemplate, VariablesTemplateError, +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 +/// Errors that can occur during `OpenTofu` template rendering #[derive(Error, Debug)] -pub enum ProvisionTemplateError { +pub enum TofuTemplateRendererError { /// Failed to create the build directory #[error("Failed to create build directory '{directory}': {source}")] DirectoryCreationFailed { @@ -88,31 +61,62 @@ 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: VariablesTemplateError, + 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 { +impl crate::shared::Traceable for TofuTemplateRendererError { fn trace_format(&self) -> String { match self { Self::DirectoryCreationFailed { directory, .. } => { - format!("ProvisionTemplateError: Failed to create build directory '{directory}'") + format!("TofuTemplateRendererError: Failed to create build directory '{directory}'") } Self::TemplatePathFailed { file_name, .. } => { - format!("ProvisionTemplateError: Failed to get template path for '{file_name}'") + format!("TofuTemplateRendererError: Failed to get template path for '{file_name}'") } Self::FileCopyFailed { file_name, .. } => { - format!("ProvisionTemplateError: Failed to copy template file '{file_name}'") + format!("TofuTemplateRendererError: Failed to copy template file '{file_name}'") } Self::CloudInitRenderingFailed { .. } => { - "ProvisionTemplateError: Cloud-init template rendering failed".to_string() + "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::VariablesRenderingFailed { .. } => { - "ProvisionTemplateError: 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") } } } @@ -130,23 +134,22 @@ 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, ssh_credentials: SshCredentials, 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 @@ -155,15 +158,19 @@ 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 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, @@ -171,21 +178,32 @@ impl TofuTemplateRenderer { ssh_credentials, 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 /// /// # Returns /// - /// * `Result<(), ProvisionTemplateError>` - Success or error from the template rendering operation + /// * `Result<(), TofuTemplateRendererError>` - Success or error from the template rendering operation /// /// # Errors /// @@ -194,18 +212,18 @@ impl TofuTemplateRenderer { /// - Template copying fails /// - Template manager cannot provide required templates /// - Tera template rendering fails - pub async fn render(&self) -> Result<(), ProvisionTemplateError> { + 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?; - // 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 +234,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,27 +279,29 @@ 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 /// /// # Returns /// - /// * `Result` - The created build directory path or an error + /// * `Result` - The created build directory path or an error /// /// # Errors /// /// Returns an error if directory creation fails - async fn create_build_directory(&self) -> Result { + 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, - })?; + .map_err( + |source| TofuTemplateRendererError::DirectoryCreationFailed { + directory: build_tofu_dir.display().to_string(), + source, + }, + )?; Ok(build_tofu_dir) } @@ -279,7 +314,7 @@ impl TofuTemplateRenderer { /// /// # Returns /// - /// * `Result<(), ProvisionTemplateError>` - Success or error from the file copying operations + /// * `Result<(), TofuTemplateRendererError>` - Success or error from the file copying operations /// /// # Errors /// @@ -290,7 +325,7 @@ impl TofuTemplateRenderer { &self, file_names: &[&str], destination_dir: &Path, - ) -> Result<(), ProvisionTemplateError> { + ) -> Result<(), TofuTemplateRendererError> { tracing::debug!( "Copying {} template files to {}", file_names.len(), @@ -298,12 +333,12 @@ 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 .get_template_path(&template_path) - .map_err(|source| ProvisionTemplateError::TemplatePathFailed { + .map_err(|source| TofuTemplateRendererError::TemplatePathFailed { file_name: (*file_name).to_string(), source, })?; @@ -318,7 +353,7 @@ impl TofuTemplateRenderer { tokio::fs::copy(&source_path, &dest_path) .await - .map_err(|source| ProvisionTemplateError::FileCopyFailed { + .map_err(|source| TofuTemplateRendererError::FileCopyFailed { file_name: (*file_name).to_string(), source, })?; @@ -348,14 +383,14 @@ impl TofuTemplateRenderer { async fn render_tera_templates( &self, destination_dir: &Path, - ) -> Result<(), ProvisionTemplateError> { + ) -> 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| ProvisionTemplateError::CloudInitRenderingFailed { source })?; + .map_err(|source| TofuTemplateRendererError::CloudInitRenderingFailed { source })?; // Render variables.tfvars.tera template with instance name self.render_variables_template(destination_dir).await?; @@ -379,15 +414,18 @@ impl TofuTemplateRenderer { async fn render_variables_template( &self, destination_dir: &Path, - ) -> Result<(), ProvisionTemplateError> { - tracing::debug!("Rendering variables.tfvars.tera template with instance name context"); + ) -> 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_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 { + .map_err(|source| TofuTemplateRendererError::TemplatePathFailed { file_name: "variables.tfvars.tera".to_string(), source, })?; @@ -395,7 +433,7 @@ impl TofuTemplateRenderer { // Read the template file content let template_content = tokio::fs::read_to_string(&template_file_path) .await - .map_err(|source| ProvisionTemplateError::FileCopyFailed { + .map_err(|source| TofuTemplateRendererError::FileCopyFailed { file_name: "variables.tfvars.tera".to_string(), source, })?; @@ -403,35 +441,117 @@ impl TofuTemplateRenderer { // Create template file wrapper let template_file = crate::domain::template::file::File::new("variables.tfvars.tera", template_content) - .map_err(|err| ProvisionTemplateError::FileCopyFailed { + .map_err(|err| TofuTemplateRendererError::FileCopyFailed { file_name: "variables.tfvars.tera".to_string(), 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<(), 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(self.profile_name.clone()) + .with_profile_name(lxd_config.profile_name.clone()) .build() - .map_err(|err| ProvisionTemplateError::VariablesRenderingFailed { - source: VariablesTemplateError::TemplateEngineError { - source: crate::domain::template::TemplateEngineError::ContextSerialization { - source: tera::Error::msg(err.to_string()), + .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 = VariablesTemplate::new(&template_file, context) - .map_err(|source| ProvisionTemplateError::VariablesRenderingFailed { source })?; + 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| ProvisionTemplateError::VariablesRenderingFailed { source })?; + .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(), + } + })?; - tracing::debug!("Variables template rendered successfully"); + // 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(()) } } @@ -441,6 +561,7 @@ mod tests { use super::*; use std::fs; + use crate::domain::ProfileName; use crate::shared::Username; /// Test instance name for unit tests @@ -473,6 +594,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"); @@ -485,7 +614,7 @@ mod tests { &build_path, ssh_credentials, test_instance_name(), - test_profile_name(), + test_lxd_provider_config(), ); assert_eq!(renderer.build_dir, build_path); @@ -504,7 +633,7 @@ mod tests { &build_path, ssh_credentials, test_instance_name(), - test_profile_name(), + test_lxd_provider_config(), ); let actual_path = renderer.build_opentofu_directory(); @@ -513,23 +642,48 @@ 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_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!( - 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" ); } @@ -547,7 +701,7 @@ mod tests { &build_path, ssh_credentials, test_instance_name(), - test_profile_name(), + test_lxd_provider_config(), ); let created_path = renderer .create_build_directory() @@ -588,7 +742,7 @@ mod tests { &build_path, ssh_credentials, test_instance_name(), - test_profile_name(), + test_lxd_provider_config(), ); let result = renderer.create_build_directory().await; @@ -598,7 +752,7 @@ mod tests { "Should fail when directory creation is denied" ); match result.unwrap_err() { - ProvisionTemplateError::DirectoryCreationFailed { + TofuTemplateRendererError::DirectoryCreationFailed { directory, source: _, } => { @@ -625,7 +779,7 @@ mod tests { &build_path, ssh_credentials, test_instance_name(), - test_profile_name(), + test_lxd_provider_config(), ); // Try to copy a non-existent template @@ -635,7 +789,7 @@ mod tests { assert!(result.is_err(), "Should fail when template is not found"); match result.unwrap_err() { - ProvisionTemplateError::TemplatePathFailed { + TofuTemplateRendererError::TemplatePathFailed { file_name, source: _, } => { @@ -686,14 +840,14 @@ mod tests { temp_dir.path(), ssh_credentials, test_instance_name(), - test_profile_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 { + TofuTemplateRendererError::FileCopyFailed { file_name, source: _, } => { @@ -704,47 +858,98 @@ 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_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_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_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_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}")); } @@ -768,7 +973,7 @@ mod tests { &build_path, ssh_credentials, test_instance_name(), - test_profile_name(), + test_lxd_provider_config(), ); let created_path = renderer .create_build_directory() @@ -792,7 +997,7 @@ mod tests { &build_path, ssh_credentials, test_instance_name(), - test_profile_name(), + test_lxd_provider_config(), ); // Should succeed with empty array @@ -829,7 +1034,7 @@ mod tests { temp_dir.path(), ssh_credentials, test_instance_name(), - test_profile_name(), + test_lxd_provider_config(), ); // Copy the same file twice - should succeed (overwrite) @@ -877,7 +1082,7 @@ 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()); let renderer2 = TofuTemplateRenderer::new( @@ -885,7 +1090,7 @@ mod tests { &build_path2, ssh_credentials2, test_instance_name(), - test_profile_name(), + test_lxd_provider_config(), ); tokio::fs::create_dir_all(&build_path1) @@ -944,7 +1149,7 @@ mod tests { temp_dir.path(), ssh_credentials, test_instance_name(), - test_profile_name(), + test_lxd_provider_config(), ); // Try to copy both existing and non-existing files @@ -958,7 +1163,7 @@ mod tests { // The first file might have been copied before the failure // This tests the partial failure behavior match result.unwrap_err() { - ProvisionTemplateError::TemplatePathFailed { + TofuTemplateRendererError::TemplatePathFailed { file_name, source: _, } => { @@ -1003,7 +1208,7 @@ mod tests { temp_dir.path(), 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/lxd/cloud_init/mod.rs b/src/infrastructure/external_tools/tofu/template/common/wrappers/cloud_init/cloud_init_template.rs similarity index 97% rename from src/infrastructure/external_tools/tofu/template/wrappers/lxd/cloud_init/mod.rs rename to src/infrastructure/external_tools/tofu/template/common/wrappers/cloud_init/cloud_init_template.rs index b3f2cc5f..a14dfd12 100644 --- a/src/infrastructure/external_tools/tofu/template/wrappers/lxd/cloud_init/mod.rs +++ b/src/infrastructure/external_tools/tofu/template/common/wrappers/cloud_init/cloud_init_template.rs @@ -1,8 +1,4 @@ -//! Template wrapper for templates/tofu/lxd/cloud-init.yml.tera -//! -//! This template has mandatory variables that must be provided at construction time. - -pub mod context; +//! `CloudInitTemplate` type and implementation. use crate::domain::template::file::File; use crate::domain::template::{ @@ -11,7 +7,7 @@ use crate::domain::template::{ use anyhow::Result; use std::path::Path; -pub use context::{CloudInitContext, CloudInitContextBuilder, CloudInitContextError}; +use super::CloudInitContext; #[derive(Debug)] pub struct CloudInitTemplate { @@ -70,6 +66,7 @@ impl CloudInitTemplate { #[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 { diff --git a/src/infrastructure/external_tools/tofu/template/wrappers/lxd/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/wrappers/lxd/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/wrappers/lxd/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/common/wrappers/cloud_init/mod.rs b/src/infrastructure/external_tools/tofu/template/common/wrappers/cloud_init/mod.rs new file mode 100644 index 00000000..efa2eac9 --- /dev/null +++ b/src/infrastructure/external_tools/tofu/template/common/wrappers/cloud_init/mod.rs @@ -0,0 +1,10 @@ +//! 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. + +mod cloud_init_template; +pub mod context; + +pub use cloud_init_template::CloudInitTemplate; +pub use context::{CloudInitContext, CloudInitContextBuilder, CloudInitContextError}; 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 new file mode 100644 index 00000000..e6e4a78a --- /dev/null +++ b/src/infrastructure/external_tools/tofu/template/common/wrappers/mod.rs @@ -0,0 +1,14 @@ +//! 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`) +//! - `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/mod.rs b/src/infrastructure/external_tools/tofu/template/mod.rs index b3a090d8..ddade42d 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::{TofuTemplateRenderer, TofuTemplateRendererError}; 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..17414ce2 --- /dev/null +++ b/src/infrastructure/external_tools/tofu/template/providers/hetzner/mod.rs @@ -0,0 +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::variables; 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 new file mode 100644 index 00000000..2fd13de9 --- /dev/null +++ b/src/infrastructure/external_tools/tofu/template/providers/hetzner/wrappers/mod.rs @@ -0,0 +1,14 @@ +//! `OpenTofu` Hetzner Cloud template wrappers +//! +//! Contains template wrappers for Hetzner Cloud-specific configuration files. +//! +//! - `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 variables; + +pub use variables::{ + VariablesContext, VariablesContextBuilder, VariablesContextError, VariablesTemplate, +}; diff --git a/src/infrastructure/external_tools/tofu/template/providers/hetzner/wrappers/variables/context.rs b/src/infrastructure/external_tools/tofu/template/providers/hetzner/wrappers/variables/context.rs new file mode 100644 index 00000000..c1acd27f --- /dev/null +++ b/src/infrastructure/external_tools/tofu/template/providers/hetzner/wrappers/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::providers::hetzner::wrappers::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/providers/hetzner/wrappers/variables/mod.rs b/src/infrastructure/external_tools/tofu/template/providers/hetzner/wrappers/variables/mod.rs new file mode 100644 index 00000000..0b0dee9b --- /dev/null +++ b/src/infrastructure/external_tools/tofu/template/providers/hetzner/wrappers/variables/mod.rs @@ -0,0 +1,14 @@ +//! # 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; +mod variables_template; + +pub use crate::infrastructure::external_tools::tofu::template::common::wrappers::VariablesTemplateError; +pub use context::{VariablesContext, VariablesContextBuilder, VariablesContextError}; +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/mod.rs b/src/infrastructure/external_tools/tofu/template/providers/lxd/mod.rs new file mode 100644 index 00000000..ac342e75 --- /dev/null +++ b/src/infrastructure/external_tools/tofu/template/providers/lxd/mod.rs @@ -0,0 +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::variables; 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 59% 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 index e4758e3b..f21d9f4d 100644 --- a/src/infrastructure/external_tools/tofu/template/wrappers/lxd/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, }; 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/providers/lxd/wrappers/variables/mod.rs b/src/infrastructure/external_tools/tofu/template/providers/lxd/wrappers/variables/mod.rs new file mode 100644 index 00000000..fb823e41 --- /dev/null +++ b/src/infrastructure/external_tools/tofu/template/providers/lxd/wrappers/variables/mod.rs @@ -0,0 +1,14 @@ +//! # `OpenTofu` Variables Templates +//! +//! Template wrappers for rendering `variables.tfvars.tera` with dynamic instance naming. +//! +//! This module provides the `VariablesTemplate` and `VariablesContext` for validating and rendering `OpenTofu` +//! variable files with runtime context injection, specifically for parameterizing +//! instance names in LXD infrastructure provisioning. + +pub mod context; +mod variables_template; + +pub use crate::infrastructure::external_tools::tofu::template::common::wrappers::VariablesTemplateError; +pub use context::{VariablesContext, VariablesContextBuilder, VariablesContextError}; +pub use variables_template::VariablesTemplate; 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/variables_template.rs similarity index 88% 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/variables_template.rs index 1abc1e6e..4945e5fc 100644 --- a/src/infrastructure/external_tools/tofu/template/wrappers/lxd/variables/mod.rs +++ b/src/infrastructure/external_tools/tofu/template/providers/lxd/wrappers/variables/variables_template.rs @@ -1,40 +1,12 @@ -//! # `OpenTofu` Variables Templates -//! -//! Template wrappers for rendering `variables.tfvars.tera` with dynamic instance naming. -//! -//! This module provides the `VariablesTemplate` and `VariablesContext` for validating and rendering `OpenTofu` -//! variable files with runtime context injection, specifically for parameterizing -//! instance names in LXD infrastructure provisioning. - -pub mod context; +//! `VariablesTemplate` type and implementation for LXD. 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 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, - }, -} +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 /// @@ -42,7 +14,7 @@ pub enum VariablesTemplateError { /// to produce dynamic infrastructure variable files. #[derive(Debug)] pub struct VariablesTemplate { - context: context::VariablesContext, + context: VariablesContext, content: String, } diff --git a/src/infrastructure/external_tools/tofu/template/providers/mod.rs b/src/infrastructure/external_tools/tofu/template/providers/mod.rs new file mode 100644 index 00000000..8eaaf463 --- /dev/null +++ b/src/infrastructure/external_tools/tofu/template/providers/mod.rs @@ -0,0 +1,13 @@ +//! Provider-specific `OpenTofu` template functionality. +//! +//! 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 +//! - `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/infrastructure/external_tools/tofu/template/wrappers/mod.rs b/src/infrastructure/external_tools/tofu/template/wrappers/mod.rs deleted file mode 100644 index 5cd78ddb..00000000 --- a/src/infrastructure/external_tools/tofu/template/wrappers/mod.rs +++ /dev/null @@ -1,7 +0,0 @@ -//! `OpenTofu` template wrappers -//! -//! Organized by provider (e.g., lxd/) -//! -//! Currently empty - all `OpenTofu` config files are static and copied directly. -//! Wrappers will be created when config templates need variable substitution. -pub mod lxd; 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 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:?}"), + } + } } diff --git a/src/testing/e2e/container.rs b/src/testing/e2e/container.rs index a57fc61a..b42b5f34 100644 --- a/src/testing/e2e/container.rs +++ b/src/testing/e2e/container.rs @@ -24,14 +24,15 @@ 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::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 /// @@ -70,14 +71,14 @@ impl Services { config: &Config, 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()); 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(); @@ -91,7 +92,7 @@ impl Services { config.build_dir.clone(), 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..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)] @@ -132,7 +132,7 @@ impl TestContext { &config, environment.ssh_credentials().clone(), environment.instance_name().clone(), - environment.profile_name().clone(), + environment.provider_config().clone(), ); let env = Self { @@ -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!( 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..7494e639 --- /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. +# +# 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 }}" +}