catapulta/provision/
libvirt.rs

1use std::path::PathBuf;
2
3use crate::error::{DeployError, DeployResult};
4use crate::provision::{Provisioner, ServerInfo};
5use crate::ssh::SshSession;
6
7/// Networking mode for the VM.
8#[derive(Debug, Clone)]
9pub enum NetworkMode {
10    /// Bridge the VM onto an existing host bridge (e.g. `br0`).
11    /// The VM gets an IP on the LAN, reachable from other hosts.
12    Bridged(String),
13    /// Use the default NAT network (`virbr0`).
14    /// The VM can reach the internet but is only reachable from
15    /// the hypervisor unless you add port forwards.
16    Nat,
17}
18
19/// Libvirt/KVM provisioner for local or remote hypervisors.
20///
21/// Manages virtual machines via `virsh` and `virt-install` over
22/// SSH. Cloud images are provisioned with cloud-init (`NoCloud`
23/// datasource).
24pub struct Libvirt {
25    /// SSH hostname or IP of the hypervisor.
26    pub hypervisor_host: String,
27    /// SSH user on the hypervisor (default: `root`).
28    pub hypervisor_user: String,
29    /// Optional SSH private key for the hypervisor connection.
30    pub hypervisor_key: Option<String>,
31    /// Number of vCPUs (default: 2).
32    pub vcpus: u32,
33    /// RAM in MiB (default: 2048).
34    pub memory_mib: u32,
35    /// Disk size in GiB (default: 20).
36    pub disk_gib: u32,
37    /// Cloud image URL to download on the hypervisor.
38    pub image_url: String,
39    /// Network mode (default: NAT).
40    pub network: NetworkMode,
41    /// Directory on the hypervisor for VM disk images.
42    pub storage_dir: String,
43    /// Local SSH private key whose `.pub` sibling is injected
44    /// via cloud-init. Used to SSH into the VM after creation.
45    pub vm_ssh_key: String,
46    /// `os-variant` passed to `virt-install`.
47    pub os_variant: String,
48}
49
50impl Libvirt {
51    /// Create a new Libvirt provisioner.
52    ///
53    /// # Arguments
54    ///
55    /// * `hypervisor_host` - SSH-reachable hostname of the KVM
56    ///   host
57    /// * `vm_ssh_key` - path to the local SSH private key; the
58    ///   matching `.pub` file is read and injected into the VM
59    ///   via cloud-init
60    #[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    // -- private helpers --
135
136    /// Open an SSH session to the hypervisor.
137    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    /// Read the public key content from `vm_ssh_key.pub`.
147    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    /// Create a `NoCloud` seed ISO on the hypervisor.
154    ///
155    /// Writes `user-data` and `meta-data` to a temp directory,
156    /// then generates the ISO with genisoimage or mkisofs.
157    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        // Try genisoimage first, fall back to mkisofs
181        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    /// Poll `virsh domifaddr` until we get an IP.
199    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            // Try the default agent/lease source first
210            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            // Bridged networks often need --source arp
218            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    /// Run the remote setup script on the VM (not the
239    /// hypervisor).
240    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    /// Network arguments for virt-install.
247    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        // Check local SSH key exists
262        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        // Check hypervisor is reachable and has required tools
278        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        // Check for genisoimage or mkisofs
293        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        // Download cloud image if not cached
327        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        // Create disk from base image and resize
337        ssh.exec(&format!("cp {cached} {disk_path}"))?;
338        ssh.exec(&format!("qemu-img resize {disk_path} {}G", self.disk_gib))?;
339
340        // Create cloud-init seed ISO
341        let seed_iso = self.create_seed_iso(&ssh, name)?;
342
343        // Run virt-install
344        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        // Wait for VM to get an IP
362        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        // SSH to the VM itself, not the hypervisor
376        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        // Setup SSH config (use first key for the config entry)
387        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        // Check if domain exists
415        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        // Quick IP lookup (3 attempts)
424        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        // Domain exists but no IP yet
454        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        // Force stop if running
469        let _ = ssh.exec(&format!("virsh destroy {name} 2>/dev/null"));
470
471        // Undefine and remove storage
472        ssh.exec(&format!(
473            "virsh undefine {name} \
474             --remove-all-storage 2>/dev/null || true"
475        ))?;
476
477        // Remove seed ISO if it exists
478        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        // Remove SSH config entry
484        super::remove_ssh_config_entry(name)?;
485
486        Ok(())
487    }
488}
489
490/// Parse an IP address from `virsh domifaddr` output.
491///
492/// Handles both the default (agent/lease) format and the
493/// `--source arp` format.
494///
495/// # Examples
496///
497/// Default output:
498/// ```text
499///  Name       MAC address          Protocol     Address
500/// -------------------------------------------------------
501///  vnet0      52:54:00:ab:cd:ef    ipv4         192.168.122.45/24
502/// ```
503///
504/// ARP output:
505/// ```text
506///  Name       MAC address          Protocol     Address
507/// -------------------------------------------------------
508///  vnet0      52:54:00:ab:cd:ef    ipv4         10.0.0.50
509/// ```
510#[must_use]
511pub fn parse_domifaddr(output: &str) -> Option<String> {
512    for line in output.lines() {
513        let trimmed = line.trim();
514        // Skip header and separator lines
515        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        // Expect: name, mac, protocol, address
521        if parts.len() >= 4 && parts[2] == "ipv4" {
522            let addr = parts[3];
523            // Strip CIDR suffix if present (e.g. /24)
524            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}