torrust_tracker_deployer_lib/presentation/cli/controllers/provision/
handler.rs1use std::cell::RefCell;
7use std::sync::Arc;
8
9use parking_lot::ReentrantMutex;
10
11use crate::application::command_handlers::ProvisionCommandHandler;
12use crate::domain::environment::name::EnvironmentName;
13use crate::domain::environment::repository::EnvironmentRepository;
14use crate::domain::environment::state::Provisioned;
15use crate::domain::environment::Environment;
16use crate::presentation::cli::input::cli::OutputFormat;
17use crate::presentation::cli::views::commands::provision::{
18 JsonView, ProvisionDetailsData, TextView,
19};
20use crate::presentation::cli::views::progress::ProgressReporter;
21use crate::presentation::cli::views::progress::VerboseProgressListener;
22use crate::presentation::cli::views::Render;
23use crate::presentation::cli::views::UserOutput;
24use crate::shared::clock::Clock;
25
26use super::errors::ProvisionSubcommandError;
27
28#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30enum ProvisionStep {
31 ValidateEnvironment,
32 CreateCommandHandler,
33 ProvisionInfrastructure,
34}
35
36impl ProvisionStep {
37 const ALL: &'static [Self] = &[
39 Self::ValidateEnvironment,
40 Self::CreateCommandHandler,
41 Self::ProvisionInfrastructure,
42 ];
43
44 const fn count() -> usize {
46 Self::ALL.len()
47 }
48
49 fn description(self) -> &'static str {
51 match self {
52 Self::ValidateEnvironment => "Validating environment",
53 Self::CreateCommandHandler => "Creating command handler",
54 Self::ProvisionInfrastructure => "Provisioning infrastructure",
55 }
56 }
57}
58
59pub struct ProvisionCommandController {
77 repository: Arc<dyn EnvironmentRepository + Send + Sync>,
78 clock: Arc<dyn Clock>,
79 progress: ProgressReporter,
80}
81
82impl ProvisionCommandController {
83 #[allow(clippy::needless_pass_by_value)] pub fn new(
89 repository: Arc<dyn EnvironmentRepository + Send + Sync>,
90 clock: Arc<dyn Clock>,
91 user_output: Arc<ReentrantMutex<RefCell<UserOutput>>>,
92 ) -> Self {
93 let progress = ProgressReporter::new(user_output, ProvisionStep::count());
94
95 Self {
96 repository,
97 clock,
98 progress,
99 }
100 }
101
102 #[allow(clippy::result_large_err)]
130 pub async fn execute(
131 &mut self,
132 environment_name: &str,
133 output_format: OutputFormat,
134 ) -> Result<Environment<Provisioned>, ProvisionSubcommandError> {
135 let env_name = self.validate_environment_name(environment_name)?;
136
137 let handler = self.create_command_handler()?;
138
139 let provisioned = self.provision_infrastructure(&handler, &env_name).await?;
140
141 self.complete_workflow(environment_name)?;
142
143 self.display_provision_results(&provisioned, output_format)?;
144
145 Ok(provisioned)
146 }
147
148 #[allow(clippy::result_large_err)]
153 fn validate_environment_name(
154 &mut self,
155 name: &str,
156 ) -> Result<EnvironmentName, ProvisionSubcommandError> {
157 self.progress
158 .start_step(ProvisionStep::ValidateEnvironment.description())?;
159
160 let env_name = EnvironmentName::new(name.to_string()).map_err(|source| {
161 ProvisionSubcommandError::InvalidEnvironmentName {
162 name: name.to_string(),
163 source,
164 }
165 })?;
166
167 self.progress
168 .complete_step(Some(&format!("Environment name validated: {name}")))?;
169
170 Ok(env_name)
171 }
172
173 #[allow(clippy::result_large_err)]
178 fn create_command_handler(
179 &mut self,
180 ) -> Result<ProvisionCommandHandler, ProvisionSubcommandError> {
181 self.progress
182 .start_step(ProvisionStep::CreateCommandHandler.description())?;
183 let handler = ProvisionCommandHandler::new(self.clock.clone(), self.repository.clone());
184 self.progress.complete_step(None)?;
185
186 Ok(handler)
187 }
188
189 #[allow(clippy::result_large_err)]
200 async fn provision_infrastructure(
201 &mut self,
202 handler: &ProvisionCommandHandler,
203 env_name: &EnvironmentName,
204 ) -> Result<Environment<Provisioned>, ProvisionSubcommandError> {
205 self.progress
206 .start_step(ProvisionStep::ProvisionInfrastructure.description())?;
207
208 let listener = VerboseProgressListener::new(self.progress.output().clone());
212
213 let provisioned = handler
214 .execute(env_name, Some(&listener))
215 .await
216 .map_err(
217 |source| ProvisionSubcommandError::ProvisionOperationFailed {
218 name: env_name.to_string(),
219 source: Box::new(source),
220 },
221 )?;
222
223 self.progress
224 .complete_step(Some("Infrastructure provisioned"))?;
225 Ok(provisioned)
226 }
227
228 #[allow(clippy::result_large_err)]
232 fn complete_workflow(&mut self, name: &str) -> Result<(), ProvisionSubcommandError> {
233 self.progress
234 .complete(&format!("Environment '{name}' provisioned successfully"))?;
235 Ok(())
236 }
237
238 #[allow(clippy::result_large_err)]
262 fn display_provision_results(
263 &mut self,
264 provisioned: &Environment<Provisioned>,
265 output_format: OutputFormat,
266 ) -> Result<(), ProvisionSubcommandError> {
267 self.progress.blank_line()?;
268
269 let details = ProvisionDetailsData::from(provisioned);
271
272 let output = match output_format {
274 OutputFormat::Text => TextView::render(&details)?,
275 OutputFormat::Json => JsonView::render(&details).map_err(|e| {
276 ProvisionSubcommandError::OutputFormatting {
277 reason: format!("Failed to serialize provision details as JSON: {e}"),
278 }
279 })?,
280 };
281
282 self.progress.result(&output)?;
284
285 Ok(())
286 }
287}
288
289#[cfg(test)]
290mod tests {
291 use super::*;
292 use crate::presentation::cli::controllers::constants::DEFAULT_LOCK_TIMEOUT;
293 use crate::presentation::cli::views::testing::TestUserOutput;
294 use crate::presentation::cli::views::VerbosityLevel;
295 use crate::shared::SystemClock;
296
297 #[allow(clippy::type_complexity)] fn create_test_dependencies(
305 temp_dir: &tempfile::TempDir,
306 ) -> (
307 Arc<ReentrantMutex<RefCell<UserOutput>>>,
308 Arc<dyn EnvironmentRepository + Send + Sync>,
309 Arc<dyn Clock>,
310 ) {
311 use crate::infrastructure::persistence::file_repository_factory::FileRepositoryFactory;
312 let (user_output, _, _) =
313 TestUserOutput::new(VerbosityLevel::Normal).into_reentrant_wrapped();
314 let data_dir = temp_dir.path().join("data");
315 let file_repository_factory = FileRepositoryFactory::new(DEFAULT_LOCK_TIMEOUT);
316 let repository = file_repository_factory.create(data_dir);
317 let clock = Arc::new(SystemClock);
318
319 (user_output, repository, clock)
320 }
321
322 #[tokio::test]
323 async fn it_should_return_error_for_invalid_environment_name() {
324 use tempfile::TempDir;
325
326 let temp_dir = TempDir::new().unwrap();
327
328 let (user_output, repository, clock) = create_test_dependencies(&temp_dir);
329
330 let result = ProvisionCommandController::new(repository, clock, user_output.clone())
332 .execute("invalid_name", OutputFormat::Text)
333 .await;
334
335 assert!(result.is_err());
336 match result.unwrap_err() {
337 ProvisionSubcommandError::InvalidEnvironmentName { name, .. } => {
338 assert_eq!(name, "invalid_name");
339 }
340 other => panic!("Expected InvalidEnvironmentName, got: {other:?}"),
341 }
342 }
343
344 #[tokio::test]
345 async fn it_should_return_error_for_empty_environment_name() {
346 use tempfile::TempDir;
347
348 let temp_dir = TempDir::new().unwrap();
349
350 let (user_output, repository, clock) = create_test_dependencies(&temp_dir);
351
352 let result = ProvisionCommandController::new(repository, clock, user_output.clone())
353 .execute("", OutputFormat::Text)
354 .await;
355
356 assert!(result.is_err());
357 match result.unwrap_err() {
358 ProvisionSubcommandError::InvalidEnvironmentName { name, .. } => {
359 assert_eq!(name, "");
360 }
361 other => panic!("Expected InvalidEnvironmentName, got: {other:?}"),
362 }
363 }
364
365 #[tokio::test]
366 async fn it_should_return_error_for_nonexistent_environment() {
367 use tempfile::TempDir;
368
369 let temp_dir = TempDir::new().unwrap();
370
371 let (user_output, repository, clock) = create_test_dependencies(&temp_dir);
372
373 let result = ProvisionCommandController::new(repository, clock, user_output.clone())
375 .execute("non-existent-env", OutputFormat::Text)
376 .await;
377
378 assert!(result.is_err());
379 match result.unwrap_err() {
381 ProvisionSubcommandError::ProvisionOperationFailed { name, .. } => {
382 assert_eq!(name, "non-existent-env");
383 }
384 other => panic!("Expected ProvisionOperationFailed, got: {other:?}"),
385 }
386 }
387
388 #[tokio::test]
389 async fn it_should_accept_valid_environment_name() {
390 use std::fs;
391 use tempfile::TempDir;
392
393 let temp_dir = TempDir::new().unwrap();
394 let working_dir = temp_dir.path();
395
396 let (user_output, repository, clock) = create_test_dependencies(&temp_dir);
397
398 let env_dir = working_dir.join("test-env");
400 fs::create_dir_all(&env_dir).unwrap();
401
402 let result = ProvisionCommandController::new(repository, clock, user_output.clone())
405 .execute("test-env", OutputFormat::Text)
406 .await;
407
408 if let Err(ProvisionSubcommandError::InvalidEnvironmentName { .. }) = result {
410 panic!("Should not fail at name validation for 'test-env'");
411 }
412 }
414}