ambient-ci 0.14.0

A continuous integration engine
Documentation
//! Construct a [cloud-init](https://cloudinit.readthedocs.io/en/latest/) data source
//! of the "NoCloud" kind.

#![allow(dead_code)]

use std::{
    fs::write,
    path::{Path, PathBuf},
    process::Command,
};

use serde::Serialize;
use tempfile::tempdir;

/// Local data source for "NoCloud".
#[derive(Debug, Clone)]
pub struct LocalDataStore {
    hostname: String,
    network: bool,
    bootcmd: Vec<String>,
    runcmd: Vec<String>,
}

impl LocalDataStore {
    fn meta_data(&self) -> Result<String, CloudInitError> {
        #[derive(Debug, Serialize)]
        struct Metadata<'a> {
            hostname: &'a str,
        }

        serde_norway::to_string(&Metadata {
            hostname: &self.hostname,
        })
        .map_err(CloudInitError::ToYaml)
    }

    fn user_data(&self) -> Result<String, CloudInitError> {
        #[derive(Debug, Serialize)]
        struct Userdata {
            #[serde(skip_serializing_if = "Vec::is_empty")]
            bootcmd: Vec<String>,

            #[serde(skip_serializing_if = "Vec::is_empty")]
            runcmd: Vec<String>,
        }

        let userdata = Userdata {
            bootcmd: self.bootcmd.clone(),
            runcmd: self.runcmd.clone(),
        };
        let userdata = serde_norway::to_string(&userdata).map_err(CloudInitError::ToYaml)?;

        Ok(format!("#cloud-config\n{userdata}"))
    }

    fn no_network_config(&self) -> Result<String, CloudInitError> {
        #[derive(Debug, Serialize)]
        struct NetworkConfig {
            network: NoEthernets,
        }
        #[derive(Debug, Serialize)]
        struct NoEthernets {
            version: usize,
            ethernets: Vec<String>,
        }
        let network_config = NetworkConfig {
            network: NoEthernets {
                version: 2,
                ethernets: vec![],
            },
        };
        serde_norway::to_string(&network_config).map_err(CloudInitError::ToYaml)
    }

    /// Construct an ISO image from the data source.
    pub fn iso(&self, filename: &Path) -> Result<(), CloudInitError> {
        fn write_helper(filename: &Path, s: &str) -> Result<(), CloudInitError> {
            write(filename, s.as_bytes())
                .map_err(|err| CloudInitError::Write(filename.into(), err))?;
            Ok(())
        }

        let tmp = tempdir().map_err(CloudInitError::TempDir)?;
        write_helper(&tmp.path().join("meta-data"), &self.meta_data()?)?;
        write_helper(&tmp.path().join("user-data"), &self.user_data()?)?;

        if !self.network {
            write_helper(
                &tmp.path().join("network-config"),
                r#"---
network:
  version: 2
  ethernets: {}
"#,
            )?;
        }

        // if self.network {
        //     write_helper(
        //         &tmp.path().join("network-config"),
        //         &self.no_network_config()?,
        //     )?;
        // }

        let r = Command::new("xorrisofs")
            .arg("-quiet")
            .arg("-volid")
            .arg("CIDATA")
            .arg("-joliet")
            .arg("-rock")
            .arg("-output")
            .arg(filename)
            .arg(tmp.path())
            .output()
            .map_err(|err| CloudInitError::Command("xorrisofs".into(), err))?;

        if !r.status.success() {
            let stderr = String::from_utf8_lossy(&r.stderr).to_string();
            return Err(CloudInitError::IsoFailed(stderr));
        }

        Ok(())
    }
}

/// Builder for a [`LocalDataStore`].
#[derive(Debug, Default)]
pub struct LocalDataStoreBuilder {
    hostname: Option<String>,
    network: bool,
    bootcmd: Vec<String>,
    runcmd: Vec<String>,
}

impl LocalDataStoreBuilder {
    /// Build the local data store.
    pub fn build(self) -> Result<LocalDataStore, CloudInitError> {
        if self.runcmd.is_empty() {
            return Err(CloudInitError::NeedRunCmd);
        }

        Ok(LocalDataStore {
            hostname: self.hostname.ok_or(CloudInitError::Missing("hostname"))?,
            network: self.network,
            bootcmd: self.bootcmd.clone(),
            runcmd: self.runcmd.clone(),
        })
    }

    /// Set host name via `cloud-init`.
    pub fn with_hostname(mut self, hostname: &str) -> Self {
        assert!(self.hostname.is_none());
        self.hostname = Some(hostname.into());
        self
    }

    /// Enable or disable network via `cloud-init`.
    pub fn with_network(mut self, network: bool) -> Self {
        self.network = network;
        self
    }

    /// Run command when machine boots.
    pub fn with_bootcmd(mut self, cmd: &str) -> Self {
        self.bootcmd.push(cmd.into());
        self
    }

    /// Run command when machine has booted.
    pub fn with_runcmd(mut self, cmd: &str) -> Self {
        self.runcmd.push(cmd.into());
        self
    }
}

/// Possible errors from contructing a `cloud-init` data source.
#[derive(Debug, thiserror::Error)]
pub enum CloudInitError {
    /// Programming error.
    #[error("programming error: field LocalDataStoreBuilder::{0} has not been set")]
    Missing(&'static str),

    /// Programming error.
    #[error("programming error: must add at least one command to run to LocalDataStore")]
    NeedRunCmd,

    /// Can't convert data source to YAML.
    #[error("failed to serialize data store data to YAML")]
    ToYaml(#[from] serde_norway::Error),

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

    /// Can't write file.
    #[error("failed to write data to {0}")]
    Write(PathBuf, #[source] std::io::Error),

    /// Can't run command.
    #[error("failed to execute command {0}")]
    Command(String, #[source] std::io::Error),

    /// Can't create ISO.
    #[error("failed to create ISO image with xorrisofs: {0}")]
    IsoFailed(String),
}

#[cfg(test)]
mod test {
    use super::*;

    fn setup(runcmd: &[&str]) -> Result<LocalDataStore, Box<dyn std::error::Error>> {
        let mut ds = LocalDataStoreBuilder::default().with_hostname("foo");
        for cmd in runcmd.iter() {
            ds = ds.with_runcmd(cmd);
        }

        Ok(ds.build()?)
    }

    #[test]
    fn metadata() -> Result<(), Box<dyn std::error::Error>> {
        let ds = setup(&["echo hello, world"])?;
        assert_eq!(ds.meta_data()?, "hostname: foo\n");
        Ok(())
    }

    #[test]
    fn userdata_fails_without_runcmd() -> Result<(), Box<dyn std::error::Error>> {
        let r = setup(&[]);
        assert!(r.is_err());
        Ok(())
    }

    #[test]
    fn userdata() -> Result<(), Box<dyn std::error::Error>> {
        let ds = setup(&["echo xyzzy"])?;
        assert_eq!(
            ds.user_data()?,
            r#"#cloud-config
runcmd:
- echo xyzzy
"#
        );
        Ok(())
    }

    #[test]
    fn iso() -> Result<(), Box<dyn std::error::Error>> {
        let ds = setup(&["echo plugh"])?;
        let tmp = tempdir()?;
        let filename = tmp.path().join("cloud-init.iso");
        ds.iso(&filename)?;
        assert!(filename.exists());
        Ok(())
    }
}