1use crate::compute::{ComputeBackend, ContainerConfig, NodeStatus};
7use anyhow::{Context, Result};
8use async_trait::async_trait;
9use std::process::Command;
10use tracing::info;
11
12pub struct LxdBackend {
13 storage_pool: String,
14 network_device: String,
15}
16
17impl LxdBackend {
18 pub fn new(storage_pool: &str, network_device: &str) -> Self {
19 Self {
20 storage_pool: storage_pool.to_string(),
21 network_device: network_device.to_string(),
22 }
23 }
24
25 fn run_lxc(&self, args: &[&str]) -> Result<String> {
26 let output = Command::new("lxc")
27 .args(args)
28 .output()
29 .context("Failed to execute lxc command")?;
30
31 if !output.status.success() {
32 let stderr = String::from_utf8_lossy(&output.stderr);
33 return Err(anyhow::anyhow!("lxc command failed: {}", stderr));
34 }
35
36 Ok(String::from_utf8_lossy(&output.stdout).to_string())
37 }
38
39 fn parse_lxc_json(raw: &str) -> Result<serde_json::Value> {
42 let s = if raw.trim().is_empty() { "[]" } else { raw };
43 serde_json::from_str(s).context("Failed to parse lxc list output")
44 }
45
46 fn resolve_storage_pool(&self) -> Result<String> {
49 let raw = self.run_lxc(&["storage", "list", "--format", "json"])?;
50 let pools: serde_json::Value =
51 serde_json::from_str(if raw.trim().is_empty() { "[]" } else { &raw })
52 .context("Failed to parse lxc storage list output")?;
53
54 let names: Vec<String> = pools
55 .as_array()
56 .unwrap_or(&vec![])
57 .iter()
58 .filter_map(|p| p.get("name").and_then(|n| n.as_str()).map(str::to_string))
59 .collect();
60
61 if names.contains(&self.storage_pool) {
63 return Ok(self.storage_pool.clone());
64 }
65
66 names.into_iter().next().ok_or_else(|| {
68 anyhow::anyhow!(
69 "No LXD storage pools found. Run `lxc storage create default dir` on the provider."
70 )
71 })
72 }
73}
74
75#[async_trait]
76impl ComputeBackend for LxdBackend {
77 async fn find_available_id(&self, range_start: u32, range_end: u32) -> Result<u32> {
78 let raw = self.run_lxc(&["list", "--format", "json"])?;
79 let containers = Self::parse_lxc_json(&raw)?;
80
81 let existing_ids: Vec<u32> = containers
82 .as_array()
83 .unwrap_or(&vec![])
84 .iter()
85 .filter_map(|c| c.get("name").and_then(|n| n.as_str()))
86 .filter_map(|name| {
87 if name.starts_with("paygress-") {
88 name.replace("paygress-", "").parse::<u32>().ok()
89 } else {
90 None
91 }
92 })
93 .collect();
94
95 for id in range_start..=range_end {
96 if !existing_ids.contains(&id) {
97 return Ok(id);
98 }
99 }
100
101 Err(anyhow::anyhow!(
102 "No available IDs in range {}-{}",
103 range_start,
104 range_end
105 ))
106 }
107
108 async fn create_container(&self, config: &ContainerConfig) -> Result<String> {
109 let name = format!("paygress-{}", config.id);
110
111 let image = match config.image.as_str() {
114 "alpine" => "images:alpine/3.19",
115 "ubuntu" => "ubuntu:22.04", other => other,
117 };
118
119 info!("Creating LXD container {} with image {}", name, image);
120
121 let cpu_limit = format!("limits.cpu={}", config.cpu_cores);
123 let mem_limit = format!("limits.memory={}MB", config.memory_mb);
124
125 let pool = self.resolve_storage_pool()?;
126 info!("Using storage pool: {}", pool);
127
128 self.run_lxc(&[
129 "launch",
130 image,
131 &name,
132 "-s",
133 &pool,
134 "-c",
135 &cpu_limit,
136 "-c",
137 &mem_limit,
138 "-c",
139 "security.nesting=true",
140 ])?;
141
142 let chpasswd_cmd = format!("echo 'root:{}' | chpasswd", config.password);
145
146 for _ in 0..10 {
148 match self.run_lxc(&["exec", &name, "--", "sh", "-c", &chpasswd_cmd]) {
149 Ok(_) => break,
150 Err(_) => tokio::time::sleep(std::time::Duration::from_secs(1)).await,
151 }
152 }
153
154 let setup_script = r#"
157 # Detect package manager and install SSH if missing
158 if command -v apk >/dev/null; then
159 # Alpine
160 apk add --no-cache openssh
161 rc-update add sshd default
162 service sshd start
163 elif command -v apt-get >/dev/null; then
164 # Debian/Ubuntu
165 # Usually installed, but ensure it runs
166 systemctl enable ssh
167 systemctl start ssh
168 fi
169
170 # Configure SSH for root access with password
171 # Check if config exists
172 if [ -f /etc/ssh/sshd_config ]; then
173 # Remove cloud-init config that disables password auth
174 rm -f /etc/ssh/sshd_config.d/*-cloudimg-settings.conf
175
176 sed -i 's/#PermitRootLogin.*/PermitRootLogin yes/' /etc/ssh/sshd_config
177 sed -i 's/PermitRootLogin.*/PermitRootLogin yes/' /etc/ssh/sshd_config
178 sed -i 's/PasswordAuthentication no/PasswordAuthentication yes/' /etc/ssh/sshd_config
179
180 # Restart service
181 service sshd restart || systemctl restart ssh || systemctl restart sshd
182 fi
183 "#;
184
185 let _ = self.run_lxc(&["exec", &name, "--", "sh", "-c", setup_script]);
186
187 if let Some(port) = config.host_port {
189 info!("Setting up port forwarding: Host {} -> Container 22", port);
190 self.run_lxc(&[
192 "config",
193 "device",
194 "add",
195 &name,
196 "ssh-proxy",
197 "proxy",
198 &format!("listen=tcp:0.0.0.0:{}", port),
199 "connect=tcp:127.0.0.1:22",
200 ])?;
201 }
202
203 Ok(name)
204 }
205
206 async fn start_container(&self, id: u32) -> Result<()> {
207 let name = format!("paygress-{}", id);
208 self.run_lxc(&["start", &name])?;
209 Ok(())
210 }
211
212 async fn stop_container(&self, id: u32) -> Result<()> {
213 let name = format!("paygress-{}", id);
214 self.run_lxc(&["stop", &name])?;
215 Ok(())
216 }
217
218 async fn delete_container(&self, id: u32) -> Result<()> {
219 let name = format!("paygress-{}", id);
220 self.run_lxc(&["delete", &name, "--force"])?;
221 Ok(())
222 }
223
224 async fn get_node_status(&self) -> Result<NodeStatus> {
225 let mem_output = Command::new("free").arg("-b").output()?;
227 let mem_str = String::from_utf8_lossy(&mem_output.stdout);
228
229 let mut memory_total = 0;
233 let mut memory_used = 0;
234
235 for line in mem_str.lines() {
236 if line.starts_with("Mem:") {
237 let parts: Vec<&str> = line.split_whitespace().collect();
238 if parts.len() >= 3 {
239 memory_total = parts[1].parse().unwrap_or(0);
240 memory_used = parts[2].parse().unwrap_or(0);
241 }
242 }
243 }
244
245 let disk_output = Command::new("df").args(["-B1", "/"]).output()?;
247 let disk_str = String::from_utf8_lossy(&disk_output.stdout);
248
249 let mut disk_total = 0;
250 let mut disk_used = 0;
251
252 for line in disk_str.lines().skip(1) {
253 let parts: Vec<&str> = line.split_whitespace().collect();
255 if parts.len() >= 3 {
256 disk_total = parts[1].parse().unwrap_or(0);
257 disk_used = parts[2].parse().unwrap_or(0);
258 break;
259 }
260 }
261
262 let loadavg = std::fs::read_to_string("/proc/loadavg").unwrap_or_default();
264 let load_1min: f64 = loadavg
265 .split_whitespace()
266 .next()
267 .unwrap_or("0")
268 .parse()
269 .unwrap_or(0.0);
270 let cpu_cores = num_cpus::get() as f64;
271 let cpu_usage = (load_1min / cpu_cores).min(1.0);
272
273 Ok(NodeStatus {
274 cpu_usage,
275 memory_used,
276 memory_total,
277 disk_used,
278 disk_total,
279 })
280 }
281
282 async fn get_container_ip(&self, id: u32) -> Result<Option<String>> {
283 let name = format!("paygress-{}", id);
284 let raw = self.run_lxc(&["list", &name, "--format", "json"])?;
285 let containers = Self::parse_lxc_json(&raw)?;
286
287 if let Some(container) = containers.as_array().and_then(|a| a.first()) {
288 if let Some(networks) = container.get("state").and_then(|s| s.get("network")) {
291 if let Some(eth0) = networks.get("eth0") {
292 if let Some(addrs) = eth0.get("addresses").and_then(|a| a.as_array()) {
293 for addr in addrs {
294 if addr.get("family").and_then(|f| f.as_str()) == Some("inet") {
295 if let Some(ip) = addr.get("address").and_then(|a| a.as_str()) {
296 return Ok(Some(ip.to_string()));
297 }
298 }
299 }
300 }
301 }
302 }
303 }
304
305 Ok(None)
306 }
307}