Skip to content

Commit 66e49c5

Browse files
committed
Merge #48: feat: [#40] Add struct-based template generation for configuration files
691a1c7 refactor: [#40] use struct-based template generation instead of hardcoded strings (copilot-swe-agent[bot]) 913e9be fix: [#40] make template validation extensible for future formats (copilot-swe-agent[bot]) 72d08b1 feat: [#40] implement infrastructure templates module (copilot-swe-agent[bot]) d8a99f7 Initial plan (copilot-swe-agent[bot]) Pull request description: Implements template generation for JSON configuration files using struct-based generation instead of hardcoded strings, following the domain-driven design approach. ## Implementation **Module: `src/domain/config/environment_config.rs`** Added two methods to `EnvironmentCreationConfig`: - `template()` - Creates a configuration instance with placeholder values - `generate_template_file()` - Async file generation with directory creation **Error Handling: `src/domain/config/errors.rs`** Extended `CreateConfigError` with template generation error variants: - `TemplateSerializationFailed` - JSON serialization errors - `TemplateDirectoryCreationFailed` - Directory creation failures - `TemplateFileWriteFailed` - File write errors All errors include `.help()` methods with actionable troubleshooting guidance. ## Template Design Uses simple `REPLACE_WITH_*` placeholders for user clarity: ```json { "environment": { "name": "REPLACE_WITH_ENVIRONMENT_NAME" }, "ssh_credentials": { "private_key_path": "REPLACE_WITH_SSH_PRIVATE_KEY_PATH", "public_key_path": "REPLACE_WITH_SSH_PUBLIC_KEY_PATH", "username": "torrust", "port": 22 } } ``` ## API ```rust use torrust_tracker_deployer_lib::domain::config::EnvironmentCreationConfig; use std::path::Path; // Get template instance with placeholders let template = EnvironmentCreationConfig::template(); // Generate template file at specific path EnvironmentCreationConfig::generate_template_file( Path::new("./config.json") ).await?; ``` ## Architecture Benefits - **Type-Safe**: Compiler guarantees template structure matches `EnvironmentCreationConfig` - **Zero Duplication**: Struct definition IS the template - no separate strings to maintain - **Auto-Synced**: Adding/removing fields automatically updates template - **Guaranteed Valid JSON**: `serde_json` ensures serialization correctness - **Domain Layer**: Template generation lives with domain model, not infrastructure ## Testing - ✅ 11 new tests (8 for template generation, 3 for error handling) - ✅ 962 total tests passing - ✅ All linters passing **Integration:** Ready for CLI `--generate-template` flag (Issue #37) - Fixes #40 <!-- START COPILOT CODING AGENT SUFFIX --> <details> <summary>Original prompt</summary> > > ---- > > *This section details on the original issue you should resolve* > > <issue_title>[Subissue 6/7] Template System Integration (Optional)</issue_title> > <issue_description>**Parent Epic**: #34 - Implement Create Environment Command > **Status**: OPTIONAL ENHANCEMENT > **Estimated Time**: 2-3 hours > > > ⚠️ **Note**: This is an optional enhancement that can be deferred. It is not required for the MVP functionality of the create command. > > ## Overview > > Implement the template system for configuration file generation in the infrastructure layer. This system provides embedded JSON configuration templates that can be generated on-demand, supporting the `--generate-template` functionality with proper error handling and validation. > > ## Goals > > - [ ] Extend existing `TemplateManager` from `src/domain/template/embedded.rs` for configuration templates > - [ ] Use existing template infrastructure - no duplication needed > - [ ] Add configuration template types to existing embedded template system > - [ ] Leverage existing rust-embed pattern in `templates/` directory structure > - [ ] Use existing `TemplateManagerError` for error handling > - [ ] Add configuration templates to existing template structure > - [ ] Add to existing `templates/` directory following existing patterns > - [ ] Use existing Tera variable syntax: `{{ variable_name }}` (not `{ { variable_name } }`) > - [ ] Follow existing template embedding and extraction patterns > - [ ] Add `--generate-template` functionality using existing template infrastructure > - [ ] Add unit tests that integrate with existing template system > > ## Architecture Requirements > > **DDD Layer**: Infrastructure Layer (`src/infrastructure/templates/`) > **Pattern**: Template Provider + Embedded Resources + File Operations > **Dependencies**: None (infrastructure concern) > > ## Template Example > > ```json > { > "environment": { > "name": "{{ environment_name }}" > }, > "ssh_credentials": { > "private_key_path": "{{ ssh_private_key_path }}", > "public_key_path": "{{ ssh_public_key_path }}", > "username": "{{ ssh_username }}", > "port": {{ ssh_port }} > } > } > ``` > > ## Acceptance Criteria > > - [ ] Template provider extends existing `TemplateManager` infrastructure > - [ ] Templates use correct Tera variable syntax: `{{ variable }}` > - [ ] Templates generate valid JSON files > - [ ] Error handling follows tiered help system pattern > - [ ] Unit tests verify template generation works correctly > - [ ] Integration tests verify file system operations > - [ ] Templates are embedded in binary using existing rust-embed pattern > > ## Implementation Notes > > - Reuse existing `TemplateManager` from `src/domain/template/embedded.rs` > - Add configuration templates to `templates/` directory structure > - Use existing error types from template system > - Follow patterns established in OpenTofu/Ansible template management > > For detailed specification, see: [docs/issues/epic-create-environment-command-subissue-6-template-system-integration.md](https://github.com/torrust/torrust-tracker-deployer/blob/main/docs/issues/epic-create-environment-command-subissue-6-template-system-integration.md)</issue_description> > > ## Comments on the Issue (you are @copilot in this section) > > <comments> > </comments> > </details> - Fixes #40 <!-- START COPILOT CODING AGENT TIPS --> --- 💡 You can make Copilot smarter by setting up custom instructions, customizing its development environment and configuring Model Context Protocol (MCP) servers. Learn more [Copilot coding agent tips](https://gh.io/copilot-coding-agent-tips) in the docs. ACKs for top commit: josecelano: ACK 691a1c7 Tree-SHA512: c2c9d4d0902e4aded93c49fe6d169cc0c1afed964dc371f805fdc51ea4169790b53b8bd475abbf6119ef571e50cad3d7903d289792d5550e4ead146d3b7a3a73
2 parents 70f548d + 691a1c7 commit 66e49c5

File tree

2 files changed

+326
-0
lines changed

2 files changed

+326
-0
lines changed

src/domain/config/environment_config.rs

Lines changed: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,100 @@ impl EnvironmentCreationConfig {
152152

153153
Ok((environment_name, ssh_credentials, ssh_port))
154154
}
155+
156+
/// Creates a template instance with placeholder values
157+
///
158+
/// This method generates a configuration template with placeholder values
159+
/// that users can replace with their actual configuration. The template
160+
/// structure matches the `EnvironmentCreationConfig` exactly, ensuring
161+
/// type safety and automatic synchronization with struct changes.
162+
///
163+
/// # Examples
164+
///
165+
/// ```rust
166+
/// use torrust_tracker_deployer_lib::domain::config::EnvironmentCreationConfig;
167+
///
168+
/// let template = EnvironmentCreationConfig::template();
169+
/// assert_eq!(template.environment.name, "REPLACE_WITH_ENVIRONMENT_NAME");
170+
/// ```
171+
#[must_use]
172+
pub fn template() -> Self {
173+
Self {
174+
environment: EnvironmentSection {
175+
name: "REPLACE_WITH_ENVIRONMENT_NAME".to_string(),
176+
},
177+
ssh_credentials: SshCredentialsConfig {
178+
private_key_path: "REPLACE_WITH_SSH_PRIVATE_KEY_PATH".to_string(),
179+
public_key_path: "REPLACE_WITH_SSH_PUBLIC_KEY_PATH".to_string(),
180+
username: "torrust".to_string(), // default value
181+
port: 22, // default value
182+
},
183+
}
184+
}
185+
186+
/// Generates a configuration template file at the specified path
187+
///
188+
/// This method creates a JSON configuration file with placeholder values
189+
/// that users can edit. The file is formatted with pretty-printing for
190+
/// better readability.
191+
///
192+
/// # Arguments
193+
///
194+
/// * `path` - Path where the template file should be created
195+
///
196+
/// # Returns
197+
///
198+
/// * `Ok(())` - Template file created successfully
199+
/// * `Err(CreateConfigError)` - File creation or serialization failed
200+
///
201+
/// # Errors
202+
///
203+
/// Returns an error if:
204+
/// - Parent directory cannot be created
205+
/// - Template serialization fails (unlikely - indicates a bug)
206+
/// - File cannot be written due to permissions or I/O errors
207+
///
208+
/// # Examples
209+
///
210+
/// ```rust,no_run
211+
/// use torrust_tracker_deployer_lib::domain::config::EnvironmentCreationConfig;
212+
/// use std::path::Path;
213+
///
214+
/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
215+
/// EnvironmentCreationConfig::generate_template_file(
216+
/// Path::new("./environment-config.json")
217+
/// ).await?;
218+
/// # Ok(())
219+
/// # }
220+
/// ```
221+
pub async fn generate_template_file(path: &std::path::Path) -> Result<(), CreateConfigError> {
222+
// Create template instance with placeholders
223+
let template = Self::template();
224+
225+
// Serialize to pretty-printed JSON
226+
let json = serde_json::to_string_pretty(&template)
227+
.map_err(|source| CreateConfigError::TemplateSerializationFailed { source })?;
228+
229+
// Create parent directories if they don't exist
230+
if let Some(parent) = path.parent() {
231+
tokio::fs::create_dir_all(parent).await.map_err(|source| {
232+
CreateConfigError::TemplateDirectoryCreationFailed {
233+
path: parent.to_path_buf(),
234+
source,
235+
}
236+
})?;
237+
}
238+
239+
// Write template to file
240+
tokio::fs::write(path, json).await.map_err(|source| {
241+
CreateConfigError::TemplateFileWriteFailed {
242+
path: path.to_path_buf(),
243+
source,
244+
}
245+
})?;
246+
247+
Ok(())
248+
}
155249
}
156250

