Skip to content

Commit 9a77348

Browse files
committed
feat: [#232] convert docker-compose template to dynamic Tera with conditional MySQL support
Phase 1: Add MySQL service to docker-compose template Changes: - Convert docker-compose.yml to docker-compose.yml.tera (dynamic template) - Add conditional MySQL 8.0 service (renders only when database.driver == 'mysql') - Add MySQL healthcheck using mysqladmin ping - Add mysql_data volume for persistence - Create DockerComposeContext wrapper (supports SQLite and MySQL) - Create DockerComposeTemplate wrapper with validation - Create DockerComposeRenderer following renderer pattern - Update DockerComposeProjectGenerator to render dynamically - Add comprehensive unit tests for MySQL rendering (1460 tests passing) MySQL Service Features: - MySQL 8.0 with native password authentication - Healthcheck: mysqladmin ping (10s interval, 5s timeout, 5 retries, 30s start_period) - Environment variables: root password, database, user, password - Port mapping: 3306 - Named volume for data persistence - Network: backend_network Testing: - Unit tests verify MySQL service renders with all components - Unit tests verify SQLite doesn't render MySQL service - All 1460 unit tests passing - Pre-commit checks passing Limitation: - Phase 2 needed: Environment configuration doesn't support MySQL yet - Full E2E test with MySQL deferred to Phase 2 Related to #232
1 parent b682d70 commit 9a77348

File tree

11 files changed

+853
-186
lines changed

11 files changed

+853
-186
lines changed

project-words.txt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,7 @@ mtorrust
140140
multiprocess
141141
myapp
142142
myenv
143+
mysqladmin
143144
nameof
144145
namespacing
145146
nanos
@@ -243,6 +244,7 @@ unrepresentable
243244
unsubscription
244245
usermod
245246
useroutput
247+
userpass
246248
userspace
247249
usize
248250
utmp

src/application/steps/rendering/docker_compose_templates.rs

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ use tracing::{info, instrument};
3131

3232
use crate::domain::environment::Environment;
3333
use crate::domain::template::TemplateManager;
34+
use crate::infrastructure::templating::docker_compose::template::wrappers::docker_compose::DockerComposeContext;
3435
use crate::infrastructure::templating::docker_compose::template::wrappers::env::EnvContext;
3536
use crate::infrastructure::templating::docker_compose::{
3637
DockerComposeProjectGenerator, DockerComposeProjectGeneratorError,
@@ -99,8 +100,7 @@ impl<S> RenderDockerComposeTemplatesStep<S> {
99100
"Rendering Docker Compose templates"
100101
);
101102

102-
let generator =
103-
DockerComposeProjectGenerator::new(&self.build_dir, self.template_manager.clone());
103+
let generator = DockerComposeProjectGenerator::new(&self.build_dir, &self.template_manager);
104104

105105
// Extract admin token from environment config
106106
let admin_token = self
@@ -113,7 +113,12 @@ impl<S> RenderDockerComposeTemplatesStep<S> {
113113
.clone();
114114
let env_context = EnvContext::new(admin_token);
115115

116-
let compose_build_dir = generator.render(&env_context).await?;
116+
// For now, use SQLite configuration (MySQL support will be added in Phase 2)
117+
let docker_compose_context = DockerComposeContext::new_sqlite();
118+
119+
let compose_build_dir = generator
120+
.render(&env_context, &docker_compose_context)
121+
.await?;
117122

118123
info!(
119124
step = "render_docker_compose_templates",
Lines changed: 326 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,326 @@
1+
//! # docker-compose.yml Template Renderer
2+
//!
3+
//! This module handles rendering of the `docker-compose.yml.tera` template for Docker Compose deployments.
4+
//! It's responsible for creating `docker-compose.yml` files with service configurations from dynamic configuration.
5+
//!
6+
//! ## Responsibilities
7+
//!
8+
//! - Load the `docker-compose.yml.tera` template file
9+
//! - Process template with runtime context (database configuration, etc.)
10+
//! - Render final `docker-compose.yml` file for Docker Compose consumption
11+
12+
use std::path::Path;
13+
use std::sync::Arc;
14+
use thiserror::Error;
15+
16+
use crate::domain::template::file::File;
17+
use crate::domain::template::{FileOperationError, TemplateManager, TemplateManagerError};
18+
use crate::infrastructure::templating::docker_compose::template::wrappers::docker_compose::{
19+
DockerComposeContext, DockerComposeTemplate,
20+
};
21+
22+
/// Errors that can occur during docker-compose.yml template rendering
23+
#[derive(Error, Debug)]
24+
pub enum DockerComposeRendererError {
25+
/// Failed to get template path from template manager
26+
#[error("Failed to get template path for '{file_name}': {source}")]
27+
TemplatePathFailed {
28+
file_name: String,
29+
#[source]
30+
source: TemplateManagerError,
31+
},
32+
33+
/// Failed to read Tera template file content
34+
#[error("Failed to read Tera template file '{file_name}': {source}")]
35+
TeraTemplateReadFailed {
36+
file_name: String,
37+
#[source]
38+
source: std::io::Error,
39+
},
40+
41+
/// Failed to create File object from template content
42+
#[error("Failed to create File object for '{file_name}': {source}")]
43+
FileCreationFailed {
44+
file_name: String,
45+
#[source]
46+
source: crate::domain::template::file::Error,
47+
},
48+
49+
/// Failed to create docker-compose template with provided context
50+
#[error("Failed to create DockerComposeTemplate: {source}")]
51+
DockerComposeTemplateCreationFailed {
52+
#[source]
53+
source: crate::domain::template::TemplateEngineError,
54+
},
55+
56+
/// Failed to render docker-compose template to output file
57+
#[error("Failed to render docker-compose.yml template to file: {source}")]
58+
DockerComposeTemplateRenderFailed {
59+
#[source]
60+
source: FileOperationError,
61+
},
62+
}
63+
64+
/// Handles rendering of the docker-compose.yml.tera template for Docker Compose deployments
65+
///
66+
/// This collaborator is responsible for all docker-compose.yml template-specific operations:
67+
/// - Loading the docker-compose.yml.tera template
68+
/// - Processing it with runtime context (database configuration, etc.)
69+
/// - Rendering the final docker-compose.yml file for Docker Compose consumption
70+
pub struct DockerComposeRenderer {
71+
template_manager: Arc<TemplateManager>,
72+
}
73+
74+
impl DockerComposeRenderer {
75+
/// Template filename for the docker-compose.yml Tera template
76+
const DOCKER_COMPOSE_TEMPLATE_FILE: &'static str = "docker-compose.yml.tera";
77+
78+
/// Output filename for the rendered docker-compose.yml file
79+
const DOCKER_COMPOSE_OUTPUT_FILE: &'static str = "docker-compose.yml";
80+
81+
/// Default template path prefix for Docker Compose templates
82+
const DOCKER_COMPOSE_TEMPLATE_PATH: &'static str = "docker-compose";
83+
84+
/// Creates a new docker-compose.yml template renderer
85+
///
86+
/// # Arguments
87+
///
88+
/// * `template_manager` - The template manager to source templates from
89+
#[must_use]
90+
pub fn new(template_manager: Arc<TemplateManager>) -> Self {
91+
Self { template_manager }
92+
}
93+
94+
/// Renders the docker-compose.yml.tera template with the provided context
95+
///
96+
/// This method:
97+
/// 1. Loads the docker-compose.yml.tera template from the template manager
98+
/// 2. Reads the template content
99+
/// 3. Creates a File object for template processing
100+
/// 4. Creates a `DockerComposeTemplate` with the runtime context
101+
/// 5. Renders the template to docker-compose.yml in the output directory
102+
///
103+
/// # Arguments
104+
///
105+
/// * `context` - The context containing service configuration
106+
/// * `output_dir` - The directory where docker-compose.yml should be written
107+
///
108+
/// # Returns
109+
///
110+
/// * `Result<(), DockerComposeRendererError>` - Success or error from the template rendering operation
111+
///
112+
/// # Errors
113+
///
114+
/// Returns an error if:
115+
/// - Template file cannot be found or read
116+
/// - Template content is invalid
117+
/// - Variable substitution fails
118+
/// - Output file cannot be written
119+
pub fn render(
120+
&self,
121+
context: &DockerComposeContext,
122+
output_dir: &Path,
123+
) -> Result<(), DockerComposeRendererError> {
124+
tracing::debug!("Rendering docker-compose.yml template with runtime variables");
125+
126+
// Get the docker-compose.yml template path
127+
let template_path = self
128+
.template_manager
129+
.get_template_path(&Self::build_template_path())
130+
.map_err(|source| DockerComposeRendererError::TemplatePathFailed {
131+
file_name: Self::DOCKER_COMPOSE_TEMPLATE_FILE.to_string(),
132+
source,
133+
})?;
134+
135+
// Read the template file content
136+
let template_content = std::fs::read_to_string(&template_path).map_err(|source| {
137+
DockerComposeRendererError::TeraTemplateReadFailed {
138+
file_name: Self::DOCKER_COMPOSE_TEMPLATE_FILE.to_string(),
139+
source,
140+
}
141+
})?;
142+
143+
// Create File object for template processing
144+
let template_file = File::new(Self::DOCKER_COMPOSE_TEMPLATE_FILE, template_content)
145+
.map_err(|source| DockerComposeRendererError::FileCreationFailed {
146+
file_name: Self::DOCKER_COMPOSE_TEMPLATE_FILE.to_string(),
147+
source,
148+
})?;
149+
150+
// Create the template with context
151+
let docker_compose_template = DockerComposeTemplate::new(&template_file, context.clone())
152+
.map_err(|source| {
153+
DockerComposeRendererError::DockerComposeTemplateCreationFailed { source }
154+
})?;
155+
156+
// Render to output file
157+
let output_path = output_dir.join(Self::DOCKER_COMPOSE_OUTPUT_FILE);
158+
docker_compose_template
159+
.render(&output_path)
160+
.map_err(
161+
|source| DockerComposeRendererError::DockerComposeTemplateRenderFailed { source },
162+
)?;
163+
164+
tracing::debug!(
165+
output_path = %output_path.display(),
166+
"docker-compose.yml template rendered successfully"
167+
);
168+
169+
Ok(())
170+
}
171+
172+
/// Builds the template path for the docker-compose.yml.tera file
173+
///
174+
/// # Returns
175+
///
176+
/// * `String` - The complete template path
177+
fn build_template_path() -> String {
178+
format!(
179+
"{}/{}",
180+
Self::DOCKER_COMPOSE_TEMPLATE_PATH,
181+
Self::DOCKER_COMPOSE_TEMPLATE_FILE
182+
)
183+
}
184+
}
185+
186+
#[cfg(test)]
187+
mod tests {
188+
use super::*;
189+
use tempfile::TempDir;
190+
191+
use crate::infrastructure::templating::docker_compose::template::wrappers::docker_compose::DockerComposeContext;
192+
193+
#[test]
194+
fn it_should_create_renderer_with_template_manager() {
195+
let temp_dir = TempDir::new().unwrap();
196+
let template_manager = Arc::new(TemplateManager::new(temp_dir.path()));
197+
198+
let renderer = DockerComposeRenderer::new(template_manager);
199+
200+
// Verify the renderer is created (smoke test)
201+
assert!(std::mem::size_of_val(&renderer) > 0);
202+
}
203+
204+
#[test]
205+
fn it_should_build_correct_template_path() {
206+
let path = DockerComposeRenderer::build_template_path();
207+
assert_eq!(path, "docker-compose/docker-compose.yml.tera");
208+
}
209+
210+
#[test]
211+
fn it_should_render_docker_compose_with_mysql_service_when_driver_is_mysql() {
212+
let temp_dir = TempDir::new().unwrap();
213+
let template_manager = Arc::new(TemplateManager::new(temp_dir.path()));
214+
215+
let mysql_context = DockerComposeContext::new_mysql(
216+
"rootpass123".to_string(),
217+
"tracker_db".to_string(),
218+
"tracker_user".to_string(),
219+
"userpass123".to_string(),
220+
3306,
221+
);
222+
223+
let renderer = DockerComposeRenderer::new(template_manager);
224+
let output_dir = TempDir::new().unwrap();
225+
226+
let result = renderer.render(&mysql_context, output_dir.path());
227+
assert!(
228+
result.is_ok(),
229+
"Rendering with MySQL context should succeed"
230+
);
231+
232+
let output_path = output_dir.path().join("docker-compose.yml");
233+
let content = std::fs::read_to_string(&output_path)
234+
.expect("Should be able to read rendered docker-compose.yml");
235+
236+
// Verify MySQL service is present
237+
assert!(
238+
content.contains("mysql:"),
239+
"Rendered output should contain mysql service"
240+
);
241+
assert!(
242+
content.contains("image: mysql:8.0"),
243+
"Should use MySQL 8.0 image"
244+
);
245+
246+
// Verify MySQL environment variables
247+
assert!(
248+
content.contains("MYSQL_ROOT_PASSWORD=rootpass123"),
249+
"Should contain root password"
250+
);
251+
assert!(
252+
content.contains("MYSQL_DATABASE=tracker_db"),
253+
"Should contain database name"
254+
);
255+
assert!(
256+
content.contains("MYSQL_USER=tracker_user"),
257+
"Should contain username"
258+
);
259+
assert!(
260+
content.contains("MYSQL_PASSWORD=userpass123"),
261+
"Should contain password"
262+
);
263+
264+
// Verify MySQL healthcheck
265+
assert!(
266+
content.contains("healthcheck:"),
267+
"Should have healthcheck section"
268+
);
269+
assert!(
270+
content.contains("mysqladmin"),
271+
"Should use mysqladmin for healthcheck"
272+
);
273+
assert!(content.contains("ping"), "Should use ping command");
274+
275+
// Verify MySQL volume
276+
assert!(
277+
content.contains("mysql_data:"),
278+
"Should have mysql_data volume definition"
279+
);
280+
assert!(
281+
content.contains("driver: local"),
282+
"Volume should use local driver"
283+
);
284+
285+
// Verify port mapping
286+
assert!(
287+
content.contains("3306:3306"),
288+
"Should expose MySQL port 3306"
289+
);
290+
}
291+
292+
#[test]
293+
fn it_should_not_render_mysql_service_when_driver_is_sqlite() {
294+
let temp_dir = TempDir::new().unwrap();
295+
let template_manager = Arc::new(TemplateManager::new(temp_dir.path()));
296+
297+
let sqlite_context = DockerComposeContext::new_sqlite();
298+
299+
let renderer = DockerComposeRenderer::new(template_manager);
300+
let output_dir = TempDir::new().unwrap();
301+
302+
let result = renderer.render(&sqlite_context, output_dir.path());
303+
assert!(
304+
result.is_ok(),
305+
"Rendering with SQLite context should succeed"
306+
);
307+
308+
let output_path = output_dir.path().join("docker-compose.yml");
309+
let content = std::fs::read_to_string(&output_path)
310+
.expect("Should be able to read rendered docker-compose.yml");
311+
312+
// Verify MySQL service is NOT present
313+
assert!(
314+
!content.contains("image: mysql:8.0"),
315+
"Should not contain MySQL service"
316+
);
317+
assert!(
318+
!content.contains("mysqladmin"),
319+
"Should not contain MySQL healthcheck"
320+
);
321+
assert!(
322+
!content.contains("mysql_data:"),
323+
"Should not contain mysql_data volume"
324+
);
325+
}
326+
}

0 commit comments

Comments
 (0)