Skip to main content

torrust_tracker_deployer_lib/application/command_handlers/register/
handler.rs

1//! Register command handler implementation
2
3use std::net::{IpAddr, SocketAddr};
4use std::sync::Arc;
5
6use tracing::{info, instrument};
7
8use super::errors::RegisterCommandHandlerError;
9use crate::adapters::ssh::{SshClient, SshConfig};
10use crate::application::services::rendering::AnsibleTemplateRenderingService;
11use crate::domain::environment::repository::{EnvironmentRepository, TypedEnvironmentRepository};
12use crate::domain::environment::state::{Created, Provisioned};
13use crate::domain::environment::Environment;
14use crate::domain::EnvironmentName;
15
16/// `RegisterCommandHandler` registers existing instances with environments
17///
18/// This command handler provides an alternative path to `ProvisionCommandHandler`.
19/// Instead of provisioning new infrastructure, it registers an existing instance
20/// (VM, physical server, or container) with an environment.
21///
22/// # State Management
23///
24/// The command handler integrates with the type-state pattern for environment lifecycle:
25/// - Accepts `Environment<Created>` as input
26/// - Returns `Environment<Provisioned>` on success
27///
28/// This allows the environment to continue with `configure`, `release`, and `run`
29/// commands just like a normally provisioned environment.
30///
31/// # Workflow
32///
33/// 1. Load environment from repository (must be in Created state)
34/// 2. Validate SSH connectivity to the provided IP address
35/// 3. Render Ansible templates with the instance IP
36/// 4. Update runtime outputs with the instance IP and provision method
37/// 5. Transition to Provisioned state
38/// 6. Persist the updated environment
39pub struct RegisterCommandHandler {
40    clock: Arc<dyn crate::shared::Clock>,
41    repository: TypedEnvironmentRepository,
42}
43
44impl RegisterCommandHandler {
45    /// Create a new `RegisterCommandHandler`
46    #[must_use]
47    pub fn new(
48        clock: Arc<dyn crate::shared::Clock>,
49        repository: Arc<dyn EnvironmentRepository>,
50    ) -> Self {
51        Self {
52            clock,
53            repository: TypedEnvironmentRepository::new(repository),
54        }
55    }
56
57    /// Execute the register workflow
58    ///
59    /// # Arguments
60    ///
61    /// * `env_name` - The name of the environment to register the instance with
62    /// * `instance_ip` - The IP address of the existing instance
63    /// * `ssh_port` - Optional SSH port (overrides environment config if provided)
64    ///
65    /// # Returns
66    ///
67    /// Returns the environment in Provisioned state
68    ///
69    /// # Errors
70    ///
71    /// Returns an error if:
72    /// * Environment not found or not in `Created` state
73    /// * SSH connectivity validation fails
74    /// * Ansible template rendering fails
75    /// * Unable to persist the environment state
76    #[instrument(
77        name = "register_command",
78        skip_all,
79        fields(
80            command_type = "register",
81            environment = %env_name,
82            instance_ip = %instance_ip,
83            ssh_port = ?ssh_port
84        )
85    )]
86    pub async fn execute(
87        &self,
88        env_name: &EnvironmentName,
89        instance_ip: IpAddr,
90        ssh_port: Option<u16>,
91    ) -> Result<Environment<Provisioned>, RegisterCommandHandlerError> {
92        let environment = self.load_created_environment(env_name)?;
93
94        self.validate_ssh_connectivity(&environment, instance_ip, ssh_port)?;
95
96        self.prepare_for_configuration(&environment, instance_ip, ssh_port)
97            .await?;
98
99        let provisioned = environment.register(instance_ip);
100
101        self.repository.save_provisioned(&provisioned)?;
102
103        info!(
104            command = "register",
105            environment = %provisioned.name(),
106            instance_ip = ?provisioned.instance_ip(),
107            "Instance registered successfully"
108        );
109
110        Ok(provisioned)
111    }
112
113    /// Validate SSH connectivity to the instance
114    ///
115    /// This performs a minimal validation by attempting to establish an SSH connection
116    /// to the instance using the credentials from the environment.
117    ///
118    /// # Arguments
119    ///
120    /// * `environment` - The environment in Created state
121    /// * `instance_ip` - The IP address to test connectivity against
122    /// * `ssh_port` - Optional SSH port (overrides environment config if provided)
123    ///
124    /// # Errors
125    ///
126    /// Returns `ConnectivityFailed` if unable to connect via SSH.
127    #[allow(clippy::unused_self)] // Method may use self in future for configuration
128    fn validate_ssh_connectivity(
129        &self,
130        environment: &Environment<Created>,
131        instance_ip: IpAddr,
132        ssh_port: Option<u16>,
133    ) -> Result<(), RegisterCommandHandlerError> {
134        info!(
135            instance_ip = %instance_ip,
136            ssh_port = ?ssh_port,
137            "Validating SSH connectivity to instance"
138        );
139
140        let ssh_credentials = environment.ssh_credentials();
141        let config_ssh_port = environment.ssh_port();
142        let effective_ssh_port = ssh_port.unwrap_or(config_ssh_port);
143
144        let ssh_socket_addr = SocketAddr::new(instance_ip, effective_ssh_port);
145        let ssh_config = SshConfig::new(ssh_credentials.clone(), ssh_socket_addr);
146        let ssh_client = SshClient::new(ssh_config);
147
148        let connected = ssh_client.test_connectivity().map_err(|source| {
149            RegisterCommandHandlerError::ConnectivityFailed {
150                address: instance_ip,
151                reason: source.to_string(),
152            }
153        })?;
154
155        if !connected {
156            return Err(RegisterCommandHandlerError::ConnectivityFailed {
157                address: instance_ip,
158                reason: "SSH connection test returned false".to_string(),
159            });
160        }
161
162        info!(
163            instance_ip = %instance_ip,
164            ssh_port = effective_ssh_port,
165            "SSH connectivity validated successfully"
166        );
167
168        Ok(())
169    }
170
171    /// Prepare for configuration stages
172    ///
173    /// This method handles preparation for future configuration stages:
174    /// - Render Ansible templates with user inputs and instance IP
175    ///
176    /// # Arguments
177    ///
178    /// * `environment` - The environment in Created state
179    /// * `instance_ip` - IP address of the instance to register
180    /// * `ssh_port_override` - Optional SSH port override for Ansible inventory
181    ///
182    /// # Errors
183    ///
184    /// Returns an error if Ansible template rendering fails
185    async fn prepare_for_configuration(
186        &self,
187        environment: &Environment<Created>,
188        instance_ip: IpAddr,
189        ssh_port_override: Option<u16>,
190    ) -> Result<(), RegisterCommandHandlerError> {
191        let ansible_template_service = AnsibleTemplateRenderingService::from_paths(
192            environment.templates_dir(),
193            environment.build_dir().clone(),
194            self.clock.clone(),
195        );
196
197        ansible_template_service
198            .render_templates(
199                &environment.context().user_inputs,
200                instance_ip,
201                ssh_port_override,
202            )
203            .await
204            .map_err(|e| RegisterCommandHandlerError::TemplateRenderingFailed {
205                reason: e.to_string(),
206            })?;
207
208        Ok(())
209    }
210
211    /// Load environment from storage and validate it is in `Created` state
212    ///
213    /// # Errors
214    ///
215    /// Returns an error if:
216    /// * Persistence error occurs during load
217    /// * Environment does not exist
218    /// * Environment is not in `Created` state
219    fn load_created_environment(
220        &self,
221        env_name: &EnvironmentName,
222    ) -> Result<Environment<Created>, RegisterCommandHandlerError> {
223        let any_env = self
224            .repository
225            .inner()
226            .load(env_name)
227            .map_err(RegisterCommandHandlerError::RepositorySave)?;
228
229        let any_env = any_env.ok_or_else(|| RegisterCommandHandlerError::EnvironmentNotFound {
230            name: env_name.clone(),
231        })?;
232
233        any_env
234            .try_into_created()
235            .map_err(|e| RegisterCommandHandlerError::InvalidState {
236                name: env_name.clone(),
237                current_state: e.to_string(),
238            })
239    }
240}
241
242#[cfg(test)]
243mod tests {
244    // Tests will be added after the domain layer changes are complete
245}