Skip to main content

torrust_tracker_deployer_lib/application/services/rendering/
caddy.rs

1//! Caddy template rendering service
2//!
3//! This service handles rendering of Caddy TLS proxy configuration templates,
4//! including automatic extraction of TLS-enabled services from tracker configuration.
5
6use std::path::PathBuf;
7use std::sync::Arc;
8
9use tracing::{info, instrument};
10
11use crate::domain::TemplateManager;
12use crate::infrastructure::templating::caddy::{
13    CaddyContext, CaddyProjectGenerator, CaddyProjectGeneratorError, CaddyService,
14};
15use crate::infrastructure::templating::TemplateMetadata;
16use crate::shared::Clock;
17
18use crate::domain::environment::user_inputs::UserInputs;
19
20/// Service for rendering Caddy TLS proxy templates
21///
22/// This service encapsulates the logic for building Caddy contexts from
23/// user configuration, including:
24/// - Extracting TLS-enabled services (Tracker API, HTTP Trackers, Health Check API, Grafana)
25/// - Building `CaddyContext` with Let's Encrypt configuration
26/// - Conditional rendering (only when HTTPS + TLS services are configured)
27pub struct CaddyTemplateRenderingService {
28    templates_dir: PathBuf,
29    build_dir: PathBuf,
30    clock: Arc<dyn Clock>,
31}
32
33impl CaddyTemplateRenderingService {
34    /// Create a new service with explicit dependencies
35    ///
36    /// # Arguments
37    ///
38    /// * `templates_dir` - Directory containing template source files
39    /// * `build_dir` - Directory where rendered templates will be written
40    /// * `clock` - Clock service for timestamps
41    #[must_use]
42    pub fn from_paths(templates_dir: PathBuf, build_dir: PathBuf, clock: Arc<dyn Clock>) -> Self {
43        Self {
44            templates_dir,
45            build_dir,
46            clock,
47        }
48    }
49
50    /// Render Caddy templates if HTTPS and TLS services are configured
51    ///
52    /// This method builds the complete Caddy context by extracting all
53    /// TLS-enabled services from the user configuration. Returns `None`
54    /// if HTTPS is not configured or no services have TLS enabled.
55    ///
56    /// # Arguments
57    ///
58    /// * `user_inputs` - Complete user configuration
59    ///
60    /// # Returns
61    ///
62    /// `Some(PathBuf)` with path to the rendered Caddy build directory if
63    /// HTTPS + TLS services are configured, or `None` if Caddy should not
64    /// be deployed.
65    ///
66    /// # Errors
67    ///
68    /// Returns error if template rendering fails
69    #[instrument(
70        name = "caddy_rendering_service",
71        skip_all,
72        fields(
73            templates_dir = %self.templates_dir.display(),
74            build_dir = %self.build_dir.display()
75        )
76    )]
77    pub fn render(
78        &self,
79        user_inputs: &UserInputs,
80    ) -> Result<Option<PathBuf>, CaddyTemplateRenderingServiceError> {
81        // Check if HTTPS is configured
82        let Some(https_config) = user_inputs.https() else {
83            info!(
84                reason = "https_not_configured",
85                "Skipping Caddy template rendering - HTTPS not configured"
86            );
87            return Ok(None);
88        };
89
90        // Build CaddyContext from environment configuration
91        let caddy_context = self.build_caddy_context(user_inputs, https_config);
92
93        // Check if any service has TLS configured
94        if !caddy_context.has_any_tls() {
95            info!(
96                reason = "no_tls_services",
97                "Skipping Caddy template rendering - no services have TLS configured"
98            );
99            return Ok(None);
100        }
101
102        info!(
103            templates_dir = %self.templates_dir.display(),
104            build_dir = %self.build_dir.display(),
105            admin_email = %https_config.admin_email(),
106            use_staging = https_config.use_staging(),
107            "Rendering Caddy configuration templates"
108        );
109
110        let template_manager = Arc::new(TemplateManager::new(self.templates_dir.clone()));
111        let generator = CaddyProjectGenerator::new(&self.build_dir, template_manager);
112
113        generator
114            .render(&caddy_context)
115            .map_err(CaddyTemplateRenderingServiceError::RenderingFailed)?;
116
117        let caddy_build_dir = self.build_dir.join("caddy");
118
119        info!(
120            caddy_build_dir = %caddy_build_dir.display(),
121            "Caddy templates rendered successfully"
122        );
123
124        Ok(Some(caddy_build_dir))
125    }
126
127    /// Build a `CaddyContext` from the user configuration
128    ///
129    /// Extracts TLS-enabled services from tracker config and builds
130    /// the context with pre-extracted ports.
131    fn build_caddy_context(
132        &self,
133        user_inputs: &UserInputs,
134        https_config: &crate::domain::https::HttpsConfig,
135    ) -> CaddyContext {
136        let tracker = user_inputs.tracker();
137
138        let metadata = TemplateMetadata::new(self.clock.now());
139
140        let mut context = CaddyContext::new(
141            metadata,
142            https_config.admin_email(),
143            https_config.use_staging(),
144        );
145
146        // Add Tracker HTTP API if TLS configured
147        if let Some(tls_config) = tracker.http_api_tls_domain() {
148            let port = tracker.http_api_port();
149            context = context.with_tracker_api(CaddyService::new(tls_config, port));
150        }
151
152        // Add HTTP Trackers with TLS configured
153        for (domain, port) in tracker.http_trackers_with_tls() {
154            context = context.with_http_tracker(CaddyService::new(domain, port));
155        }
156
157        // Add Health Check API if TLS configured
158        if let Some(tls_domain) = tracker.health_check_api_tls_domain() {
159            let port = tracker.health_check_api_port();
160            context = context.with_health_check_api(CaddyService::new(tls_domain, port));
161        }
162
163        // Add Grafana if TLS configured
164        if let Some(grafana) = user_inputs.grafana() {
165            if let Some(tls_domain) = grafana.tls_domain() {
166                // Grafana default port is 3000
167                context = context.with_grafana(CaddyService::new(tls_domain, 3000));
168            }
169        }
170
171        context
172    }
173}
174
175/// Errors that can occur during Caddy template rendering
176#[derive(Debug, thiserror::Error)]
177pub enum CaddyTemplateRenderingServiceError {
178    /// Template rendering failed
179    #[error("Caddy template rendering failed: {0}")]
180    RenderingFailed(#[from] CaddyProjectGeneratorError),
181}
182
183#[cfg(test)]
184mod tests {
185    use super::*;
186    use tempfile::TempDir;
187
188    use crate::domain::environment::testing::EnvironmentTestBuilder;
189    use crate::shared::SystemClock;
190
191    #[test]
192    fn it_should_create_service_with_from_paths() {
193        let templates_dir = TempDir::new().expect("Failed to create temp dir");
194        let build_dir = TempDir::new().expect("Failed to create temp dir");
195        let clock: Arc<dyn Clock> = Arc::new(SystemClock);
196
197        let service = CaddyTemplateRenderingService::from_paths(
198            templates_dir.path().to_path_buf(),
199            build_dir.path().to_path_buf(),
200            clock,
201        );
202
203        assert_eq!(service.templates_dir, templates_dir.path());
204        assert_eq!(service.build_dir, build_dir.path());
205    }
206
207    #[test]
208    fn it_should_return_none_when_https_not_configured() {
209        let templates_dir = TempDir::new().expect("Failed to create temp dir");
210        let build_dir = TempDir::new().expect("Failed to create temp dir");
211        let clock: Arc<dyn Clock> = Arc::new(SystemClock);
212
213        let service = CaddyTemplateRenderingService::from_paths(
214            templates_dir.path().to_path_buf(),
215            build_dir.path().to_path_buf(),
216            clock,
217        );
218
219        let (environment, _, _, _temp_dir) =
220            EnvironmentTestBuilder::new().build_with_custom_paths();
221        let user_inputs = &environment.context().user_inputs;
222
223        let result = service.render(user_inputs);
224
225        assert!(result.is_ok());
226        assert!(result.unwrap().is_none());
227    }
228
229    // TODO: Add test cases for HTTPS + TLS configured when EnvironmentTestBuilder supports it
230}