vmexec 0.7.1

Run a single command in a speedy virtual machine with zero-setup
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
use std::fmt::Write;
use std::{
    path::{Path, PathBuf},
    process::Stdio,
    sync::{
        Arc,
        atomic::{AtomicBool, Ordering},
    },
    time::Duration,
};

use base64ct::{Base64, Encoding};
use dir_lock::DirLock;
use eyre::{Context, OptionExt, Result, bail};
use serde::{Deserialize, Serialize};
use tokio::fs;
use tokio::io::{AsyncBufReadExt, AsyncRead};
use tokio::process::{Child, Command};
use tracing::{Instrument, debug, debug_span, error, info, instrument, trace};

use crate::types::PmemMount;
use crate::vm_images::VmImage;
use crate::vms::QEMU_PID_FILENAME;
use crate::{
    runner::CancellationTokens,
    types::{BindMount, PublishPort},
    utils::ExecutablePaths,
};

/// Get the full command that would be run
pub(crate) fn command_as_string(cmd: &Command) -> String {
    let program_str = cmd.as_std().get_program().to_string_lossy();
    let args_str = cmd
        .as_std()
        .get_args()
        .map(|x| x.to_string_lossy())
        .map(|x| {
            // Make sure that commands that contain spaces will be properly quoted.
            if x.contains(' ') {
                format!("\"{x}\"")
            } else {
                format!("{x}")
            }
        })
        .collect::<Vec<_>>()
        .join(" ");
    format!("{program_str} {args_str}")
}

/// Extract the kernel and initrd from a given image
///
/// It will extract it into the same dir of the `image_path`.
pub(crate) async fn extract_kernel(virt_copy_out_path: &Path, vm_image: &VmImage) -> Result<()> {
    let dest_dir = vm_image.image_path.parent().ok_or_eyre(format!(
        "Image {:?} doesn't have a parent",
        vm_image.image_path
    ))?;
    let mut virt_copy_out_cmd = Command::new(virt_copy_out_path);

    let files_to_extract = if let Some(initrd) = &vm_image.initrd_path {
        vec![
            format!("/boot/{}", initrd.file_name().unwrap().to_string_lossy()),
            format!(
                "/boot/{}",
                vm_image.kernel_path.file_name().unwrap().to_string_lossy()
            ),
        ]
    } else {
        vec![format!(
            "/boot/{}",
            vm_image.kernel_path.file_name().unwrap().to_string_lossy()
        )]
    };

    virt_copy_out_cmd
        .args(["-a", &vm_image.image_path.to_string_lossy()])
        .args(files_to_extract)
        .arg(dest_dir);

    let virt_copy_out_cmd_str = command_as_string(&virt_copy_out_cmd);
    info!("Extracting kernel from {:?}", vm_image.image_path);
    debug!("{virt_copy_out_cmd_str}");

    let virt_copy_out_output = virt_copy_out_cmd.output().await?;
    if !virt_copy_out_output.status.success() {
        bail!(
            "virt_copy_out failed: {}",
            String::from_utf8_lossy(&virt_copy_out_output.stderr)
        );
    }

    Ok(())
}

/// Convert OVMF UEFI variables raw image to qcow2
///
/// We need it to be qcow2 so that snapshotting will work. We don't particularly want to snaphot
/// the UEFI variables, however, snapshotting the VM only works if all its writeable disks support
/// it so here we are.
///
/// Also, if we don't provide a read-write OVMF_VARS file on boot, we'll get an `NvVars` file in
/// our writeable mounts which is what QEMU uses to emulate writeable UEFI vars.
#[instrument]
pub(crate) async fn convert_ovmf_uefi_variables(
    vm_dir: &Path,
    source_image: &Path,
) -> Result<PathBuf> {
    let output_file = vm_dir.join("OVMF_VARS.4m.fd.qcow2");

    let mut qemu_img_cmd = Command::new("qemu-img");
    qemu_img_cmd
        .arg("convert")
        .args(["-O", "qcow2"])
        .arg(source_image)
        .arg(&output_file);

    let qemu_img_cmd_str = command_as_string(&qemu_img_cmd);
    info!("Converting OVMF UEFI vars file to qcow2");
    debug!("{qemu_img_cmd_str}");
    let qemu_img_output = qemu_img_cmd.output().await?;
    if !qemu_img_output.status.success() {
        bail!(
            "qemu-img convert failed: {}",
            String::from_utf8_lossy(&qemu_img_output.stderr)
        );
    }

    Ok(output_file)
}

