Skip to main content

torrust_tracker_deployer_lib/adapters/lxd/
client.rs

1//! LXD client for container and VM instance management
2//!
3//! This module provides the `LxdClient` which wraps LXD command-line tools to provide
4//! a Rust-native interface for managing LXD containers and virtual machines.
5//!
6//! ## Key Features
7//!
8//! - Instance lifecycle management (list, inspect, control)
9//! - IP address retrieval and network information
10//! - JSON output parsing for structured data access
11//! - Integration with the command execution framework
12//! - Support for both containers and virtual machines
13//!
14//! The client abstracts the complexity of LXD command-line interaction and provides
15//! type-safe APIs for common instance management tasks.
16
17use std::net::IpAddr;
18
19use anyhow::{Context, Result};
20use tracing::info;
21
22use crate::shared::command::CommandExecutor;
23
24#[allow(unused_imports)]
25use super::instance::{InstanceInfo, InstanceName};
26use super::json_parser::LxdJsonParser;
27
28/// A specialized LXD client for instance management.
29///
30/// This client provides a consistent interface for LXD operations:
31/// - List instances (containers and virtual machines) and their information
32/// - Retrieve instance IP addresses
33/// - Execute LXD commands with proper error handling
34///
35/// Uses `CommandExecutor` as a collaborator for actual command execution.
36pub struct LxdClient {
37    command_executor: CommandExecutor,
38}
39
40impl Default for LxdClient {
41    fn default() -> Self {
42        Self::new()
43    }
44}
45
46impl LxdClient {
47    /// Creates a new `LxdClient`
48    #[must_use]
49    pub fn new() -> Self {
50        Self {
51            command_executor: CommandExecutor::new(),
52        }
53    }
54
55    /// Get the IPv4 address of a specific instance
56    ///
57    /// # Arguments
58    ///
59    /// * `instance_name` - Name of the instance to get the IP address for
60    ///
61    /// # Returns
62    /// * `Ok(Some(IpAddr))` - The IPv4 address if found
63    /// * `Ok(None)` - Instance not found or no IPv4 address available
64    /// * `Err(anyhow::Error)` - Error describing what went wrong
65    ///
66    /// # Errors
67    ///
68    /// This function will return an error if:
69    /// * LXD command execution fails
70    /// * JSON parsing fails
71    pub fn get_instance_ip(&self, instance_name: &InstanceName) -> Result<Option<IpAddr>> {
72        info!("Getting IP address for instance: {}", instance_name);
73
74        let Some(instance) = self.get_instance_by_name(instance_name)? else {
75            info!("Instance '{}' not found", instance_name);
76            return Ok(None);
77        };
78
79        let Some(ip) = instance.ip_address else {
80            info!("Instance '{}' has no IPv4 address", instance_name);
81            return Ok(None);
82        };
83
84        info!(
85            "Found IPv4 address for instance '{}': {}",
86            instance_name, ip
87        );
88
89        Ok(Some(ip))
90    }
91
92    /// Wait for an instance to get an IP address (useful for VMs that take time to boot)
93    ///
94    /// # Arguments
95    ///
96    /// * `instance_name` - Name of the instance to wait for
97    /// * `timeout_seconds` - Maximum time to wait in seconds
98    /// * `poll_interval_seconds` - How often to check in seconds
99    ///
100    /// # Returns
101    /// * `Ok(IpAddr)` - The IP address when found
102    /// * `Err(anyhow::Error)` - Timeout or other error
103    ///
104    /// # Errors
105    ///
106    /// This function will return an error if:
107    /// * Timeout is reached without getting an IP
108    /// * LXD command execution fails
109    /// * JSON parsing fails
110    pub fn wait_for_instance_ip(
111        &self,
112        instance_name: &InstanceName,
113        timeout_seconds: u64,
114        poll_interval_seconds: u64,
115    ) -> Result<IpAddr> {
116        use std::time::{Duration, Instant};
117
118        info!(
119            "Waiting for instance '{}' to get IP address (timeout: {}s, poll interval: {}s)",
120            instance_name, timeout_seconds, poll_interval_seconds
121        );
122
123        let start_time = Instant::now();
124        let timeout = Duration::from_secs(timeout_seconds);
125        let poll_interval = Duration::from_secs(poll_interval_seconds);
126
127        loop {
128            if let Some(ip) = self.get_instance_ip(instance_name)? {
129                info!(
130                    "Instance '{}' got IP address: {} (waited {:?})",
131                    instance_name,
132                    ip,
133                    start_time.elapsed()
134                );
135                return Ok(ip);
136            }
137
138            if start_time.elapsed() >= timeout {
139                return Err(anyhow::anyhow!(
140                    "Timeout waiting for instance '{instance_name}' to get IP address after {timeout:?}"
141                ));
142            }
143
144            std::thread::sleep(poll_interval);
145        }
146    }
147
148    /// Get a specific instance by name
149    ///
150    /// # Arguments
151    ///
152    /// * `instance_name` - Name of the instance to retrieve
153    ///
154    /// # Returns
155    /// * `Ok(Some(InstanceInfo))` - Instance information if found
156    /// * `Ok(None)` - Instance not found
157    /// * `Err(anyhow::Error)` - Error describing what went wrong
158    ///
159    /// # Errors
160    ///
161    /// This function will return an error if:
162    /// * LXD command execution fails
163    /// * JSON parsing fails
164    pub fn get_instance_by_name(
165        &self,
166        instance_name: &InstanceName,
167    ) -> Result<Option<InstanceInfo>> {
168        info!("Getting instance by name: {}", instance_name);
169
170        let instances = self.list(Some(instance_name))?;
171
172        Ok(instances
173            .into_iter()
174            .find(|inst| inst.name.as_str() == instance_name.as_str()))
175    }
176
177    /// List instances in JSON format
178    ///
179    /// # Arguments
180    ///
181    /// * `instance_name` - Optional instance name to filter results
182    ///
183    /// # Returns
184    /// * `Ok(Vec<InstanceInfo>)` - List of instance information if the command succeeds
185    /// * `Err(anyhow::Error)` - Error describing what went wrong
186    ///
187    /// # Errors
188    ///
189    /// This function will return an error if:
190    /// * The LXD command fails
191    /// * LXD is not installed or accessible
192    /// * JSON parsing fails
193    fn list(&self, instance_name: Option<&InstanceName>) -> Result<Vec<InstanceInfo>> {
194        info!("Listing LXD instances");
195
196        let mut args = vec!["list", "--format=json"];
197
198        if let Some(name) = instance_name {
199            args.push(name.as_str());
200            info!("Filtering by instance name: {}", name);
201        }
202
203        let output = self
204            .command_executor
205            .run_command("lxc", &args, None)
206            .map_err(anyhow::Error::from)
207            .context("Failed to execute lxc list command")?;
208
209        LxdJsonParser::parse_instances_json(&output.stdout)
210    }
211
212    /// Delete an LXD instance
213    ///
214    /// # Arguments
215    ///
216    /// * `instance_name` - Name of the instance to delete
217    /// * `force` - Whether to force deletion (stop running instances)
218    ///
219    /// # Returns
220    /// * `Ok(())` - Instance deleted successfully or didn't exist
221    /// * `Err(anyhow::Error)` - Error describing what went wrong
222    ///
223    /// # Errors
224    ///
225    /// This function will return an error if:
226    /// * The LXD command fails with an unexpected error
227    /// * LXD is not installed or accessible
228    pub fn delete_instance(&self, instance_name: &InstanceName, force: bool) -> Result<()> {
229        info!("Deleting LXD instance: {}", instance_name);
230
231        let mut args = vec!["delete", instance_name.as_str()];
232        if force {
233            args.push("--force");
234        }
235
236        let result = self.command_executor.run_command("lxc", &args, None);
237
238        match result {
239            Ok(_) => {
240                info!("LXD instance '{}' deleted successfully", instance_name);
241                Ok(())
242            }
243            Err(e) => {
244                let error_msg = e.to_string();
245                // Instance not found is not an error for cleanup operations
246                if error_msg.contains("not found") || error_msg.contains("does not exist") {
247                    info!(
248                        "LXD instance '{}' doesn't exist, skipping deletion",
249                        instance_name
250                    );
251                    Ok(())
252                } else {
253                    Err(anyhow::Error::from(e)
254                        .context(format!("Failed to delete LXD instance '{instance_name}'")))
255                }
256            }
257        }
258    }
259
260    /// Delete an LXD profile
261    ///
262    /// # Arguments
263    ///
264    /// * `profile_name` - Name of the profile to delete
265    ///
266    /// # Returns
267    /// * `Ok(())` - Profile deleted successfully or didn't exist
268    /// * `Err(anyhow::Error)` - Error describing what went wrong
269    ///
270    /// # Errors
271    ///
272    /// This function will return an error if:
273    /// * The LXD command fails with an unexpected error
274    /// * LXD is not installed or accessible
275    /// * Profile is in use by existing instances
276    pub fn delete_profile(&self, profile_name: &str) -> Result<()> {
277        info!("Deleting LXD profile: {}", profile_name);
278
279        let args = vec!["profile", "delete", profile_name];
280
281        let result = self.command_executor.run_command("lxc", &args, None);
282
283        match result {
284            Ok(_) => {
285                info!("LXD profile '{}' deleted successfully", profile_name);
286                Ok(())
287            }
288            Err(e) => {
289                let error_msg = e.to_string();
290                // Profile not found is not an error for cleanup operations
291                if error_msg.contains("not found") || error_msg.contains("does not exist") {
292                    info!(
293                        "LXD profile '{}' doesn't exist, skipping deletion",
294                        profile_name
295                    );
296                    Ok(())
297                } else {
298                    Err(anyhow::Error::from(e)
299                        .context(format!("Failed to delete LXD profile '{profile_name}'")))
300                }
301            }
302        }
303    }
304}
305
306#[cfg(test)]
307mod tests {
308    use super::*;
309
310    #[test]
311    fn it_should_create_lxd_client_successfully() {
312        let _client = LxdClient::new();
313        // Client should be created successfully
314        // Note: Logging is handled by the tracing crate via CommandExecutor
315    }
316
317    #[test]
318    fn it_should_create_lxd_client_with_default_implementation() {
319        let _client = LxdClient::default();
320        // Client should be created successfully using Default trait
321    }
322
323    #[test]
324    fn it_should_return_none_when_instance_not_found() {
325        let _client = LxdClient::new();
326        // We can't easily test this without mocking CommandExecutor, but the behavior
327        // is now that get_instance_ip returns Ok(None) instead of an error when
328        // the instance is not found or has no IP address.
329        // This is tested implicitly through the other unit tests of the parser.
330    }
331}