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}