/// Create an overlay image based on a source image
#[instrument]
pub(crate) async fn create_overlay_image(source_image: &Path, overlay_image: &Path) -> Result<()> {
    let source_image_str = source_image.to_string_lossy();
    let backing_file = format!("backing_file={source_image_str},backing_fmt=qcow2,nocow=on");
    let mut qemu_img_cmd = Command::new("qemu-img");
    qemu_img_cmd
        .arg("create")
        .args(["-o", &backing_file])
        .args(["-f", "qcow2"])
        .arg(overlay_image);

    let qemu_img_cmd_str = command_as_string(&qemu_img_cmd);
    info!("Creating overlay image");
    debug!("{qemu_img_cmd_str}");

    let qemu_img_output = qemu_img_cmd.output().await?;
    if !qemu_img_output.status.success() {
        bail!(
            "qemu-img create failed: {}",
            String::from_utf8_lossy(&qemu_img_output.stderr)
        );
    }

    Ok(())
}

/// Spawn a background task that logs a child process's piped output stream
///
/// This logs a child process's stream with a prefix.
/// This prevents child processes from blocking on write() when their pipe
/// buffer fills up, which would otherwise deadlock them.
fn log_child_output(stream: impl AsyncRead + Unpin + Send + 'static) {
    let span = debug_span!(parent: None, "virtofsd process");
    tokio::spawn(
        async move {
            let reader = tokio::io::BufReader::new(stream);
            let mut lines = reader.lines();
            while let Ok(Some(line)) = lines.next_line().await {
                debug!("{line}");
            }
        }
        .instrument(span),
    );
}

