1use std::net::{IpAddr, SocketAddr};
4use std::sync::Arc;
5
6use tracing::{error, info, instrument};
7
8use super::errors::ProvisionCommandHandlerError;
9use crate::adapters::ansible::AnsibleClient;
10use crate::adapters::ssh::SshConfig;
11use crate::adapters::tofu::client::InstanceInfo;
12use crate::adapters::OpenTofuClient;
13use crate::application::command_handlers::common::StepResult;
14use crate::application::services::rendering::AnsibleTemplateRenderingService;
15use crate::application::steps::{
16 ApplyInfrastructureStep, GetInstanceInfoStep, InitializeInfrastructureStep,
17 PlanInfrastructureStep, RenderOpenTofuTemplatesStep, ValidateInfrastructureStep,
18 WaitForCloudInitStep, WaitForSSHConnectivityStep,
19};
20use crate::application::traits::CommandProgressListener;
21use crate::domain::environment::repository::{EnvironmentRepository, TypedEnvironmentRepository};
22use crate::domain::environment::runtime_outputs::ProvisionMethod;
23use crate::domain::environment::state::{ProvisionFailureContext, ProvisionStep};
24use crate::domain::environment::{Environment, Provisioned, Provisioning};
25use crate::domain::EnvironmentName;
26use crate::infrastructure::templating::tofu::TofuProjectGenerator;
27use crate::shared::clock::SystemClock;
28use crate::shared::error::Traceable;
29
30const TOTAL_PROVISION_STEPS: usize = 9;
35
36pub struct ProvisionCommandHandler {
62 clock: Arc<dyn crate::shared::Clock>,
63 repository: TypedEnvironmentRepository,
64}
65
66impl ProvisionCommandHandler {
67 #[must_use]
69 pub fn new(
70 clock: Arc<dyn crate::shared::Clock>,
71 repository: Arc<dyn EnvironmentRepository>,
72 ) -> Self {
73 Self {
74 clock,
75 repository: TypedEnvironmentRepository::new(repository),
76 }
77 }
78
79 #[instrument(
104 name = "provision_command",
105 skip_all,
106 fields(
107 command_type = "provision",
108 environment = %env_name
109 )
110 )]
111 pub async fn execute(
112 &self,
113 env_name: &EnvironmentName,
114 listener: Option<&dyn CommandProgressListener>,
115 ) -> Result<Environment<Provisioned>, ProvisionCommandHandlerError> {
116 let environment = self.load_created_environment(env_name)?;
117
118 let started_at = self.clock.now();
119
120 let environment = environment.start_provisioning();
121
122 self.repository.save_provisioning(&environment)?;
123
124 match self
127 .execute_provisioning_workflow(&environment, listener)
128 .await
129 {
130 Ok(provisioned) => {
131 info!(
132 command = "provision",
133 environment = %provisioned.name(),
134 instance_ip = ?provisioned.instance_ip(),
135 "Infrastructure provisioning completed successfully"
136 );
137
138 self.repository.save_provisioned(&provisioned)?;
139
140 Ok(provisioned)
141 }
142 Err((e, current_step)) => {
143 error!(
144 command = "provision",
145 environment = %environment.name(),
146 error = %e,
147 step = ?current_step,
148 "Infrastructure provisioning failed"
149 );
150
151 let context =
152 self.build_failure_context(&environment, &e, current_step, started_at);
153 let failed = environment.provision_failed(context);
154
155 self.repository.save_provision_failed(&failed)?;
156
157 Err(e)
158 }
159 }
160 }
161
162 async fn execute_provisioning_workflow(
180 &self,
181 environment: &Environment<Provisioning>,
182 listener: Option<&dyn CommandProgressListener>,
183 ) -> StepResult<Environment<Provisioned>, ProvisionCommandHandlerError, ProvisionStep> {
184 let instance_ip = self.provision_infrastructure(environment, listener).await?;
185
186 self.prepare_for_configuration(environment, instance_ip, listener)
187 .await?;
188
189 self.wait_for_system_readiness(environment, instance_ip, listener)
190 .await?;
191
192 let provisioned = environment
193 .clone()
194 .provisioned(instance_ip, ProvisionMethod::Provisioned);
195
196 Ok(provisioned)
197 }
198
199 async fn provision_infrastructure(
224 &self,
225 environment: &Environment<Provisioning>,
226 listener: Option<&dyn CommandProgressListener>,
227 ) -> StepResult<IpAddr, ProvisionCommandHandlerError, ProvisionStep> {
228 let (tofu_template_renderer, opentofu_client) =
229 Self::build_infrastructure_dependencies(environment);
230
231 let current_step = ProvisionStep::RenderOpenTofuTemplates;
233 Self::notify_step_started(listener, 1, "Rendering OpenTofu templates");
234 self.render_opentofu_templates(&tofu_template_renderer, listener)
235 .await
236 .map_err(|e| (e, current_step))?;
237
238 let current_step = ProvisionStep::OpenTofuInit;
240 Self::notify_step_started(listener, 2, "Initializing OpenTofu");
241 InitializeInfrastructureStep::new(Arc::clone(&opentofu_client))
242 .execute(listener)
243 .map_err(|e| (ProvisionCommandHandlerError::from(e), current_step))?;
244
245 let current_step = ProvisionStep::OpenTofuValidate;
247 Self::notify_step_started(listener, 3, "Validating infrastructure configuration");
248 ValidateInfrastructureStep::new(Arc::clone(&opentofu_client))
249 .execute(listener)
250 .map_err(|e| (ProvisionCommandHandlerError::from(e), current_step))?;
251
252 let current_step = ProvisionStep::OpenTofuPlan;
254 Self::notify_step_started(listener, 4, "Planning infrastructure changes");
255 PlanInfrastructureStep::new(Arc::clone(&opentofu_client))
256 .execute(listener)
257 .map_err(|e| (ProvisionCommandHandlerError::from(e), current_step))?;
258
259 let current_step = ProvisionStep::OpenTofuApply;
261 Self::notify_step_started(listener, 5, "Applying infrastructure changes");
262 ApplyInfrastructureStep::new(Arc::clone(&opentofu_client))
263 .execute(listener)
264 .map_err(|e| (ProvisionCommandHandlerError::from(e), current_step))?;
265
266 let current_step = ProvisionStep::GetInstanceInfo;
268 Self::notify_step_started(listener, 6, "Retrieving instance information");
269 let instance_info =
270 Self::get_instance_info(&opentofu_client, listener).map_err(|e| (e, current_step))?;
271 let instance_ip = instance_info.ip_address;
272
273 Ok(instance_ip)
274 }
275
276 fn build_infrastructure_dependencies(
290 environment: &Environment<Provisioning>,
291 ) -> (Arc<TofuProjectGenerator>, Arc<OpenTofuClient>) {
292 let opentofu_client = Arc::new(OpenTofuClient::new(environment.tofu_build_dir()));
293
294 let template_manager = Arc::new(crate::domain::TemplateManager::new(
295 environment.templates_dir(),
296 ));
297
298 let clock = Arc::new(SystemClock);
299
300 let tofu_template_renderer = Arc::new(TofuProjectGenerator::new(
301 template_manager,
302 environment.build_dir(),
303 environment.ssh_credentials().clone(),
304 environment.ssh_port(),
305 environment.instance_name().clone(),
306 environment.provider_config().clone(),
307 clock,
308 ));
309
310 (tofu_template_renderer, opentofu_client)
311 }
312
313 async fn prepare_for_configuration(
328 &self,
329 environment: &Environment<Provisioning>,
330 instance_ip: IpAddr,
331 listener: Option<&dyn CommandProgressListener>,
332 ) -> StepResult<(), ProvisionCommandHandlerError, ProvisionStep> {
333 let current_step = ProvisionStep::RenderAnsibleTemplates;
335 Self::notify_step_started(listener, 7, "Rendering Ansible templates");
336
337 if let Some(l) = listener {
338 l.on_debug(&format!(
339 "Template directory: {}",
340 environment.templates_dir().display()
341 ));
342 l.on_debug(&format!(
343 "Build directory: {}",
344 environment.ansible_build_dir().display()
345 ));
346 l.on_debug(&format!("Instance IP: {instance_ip}"));
347 }
348
349 let ansible_template_service = AnsibleTemplateRenderingService::from_paths(
350 environment.templates_dir(),
351 environment.build_dir().clone(),
352 self.clock.clone(),
353 );
354
355 ansible_template_service
356 .render_templates(&environment.context().user_inputs, instance_ip, None)
357 .await
358 .map_err(|e| {
359 (
360 ProvisionCommandHandlerError::TemplateRendering(e.to_string()),
361 current_step,
362 )
363 })?;
364
365 if let Some(l) = listener {
366 l.on_detail(&format!(
367 "Template directory: {}",
368 environment.ansible_build_dir().display()
369 ));
370 l.on_detail("Generated inventory and playbooks");
371 }
372
373 Ok(())
374 }
375
376 async fn wait_for_system_readiness(
392 &self,
393 environment: &Environment<Provisioning>,
394 instance_ip: IpAddr,
395 listener: Option<&dyn CommandProgressListener>,
396 ) -> StepResult<(), ProvisionCommandHandlerError, ProvisionStep> {
397 let ansible_client = Self::build_ansible_client(environment);
398 let ssh_credentials = environment.ssh_credentials();
399 let ssh_port = environment.ssh_port();
400 let ssh_socket_addr = SocketAddr::new(instance_ip, ssh_port);
401 let ssh_config = SshConfig::new(ssh_credentials.clone(), ssh_socket_addr);
402
403 let current_step = ProvisionStep::WaitSshConnectivity;
405 Self::notify_step_started(listener, 8, "Waiting for SSH connectivity");
406 WaitForSSHConnectivityStep::new(ssh_config)
407 .execute(listener)
408 .await
409 .map_err(|e| (ProvisionCommandHandlerError::from(e), current_step))?;
410
411 let current_step = ProvisionStep::CloudInitWait;
413 Self::notify_step_started(listener, 9, "Waiting for cloud-init completion");
414 WaitForCloudInitStep::new(Arc::clone(&ansible_client))
415 .execute(listener)
416 .map_err(|e| (ProvisionCommandHandlerError::from(e), current_step))?;
417
418 Ok(())
419 }
420
421 fn build_ansible_client(environment: &Environment<Provisioning>) -> Arc<AnsibleClient> {
433 Arc::new(AnsibleClient::new(environment.ansible_build_dir()))
434 }
435
436 async fn render_opentofu_templates(
449 &self,
450 tofu_template_renderer: &Arc<TofuProjectGenerator>,
451 listener: Option<&dyn CommandProgressListener>,
452 ) -> Result<(), ProvisionCommandHandlerError> {
453 RenderOpenTofuTemplatesStep::new(tofu_template_renderer.clone())
454 .execute(listener)
455 .await?;
456
457 Ok(())
458 }
459
460 fn get_instance_info(
473 opentofu_client: &Arc<OpenTofuClient>,
474 listener: Option<&dyn CommandProgressListener>,
475 ) -> Result<InstanceInfo, ProvisionCommandHandlerError> {
476 let instance_info =
477 GetInstanceInfoStep::new(Arc::clone(opentofu_client)).execute(listener)?;
478 Ok(instance_info)
479 }
480
481 fn notify_step_started(
486 listener: Option<&dyn CommandProgressListener>,
487 step_number: usize,
488 description: &str,
489 ) {
490 if let Some(l) = listener {
491 l.on_step_started(step_number, TOTAL_PROVISION_STEPS, description);
492 }
493 }
494
495 fn build_failure_context(
512 &self,
513 environment: &Environment<Provisioning>,
514 error: &ProvisionCommandHandlerError,
515 current_step: ProvisionStep,
516 started_at: chrono::DateTime<chrono::Utc>,
517 ) -> ProvisionFailureContext {
518 use crate::application::command_handlers::common::failure_context::build_base_failure_context;
519 use crate::infrastructure::trace::ProvisionTraceWriter;
520
521 let failed_step = current_step;
523
524 let error_kind = error.error_kind();
526
527 let base = build_base_failure_context(&self.clock, started_at, error.to_string());
529
530 let mut context = ProvisionFailureContext {
532 failed_step,
533 error_kind,
534 base,
535 };
536
537 let traces_dir = environment.traces_dir();
539 let writer = ProvisionTraceWriter::new(traces_dir, Arc::clone(&self.clock));
540
541 if let Ok(trace_file) = writer.write_trace(&context, error) {
542 context.base.trace_file_path = Some(trace_file);
543 }
544
545 context
546 }
547
548 fn load_created_environment(
557 &self,
558 env_name: &EnvironmentName,
559 ) -> Result<Environment<crate::domain::environment::Created>, ProvisionCommandHandlerError>
560 {
561 let any_env = self
562 .repository
563 .inner()
564 .load(env_name)
565 .map_err(|e| ProvisionCommandHandlerError::StatePersistence(e.into()))?;
566
567 let any_env = any_env.ok_or_else(|| ProvisionCommandHandlerError::EnvironmentNotFound {
568 name: env_name.to_string(),
569 })?;
570
571 Ok(any_env.try_into_created()?)
572 }
573}
574
575#[cfg(test)]
576mod tests {
577 use super::*;
578 use crate::testing::{ProgressEvent, RecordingProgressListener};
579
580 #[test]
581 fn it_should_have_nine_total_provision_steps() {
582 assert_eq!(TOTAL_PROVISION_STEPS, 9);
583 }
584
585 #[test]
586 fn it_should_notify_listener_when_provided() {
587 let listener = RecordingProgressListener::new();
588
589 ProvisionCommandHandler::notify_step_started(Some(&listener), 1, "Test step");
590
591 let events = listener.events();
592 assert_eq!(events.len(), 1);
593 assert_eq!(
594 events[0],
595 ProgressEvent::StepStarted {
596 step_number: 1,
597 total_steps: TOTAL_PROVISION_STEPS,
598 description: "Test step".to_string(),
599 }
600 );
601 }
602
603 #[test]
604 fn it_should_not_panic_when_listener_is_none() {
605 ProvisionCommandHandler::notify_step_started(None, 1, "Test step");
606 }
607
608 #[test]
609 fn it_should_pass_correct_total_steps_to_listener() {
610 let listener = RecordingProgressListener::new();
611
612 ProvisionCommandHandler::notify_step_started(Some(&listener), 5, "Some step");
613
614 let events = listener.events();
615 assert_eq!(events.len(), 1);
616 if let ProgressEvent::StepStarted { total_steps, .. } = &events[0] {
617 assert_eq!(*total_steps, 9);
618 } else {
619 panic!("Expected StepStarted event");
620 }
621 }
622
623 #[test]
624 fn it_should_record_all_nine_step_descriptions_when_notified_sequentially() {
625 let listener = RecordingProgressListener::new();
626
627 let step_descriptions = [
628 (1, "Rendering OpenTofu templates"),
629 (2, "Initializing OpenTofu"),
630 (3, "Validating infrastructure configuration"),
631 (4, "Planning infrastructure changes"),
632 (5, "Applying infrastructure changes"),
633 (6, "Retrieving instance information"),
634 (7, "Rendering Ansible templates"),
635 (8, "Waiting for SSH connectivity"),
636 (9, "Waiting for cloud-init completion"),
637 ];
638
639 for (step_number, description) in &step_descriptions {
640 ProvisionCommandHandler::notify_step_started(
641 Some(&listener),
642 *step_number,
643 description,
644 );
645 }
646
647 let events = listener.step_started_events();
648 assert_eq!(events.len(), 9);
649
650 for (i, (expected_number, expected_desc)) in step_descriptions.iter().enumerate() {
651 if let ProgressEvent::StepStarted {
652 step_number,
653 total_steps,
654 description,
655 } = &events[i]
656 {
657 assert_eq!(step_number, expected_number);
658 assert_eq!(*total_steps, TOTAL_PROVISION_STEPS);
659 assert_eq!(description, *expected_desc);
660 } else {
661 panic!("Expected StepStarted event at index {i}");
662 }
663 }
664 }
665}