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}