torrust_tracker_deployer_lib/application/services/rendering/
docker_compose.rs1use 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
27pub struct DockerComposeTemplateRenderingService {
37 templates_dir: PathBuf,
38 build_dir: PathBuf,
39 clock: Arc<dyn Clock>,
40}
41
42impl DockerComposeTemplateRenderingService {
43 #[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 #[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 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 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 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 fn build_tracker_config(user_inputs: &UserInputs) -> TrackerServiceContext {
148 let tracker_config = user_inputs.tracker();
149
150 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 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 fn has_caddy_enabled(user_inputs: &UserInputs) -> bool {
181 if user_inputs.https().is_none() {
183 return false;
184 }
185
186 let tracker = user_inputs.tracker();
187
188 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 tracker_api_has_tls || http_trackers_have_tls || grafana_has_tls
197 }
198
199 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 #[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 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 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 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 fn apply_caddy_config(
290 builder: DockerComposeContextBuilder,
291 user_inputs: &UserInputs,
292 ) -> DockerComposeContextBuilder {
293 let Some(_https_config) = user_inputs.https() else {
295 return builder;
296 };
297
298 let tracker = user_inputs.tracker();
299
300 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 if has_any_tls {
315 builder.with_caddy()
316 } else {
317 builder
318 }
319 }
320
321 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#[derive(Debug, thiserror::Error)]
345pub enum DockerComposeTemplateRenderingServiceError {
346 #[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 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 }
412}