Skip to main content

torrust_tracker_deployer_lib/adapters/tofu/
client.rs

1//! `OpenTofu` client for infrastructure management
2//!
3//! This module provides the `OpenTofuClient` which wraps `OpenTofu` command-line tools
4//! to provide a Rust-native interface for infrastructure provisioning and management.
5//!
6//! ## Key Features
7//!
8//! - Full `OpenTofu` workflow support (init, plan, apply, destroy)
9//! - Instance information extraction from Terraform state
10//! - JSON output parsing for structured data access
11//! - Working directory management for Terraform projects
12//! - Comprehensive error handling for all operations
13//!
14//! ## Supported Operations
15//!
16//! - `init` - Initialize Terraform working directory
17//! - `plan` - Create execution plan showing changes
18//! - `apply` - Apply infrastructure changes
19//! - `destroy` - Destroy managed infrastructure
20//! - `output` - Extract output values from state
21
22use std::net::IpAddr;
23use std::path::{Path, PathBuf};
24
25use serde::{Deserialize, Serialize};
26use thiserror::Error;
27use tracing::info;
28
29use crate::shared::command::{CommandError, CommandExecutor};
30
31use super::json_parser::{OpenTofuJsonParser, ParseError};
32
33/// Container information extracted from `OpenTofu` outputs
34#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
35pub struct InstanceInfo {
36    pub image: String,
37    pub ip_address: IpAddr,
38    pub name: String,
39    pub status: String,
40}
41
42/// Errors that can occur during `OpenTofu` operations
43#[derive(Error, Debug)]
44pub enum OpenTofuError {
45    /// Command execution failed
46    #[error("Command execution failed: {0}")]
47    CommandError(#[from] CommandError),
48
49    /// JSON parsing failed
50    #[error("Parse error: {0}")]
51    ParseError(#[from] ParseError),
52}
53
54impl crate::shared::Traceable for OpenTofuError {
55    fn trace_format(&self) -> String {
56        match self {
57            Self::CommandError(e) => format!("OpenTofuError: Command execution failed - {e}"),
58            Self::ParseError(e) => format!("OpenTofuError: JSON parsing failed - {e}"),
59        }
60    }
61
62    fn trace_source(&self) -> Option<&dyn crate::shared::Traceable> {
63        match self {
64            Self::CommandError(e) => Some(e),
65            Self::ParseError(_) => None, // ParseError doesn't implement Traceable
66        }
67    }
68
69    fn error_kind(&self) -> crate::shared::ErrorKind {
70        crate::shared::ErrorKind::InfrastructureOperation
71    }
72}
73
74/// A specialized `OpenTofu` client for infrastructure management.
75/// This client provides a consistent interface for `OpenTofu` operations:
76/// - Initialize `OpenTofu` configurations  
77/// - Plan infrastructure changes
78/// - Apply infrastructure changes
79/// - Destroy infrastructure
80///
81/// Uses `CommandExecutor` as a collaborator for actual command execution.
82pub struct OpenTofuClient {
83    working_dir: PathBuf,
84    command_executor: CommandExecutor,
85}
86
87impl OpenTofuClient {
88    /// Creates a new `OpenTofuClient`
89    ///
90    /// # Arguments
91    /// * `working_dir` - Path to the directory containing `OpenTofu` configuration files
92    #[must_use]
93    pub fn new<P: Into<PathBuf>>(working_dir: P) -> Self {
94        Self {
95            working_dir: working_dir.into(),
96            command_executor: CommandExecutor::new(),
97        }
98    }
99
100    /// Initialize `OpenTofu` configuration
101    ///
102    /// # Returns
103    ///
104    /// * `Ok(String)` - The stdout output if the command succeeds
105    /// * `Err(CommandError)` - Error describing what went wrong
106    ///
107    /// # Errors
108    ///
109    /// This function will return an error if:
110    /// * The `OpenTofu` initialization fails
111    /// * The working directory does not exist or is not accessible
112    pub fn init(&self) -> Result<String, CommandError> {
113        info!(
114            "Initializing OpenTofu in directory: {}",
115            self.working_dir.display()
116        );
117
118        self.command_executor
119            .run_command("tofu", &["init"], Some(&self.working_dir))
120            .map(|result| result.stdout)
121    }
122
123    /// Validate configuration syntax and consistency
124    ///
125    /// # Returns
126    ///
127    /// * `Ok(String)` - The stdout output if the command succeeds
128    /// * `Err(CommandError)` - Error describing what went wrong
129    ///
130    /// # Errors
131    ///
132    /// This function will return an error if:
133    /// * The `OpenTofu` validate fails due to syntax or consistency errors
134    /// * The configuration is not initialized
135    /// * The working directory does not exist or is not accessible
136    pub fn validate(&self) -> Result<String, CommandError> {
137        info!(
138            "Validating OpenTofu configuration in directory: {}",
139            self.working_dir.display()
140        );
141
142        self.command_executor
143            .run_command("tofu", &["validate"], Some(&self.working_dir))
144            .map(|result| result.stdout)
145    }
146
147    /// Plan infrastructure changes
148    ///
149    /// # Arguments
150    ///
151    /// * `extra_args` - Additional arguments to pass to the tofu plan command (e.g., "-var-file=variables.tfvars")
152    ///
153    /// # Returns
154    ///
155    /// * `Ok(String)` - The stdout output if the command succeeds
156    /// * `Err(CommandError)` - Error describing what went wrong
157    ///
158    /// # Errors
159    ///
160    /// This function will return an error if:
161    /// * The `OpenTofu` plan fails
162    /// * The configuration is not initialized
163    pub fn plan(&self, extra_args: &[&str]) -> Result<String, CommandError> {
164        info!(
165            "Planning infrastructure changes in directory: {}",
166            self.working_dir.display()
167        );
168
169        let mut args = vec!["plan"];
170        args.extend_from_slice(extra_args);
171
172        self.command_executor
173            .run_command("tofu", &args, Some(&self.working_dir))
174            .map(|result| result.stdout)
175    }
176
177    /// Apply infrastructure changes
178    ///
179    /// # Arguments
180    ///
181    /// * `auto_approve` - Whether to automatically approve the changes without interactive confirmation
182    /// * `extra_args` - Additional arguments to pass to the tofu apply command (e.g., "-var-file=variables.tfvars")
183    ///
184    /// # Returns
185    ///
186    /// * `Ok(String)` - The stdout output if the command succeeds
187    /// * `Err(CommandError)` - Error describing what went wrong
188    ///
189    /// # Errors
190    ///
191    /// This function will return an error if:
192    /// * The `OpenTofu` apply fails
193    /// * The configuration is not initialized
194    pub fn apply(&self, auto_approve: bool, extra_args: &[&str]) -> Result<String, CommandError> {
195        info!(
196            "Applying infrastructure changes in directory: {}",
197            self.working_dir.display()
198        );
199
200        let mut args = vec!["apply"];
201        args.extend_from_slice(extra_args);
202        if auto_approve {
203            args.push("-auto-approve");
204        }
205
206        self.command_executor
207            .run_command("tofu", &args, Some(&self.working_dir))
208            .map(|result| result.stdout)
209    }
210
211    /// Destroy infrastructure
212    ///
213    /// # Arguments
214    ///
215    /// * `auto_approve` - Whether to automatically approve the destruction without interactive confirmation
216    /// * `extra_args` - Additional arguments to pass to the tofu destroy command (e.g., "-var-file=variables.tfvars")
217    ///
218    /// # Returns
219    ///
220    /// * `Ok(String)` - The stdout output if the command succeeds
221    /// * `Err(CommandError)` - Error describing what went wrong
222    ///
223    /// # Errors
224    ///
225    /// This function will return an error if:
226    /// * The `OpenTofu` destroy fails
227    /// * The configuration is not initialized
228    pub fn destroy(&self, auto_approve: bool, extra_args: &[&str]) -> Result<String, CommandError> {
229        info!(
230            "Destroying infrastructure in directory: {}",
231            self.working_dir.display()
232        );
233
234        let mut args = vec!["destroy"];
235        args.extend_from_slice(extra_args);
236        if auto_approve {
237            args.push("-auto-approve");
238        }
239
240        self.command_executor
241            .run_command("tofu", &args, Some(&self.working_dir))
242            .map(|result| result.stdout)
243    }
244
245    /// Get `OpenTofu` outputs and parse container information
246    ///
247    /// # Returns
248    ///
249    /// * `Ok(ContainerInfo)` - Parsed container information from `OpenTofu` outputs
250    /// * `Err(OpenTofuError)` - Error describing what went wrong
251    ///
252    /// # Errors
253    ///
254    /// This function will return an error if:
255    /// * The `OpenTofu` output command fails
256    /// * The output cannot be parsed as JSON
257    /// * The `instance_info` section is missing or malformed
258    pub fn get_instance_info(&self) -> Result<InstanceInfo, OpenTofuError> {
259        info!(
260            "Getting OpenTofu outputs from directory: {}",
261            self.working_dir.display()
262        );
263
264        let output = self.command_executor.run_command(
265            "tofu",
266            &["output", "-json"],
267            Some(&self.working_dir),
268        )?;
269
270        let instance_info = OpenTofuJsonParser::parse_instance_info(&output.stdout)?;
271        Ok(instance_info)
272    }
273
274    /// Get the working directory path
275    #[must_use]
276    pub fn working_dir(&self) -> &Path {
277        &self.working_dir
278    }
279}
280
281#[cfg(test)]
282mod tests {
283    use super::*;
284
285    #[test]
286    fn it_should_create_opentofu_client_with_valid_parameters() {
287        let client = OpenTofuClient::new("/path/to/config");
288
289        assert_eq!(client.working_dir.to_string_lossy(), "/path/to/config");
290    }
291
292    #[test]
293    fn it_should_create_opentofu_client_with_working_directory() {
294        let client = OpenTofuClient::new("/path/to/config");
295
296        assert_eq!(client.working_dir.to_string_lossy(), "/path/to/config");
297        // Note: logging is now handled by the tracing crate via CommandExecutor
298    }
299
300    #[test]
301    fn it_should_return_working_directory_path() {
302        let client = OpenTofuClient::new("/test/path");
303
304        assert_eq!(client.working_dir(), Path::new("/test/path"));
305    }
306
307    #[test]
308    fn it_should_construct_pathbuf_from_string() {
309        let path_str = "/some/test/path";
310        let client = OpenTofuClient::new(path_str);
311
312        assert_eq!(client.working_dir(), Path::new(path_str));
313    }
314
315    #[test]
316    fn it_should_construct_pathbuf_from_path() {
317        let path = Path::new("/another/test/path");
318        let client = OpenTofuClient::new(path);
319
320        assert_eq!(client.working_dir(), path);
321    }
322
323    #[test]
324    fn it_should_wrap_parse_error_in_opentofu_error() {
325        use crate::adapters::tofu::json_parser::OpenTofuJsonParser;
326
327        let invalid_json = "not valid json";
328
329        let parse_error = OpenTofuJsonParser::parse_instance_info(invalid_json).unwrap_err();
330        let opentofu_error = OpenTofuError::ParseError(parse_error);
331
332        assert!(matches!(opentofu_error, OpenTofuError::ParseError(_)));
333        assert!(opentofu_error.to_string().contains("Parse error"));
334    }
335
336    #[test]
337    fn it_should_wrap_command_error_in_opentofu_error() {
338        let command_error = CommandError::StartupFailed {
339            command: "tofu".to_string(),
340            source: std::io::Error::new(std::io::ErrorKind::NotFound, "Command not found"),
341        };
342        let opentofu_error = OpenTofuError::CommandError(command_error);
343
344        assert!(matches!(opentofu_error, OpenTofuError::CommandError(_)));
345        assert!(opentofu_error
346            .to_string()
347            .contains("Command execution failed"));
348    }
349}