Skip to main content

torrust_tracker_deployer_lib/application/services/rendering/
docker_compose.rs

1//! Docker Compose template rendering service
2//!
3//! This service handles rendering of Docker Compose configuration templates,
4//! including complex context building for database variants (SQLite/MySQL),
5//! topology computation, and optional service configuration.
6
7use std::path::PathBuf;
8use std::sync::Arc;
9
10use tracing::{info, instrument};
11
12use crate::domain::topology::EnabledServices;
13use crate::domain::tracker::DatabaseConfig;
14use crate::domain::TemplateManager;
15use crate::infrastructure::templating::docker_compose::template::wrappers::docker_compose::{
16    DockerComposeContext, DockerComposeContextBuilder, MysqlSetupConfig, TrackerServiceContext,
17};
18use crate::infrastructure::templating::docker_compose::template::wrappers::env::EnvContext;
19use crate::infrastructure::templating::docker_compose::{
20    DockerComposeProjectGenerator, DockerComposeProjectGeneratorError,
21};
22use crate::infrastructure::templating::TemplateMetadata;
23use crate::shared::{Clock, PlainPassword};
24
25use crate::domain::environment::user_inputs::UserInputs;
26
27/// Service for rendering Docker Compose templates
28///
29/// This service encapsulates the complex logic for building Docker Compose
30/// contexts including:
31/// - Database variant selection (`SQLite` vs `MySQL`)
32/// - Topology computation (which services are enabled)
33/// - Optional service configuration (Prometheus, Grafana, Backup, Caddy)
34/// - Grafana environment context
35/// - `MySQL` setup configuration
36pub struct DockerComposeTemplateRenderingService {
37    templates_dir: PathBuf,
38    build_dir: PathBuf,
39    clock: Arc<dyn Clock>,
40}
41
42impl DockerComposeTemplateRenderingService {
43    /// Create a new service with explicit dependencies
44    ///
45    /// # Arguments
46    ///
47    /// * `templates_dir` - Directory containing template source files
48    /// * `build_dir` - Directory where rendered templates will be written
49    /// * `clock` - Clock service for timestamps
50    #[must_use]
51    pub fn from_paths(templates_dir: PathBuf, build_dir: PathBuf, clock: Arc<dyn Clock>) -> Self {
52        Self {
53            templates_dir,
54            build_dir,
55            clock,
56        }
57    }
58
59    /// Render Docker Compose templates with full context building
60    ///
61    /// This method builds the complete Docker Compose context from user inputs,
62    /// including database-specific configuration, topology computation, and
63    /// optional service integration.
64    ///
65    /// # Arguments
66    ///
67    /// * `user_inputs` - Complete user configuration
68    /// * `admin_token` - Tracker admin token
69    ///
70    /// # Returns
71    ///
72    /// Path to the rendered docker-compose build directory
73    ///
74    /// # Errors
75    ///
76    /// Returns error if template rendering fails
77    #[instrument(
78        name = "docker_compose_rendering_service",
79        skip_all,
80        fields(
81            templates_dir = %self.templates_dir.display(),
82            build_dir = %self.build_dir.display()
83        )
84    )]
85    pub async fn render(
86        &self,
87        user_inputs: &UserInputs,
88        admin_token: &str,
89    ) -> Result<PathBuf, DockerComposeTemplateRenderingServiceError> {
90        info!(
91            templates_dir = %self.templates_dir.display(),
92            build_dir = %self.build_dir.display(),
93            "Rendering Docker Compose templates"
94        );
95
96        let template_manager = Arc::new(TemplateManager::new(self.templates_dir.clone()));
97        let generator = DockerComposeProjectGenerator::new(&self.build_dir, &template_manager);
98
99        let tracker = Self::build_tracker_config(user_inputs);
100        let database_config = user_inputs.tracker().core().database();
101
102        // Create contexts based on database configuration
103        let (env_context, builder) = match database_config {
104            DatabaseConfig::Sqlite(..) => {
105                self.create_sqlite_contexts(admin_token.to_string(), tracker)
106            }
107            DatabaseConfig::Mysql(mysql_config) => self.create_mysql_contexts(
108                admin_token.to_string(),
109                tracker,
110                mysql_config.host(),
111                mysql_config.port(),
112                mysql_config.database_name().to_string(),
113                mysql_config.username().to_string(),
114                mysql_config.password().expose_secret().to_string(),
115                mysql_config.root_password().expose_secret().to_string(),
116            ),
117        };
118
119        // Apply optional service configurations
120        let builder = Self::apply_prometheus_config(builder, user_inputs);
121        let builder = Self::apply_grafana_config(builder, user_inputs);
122        let builder = Self::apply_backup_config(builder, user_inputs);
123        let builder = Self::apply_caddy_config(builder, user_inputs);
124
125        let docker_compose_context = builder.build();
126
127        // Apply Grafana credentials to env context
128        let env_context = Self::apply_grafana_env_context(env_context, user_inputs);
129
130        let compose_build_dir = generator
131            .render(&env_context, &docker_compose_context)
132            .await
133            .map_err(DockerComposeTemplateRenderingServiceError::RenderingFailed)?;
134
135        info!(
136            compose_build_dir = %compose_build_dir.display(),
137            "Docker Compose templates rendered successfully"
138        );
139
140        Ok(compose_build_dir)
141    }
142
143    /// Build tracker service context with topology information
144    ///
145    /// Determines which services are enabled and builds the complete
146    /// tracker context including network configuration.
147    fn build_tracker_config(user_inputs: &UserInputs) -> TrackerServiceContext {
148        let tracker_config = user_inputs.tracker();
149
150        // Determine which features are enabled (affects tracker networks)
151        let has_prometheus = user_inputs.prometheus().is_some();
152        let has_mysql = matches!(
153            user_inputs.tracker().core().database(),
154            DatabaseConfig::Mysql(..)
155        );
156        let has_caddy = Self::has_caddy_enabled(user_inputs);
157        let has_grafana = user_inputs.grafana().is_some();
158
159        // Build list of enabled services for topology context
160        let mut enabled_services = Vec::new();
161        if has_prometheus {
162            enabled_services.push(crate::domain::topology::Service::Prometheus);
163        }
164        if has_grafana {
165            enabled_services.push(crate::domain::topology::Service::Grafana);
166        }
167        if has_mysql {
168            enabled_services.push(crate::domain::topology::Service::MySQL);
169        }
170        if has_caddy {
171            enabled_services.push(crate::domain::topology::Service::Caddy);
172        }
173
174        let topology_context = EnabledServices::from(&enabled_services);
175
176        TrackerServiceContext::from_domain_config(tracker_config, &topology_context)
177    }
178
179    /// Check if Caddy is enabled (HTTPS with at least one TLS-configured service)
180    fn has_caddy_enabled(user_inputs: &UserInputs) -> bool {
181        // Check if HTTPS is configured
182        if user_inputs.https().is_none() {
183            return false;
184        }
185
186        let tracker = user_inputs.tracker();
187
188        // Check if any service has TLS configured
189        let tracker_api_has_tls = tracker.http_api_tls_domain().is_some();
190        let http_trackers_have_tls = !tracker.http_trackers_with_tls().is_empty();
191        let grafana_has_tls = user_inputs
192            .grafana()
193            .is_some_and(|g| g.tls_domain().is_some());
194
195        // Caddy is enabled if HTTPS is configured AND at least one service has TLS
196        tracker_api_has_tls || http_trackers_have_tls || grafana_has_tls
197    }
198
199    /// Create contexts for `SQLite` database configuration
200    fn create_sqlite_contexts(
201        &self,
202        admin_token: String,
203        tracker: TrackerServiceContext,
204    ) -> (EnvContext, DockerComposeContextBuilder) {
205        let metadata = TemplateMetadata::new(self.clock.now());
206        let env_context = EnvContext::new(metadata.clone(), admin_token);
207        let builder = DockerComposeContext::builder(tracker).with_metadata(metadata);
208
209        (env_context, builder)
210    }
211
212    /// Create contexts for `MySQL` database configuration
213    #[allow(clippy::too_many_arguments)]
214    fn create_mysql_contexts(
215        &self,
216        admin_token: String,
217        tracker: TrackerServiceContext,
218        host: &str,
219        port: u16,
220        database_name: String,
221        username: String,
222        password: PlainPassword,
223        root_password: PlainPassword,
224    ) -> (EnvContext, DockerComposeContextBuilder) {
225        let metadata = TemplateMetadata::new(self.clock.now());
226        let env_context = EnvContext::new_with_mysql(
227            metadata.clone(),
228            admin_token,
229            root_password.clone(),
230            database_name.clone(),
231            username.clone(),
232            password.clone(),
233            host,
234            port,
235        );
236
237        let mysql_config = MysqlSetupConfig {
238            root_password,
239            database: database_name,
240            user: username,
241            password,
242            port,
243        };
244
245        let builder = DockerComposeContext::builder(tracker)
246            .with_metadata(metadata)
247            .with_mysql(mysql_config);
248
249        (env_context, builder)
250    }
251
252    /// Apply Prometheus configuration if present
253    fn apply_prometheus_config(
254        builder: DockerComposeContextBuilder,
255        user_inputs: &UserInputs,
256    ) -> DockerComposeContextBuilder {
257        if let Some(prometheus_config) = user_inputs.prometheus() {
258            builder.with_prometheus(prometheus_config.clone())
259        } else {
260            builder
261        }
262    }
263
264    /// Apply Grafana configuration if present
265    fn apply_grafana_config(
266        builder: DockerComposeContextBuilder,
267        user_inputs: &UserInputs,
268    ) -> DockerComposeContextBuilder {
269        if let Some(grafana_config) = user_inputs.grafana() {
270            builder.with_grafana(grafana_config.clone())
271        } else {
272            builder
273        }
274    }
275
276    /// Apply Backup configuration if present
277    fn apply_backup_config(
278        builder: DockerComposeContextBuilder,
279        user_inputs: &UserInputs,
280    ) -> DockerComposeContextBuilder {
281        if let Some(backup_config) = user_inputs.backup() {
282            builder.with_backup(backup_config.clone())
283        } else {
284            builder
285        }
286    }
287
288    /// Apply Caddy configuration if HTTPS and TLS services are configured
289    fn apply_caddy_config(
290        builder: DockerComposeContextBuilder,
291        user_inputs: &UserInputs,
292    ) -> DockerComposeContextBuilder {
293        // Check if HTTPS is configured
294        let Some(_https_config) = user_inputs.https() else {
295            return builder;
296        };
297
298        let tracker = user_inputs.tracker();
299
300        // Check if any service has TLS configured
301        let has_tracker_api_tls = tracker.http_api_tls_domain().is_some();
302        let has_http_tracker_tls = !tracker.http_trackers_with_tls().is_empty();
303        let has_grafana_tls = user_inputs
304            .grafana()
305            .is_some_and(|g| g.tls_domain().is_some());
306
307        let has_any_tls = has_tracker_api_tls || has_http_tracker_tls || has_grafana_tls;
308
309        // Note: The CaddyContext with full service details is built separately
310        // in CaddyTemplateRenderingService. The docker-compose template only needs
311        // to know if Caddy is enabled, not the service details.
312
313        // Only add Caddy if at least one service has TLS
314        if has_any_tls {
315            builder.with_caddy()
316        } else {
317            builder
318        }
319    }
320
321    /// Apply Grafana credentials to environment context if Grafana is configured
322    fn apply_grafana_env_context(env_context: EnvContext, user_inputs: &UserInputs) -> EnvContext {
323        if let Some(grafana_config) = user_inputs.grafana() {
324            let server_root_url = grafana_config.domain().map(|domain| {
325                let scheme = if grafana_config.use_tls_proxy() {
326                    "https"
327                } else {
328                    "http"
329                };
330                format!("{scheme}://{}", domain.as_str())
331            });
332            env_context.with_grafana(
333                grafana_config.admin_user().to_string(),
334                grafana_config.admin_password().expose_secret().to_string(),
335                server_root_url,
336            )
337        } else {
338            env_context
339        }
340    }
341}
342
343/// Errors that can occur during Docker Compose template rendering
344#[derive(Debug, thiserror::Error)]
345pub enum DockerComposeTemplateRenderingServiceError {
346    /// Template rendering failed
347    #[error("Docker Compose template rendering failed: {0}")]
348    RenderingFailed(#[from] DockerComposeProjectGeneratorError),
349}
350
351#[cfg(test)]
352mod tests {
353    use super::*;
354    use tempfile::TempDir;
355
356    use crate::domain::environment::testing::EnvironmentTestBuilder;
357    use crate::shared::SystemClock;
358
359    #[tokio::test]
360    async fn it_should_create_service_with_from_paths() {
361        let templates_dir = TempDir::new().expect("Failed to create temp dir");
362        let build_dir = TempDir::new().expect("Failed to create temp dir");
363        let clock: Arc<dyn Clock> = Arc::new(SystemClock);
364
365        let service = DockerComposeTemplateRenderingService::from_paths(
366            templates_dir.path().to_path_buf(),
367            build_dir.path().to_path_buf(),
368            clock,
369        );
370
371        assert_eq!(service.templates_dir, templates_dir.path());
372        assert_eq!(service.build_dir, build_dir.path());
373    }
374
375    #[tokio::test]
376    async fn it_should_render_docker_compose_templates_for_sqlite() {
377        let templates_dir = TempDir::new().expect("Failed to create temp dir");
378        let build_dir = TempDir::new().expect("Failed to create temp dir");
379        let clock: Arc<dyn Clock> = Arc::new(SystemClock);
380
381        let service = DockerComposeTemplateRenderingService::from_paths(
382            templates_dir.path().to_path_buf(),
383            build_dir.path().to_path_buf(),
384            clock,
385        );
386
387        let (environment, _, _, _temp_dir) =
388            EnvironmentTestBuilder::new().build_with_custom_paths();
389        let user_inputs = &environment.context().user_inputs;
390        let admin_token = "test-admin-token";
391
392        let result = service.render(user_inputs, admin_token).await;
393
394        assert!(result.is_ok());
395        let compose_dir = result.unwrap();
396        assert!(compose_dir.exists());
397        assert!(compose_dir.join("docker-compose.yml").exists());
398    }
399
400    #[tokio::test]
401    async fn it_should_check_caddy_enabled_correctly() {
402        // Test without HTTPS - should be false
403        let (environment, _, _, _temp_dir) =
404            EnvironmentTestBuilder::new().build_with_custom_paths();
405        let user_inputs_no_https = &environment.context().user_inputs;
406        assert!(!DockerComposeTemplateRenderingService::has_caddy_enabled(
407            user_inputs_no_https
408        ));
409
410        // TODO: Add test with HTTPS + TLS when EnvironmentTestBuilder supports it
411    }
412}