157251
#[cfg(test)]
@@ -419,4 +513,128 @@ mod tests {
419513

420514
assert_eq!(original, deserialized);
421515
}
516+
517+
#[test]
518+
fn test_template_has_placeholder_values() {
519+
let template = EnvironmentCreationConfig::template();
520+
521+
assert_eq!(template.environment.name, "REPLACE_WITH_ENVIRONMENT_NAME");
522+
assert_eq!(
523+
template.ssh_credentials.private_key_path,
524+
"REPLACE_WITH_SSH_PRIVATE_KEY_PATH"
525+
);
526+
assert_eq!(
527+
template.ssh_credentials.public_key_path,
528+
"REPLACE_WITH_SSH_PUBLIC_KEY_PATH"
529+
);
530+
assert_eq!(template.ssh_credentials.username, "torrust");
531+
assert_eq!(template.ssh_credentials.port, 22);
532+
}
533+
534+
#[test]
535+
fn test_template_serializes_to_valid_json() {
536+
let template = EnvironmentCreationConfig::template();
537+
let json = serde_json::to_string_pretty(&template).unwrap();
538+
539+
// Verify it can be deserialized back
540+
let deserialized: EnvironmentCreationConfig = serde_json::from_str(&json).unwrap();
541+
assert_eq!(template, deserialized);
542+
}
543+
544+
#[test]
545+
fn test_template_structure_matches_config() {
546+
let template = EnvironmentCreationConfig::template();
547+
548+
// Verify template has same structure as regular config
549+
let regular_config = EnvironmentCreationConfig::new(
550+
EnvironmentSection {
551+
name: "test".to_string(),
552+
},
553+
SshCredentialsConfig::new(
554+
"path1".to_string(),
555+
"path2".to_string(),
556+
"user".to_string(),
557+
22,
558+
),
559+
);
560+
561+
// Both should serialize to same structure (different values)
562+
let template_json = serde_json::to_value(&template).unwrap();
563+
let config_json = serde_json::to_value(&regular_config).unwrap();
564+
565+
// Check structure matches
566+
assert!(template_json.is_object());
567+
assert!(config_json.is_object());
568+
569+
let template_obj = template_json.as_object().unwrap();
570+
let config_obj = config_json.as_object().unwrap();
571+
572+
assert_eq!(template_obj.keys().len(), config_obj.keys().len());
573+
assert!(template_obj.contains_key("environment"));
574+
assert!(template_obj.contains_key("ssh_credentials"));
575+
}
576+
577+
#[tokio::test]
578+
async fn test_generate_template_file() {
579+
use tempfile::TempDir;
580+
581+
let temp_dir = TempDir::new().unwrap();
582+
let template_path = temp_dir.path().join("config.json");
583+
584+
let result = EnvironmentCreationConfig::generate_template_file(&template_path).await;
585+
assert!(result.is_ok());
586+
587+
// Verify file exists
588+
assert!(template_path.exists());
589+
590+
// Verify content is valid JSON
591+
let content = std::fs::read_to_string(&template_path).unwrap();
592+
let parsed: EnvironmentCreationConfig = serde_json::from_str(&content).unwrap();
593+
594+
// Verify placeholders are present
595+
assert_eq!(parsed.environment.name, "REPLACE_WITH_ENVIRONMENT_NAME");
596+
assert_eq!(
597+
parsed.ssh_credentials.private_key_path,
598+
"REPLACE_WITH_SSH_PRIVATE_KEY_PATH"
599+
);
600+
}
601+
602+
#[tokio::test]
603+
async fn test_generate_template_file_creates_parent_directories() {
604+
use tempfile::TempDir;
605+
606+
let temp_dir = TempDir::new().unwrap();
607+
let nested_path = temp_dir
608+
.path()
609+
.join("configs")
610+
.join("env")
611+
.join("test.json");
612+
613+
let result = EnvironmentCreationConfig::generate_template_file(&nested_path).await;
614+
assert!(result.is_ok());
615+
616+
// Verify nested directories were created
617+
assert!(nested_path.exists());
618+
assert!(nested_path.parent().unwrap().exists());
619+
}
620+
621+
#[tokio::test]
622+
async fn test_generate_template_file_overwrites_existing() {
623+
use tempfile::TempDir;
624+
625+
let temp_dir = TempDir::new().unwrap();
626+
let template_path = temp_dir.path().join("config.json");
627+
628+
// Create initial file
629+
std::fs::write(&template_path, "old content").unwrap();
630+
631+
// Generate template should overwrite
632+
let result = EnvironmentCreationConfig::generate_template_file(&template_path).await;
633+
assert!(result.is_ok());
634+
635+
// Verify content was replaced
636+
let content = std::fs::read_to_string(&template_path).unwrap();
637+
assert!(content.contains("REPLACE_WITH_ENVIRONMENT_NAME"));
638+
assert!(!content.contains("old content"));
639+
}
422640
}

