ambient-ci 0.14.0

A continuous integration engine
Documentation
use std::path::PathBuf;

use clap::Parser;
use tempfile::tempdir_in;

use ambient_ci::{
    action::RunnableAction,
    action_impl::Shell,
    cloud_init::CloudInitError,
    git::GitError,
    plan::{PlanError, RunnablePlan},
    qemu::{QemuError, QemuRunner},
    qemu_utils::convert_image,
    run::{create_cloud_init_iso, create_executor_vdrive},
    runlog::{RunLog, RunLogSource},
    util::{mkdir, UtilError},
    vdrive::{create_tar, create_tar_with_size, VirtualDriveError},
};

use super::{AmbientError, Config, Leaf};

/// Run QEMU with a specific runnable plan. Empty source, cache,
/// dependencies, and artifacts.
#[derive(Debug, Parser)]
pub struct QemuCmd {
    /// File with runnable plan.
    #[clap(long, required_unless_present = "shell", conflicts_with = "shell")]
    plan: Option<PathBuf>,

    /// Execute shell snippet in VM.
    #[clap(long, required_unless_present = "plan", conflicts_with = "plan")]
    shell: Vec<String>,

    /// Use this virtual machine image as the base image. The base
    /// image will not be modified, even if the virtual machine changes
    /// things on its disk. The changes are written to a copy-on-write
    /// temporary image (but see `--persist`).
    #[clap(long)]
    image: PathBuf,

    /// Save the image after the VM shuts to this file. This allows
    /// capturing changes made inside the virtual machine.
    #[clap(long)]
    persist: Option<PathBuf>,

    /// Allow network?
    #[clap(long)]
    network: bool,

    /// Write console log to this file.
    #[clap(long)]
    console: Option<PathBuf>,

    /// Write run log to this file.
    #[clap(long)]
    run_log: Option<PathBuf>,

    /// Write artifacts to this file.
    #[clap(long)]
    artifacts: Option<PathBuf>,

    /// Use UEFI.
    #[clap(long)]
    uefi: bool,
}

impl QemuCmd {
    fn helper(&self, config: &Config) -> Result<(), QemuCmdError> {
        let mut runnable_plan = if let Some(plan) = &self.plan {
            RunnablePlan::from_file(plan)?
        } else {
            let mut runnable_plan = RunnablePlan::default();
            for shell in self.shell.iter() {
                let shell = Shell::new(shell);
                runnable_plan.push(RunnableAction::Shell(shell));
            }
            runnable_plan
        };
        runnable_plan.set_unset_dirs(".");

        let tmp = tempdir_in(config.tmpdir()).map_err(QemuCmdError::TempDir)?;

        let console_log = self
            .console
            .clone()
            .unwrap_or_else(|| tmp.path().join("console.log"));
        let run_log = self
            .run_log
            .clone()
            .unwrap_or_else(|| tmp.path().join("run.log"));

        let empty = tmp.path().join("src");
        mkdir(&empty)?;

        let executor = config.executor().ok_or(QemuCmdError::NoExecutor)?;
        let executor_drive = create_executor_vdrive(&tmp, &runnable_plan, executor)?;
        let source_drive = create_tar(tmp.path().join("src.tar"), &empty)?;
        let artifacts_drive = create_tar_with_size(
            self.artifacts
                .clone()
                .unwrap_or_else(|| tmp.path().join("artifacts.tar")),
            &empty,
            1024 * 1024 * 1024,
        )?;
        let ds = create_cloud_init_iso(self.network)?;

        let backing_image = self
            .image
            .canonicalize()
            .map_err(|err| QemuCmdError::Canonicalize(self.image.clone(), err))?;
        let cow_image = tmp.path().join("vm.qcow2");

        let qemu = QemuRunner::default()
            .config(config)
            .base_image(&backing_image)
            .cow_image(&cow_image)
            .executor(&executor_drive)
            .cloud_init(&ds)
            .source(&source_drive)
            .artifacts(&artifacts_drive)
            .cloud_init(&ds)
            .console_log(&console_log)
            .raw_log(&run_log)
            .network(self.network)
            .uefi(self.uefi || config.uefi());

        let mut runlog = RunLog::default();
        qemu.run(RunLogSource::Plan, &mut runlog)?;

        if let Some(persist) = &self.persist {
            convert_image(&cow_image, persist).map_err(|err| {
                QemuCmdError::ConvertImage(cow_image.clone(), persist.to_path_buf(), err)
            })?;
        }

        Ok(())
    }
}

impl Leaf for QemuCmd {
    fn run(&self, config: &Config, _runlog: &mut RunLog) -> Result<(), AmbientError> {
        Ok(self.helper(config)?)
    }
}

#[derive(Debug, thiserror::Error)]
pub enum QemuCmdError {
    #[error(transparent)]
    Qemu(#[from] QemuError),

    #[error("failed to create a temporary directory")]
    TempDir(#[source] std::io::Error),

    #[error(transparent)]
    Util(#[from] UtilError),

    #[error(transparent)]
    VDrive(#[from] VirtualDriveError),

    #[error(transparent)]
    CloudInit(#[from] CloudInitError),

    #[error(transparent)]
    CreateDrive(#[from] ambient_ci::run::RunError),

    #[error(transparent)]
    Plan(#[from] PlanError),

    #[error(transparent)]
    Git(#[from] GitError),

    #[error("no executor specified in configuration")]
    NoExecutor,

    #[error("failed to convert image {0} to {1}")]
    ConvertImage(
        PathBuf,
        PathBuf,
        #[source] ambient_ci::qemu_utils::QemuUtilError,
    ),

    #[error("failed to make path absolute: {0}")]
    Canonicalize(PathBuf, #[source] std::io::Error),
}