1use std::convert::TryInto;
4use std::fs;
5use std::net::IpAddr;
6use std::path::{Path, PathBuf};
7use std::sync::Arc;
8
9use tracing::{info, instrument};
10
11use super::errors::RenderCommandHandlerError;
12use crate::application::command_handlers::create::config::{
13 CreateConfigError, EnvironmentCreationConfig,
14};
15use crate::application::services::rendering::{
16 AnsibleTemplateRenderingService, BackupTemplateRenderingService, CaddyTemplateRenderingService,
17 DockerComposeTemplateRenderingService, GrafanaTemplateRenderingService,
18 OpenTofuTemplateRenderingService, PrometheusTemplateRenderingService,
19 TrackerTemplateRenderingService,
20};
21use crate::domain::environment::repository::EnvironmentRepository;
22use crate::domain::environment::{Created, Environment, EnvironmentParams};
23use crate::domain::EnvironmentName;
24use crate::shared::{Clock, SystemClock};
25
26#[derive(Debug, Clone)]
32pub enum RenderInputMode {
33 EnvironmentName(EnvironmentName),
35 ConfigFile(PathBuf),
37}
38
39#[derive(Debug, Clone)]
43pub struct RenderResult {
44 pub environment_name: String,
46 pub target_ip: IpAddr,
48 pub output_dir: PathBuf,
50 pub config_source: String,
52}
53
54pub struct RenderCommandHandler {
79 repository: Arc<dyn EnvironmentRepository>,
80}
81
82impl RenderCommandHandler {
83 #[must_use]
85 pub fn new(repository: Arc<dyn EnvironmentRepository>) -> Self {
86 Self { repository }
87 }
88
89 #[instrument(
112 name = "render_command",
113 skip_all,
114 fields(
115 command_type = "render",
116 input_mode = ?input_mode,
117 target_ip = %target_ip,
118 output_dir = %output_dir.display()
119 )
120 )]
121 pub async fn execute(
122 &self,
123 input_mode: RenderInputMode,
124 target_ip: &str,
125 output_dir: &Path,
126 force: bool,
127 working_dir: &Path,
128 ) -> Result<RenderResult, RenderCommandHandlerError> {
129 let ip_addr = Self::parse_ip_address(target_ip)?;
131
132 match input_mode {
134 RenderInputMode::EnvironmentName(ref env_name) => {
135 Self::validate_output_directory(output_dir, force)?;
137
138 self.render_from_environment(env_name, ip_addr, output_dir, working_dir)
139 .await
140 }
141 RenderInputMode::ConfigFile(ref config_path) => {
142 Self::validate_output_directory(output_dir, force)?;
144
145 self.render_from_config_file(config_path, ip_addr, output_dir, working_dir)
146 .await
147 }
148 }
149 }
150
151 async fn render_from_environment(
166 &self,
167 env_name: &EnvironmentName,
168 ip_addr: IpAddr,
169 output_dir: &Path,
170 _working_dir: &Path,
171 ) -> Result<RenderResult, RenderCommandHandlerError> {
172 info!(
173 environment = %env_name,
174 target_ip = %ip_addr,
175 output_dir = %output_dir.display(),
176 "Rendering artifacts from existing environment"
177 );
178
179 let environment = self.repository.load(env_name)?.ok_or_else(|| {
181 RenderCommandHandlerError::EnvironmentNotFound {
182 name: env_name.clone(),
183 }
184 })?;
185
186 let current_state = environment.state_name().to_string();
189 let created_env: Environment<Created> = environment.try_into_created().map_err(|_| {
190 RenderCommandHandlerError::EnvironmentAlreadyProvisioned {
191 name: env_name.clone(),
192 current_state,
193 }
194 })?;
195
196 self.render_all_templates(&created_env, ip_addr, output_dir)
198 .await?;
199
200 Ok(RenderResult {
201 environment_name: created_env.name().to_string(),
202 target_ip: ip_addr,
203 output_dir: output_dir.to_path_buf(),
204 config_source: format!("Environment: {}", created_env.name()),
205 })
206 }
207
208 async fn render_from_config_file(
224 &self,
225 config_path: &Path,
226 ip_addr: IpAddr,
227 output_dir: &Path,
228 working_dir: &Path,
229 ) -> Result<RenderResult, RenderCommandHandlerError> {
230 info!(
231 config_file = %config_path.display(),
232 target_ip = %ip_addr,
233 output_dir = %output_dir.display(),
234 "Rendering artifacts from configuration file"
235 );
236
237 let content = fs::read_to_string(config_path).map_err(|_| {
239 RenderCommandHandlerError::ConfigFileNotFound {
240 path: config_path.to_path_buf(),
241 }
242 })?;
243
244 let config: EnvironmentCreationConfig =
246 serde_json::from_str(&content).map_err(|source| {
247 RenderCommandHandlerError::ConfigParsingFailed {
248 path: config_path.to_path_buf(),
249 source,
250 }
251 })?;
252
253 let params: EnvironmentParams = config.try_into().map_err(|e: CreateConfigError| {
255 RenderCommandHandlerError::DomainValidationFailed {
256 reason: e.to_string(),
257 }
258 })?;
259
260 let env_name = params.environment_name.clone();
262 let clock: Arc<dyn Clock> = Arc::new(SystemClock);
263 let created_env = Environment::<Created>::create(params, working_dir, clock.now())
264 .map_err(|e| RenderCommandHandlerError::DomainValidationFailed {
265 reason: e.to_string(),
266 })?;
267
268 self.render_all_templates(&created_env, ip_addr, output_dir)
270 .await?;
271
272 Ok(RenderResult {
273 environment_name: env_name.to_string(),
274 target_ip: ip_addr,
275 output_dir: output_dir.to_path_buf(),
276 config_source: format!("Config file: {}", config_path.display()),
277 })
278 }
279
280 async fn render_all_templates(
296 &self,
297 environment: &Environment<Created>,
298 target_ip: IpAddr,
299 output_dir: &Path,
300 ) -> Result<(), RenderCommandHandlerError> {
301 info!(
302 environment = %environment.name(),
303 target_ip = %target_ip,
304 output_dir = %output_dir.display(),
305 "Rendering all deployment templates"
306 );
307
308 let clock: Arc<dyn Clock> = Arc::new(SystemClock);
309 let templates_dir = environment.templates_dir();
310 let build_dir = output_dir.to_path_buf();
311 let user_inputs = &environment.context().user_inputs;
312
313 OpenTofuTemplateRenderingService::from_params(
315 templates_dir.clone(),
316 build_dir.clone(),
317 environment.ssh_credentials().clone(),
318 environment.ssh_port(),
319 environment.instance_name().clone(),
320 environment.provider_config().clone(),
321 clock.clone(),
322 )
323 .render()
324 .await
325 .map_err(|e| RenderCommandHandlerError::TemplateRenderingFailed {
326 reason: e.to_string(),
327 })?;
328
329 AnsibleTemplateRenderingService::from_paths(
331 templates_dir.clone(),
332 build_dir.clone(),
333 clock.clone(),
334 )
335 .render_templates(user_inputs, target_ip, None)
336 .await
337 .map_err(|e| RenderCommandHandlerError::TemplateRenderingFailed {
338 reason: e.to_string(),
339 })?;
340
341 DockerComposeTemplateRenderingService::from_paths(
343 templates_dir.clone(),
344 build_dir.clone(),
345 clock.clone(),
346 )
347 .render(user_inputs, environment.admin_token())
348 .await
349 .map_err(|e| RenderCommandHandlerError::TemplateRenderingFailed {
350 reason: e.to_string(),
351 })?;
352
353 TrackerTemplateRenderingService::from_paths(
355 templates_dir.clone(),
356 build_dir.clone(),
357 clock.clone(),
358 )
359 .render(user_inputs.tracker())
360 .map_err(|e| RenderCommandHandlerError::TemplateRenderingFailed {
361 reason: e.to_string(),
362 })?;
363
364 PrometheusTemplateRenderingService::from_paths(
366 templates_dir.clone(),
367 build_dir.clone(),
368 clock.clone(),
369 )
370 .render(user_inputs.prometheus(), user_inputs.tracker())
371 .map_err(|e| RenderCommandHandlerError::TemplateRenderingFailed {
372 reason: e.to_string(),
373 })?;
374
375 GrafanaTemplateRenderingService::from_paths(
377 templates_dir.clone(),
378 build_dir.clone(),
379 clock.clone(),
380 )
381 .render(user_inputs.grafana().is_some(), user_inputs.prometheus())
382 .map_err(|e| RenderCommandHandlerError::TemplateRenderingFailed {
383 reason: e.to_string(),
384 })?;
385
386 CaddyTemplateRenderingService::from_paths(
388 templates_dir.clone(),
389 build_dir.clone(),
390 clock.clone(),
391 )
392 .render(user_inputs)
393 .map_err(|e| RenderCommandHandlerError::TemplateRenderingFailed {
394 reason: e.to_string(),
395 })?;
396
397 BackupTemplateRenderingService::from_paths(templates_dir.clone(), build_dir.clone())
399 .render(
400 user_inputs.backup(),
401 user_inputs.tracker().core().database(),
402 environment.context().created_at(),
403 )
404 .await
405 .map_err(|e| RenderCommandHandlerError::TemplateRenderingFailed {
406 reason: e.to_string(),
407 })?;
408
409 info!(
410 environment = %environment.name(),
411 "All deployment templates rendered successfully"
412 );
413
414 Ok(())
415 }
416
417 fn parse_ip_address(ip_str: &str) -> Result<IpAddr, RenderCommandHandlerError> {
427 ip_str
428 .parse::<IpAddr>()
429 .map_err(|_| RenderCommandHandlerError::InvalidIpAddress {
430 value: ip_str.to_string(),
431 })
432 }
433
434 fn validate_output_directory(
449 output_dir: &Path,
450 force: bool,
451 ) -> Result<(), RenderCommandHandlerError> {
452 if output_dir.exists() {
453 if !force {
454 return Err(RenderCommandHandlerError::OutputDirectoryExists {
455 path: output_dir.to_path_buf(),
456 });
457 }
458 info!(
460 output_dir = %output_dir.display(),
461 "Output directory exists, overwriting with --force"
462 );
463 } else {
464 fs::create_dir_all(output_dir).map_err(|e| {
466 RenderCommandHandlerError::OutputDirectoryCreationFailed {
467 path: output_dir.to_path_buf(),
468 reason: e.to_string(),
469 }
470 })?;
471 info!(
472 output_dir = %output_dir.display(),
473 "Created output directory"
474 );
475 }
476 Ok(())
477 }
478}
479
480#[cfg(test)]
481mod tests {
482 use super::*;
483 use std::net::{Ipv4Addr, Ipv6Addr};
484
485 use crate::infrastructure::persistence::file_repository_factory::FileRepositoryFactory;
486
487 fn create_test_repository() -> Arc<dyn EnvironmentRepository> {
488 let file_repository_factory =
489 FileRepositoryFactory::new(std::time::Duration::from_secs(30));
490 file_repository_factory.create(PathBuf::from("."))
491 }
492
493 #[test]
494 fn it_should_create_handler() {
495 let repository = create_test_repository();
496 let _handler = RenderCommandHandler::new(repository);
497 }
498
499 #[test]
500 fn it_should_parse_valid_ipv4_address() {
501 let result = RenderCommandHandler::parse_ip_address("192.168.1.100");
502 assert!(result.is_ok());
503 assert_eq!(result.unwrap(), IpAddr::V4(Ipv4Addr::new(192, 168, 1, 100)));
504 }
505
506 #[test]
507 fn it_should_parse_valid_ipv6_address() {
508 let result = RenderCommandHandler::parse_ip_address("2001:db8::1");
509 assert!(result.is_ok());
510 assert_eq!(
511 result.unwrap(),
512 IpAddr::V6("2001:db8::1".parse::<Ipv6Addr>().unwrap())
513 );
514 }
515
516 #[test]
517 fn it_should_reject_invalid_ip_address() {
518 let result = RenderCommandHandler::parse_ip_address("not-an-ip");
519 assert!(result.is_err());
520 assert!(matches!(
521 result.unwrap_err(),
522 RenderCommandHandlerError::InvalidIpAddress { .. }
523 ));
524 }
525
526 #[tokio::test]
527 async fn it_should_return_error_for_nonexistent_environment() {
528 let repository = create_test_repository();
529 let handler = RenderCommandHandler::new(repository);
530 let working_dir = PathBuf::from(".");
531
532 let temp_dir = tempfile::tempdir().unwrap();
534 let output_dir = temp_dir.path().join("nonexistent-output");
535
536 let env_name = EnvironmentName::new("nonexistent").unwrap();
537 let result = handler
538 .execute(
539 RenderInputMode::EnvironmentName(env_name.clone()),
540 "10.0.0.1",
541 output_dir.as_path(),
542 false,
543 &working_dir,
544 )
545 .await;
546
547 assert!(result.is_err());
548 assert!(matches!(
549 result.unwrap_err(),
550 RenderCommandHandlerError::EnvironmentNotFound { name } if name == env_name
551 ));
552 }
553
554 #[tokio::test]
555 async fn it_should_return_error_for_nonexistent_config_file() {
556 let repository = create_test_repository();
557 let handler = RenderCommandHandler::new(repository);
558 let working_dir = PathBuf::from(".");
559
560 let temp_dir = tempfile::tempdir().unwrap();
562 let output_dir = temp_dir.path().join("test-output");
563
564 let config_path = PathBuf::from("/tmp/nonexistent-config.json");
565 let result = handler
566 .execute(
567 RenderInputMode::ConfigFile(config_path.clone()),
568 "10.0.0.1",
569 output_dir.as_path(),
570 false,
571 &working_dir,
572 )
573 .await;
574
575 assert!(result.is_err());
576 assert!(matches!(
577 result.unwrap_err(),
578 RenderCommandHandlerError::ConfigFileNotFound { path } if path == config_path
579 ));
580 }
581
582 #[tokio::test]
583 async fn it_should_validate_ip_before_loading_environment() {
584 let repository = create_test_repository();
586 let handler = RenderCommandHandler::new(repository);
587 let working_dir = PathBuf::from(".");
588
589 let temp_dir = tempfile::tempdir().unwrap();
591 let output_dir = temp_dir.path().join("test-output");
592
593 let env_name = EnvironmentName::new("any-env").unwrap();
594
595 let result = handler
597 .execute(
598 RenderInputMode::EnvironmentName(env_name),
599 "invalid-ip",
600 output_dir.as_path(),
601 false,
602 &working_dir,
603 )
604 .await;
605
606 assert!(result.is_err());
607 assert!(matches!(
608 result.unwrap_err(),
609 RenderCommandHandlerError::InvalidIpAddress { .. }
610 ));
611 }
612}