torrust_tracker_deployer_lib/presentation/cli/controllers/release/
handler.rs1use std::cell::RefCell;
7use std::sync::Arc;
8
9use parking_lot::ReentrantMutex;
10use tracing::info;
11
12use crate::application::command_handlers::release::ReleaseCommandHandler;
13use crate::domain::environment::name::EnvironmentName;
14use crate::domain::environment::repository::EnvironmentRepository;
15use crate::domain::environment::state::Released;
16use crate::domain::environment::Environment;
17use crate::presentation::cli::input::cli::OutputFormat;
18use crate::presentation::cli::views::commands::release::{JsonView, ReleaseDetailsData, TextView};
19use crate::presentation::cli::views::progress::{ProgressReporter, VerboseProgressListener};
20use crate::presentation::cli::views::Render;
21use crate::presentation::cli::views::UserOutput;
22use crate::shared::clock::Clock;
23
24use super::errors::ReleaseSubcommandError;
25
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28enum ReleaseStep {
29 ValidateEnvironment,
30 ReleaseApplication,
31}
32
33impl ReleaseStep {
34 const ALL: &'static [Self] = &[Self::ValidateEnvironment, Self::ReleaseApplication];
36
37 const fn count() -> usize {
39 Self::ALL.len()
40 }
41
42 fn description(self) -> &'static str {
44 match self {
45 Self::ValidateEnvironment => "Validating environment",
46 Self::ReleaseApplication => "Releasing application",
47 }
48 }
49}
50
51pub struct ReleaseCommandController {
69 repository: Arc<dyn EnvironmentRepository + Send + Sync>,
70 clock: Arc<dyn Clock>,
71 progress: ProgressReporter,
72}
73
74impl ReleaseCommandController {
75 #[allow(clippy::needless_pass_by_value)] pub fn new(
81 repository: Arc<dyn EnvironmentRepository + Send + Sync>,
82 clock: Arc<dyn Clock>,
83 user_output: Arc<ReentrantMutex<RefCell<UserOutput>>>,
84 ) -> Self {
85 let progress = ProgressReporter::new(user_output, ReleaseStep::count());
86
87 Self {
88 repository,
89 clock,
90 progress,
91 }
92 }
93
94 #[allow(clippy::result_large_err)]
117 pub async fn execute(
118 &mut self,
119 environment_name: &str,
120 output_format: OutputFormat,
121 ) -> Result<(), ReleaseSubcommandError> {
122 let env_name = self.validate_environment_name(environment_name)?;
123
124 let released_env = self.release_application(&env_name).await?;
125
126 self.complete_workflow(&released_env, output_format)?;
127
128 Ok(())
129 }
130
131 #[allow(clippy::result_large_err)]
136 fn validate_environment_name(
137 &mut self,
138 name: &str,
139 ) -> Result<EnvironmentName, ReleaseSubcommandError> {
140 self.progress
141 .start_step(ReleaseStep::ValidateEnvironment.description())?;
142
143 let env_name = EnvironmentName::new(name.to_string()).map_err(|source| {
144 ReleaseSubcommandError::InvalidEnvironmentName {
145 name: name.to_string(),
146 source,
147 }
148 })?;
149
150 self.progress
151 .complete_step(Some(&format!("Environment name validated: {name}")))?;
152
153 Ok(env_name)
154 }
155
156 #[allow(clippy::result_large_err)]
160 async fn release_application(
161 &mut self,
162 env_name: &EnvironmentName,
163 ) -> Result<Environment<Released>, ReleaseSubcommandError> {
164 self.progress
165 .start_step(ReleaseStep::ReleaseApplication.description())?;
166
167 let handler = ReleaseCommandHandler::new(self.repository.clone(), self.clock.clone());
168
169 let listener = VerboseProgressListener::new(self.progress.output().clone());
173
174 let released_env = handler
175 .execute(env_name, Some(&listener))
176 .await
177 .map_err(|source| ReleaseSubcommandError::ApplicationLayerError { source })?;
178
179 info!(
180 environment = %env_name,
181 final_state = "Released",
182 "Application released successfully"
183 );
184
185 self.progress
186 .complete_step(Some("Application released successfully"))?;
187
188 Ok(released_env)
189 }
190
191 #[allow(clippy::result_large_err)]
196 fn complete_workflow(
197 &mut self,
198 released_env: &Environment<Released>,
199 output_format: OutputFormat,
200 ) -> Result<(), ReleaseSubcommandError> {
201 let details = ReleaseDetailsData::from(released_env);
202
203 let output = match output_format {
204 OutputFormat::Text => TextView::render(&details)?,
205 OutputFormat::Json => JsonView::render(&details)?,
206 };
207
208 self.progress.result(&output)?;
209
210 Ok(())
211 }
212}
213
214#[cfg(test)]
215mod tests {
216 use super::*;
217 use crate::infrastructure::persistence::file_repository_factory::FileRepositoryFactory;
218 use crate::presentation::cli::controllers::constants::DEFAULT_LOCK_TIMEOUT;
219 use crate::presentation::cli::views::testing::TestUserOutput;
220 use crate::presentation::cli::views::VerbosityLevel;
221 use crate::shared::SystemClock;
222 use tempfile::TempDir;
223
224 #[allow(clippy::type_complexity)]
226 fn create_test_dependencies(
227 temp_dir: &TempDir,
228 ) -> (
229 Arc<ReentrantMutex<RefCell<UserOutput>>>,
230 Arc<dyn EnvironmentRepository + Send + Sync>,
231 Arc<dyn Clock>,
232 ) {
233 let (user_output, _, _) =
234 TestUserOutput::new(VerbosityLevel::Normal).into_reentrant_wrapped();
235 let data_dir = temp_dir.path().join("data");
236 let file_repository_factory = FileRepositoryFactory::new(DEFAULT_LOCK_TIMEOUT);
237 let repository = file_repository_factory.create(data_dir);
238 let clock = Arc::new(SystemClock);
239
240 (user_output, repository, clock)
241 }
242
243 #[tokio::test]
244 async fn it_should_return_error_for_invalid_environment_name() {
245 let temp_dir = TempDir::new().unwrap();
246
247 let (user_output, repository, clock) = create_test_dependencies(&temp_dir);
248
249 let result = ReleaseCommandController::new(repository, clock, user_output.clone())
251 .execute("invalid_name", OutputFormat::Text)
252 .await;
253
254 assert!(result.is_err());
255 match result.unwrap_err() {
256 ReleaseSubcommandError::InvalidEnvironmentName { name, .. } => {
257 assert_eq!(name, "invalid_name");
258 }
259 other => panic!("Expected InvalidEnvironmentName, got: {other:?}"),
260 }
261 }
262
263 #[tokio::test]
264 async fn it_should_return_error_for_empty_environment_name() {
265 let temp_dir = TempDir::new().unwrap();
266
267 let (user_output, repository, clock) = create_test_dependencies(&temp_dir);
268
269 let result = ReleaseCommandController::new(repository, clock, user_output.clone())
270 .execute("", OutputFormat::Text)
271 .await;
272
273 assert!(result.is_err());
274 match result.unwrap_err() {
275 ReleaseSubcommandError::InvalidEnvironmentName { name, .. } => {
276 assert_eq!(name, "");
277 }
278 other => panic!("Expected InvalidEnvironmentName, got: {other:?}"),
279 }
280 }
281
282 #[tokio::test]
283 async fn it_should_return_error_for_nonexistent_environment() {
284 let temp_dir = TempDir::new().unwrap();
285
286 let (user_output, repository, clock) = create_test_dependencies(&temp_dir);
287
288 let result = ReleaseCommandController::new(repository, clock, user_output.clone())
290 .execute("test-env", OutputFormat::Text)
291 .await;
292
293 assert!(result.is_err());
295 match result.unwrap_err() {
296 ReleaseSubcommandError::ApplicationLayerError { .. } => (),
297 other => panic!("Expected ApplicationLayerError, got: {other:?}"),
298 }
299 }
300}