/// Launch an instance of virtiofsd for a particular volume
#[instrument]
pub(crate) async fn launch_virtiofsd(
    virtiofsd_path: &Path,
    vm_dir: &Path,
    volume: &BindMount,
) -> Result<Child> {
    let socket_path = vm_dir.join(volume.socket_name());
    let mut virtiofsd_cmd = Command::new("unshare");
    virtiofsd_cmd
        // Map the current user to be root
        .arg("--map-root-user")
        // Map the rest of the ids according to the host's /etc/subuid and /etc/subgid
        .arg("--map-auto")
        .arg("--")
        .arg(virtiofsd_path)
        .args(["--shared-dir", &volume.source.to_string_lossy()])
        .args(["--socket-path", &socket_path.to_string_lossy()])
        // This seems to allow us to skip past the guest's page cache which is very desireable
        // since we want to ensure that the memory usage inside the guest stays minimal. If we
        // instead hit the host directly then the host can manage the cache for us.
        .args(["--cache", "never"])
        // Like the above, we want to skip the caches as much as possible. so allowing direct IO
        // seems prudent.
        .arg("--allow-direct-io")
        // It seems like a good idea to allow mmap to work so that users are not suprised by weird
        // kernel errors.
        .arg("--allow-mmap")
        // Create a thread pool with 8 threads. We have yet to test whether this does anything in
        // terms of performance.
        .args(["--thread-pool-size", "8"])
        .args(["--sandbox", "chroot"]);

    if volume.read_only {
        virtiofsd_cmd.arg("--readonly");
    }

    let virtiofsd_cmd_str = command_as_string(&virtiofsd_cmd);

    info!("Running virtiofsd for share '{volume}'");
    trace!("{virtiofsd_cmd_str}");

    let mut virtiofsd_child = virtiofsd_cmd
        .stdin(Stdio::piped())
        .stdout(Stdio::piped())
        .stderr(Stdio::piped())
        .kill_on_drop(true)
        .spawn()?;

    tokio::select! {
        // I tried very hard to find a reasonable way to check properly for connectivity but there
        // doesn't seem to be a good way as the server quits after the first connection, see also:
        // https://gitlab.com/virtio-fs/virtiofsd/-/issues/62
        // As such, we're going to use a timing based approach for the time being.
        _ = tokio::time::sleep(Duration::from_millis(250)) => {},
        _ = virtiofsd_child.wait() => {
            error!("virtiofsd process exited early, that's usually a bad sign");
            let virtiofsd_output = virtiofsd_child.wait_with_output().await?;
            bail!("virtiofsd failed: {}", String::from_utf8(virtiofsd_output.stderr)?);
        }
    }

    if let Some(stdout) = virtiofsd_child.stdout.take() {
        log_child_output(stdout);
    }
    if let Some(stderr) = virtiofsd_child.stderr.take() {
        log_child_output(stderr);
    }

    Ok(virtiofsd_child)
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct QemuLaunchOpts {
    pub volumes: Vec<BindMount>,
    pub pmems: Vec<PmemMount>,
    pub published_ports: Vec<PublishPort>,
    pub vm_image: VmImage,
    pub ovmf_uefi_vars_path: PathBuf,
    pub show_vm_window: bool,
    pub pubkey: String,
    pub cid: u32,
    pub is_warmup: bool,
    pub disable_kvm: bool,
    pub memory: u64,
}

/// Launch QEMU
#[instrument(skip(cancellation_tokens, lock, tool_paths, qemu_launch_opts))]
pub(crate) async fn launch_qemu(
    cancellation_tokens: CancellationTokens,
    qemu_should_exit: Arc<AtomicBool>,
    vm_dir: &Path,
    lock: Option<DirLock>,
    tool_paths: ExecutablePaths,
    qemu_launch_opts: QemuLaunchOpts,
) -> Result<()> {
    let overlay_image_str = qemu_launch_opts.vm_image.image_path.to_string_lossy();
    let kernel_path_str = qemu_launch_opts.vm_image.kernel_path.to_string_lossy();
    let ovmf_uefi_vars_str = qemu_launch_opts.ovmf_uefi_vars_path.to_string_lossy();

    let sysinfo_system = sysinfo::System::new_with_specifics(
        sysinfo::RefreshKind::nothing().with_cpu(sysinfo::CpuRefreshKind::everything()),
    );
    let memory = qemu_launch_opts.memory;
    let logical_core_count = sysinfo_system.cpus().len();

    let ssh_pubkey_base64 = Base64::encode_string(qemu_launch_opts.pubkey.as_bytes());

    let cid = qemu_launch_opts.cid;

    let hostfwd: String =
        qemu_launch_opts
            .published_ports
            .iter()
            .fold(String::new(), |mut output, p| {
                let _ = write!(
                    output,
                    ",hostfwd=:{}:{}-:{}",
                    p.host_ip, p.host_port, p.vm_port
                );
                output
            });

    let qmp_socket_path = vm_dir.join("qmp.sock,server,wait=off");
    let qmp_socket_path_str = qmp_socket_path.to_string_lossy();

    // In case we're using virtio pmem devices, we'll have to accumulate their memory and add it on
    // top of the regular memory as `maxmem`.
    let total_pmem_size = qemu_launch_opts.pmems.iter().fold(0, |acc, x| acc + x.size);
    let qemu_maxmem = format!(",maxmem={}G", memory + total_pmem_size);

    let mut qemu_cmd = Command::new(tool_paths.qemu_path.clone());
    qemu_cmd
        // Decrease idle CPU usage
        .args(["-machine", "hpet=off"])

        .args(["-smp", &logical_core_count.to_string()])

        // We extracted the kernel and initrd from this image earlier in order to boot it more
        // quickly.
        .args(["-kernel", &kernel_path_str])
        .args(["-append", "rw root=/dev/vda3"])

        // SSH port forwarding
        .args(["-device", &format!("vhost-vsock-pci,id=vhost-vsock-pci0,guest-cid={cid}")])

        // Network controller
        .args(["-nic", &format!("user,model=virtio{hostfwd}")])

        // Free Page Reporting allows the guest to signal to the host that memory can be reclaimed.
        .args(["-device", "virtio-balloon,free-page-reporting=on"])

        // Memory configuration
        .args(["-m", &format!("{memory}G{qemu_maxmem}")])
        .args(["-object", &format!("memory-backend-memfd,id=mem0,merge=on,share=on,size={memory}G")])
        .args(["-numa", "node,memdev=mem0"])

        // UEFI
        .args([
            "-drive",
            "if=pflash,format=raw,unit=0,file=/usr/share/edk2/x64/OVMF_CODE.4m.fd,readonly=on",
        ])
        .args([
            "-drive",
            &format!("if=pflash,unit=1,file={ovmf_uefi_vars_str}"),
        ])

        // Overlay image
        .args(["-drive", &format!("if=virtio,node-name=overlay-disk,file={overlay_image_str}")])

        // QMP API to expose QEMU command API
        .args(["-qmp", &format!("unix:{qmp_socket_path_str}")])

        // Here we inject the SSH using systemd.system-credentials, see:
        // https://www.freedesktop.org/software/systemd/man/latest/systemd.system-credentials.html
        .args([
            "-smbios",
            &format!(
                "type=11,value=io.systemd.credential.binary:ssh.authorized_keys.root={ssh_pubkey_base64}"
            ),
        ]);

    if !qemu_launch_opts.disable_kvm {
        qemu_cmd.args(["-accel", "kvm"]).args(["-cpu", "host"]);
    }

    if let Some(ref initrd_path) = qemu_launch_opts.vm_image.initrd_path {
        let initrd_path_str = initrd_path.to_string_lossy();
        qemu_cmd.args(["-initrd", &initrd_path_str]);
    }

    // It's important we keep `virtiofsd_handles` in scope here and that we don't drop them too
    // early as otherwise the process would exit and the VM would be unhappy.
    let mut virtiofsd_handles = vec![];

    if !qemu_launch_opts.is_warmup {
        qemu_cmd.arg("-snapshot");

        // We need fstab entries for virtiofsd mounts and pmem devices.
        let mut fstab_entries = vec![];

        // Add virtiofsd-based directory shares
        for (i, vol) in qemu_launch_opts.volumes.iter().enumerate() {
            let virtiofsd_child = launch_virtiofsd(&tool_paths.virtiofsd_path, vm_dir, vol)
                .await
                .wrap_err(format!("Failed to launch virtiofsd for {vol}"))?;
            virtiofsd_handles.push(virtiofsd_child);

            let socket_path = vm_dir.join(vol.socket_name());
            let socket_path_str = socket_path.to_string_lossy();
            let tag = vol.tag();
            let dest_path = vol.dest.to_string_lossy();
            let read_only = if vol.read_only {
                String::from(",ro")
            } else {
                String::new()
            };
            let fstab_entry = format!("{tag} {dest_path} virtiofs defaults{read_only} 0 0");
            fstab_entries.push(fstab_entry);
            qemu_cmd
                .args([
                    "-chardev",
                    &format!("socket,id=char{i},path={socket_path_str}"),
                ])
                .args([
                    "-device",
                    &format!("vhost-user-fs-pci,chardev=char{i},tag={tag}"),
                ]);
        }

        // Add virtio-pmem devices
        for (i, pmem) in qemu_launch_opts.pmems.iter().enumerate() {
            // Systemd will conveniently auto-format the device on mount, neat!
            let fstab_entry = format!(
                "/dev/pmem{i} {} ext4 rw,relatime,dax=always,x-systemd.makefs 0 0",
                pmem.dest.to_string_lossy()
            );
            fstab_entries.push(fstab_entry);

            let pmem_file = vm_dir.join(format!("pmem{i}.pmem"));
            qemu_cmd.args([
                "-object",
                &format!("memory-backend-file,id=pmem{i},share=on,merge=on,discard-data=on,mem-path={},size={}G", pmem_file.to_string_lossy(), pmem.size)]);
            qemu_cmd.args([
                "-device",
                &format!("virtio-pmem-pci,memdev=pmem{i},id=nv{i}"),
            ]);
        }

        if !fstab_entries.is_empty() {
            let fstab = fstab_entries.join("\n");
            let fstab_base64 = Base64::encode_string(fstab.as_bytes());
            qemu_cmd.args([
                "-smbios",
                &format!("type=11,value=io.systemd.credential.binary:fstab.extra={fstab_base64}"),
            ]);
        }
    }

    if !qemu_launch_opts.show_vm_window {
        qemu_cmd.arg("-nographic");
    }

    let qemu_cmd_str = command_as_string(&qemu_cmd);
    info!("Running QEMU");
    trace!("{qemu_cmd_str}");

    let qemu_child = qemu_cmd
        .stdin(Stdio::piped())
        .stdout(Stdio::piped())
        .stderr(Stdio::piped())
        .kill_on_drop(true)
        .spawn()?;

    // Write QEMU's pid into a `qemu-pid` file in the VM dir. This allows a cleanup job to run and
    // some point and remove all the VM dirs that have a dead QEMU (which can happen if vmexec is
    // cancelled at the wrong time).
    let qemu_pid_path = vm_dir.join(QEMU_PID_FILENAME);
    let qemu_pid = qemu_child
        .id()
        .ok_or_eyre("QEMU has no pid, maybe it exited early?")?
        .to_string();
    fs::write(&qemu_pid_path, &qemu_pid).await?;
    trace!("Writing QEMU pid {qemu_pid} to {qemu_pid_path:?}");

    // We drop the lock at this point because now we have a live qemu.pid that'll make sure that
    // this vm dir won't be reaped.
    // `lock` is `None` in case this is a warmup run.
    if let Some(lock) = lock {
        trace!("Unlocking {:?}", lock.path());
        lock.drop_async().await.expect("Couldn't drop lock");
    }

    let qemu_output = tokio::select! {
        _ = cancellation_tokens.qemu.cancelled() => {
            debug!("QEMU task was cancelled");
            return Ok(());
        }
        val = qemu_child.wait_with_output() => {
            if qemu_should_exit.load(Ordering::SeqCst) {
                info!("QEMU has finished running");
                return Ok(());
            }
            error!("QEMU process exited early, that's usually a bad sign");
            val?
        }
    };

    if !qemu_output.status.success() {
        error!("QEMU failed: {}", String::from_utf8(qemu_output.stderr)?);
        cancellation_tokens.ssh.cancel();
    }

    Ok(())
}