1use std::path::PathBuf;
2
3use crate::error::{DeployError, DeployResult};
4use crate::provision::{Provisioner, ServerInfo};
5use crate::ssh::SshSession;
6
7#[derive(Debug, Clone)]
9pub enum NetworkMode {
10 Bridged(String),
13 Nat,
17}
18
19pub struct Libvirt {
25 pub hypervisor_host: String,
27 pub hypervisor_user: String,
29 pub hypervisor_key: Option<String>,
31 pub vcpus: u32,
33 pub memory_mib: u32,
35 pub disk_gib: u32,
37 pub image_url: String,
39 pub network: NetworkMode,
41 pub storage_dir: String,
43 pub vm_ssh_key: String,
46 pub os_variant: String,
48}
49
50impl Libvirt {
51 #[must_use]
61 pub fn new(hypervisor_host: &str, vm_ssh_key: &str) -> Self {
62 Self {
63 hypervisor_host: hypervisor_host.to_string(),
64 hypervisor_user: "root".to_string(),
65 hypervisor_key: None,
66 vcpus: 2,
67 memory_mib: 2048,
68 disk_gib: 20,
69 image_url: "https://cloud-images.ubuntu.com/\
70 releases/24.04/release/\
71 ubuntu-24.04-server-cloudimg-amd64.img"
72 .to_string(),
73 network: NetworkMode::Nat,
74 storage_dir: "/var/lib/libvirt/images".to_string(),
75 vm_ssh_key: vm_ssh_key.to_string(),
76 os_variant: "ubuntu24.04".to_string(),
77 }
78 }
79
80 #[must_use]
81 pub fn hypervisor_user(mut self, user: &str) -> Self {
82 self.hypervisor_user = user.to_string();
83 self
84 }
85
86 #[must_use]
87 pub fn hypervisor_key(mut self, key: &str) -> Self {
88 self.hypervisor_key = Some(key.to_string());
89 self
90 }
91
92 #[must_use]
93 pub const fn vcpus(mut self, n: u32) -> Self {
94 self.vcpus = n;
95 self
96 }
97
98 #[must_use]
99 pub const fn memory_mib(mut self, mib: u32) -> Self {
100 self.memory_mib = mib;
101 self
102 }
103
104 #[must_use]
105 pub const fn disk_gib(mut self, gib: u32) -> Self {
106 self.disk_gib = gib;
107 self
108 }
109
110 #[must_use]
111 pub fn image_url(mut self, url: &str) -> Self {
112 self.image_url = url.to_string();
113 self
114 }
115
116 #[must_use]
117 pub fn network(mut self, mode: NetworkMode) -> Self {
118 self.network = mode;
119 self
120 }
121
122 #[must_use]
123 pub fn storage_dir(mut self, dir: &str) -> Self {
124 self.storage_dir = dir.to_string();
125 self
126 }
127
128 #[must_use]
129 pub fn os_variant(mut self, variant: &str) -> Self {
130 self.os_variant = variant.to_string();
131 self
132 }
133
134 fn hypervisor_ssh(&self) -> SshSession {
138 let ssh = SshSession::new(&self.hypervisor_host, &self.hypervisor_user);
139 if let Some(key) = &self.hypervisor_key {
140 ssh.with_key(key)
141 } else {
142 ssh
143 }
144 }
145
146 fn read_pub_key(&self) -> DeployResult<String> {
148 let pub_path = format!("{}.pub", self.vm_ssh_key);
149 std::fs::read_to_string(&pub_path)
150 .map_err(|_| DeployError::FileNotFound(format!("public key not found: {pub_path}")))
151 }
152
153 fn create_seed_iso(&self, ssh: &SshSession, name: &str) -> DeployResult<String> {
158 let pub_key = self.read_pub_key()?;
159 let pub_key = pub_key.trim();
160
161 let seed_dir = format!("/tmp/cloud-init-{name}");
162 let iso_path = format!("{}/{name}-seed.iso", self.storage_dir);
163
164 let user_data = format!(
165 "#cloud-config\n\
166 users:\n \
167 - name: root\n \
168 ssh_authorized_keys:\n \
169 - {pub_key}\n\
170 ssh_pwauth: false\n\
171 package_update: false\n"
172 );
173
174 let meta_data = format!("instance-id: {name}\nlocal-hostname: {name}\n");
175
176 ssh.exec(&format!("mkdir -p {seed_dir}"))?;
177 ssh.write_remote_file(&user_data, &format!("{seed_dir}/user-data"))?;
178 ssh.write_remote_file(&meta_data, &format!("{seed_dir}/meta-data"))?;
179
180 let iso_cmd = format!(
182 "if command -v genisoimage >/dev/null 2>&1; then \
183 genisoimage -output {iso_path} -volid cidata \
184 -joliet -rock {seed_dir}/user-data \
185 {seed_dir}/meta-data; \
186 else \
187 mkisofs -output {iso_path} -volid cidata \
188 -joliet -rock {seed_dir}/user-data \
189 {seed_dir}/meta-data; \
190 fi"
191 );
192 ssh.exec(&iso_cmd)?;
193 ssh.exec(&format!("rm -rf {seed_dir}"))?;
194
195 Ok(iso_path)
196 }
197
198 fn wait_for_ip(ssh: &SshSession, name: &str) -> DeployResult<String> {
200 let max_attempts = 30;
201 let interval = std::time::Duration::from_secs(5);
202
203 for attempt in 1..=max_attempts {
204 eprint!(
205 "Waiting for IP \
206 ({attempt}/{max_attempts})... "
207 );
208
209 if let Ok(output) = ssh.exec(&format!("virsh domifaddr {name} 2>/dev/null")) {
211 if let Some(ip) = parse_domifaddr(&output) {
212 eprintln!("got {ip}");
213 return Ok(ip);
214 }
215 }
216
217 if let Ok(output) = ssh.exec(&format!(
219 "virsh domifaddr {name} \
220 --source arp 2>/dev/null"
221 )) {
222 if let Some(ip) = parse_domifaddr(&output) {
223 eprintln!("got {ip}");
224 return Ok(ip);
225 }
226 }
227
228 eprintln!("not yet");
229 std::thread::sleep(interval);
230 }
231
232 Err(DeployError::Other(format!(
233 "VM '{name}' did not get an IP after \
234 {max_attempts} attempts"
235 )))
236 }
237
238 fn run_setup_script(ssh: &SshSession, domain: &str, remote_dir: &str) -> DeployResult<()> {
241 let script = include_str!("../../scripts/setup-server.sh");
242 let escaped = script.replace('\'', "'\\''");
243 ssh.exec_interactive(&format!("bash -c '{escaped}' _ '{domain}' '{remote_dir}'"))
244 }
245
246 fn network_args(&self) -> String {
248 match &self.network {
249 NetworkMode::Bridged(bridge) => {
250 format!("bridge={bridge}")
251 }
252 NetworkMode::Nat => "network=default".to_string(),
253 }
254 }
255}
256
257impl Provisioner for Libvirt {
258 fn check_prerequisites(&self) -> DeployResult<()> {
259 eprintln!("Checking prerequisites...");
260
261 let key_path = PathBuf::from(&self.vm_ssh_key);
263 if !key_path.exists() {
264 return Err(DeployError::FileNotFound(format!(
265 "VM SSH key not found: {}",
266 self.vm_ssh_key
267 )));
268 }
269 let pub_path = PathBuf::from(format!("{}.pub", self.vm_ssh_key));
270 if !pub_path.exists() {
271 return Err(DeployError::FileNotFound(format!(
272 "VM SSH public key not found: {}.pub",
273 self.vm_ssh_key
274 )));
275 }
276
277 let ssh = self.hypervisor_ssh();
279 ssh.exec("echo ok").map_err(|_| {
280 DeployError::PrerequisiteMissing(format!(
281 "cannot SSH to hypervisor {}@{}",
282 self.hypervisor_user, self.hypervisor_host
283 ))
284 })?;
285
286 for tool in &["virsh", "virt-install", "qemu-img"] {
287 ssh.exec(&format!("command -v {tool}")).map_err(|_| {
288 DeployError::PrerequisiteMissing(format!("'{tool}' not found on hypervisor"))
289 })?;
290 }
291
292 let has_iso_tool = ssh
294 .exec(
295 "command -v genisoimage \
296 || command -v mkisofs",
297 )
298 .is_ok();
299 if !has_iso_tool {
300 return Err(DeployError::PrerequisiteMissing(
301 "neither genisoimage nor mkisofs found on \
302 hypervisor (apt install genisoimage)"
303 .into(),
304 ));
305 }
306
307 eprintln!("Prerequisites OK");
308 Ok(())
309 }
310
311 fn detect_ssh_keys(&self) -> DeployResult<Vec<(String, String)>> {
312 Ok(vec![(String::new(), self.vm_ssh_key.clone())])
313 }
314
315 fn create_server(
316 &self,
317 name: &str,
318 _region: &str,
319 _ssh_key_ids: &[String],
320 ) -> DeployResult<ServerInfo> {
321 let ssh = self.hypervisor_ssh();
322 let disk_path = format!("{}/{name}.qcow2", self.storage_dir);
323
324 eprintln!("Creating VM '{name}'...");
325
326 let cached = format!("{}/cloud-base.img", self.storage_dir);
328 let has_cache = ssh
329 .exec(&format!("test -f {cached} && echo yes"))
330 .unwrap_or_default();
331 if has_cache.trim() != "yes" {
332 eprintln!("Downloading cloud image...");
333 ssh.exec(&format!("wget -q -O {cached} '{}'", self.image_url))?;
334 }
335
336 ssh.exec(&format!("cp {cached} {disk_path}"))?;
338 ssh.exec(&format!("qemu-img resize {disk_path} {}G", self.disk_gib))?;
339
340 let seed_iso = self.create_seed_iso(&ssh, name)?;
342
343 let net_arg = self.network_args();
345 let install_cmd = format!(
346 "virt-install \
347 --name {name} \
348 --vcpus {} \
349 --memory {} \
350 --disk path={disk_path},format=qcow2 \
351 --disk path={seed_iso},device=cdrom \
352 --os-variant {} \
353 --network {net_arg} \
354 --graphics none \
355 --noautoconsole \
356 --import",
357 self.vcpus, self.memory_mib, self.os_variant
358 );
359 ssh.exec(&install_cmd)?;
360
361 let ip = Self::wait_for_ip(&ssh, name)?;
363 eprintln!("VM created! IP: {ip}");
364
365 Ok(ServerInfo {
366 name: name.to_string(),
367 ip,
368 region: "local".to_string(),
369 ssh_key_ids: Vec::new(),
370 ssh_key_files: vec![self.vm_ssh_key.clone()],
371 })
372 }
373
374 fn setup_server(&self, server: &ServerInfo, domain: Option<&str>) -> DeployResult<()> {
375 SshSession::clear_known_host(&server.ip);
377 let ssh = SshSession::new(&server.ip, "root").with_keys(&server.ssh_key_files);
378
379 ssh.wait_for_ready(30, std::time::Duration::from_secs(10))?;
380
381 let domain_str = domain.unwrap_or(&server.ip);
382 let remote_dir = "/opt/app";
383
384 Self::run_setup_script(&ssh, domain_str, remote_dir)?;
385
386 let host_alias = domain.unwrap_or(&server.name);
388 let first_key = server.ssh_key_files.first().map_or("", String::as_str);
389 super::setup_ssh_config(&server.ip, host_alias, first_key)?;
390
391 eprintln!();
392 eprintln!("========================================");
393 eprintln!("VM provisioned successfully!");
394 eprintln!("========================================");
395 eprintln!();
396 eprintln!("VM: {}", server.name);
397 eprintln!("IP: {}", server.ip);
398 if let Some(d) = domain {
399 eprintln!("Domain: {d}");
400 }
401 let deploy_host = domain.unwrap_or(&server.ip);
402 eprintln!("SSH: ssh {deploy_host}");
403 eprintln!();
404 eprintln!("Deploy with:");
405 eprintln!(" cargo xtask deploy {deploy_host}");
406 eprintln!();
407
408 Ok(())
409 }
410
411 fn get_server(&self, name: &str) -> DeployResult<Option<ServerInfo>> {
412 let ssh = self.hypervisor_ssh();
413
414 let Ok(state) = ssh.exec(&format!("virsh domstate {name} 2>/dev/null")) else {
416 return Ok(None);
417 };
418
419 if state.trim().is_empty() {
420 return Ok(None);
421 }
422
423 for _ in 0..3 {
425 if let Ok(output) = ssh.exec(&format!("virsh domifaddr {name} 2>/dev/null")) {
426 if let Some(ip) = parse_domifaddr(&output) {
427 return Ok(Some(ServerInfo {
428 name: name.to_string(),
429 ip,
430 region: "local".to_string(),
431 ssh_key_ids: Vec::new(),
432 ssh_key_files: vec![self.vm_ssh_key.clone()],
433 }));
434 }
435 }
436 if let Ok(output) = ssh.exec(&format!(
437 "virsh domifaddr {name} \
438 --source arp 2>/dev/null"
439 )) {
440 if let Some(ip) = parse_domifaddr(&output) {
441 return Ok(Some(ServerInfo {
442 name: name.to_string(),
443 ip,
444 region: "local".to_string(),
445 ssh_key_ids: Vec::new(),
446 ssh_key_files: vec![self.vm_ssh_key.clone()],
447 }));
448 }
449 }
450 std::thread::sleep(std::time::Duration::from_secs(2));
451 }
452
453 Ok(Some(ServerInfo {
455 name: name.to_string(),
456 ip: String::new(),
457 region: "local".to_string(),
458 ssh_key_ids: Vec::new(),
459 ssh_key_files: vec![self.vm_ssh_key.clone()],
460 }))
461 }
462
463 fn destroy_server(&self, name: &str) -> DeployResult<()> {
464 let ssh = self.hypervisor_ssh();
465
466 eprintln!("Destroying VM '{name}'...");
467
468 let _ = ssh.exec(&format!("virsh destroy {name} 2>/dev/null"));
470
471 ssh.exec(&format!(
473 "virsh undefine {name} \
474 --remove-all-storage 2>/dev/null || true"
475 ))?;
476
477 let seed_iso = format!("{}/{name}-seed.iso", self.storage_dir);
479 let _ = ssh.exec(&format!("rm -f {seed_iso}"));
480
481 eprintln!("VM '{name}' destroyed");
482
483 super::remove_ssh_config_entry(name)?;
485
486 Ok(())
487 }
488}
489
490#[must_use]
511pub fn parse_domifaddr(output: &str) -> Option<String> {
512 for line in output.lines() {
513 let trimmed = line.trim();
514 if trimmed.is_empty() || trimmed.starts_with("Name") || trimmed.starts_with('-') {
516 continue;
517 }
518
519 let parts: Vec<&str> = trimmed.split_whitespace().collect();
520 if parts.len() >= 4 && parts[2] == "ipv4" {
522 let addr = parts[3];
523 let ip = addr.split('/').next().unwrap_or(addr);
525 if !ip.is_empty() {
526 return Some(ip.to_string());
527 }
528 }
529 }
530 None
531}