Skip to main content

torrust_tracker_deployer_lib/application/command_handlers/provision/
handler.rs

1//! Provision command handler implementation
2
3use 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
30/// Total number of steps in the provisioning workflow.
31///
32/// This constant is used for progress reporting via `CommandProgressListener`
33/// to display step progress like "[Step 1/9] Rendering `OpenTofu` templates...".
34const TOTAL_PROVISION_STEPS: usize = 9;
35
36/// `ProvisionCommandHandler` orchestrates the complete infrastructure provisioning workflow
37///
38/// The `ProvisionCommandHandler` orchestrates the complete infrastructure provisioning workflow.
39///
40/// This command handler handles all steps required to provision infrastructure:
41/// 1. Render `OpenTofu` templates
42/// 2. Initialize `OpenTofu`
43/// 3. Validate configuration syntax and consistency
44/// 4. Plan infrastructure
45/// 5. Apply infrastructure
46/// 6. Get instance information
47/// 7. Render `Ansible` templates (with runtime IP address)
48/// 8. Wait for SSH connectivity
49/// 9. Wait for cloud-init completion
50///
51/// # State Management
52///
53/// The command handler integrates with the type-state pattern for environment lifecycle:
54/// - Accepts `Environment<Created>` as input
55/// - Transitions to `Environment<Provisioning>` at start
56/// - Returns `Environment<Provisioned>` on success
57/// - Transitions to `Environment<ProvisionFailed>` on error
58///
59/// State is persisted after each transition using the injected repository.
60/// Persistence failures are logged but don't fail the command handler (state remains valid in memory).
61pub struct ProvisionCommandHandler {
62    clock: Arc<dyn crate::shared::Clock>,
63    repository: TypedEnvironmentRepository,
64}
65
66impl ProvisionCommandHandler {
67    /// Create a new `ProvisionCommandHandler`
68    #[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    /// Execute the complete provisioning workflow
80    ///
81    /// # Arguments
82    ///
83    /// * `env_name` - The name of the environment to provision
84    /// * `listener` - Optional progress listener for reporting step-level progress.
85    ///   When provided, the handler reports progress at each of the 9 provisioning steps.
86    ///   When `None`, the handler executes silently (backward compatible).
87    ///
88    /// # Returns
89    ///
90    /// Returns the provisioned environment
91    ///
92    /// # Errors
93    ///
94    /// Returns an error if any step in the provisioning workflow fails:
95    /// * Environment not found or not in `Created` state
96    /// * Template rendering fails
97    /// * `OpenTofu` initialization, planning, or apply fails
98    /// * Unable to retrieve instance information
99    /// * SSH connectivity cannot be established
100    /// * Cloud-init does not complete successfully
101    ///
102    /// On error, the environment transitions to `ProvisionFailed` state and is persisted.
103    #[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        // Execute provisioning workflow with explicit step tracking
125        // This allows us to know exactly which step failed if an error occurs
126        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    /// Execute the provisioning workflow
163    ///
164    /// This method orchestrates the complete provisioning workflow across multiple phases:
165    /// 1. Infrastructure provisioning (`OpenTofu`)
166    /// 2. Configuration preparation (Ansible templates and system readiness)
167    /// 3. State transition to Provisioned (with instance IP and provision method)
168    ///
169    /// If an error occurs, it returns both the error and the step that was being
170    /// executed, enabling accurate failure context generation.
171    ///
172    /// # Errors
173    ///
174    /// Returns a tuple of (error, `current_step`) if any provisioning step fails
175    ///
176    /// # Returns
177    ///
178    /// Returns the provisioned environment with instance IP and provision method set
179    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    // Private helper methods - organized from higher to lower level of abstraction
200
201    /// Provision infrastructure using `OpenTofu`
202    ///
203    /// This method handles the complete `OpenTofu`-based infrastructure provisioning:
204    /// - Render `OpenTofu` templates (step 1/9)
205    /// - Initialize `OpenTofu` (step 2/9)
206    /// - Validate configuration (step 3/9)
207    /// - Plan infrastructure changes (step 4/9)
208    /// - Apply infrastructure changes (step 5/9)
209    /// - Retrieve instance information (step 6/9)
210    ///
211    /// # Arguments
212    ///
213    /// * `environment` - The environment in Provisioning state
214    /// * `listener` - Optional progress listener for step-level reporting
215    ///
216    /// # Returns
217    ///
218    /// Returns the IP address of the provisioned instance
219    ///
220    /// # Errors
221    ///
222    /// Returns a tuple of (error, `current_step`) if any provisioning step fails
223    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        // Step 1/9: Render OpenTofu templates
232        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        // Step 2/9: Initialize OpenTofu
239        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        // Step 3/9: Validate infrastructure configuration
246        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        // Step 4/9: Plan infrastructure changes
253        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        // Step 5/9: Apply infrastructure changes
260        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        // Step 6/9: Get instance information
267        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    /// Build dependencies for infrastructure provisioning
277    ///
278    /// Creates the template renderer and `OpenTofu` client needed for infrastructure provisioning.
279    ///
280    /// # Arguments
281    ///
282    /// * `environment` - The environment in Provisioning state
283    ///
284    /// # Returns
285    ///
286    /// Returns a tuple of:
287    /// - `TofuProjectGenerator` - For rendering `OpenTofu` templates
288    /// - `OpenTofuClient` - For executing `OpenTofu` operations
289    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    /// Prepare for configuration stages
314    ///
315    /// This method handles preparation for future configuration stages:
316    /// - Render Ansible templates with user inputs and runtime instance IP (step 7/9)
317    ///
318    /// # Arguments
319    ///
320    /// * `environment` - The environment in Provisioning state
321    /// * `instance_ip` - IP address of the provisioned instance
322    /// * `listener` - Optional progress listener for step-level reporting
323    ///
324    /// # Errors
325    ///
326    /// Returns a tuple of (error, `current_step`) if any preparation step fails
327    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        // Step 7/9: Render Ansible templates
334        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    /// Wait for system readiness
377    ///
378    /// This method waits for the provisioned instance to be ready:
379    /// - Wait for SSH connectivity on the configured port (step 8/9)
380    /// - Wait for cloud-init completion (step 9/9)
381    ///
382    /// # Arguments
383    ///
384    /// * `environment` - The environment in Provisioning state
385    /// * `instance_ip` - IP address of the provisioned instance
386    /// * `listener` - Optional progress listener for step-level reporting
387    ///
388    /// # Errors
389    ///
390    /// Returns a tuple of (error, `current_step`) if any readiness check fails
391    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        // Step 8/9: Wait for SSH connectivity
404        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        // Step 9/9: Wait for cloud-init completion
412        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    /// Build Ansible client for playbook execution
422    ///
423    /// Creates the Ansible client needed for waiting on cloud-init completion.
424    ///
425    /// # Arguments
426    ///
427    /// * `environment` - The environment in Provisioning state
428    ///
429    /// # Returns
430    ///
431    /// Returns `AnsibleClient` for executing Ansible playbooks
432    fn build_ansible_client(environment: &Environment<Provisioning>) -> Arc<AnsibleClient> {
433        Arc::new(AnsibleClient::new(environment.ansible_build_dir()))
434    }
435
436    /// Render `OpenTofu` templates
437    ///
438    /// Generates `OpenTofu` configuration files from templates.
439    ///
440    /// # Arguments
441    ///
442    /// * `tofu_template_renderer` - The template renderer for generating `OpenTofu` configs
443    /// * `listener` - Optional progress listener for reporting details
444    ///
445    /// # Errors
446    ///
447    /// Returns an error if template rendering fails
448    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    /// Get instance information from `OpenTofu`
461    ///
462    /// Retrieves information about the provisioned instance, including its IP address.
463    ///
464    /// # Arguments
465    ///
466    /// * `opentofu_client` - The `OpenTofu` client for executing commands
467    /// * `listener` - Optional progress listener for reporting details
468    ///
469    /// # Errors
470    ///
471    /// Returns an error if instance information cannot be retrieved
472    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    /// Notify the progress listener that a step has started.
482    ///
483    /// This is a convenience helper that handles the `Option` check,
484    /// keeping the step-reporting code in the workflow methods clean.
485    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    /// Build failure context for a provisioning error and generate trace file
496    ///
497    /// This helper method builds structured error context including the failed step,
498    /// error classification, timing information, and generates a trace file for
499    /// post-mortem analysis.
500    ///
501    /// # Arguments
502    ///
503    /// * `environment` - The environment being provisioned (for trace directory path)
504    /// * `error` - The provisioning error that occurred
505    /// * `current_step` - The step that was executing when the error occurred
506    /// * `started_at` - The timestamp when provisioning execution started
507    ///
508    /// # Returns
509    ///
510    /// A `ProvisionFailureContext` with all failure metadata and trace file path
511    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        // Step that failed is directly provided - no reverse engineering needed
522        let failed_step = current_step;
523
524        // Get error kind from the error itself (errors are self-describing)
525        let error_kind = error.error_kind();
526
527        // Build base failure context using common helper
528        let base = build_base_failure_context(&self.clock, started_at, error.to_string());
529
530        // Build handler-specific context
531        let mut context = ProvisionFailureContext {
532            failed_step,
533            error_kind,
534            base,
535        };
536
537        // Generate trace file (logging handled by trace writer)
538        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    /// Load environment from storage and validate it is in `Created` state
549    ///
550    /// # Errors
551    ///
552    /// Returns an error if:
553    /// * Persistence error occurs during load
554    /// * Environment does not exist
555    /// * Environment is not in `Created` state
556    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}