src/domain/config/errors.rs

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,29 @@ pub enum CreateConfigError {
3535
/// Invalid SSH port (must be 1-65535)
3636
#[error("Invalid SSH port: {port} (must be between 1 and 65535)")]
3737
InvalidPort { port: u16 },
38+
39+
/// Failed to serialize configuration template to JSON
40+
#[error("Failed to serialize configuration template to JSON")]
41+
TemplateSerializationFailed {
42+
#[source]
43+
source: serde_json::Error,
44+
},
45+
46+
/// Failed to create parent directory for template file
47+
#[error("Failed to create directory: {path}")]
48+
TemplateDirectoryCreationFailed {
49+
path: PathBuf,
50+
#[source]
51+
source: std::io::Error,
52+
},
53+
54+
/// Failed to write template file
55+
#[error("Failed to write template file: {path}")]
56+
TemplateFileWriteFailed {
57+
path: PathBuf,
58+
#[source]
59+
source: std::io::Error,
60+
},
3861
}
3962

4063
impl CreateConfigError {
@@ -59,6 +82,7 @@ impl CreateConfigError {
5982
/// assert!(help.contains("Check that the file path is correct"));
6083
/// ```
6184
#[must_use]
85+
#[allow(clippy::too_many_lines)]
6286
pub fn help(&self) -> &'static str {
6387
match self {
6488
Self::InvalidEnvironmentName(_) => {
@@ -129,6 +153,51 @@ impl CreateConfigError {
129153
\n\
130154
Fix: Update the SSH port in your configuration to a valid port number (1-65535)."
131155
}
156+
Self::TemplateSerializationFailed { .. } => {
157+
"Template serialization failed.\n\
158+
\n\
159+
This indicates an internal error in template generation.\n\
160+
\n\
161+
Common causes:\n\
162+
- Software bug in template generation logic\n\
163+
- Invalid data structure for JSON serialization\n\
164+
\n\
165+
Fix:\n\
166+
1. Report this issue with full error details\n\
167+
2. Check for application updates\n\
168+
\n\
169+
This is likely a software bug that needs to be reported."
170+
}
171+
Self::TemplateDirectoryCreationFailed { .. } => {
172+
"Failed to create directory for template file.\n\
173+
\n\
174+
Common causes:\n\
175+
- Insufficient permissions to create directory\n\
176+
- No disk space available\n\
177+
- A file exists with the same name as the directory\n\
178+
- Path length exceeds system limits\n\
179+
\n\
180+
Fix:\n\
181+
1. Check write permissions for the parent directory\n\
182+
2. Verify disk space is available: df -h\n\
183+
3. Ensure no file exists with the same name as the directory\n\
184+
4. Try using a shorter path"
185+
}
186+
Self::TemplateFileWriteFailed { .. } => {
187+
"Failed to write template file.\n\
188+
\n\
189+
Common causes:\n\
190+
- Insufficient permissions to write file\n\
191+
- No disk space available\n\
192+
- File is open in another application\n\
193+
- Antivirus software blocking file creation\n\
194+
\n\
195+
Fix:\n\
196+
1. Check write permissions for the target file and directory\n\
197+
2. Verify disk space is available: df -h\n\
198+
3. Ensure the file is not open in another application\n\
199+
4. Check if antivirus software is blocking file creation"
200+
}
132201
}
133202
}
134203
}
@@ -214,4 +283,43 @@ mod tests {
214283
);
215284
}
216285
}
286+
287+
#[test]
288+
fn test_template_serialization_failed_error() {
289+
// Simulate serialization error (hard to create naturally)
290+
let json_error = serde_json::from_str::<serde_json::Value>("invalid").unwrap_err();
291+
let error = CreateConfigError::TemplateSerializationFailed { source: json_error };
292+
293+
assert!(error
294+
.to_string()
295+
.contains("serialize configuration template"));
296+
assert!(error.help().contains("internal error"));
297+
assert!(error.help().contains("Report this issue"));
298+
}
299+
300+
#[test]
301+
fn test_template_directory_creation_failed_error() {
302+
let error = CreateConfigError::TemplateDirectoryCreationFailed {
303+
path: PathBuf::from("/test/path"),
304+
source: std::io::Error::new(std::io::ErrorKind::PermissionDenied, "test"),
305+
};
306+
307+
assert!(error.to_string().contains("Failed to create directory"));
308+
assert!(error.to_string().contains("/test/path"));
309+
assert!(error.help().contains("permissions"));
310+
assert!(error.help().contains("df -h"));
311+
}
312+
313+
#[test]
314+
fn test_template_file_write_failed_error() {
315+
let error = CreateConfigError::TemplateFileWriteFailed {
316+
path: PathBuf::from("/test/file.json"),
317+
source: std::io::Error::new(std::io::ErrorKind::PermissionDenied, "test"),
318+
};
319+
320+
assert!(error.to_string().contains("Failed to write template file"));
321+
assert!(error.to_string().contains("/test/file.json"));
322+
assert!(error.help().contains("permissions"));
323+
assert!(error.help().contains("disk space"));
324+
}
217325
}

0 commit comments

Comments
 (0)