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}