Skip to main content

torrust_tracker_deployer_lib/application/steps/application/
start_services.rs

1//! Start Docker Compose services step
2//!
3//! This module provides the `StartServicesStep` which handles starting the
4//! Docker Compose application stack on a remote host via Ansible.
5//!
6//! ## Key Features
7//!
8//! - Executes `docker compose up -d` on the remote host
9//! - Pulls container images before starting
10//! - Waits for services to become healthy
11//! - Reports container status
12//!
13//! ## Architecture
14//!
15//! This step follows the three-level architecture:
16//! - **Command** (Level 1): `RunCommandHandler` orchestrates the run workflow
17//! - **Step** (Level 2): This `StartServicesStep` handles service startup
18//! - **Remote Action** (Level 3): Ansible playbook executes on the remote host
19//!
20//! ## Usage
21//!
22//! ```rust,ignore
23//! use std::sync::Arc;
24//! use std::path::PathBuf;
25//! use crate::adapters::ansible::AnsibleClient;
26//! use crate::application::steps::application::StartServicesStep;
27//!
28//! let ansible_client = Arc::new(AnsibleClient::new(PathBuf::from("/path/to/ansible/build")));
29//!
30//! let step = StartServicesStep::new(ansible_client);
31//! step.execute()?;
32//! ```
33
34use std::sync::Arc;
35
36use thiserror::Error;
37use tracing::{info, instrument};
38
39use crate::adapters::ansible::AnsibleClient;
40use crate::shared::command::CommandError;
41use crate::shared::{ErrorKind, Traceable};
42
43/// Step that starts Docker Compose services on a remote host via Ansible
44///
45/// This step handles the execution of `docker compose up -d` on the remote
46/// instance, bringing up all application containers defined in the compose file.
47pub struct StartServicesStep {
48    ansible_client: Arc<AnsibleClient>,
49}
50
51impl StartServicesStep {
52    /// Creates a new `StartServicesStep`
53    ///
54    /// # Arguments
55    ///
56    /// * `ansible_client` - The Ansible client for executing playbooks
57    #[must_use]
58    pub fn new(ansible_client: Arc<AnsibleClient>) -> Self {
59        Self { ansible_client }
60    }
61
62    /// Execute the service startup step
63    ///
64    /// This will run the "run-compose-services" Ansible playbook to start
65    /// all Docker Compose services on the remote host.
66    ///
67    /// # Errors
68    ///
69    /// Returns an error if:
70    /// * The Ansible playbook execution fails
71    /// * Docker Compose services fail to start
72    /// * Container health checks fail
73    #[instrument(
74        name = "start_services",
75        skip_all,
76        fields(step_type = "application", operation = "start_services")
77    )]
78    pub fn execute(&self) -> Result<(), StartServicesStepError> {
79        info!(
80            step = "start_services",
81            status = "starting",
82            "Starting Docker Compose services on remote host"
83        );
84
85        self.ansible_client
86            .run_playbook("run-compose-services", &[])
87            .map_err(|source| StartServicesStepError::AnsiblePlaybookFailed {
88                message: source.to_string(),
89                source,
90            })?;
91
92        info!(
93            step = "start_services",
94            status = "success",
95            "Docker Compose services started successfully"
96        );
97
98        Ok(())
99    }
100}
101
102/// Errors that can occur during the start services step
103#[derive(Debug, Error)]
104pub enum StartServicesStepError {
105    /// Ansible playbook execution failed
106    #[error("Ansible playbook 'run-compose-services' failed: {message}")]
107    AnsiblePlaybookFailed {
108        message: String,
109        #[source]
110        source: CommandError,
111    },
112}
113
114impl StartServicesStepError {
115    /// Returns troubleshooting help for this error
116    #[must_use]
117    pub fn help(&self) -> &'static str {
118        match self {
119            Self::AnsiblePlaybookFailed { .. } => {
120                "Failed to start Docker Compose services. Please check:\n\
121                 1. Docker daemon is running on the remote host\n\
122                 2. Docker Compose files were deployed via 'release' command\n\
123                 3. Container images can be pulled (network connectivity)\n\
124                 4. No port conflicts with existing services\n\
125                 5. Sufficient disk space and memory on the remote host\n\
126                 6. SSH connectivity to the remote host is working"
127            }
128        }
129    }
130}
131
132impl Traceable for StartServicesStepError {
133    fn trace_format(&self) -> String {
134        match self {
135            Self::AnsiblePlaybookFailed { message, .. } => {
136                format!("StartServicesStep::AnsiblePlaybookFailed - {message}")
137            }
138        }
139    }
140
141    fn trace_source(&self) -> Option<&dyn Traceable> {
142        match self {
143            Self::AnsiblePlaybookFailed { source, .. } => Some(source),
144        }
145    }
146
147    fn error_kind(&self) -> ErrorKind {
148        ErrorKind::InfrastructureOperation
149    }
150}
151
152#[cfg(test)]
153mod tests {
154    use std::path::PathBuf;
155    use std::sync::Arc;
156
157    use super::*;
158    use crate::adapters::ansible::AnsibleClient;
159
160    #[test]
161    fn it_should_create_start_services_step() {
162        let ansible_client = Arc::new(AnsibleClient::new(PathBuf::from("test_inventory.yml")));
163
164        let step = StartServicesStep::new(ansible_client);
165
166        // Test that the step can be created successfully
167        assert_eq!(
168            std::mem::size_of_val(&step),
169            std::mem::size_of::<Arc<AnsibleClient>>()
170        );
171    }
172
173    #[test]
174    fn errors_should_provide_help() {
175        let cmd_error = CommandError::ExecutionFailed {
176            command: "test".to_string(),
177            exit_code: "1".to_string(),
178            stdout: String::new(),
179            stderr: "test error".to_string(),
180        };
181        let error = StartServicesStepError::AnsiblePlaybookFailed {
182            message: "test".to_string(),
183            source: cmd_error,
184        };
185
186        let help = error.help();
187        assert!(help.contains("Docker daemon"));
188        assert!(help.contains("release"));
189        assert!(help.contains("port conflicts"));
190    }
191
192    #[test]
193    fn errors_should_implement_traceable() {
194        let cmd_error = CommandError::ExecutionFailed {
195            command: "test".to_string(),
196            exit_code: "1".to_string(),
197            stdout: String::new(),
198            stderr: "test error".to_string(),
199        };
200        let error = StartServicesStepError::AnsiblePlaybookFailed {
201            message: "test error".to_string(),
202            source: cmd_error,
203        };
204
205        assert!(error.trace_format().contains("AnsiblePlaybookFailed"));
206        assert!(error.trace_source().is_some());
207        assert!(matches!(
208            error.error_kind(),
209            ErrorKind::InfrastructureOperation
210        ));
211    }
212}