Skip to main content

ambient_ci/
cloud_init.rs

1//! Construct a [cloud-init](https://cloudinit.readthedocs.io/en/latest/) data source
2//! of the "NoCloud" kind.
3
4#![allow(dead_code)]
5
6use std::{
7    fs::write,
8    path::{Path, PathBuf},
9    process::Command,
10};
11
12use serde::Serialize;
13use tempfile::tempdir;
14
15/// Local data source for "NoCloud".
16#[derive(Debug, Clone)]
17pub struct LocalDataStore {
18    hostname: String,
19    network: bool,
20    bootcmd: Vec<String>,
21    runcmd: Vec<String>,
22}
23
24impl LocalDataStore {
25    fn meta_data(&self) -> Result<String, CloudInitError> {
26        #[derive(Debug, Serialize)]
27        struct Metadata<'a> {
28            hostname: &'a str,
29        }
30
31        serde_norway::to_string(&Metadata {
32            hostname: &self.hostname,
33        })
34        .map_err(CloudInitError::ToYaml)
35    }
36
37    fn user_data(&self) -> Result<String, CloudInitError> {
38        #[derive(Debug, Serialize)]
39        struct Userdata {
40            #[serde(skip_serializing_if = "Vec::is_empty")]
41            bootcmd: Vec<String>,
42
43            #[serde(skip_serializing_if = "Vec::is_empty")]
44            runcmd: Vec<String>,
45        }
46
47        let userdata = Userdata {
48            bootcmd: self.bootcmd.clone(),
49            runcmd: self.runcmd.clone(),
50        };
51        let userdata = serde_norway::to_string(&userdata).map_err(CloudInitError::ToYaml)?;
52
53        Ok(format!("#cloud-config\n{userdata}"))
54    }
55
56    fn no_network_config(&self) -> Result<String, CloudInitError> {
57        #[derive(Debug, Serialize)]
58        struct NetworkConfig {
59            network: NoEthernets,
60        }
61        #[derive(Debug, Serialize)]
62        struct NoEthernets {
63            version: usize,
64            ethernets: Vec<String>,
65        }
66        let network_config = NetworkConfig {
67            network: NoEthernets {
68                version: 2,
69                ethernets: vec![],
70            },
71        };
72        serde_norway::to_string(&network_config).map_err(CloudInitError::ToYaml)
73    }
74
75    /// Construct an ISO image from the data source.
76    pub fn iso(&self, filename: &Path) -> Result<(), CloudInitError> {
77        fn write_helper(filename: &Path, s: &str) -> Result<(), CloudInitError> {
78            write(filename, s.as_bytes())
79                .map_err(|err| CloudInitError::Write(filename.into(), err))?;
80            Ok(())
81        }
82
83        let tmp = tempdir().map_err(CloudInitError::TempDir)?;
84        write_helper(&tmp.path().join("meta-data"), &self.meta_data()?)?;
85        write_helper(&tmp.path().join("user-data"), &self.user_data()?)?;
86
87        if !self.network {
88            write_helper(
89                &tmp.path().join("network-config"),
90                r#"---
91network:
92  version: 2
93  ethernets: {}
94"#,
95            )?;
96        }
97
98        // if self.network {
99        //     write_helper(
100        //         &tmp.path().join("network-config"),
101        //         &self.no_network_config()?,
102        //     )?;
103        // }
104
105        let r = Command::new("xorrisofs")
106            .arg("-quiet")
107            .arg("-volid")
108            .arg("CIDATA")
109            .arg("-joliet")
110            .arg("-rock")
111            .arg("-output")
112            .arg(filename)
113            .arg(tmp.path())
114            .output()
115            .map_err(|err| CloudInitError::Command("xorrisofs".into(), err))?;
116
117        if !r.status.success() {
118            let stderr = String::from_utf8_lossy(&r.stderr).to_string();
119            return Err(CloudInitError::IsoFailed(stderr));
120        }
121
122        Ok(())
123    }
124}
125
126/// Builder for a [`LocalDataStore`].
127#[derive(Debug, Default)]
128pub struct LocalDataStoreBuilder {
129    hostname: Option<String>,
130    network: bool,
131    bootcmd: Vec<String>,
132    runcmd: Vec<String>,
133}
134
135impl LocalDataStoreBuilder {
136    /// Build the local data store.
137    pub fn build(self) -> Result<LocalDataStore, CloudInitError> {
138        if self.runcmd.is_empty() {
139            return Err(CloudInitError::NeedRunCmd);
140        }
141
142        Ok(LocalDataStore {
143            hostname: self.hostname.ok_or(CloudInitError::Missing("hostname"))?,
144            network: self.network,
145            bootcmd: self.bootcmd.clone(),
146            runcmd: self.runcmd.clone(),
147        })
148    }
149
150    /// Set host name via `cloud-init`.
151    pub fn with_hostname(mut self, hostname: &str) -> Self {
152        assert!(self.hostname.is_none());
153        self.hostname = Some(hostname.into());
154        self
155    }
156
157    /// Enable or disable network via `cloud-init`.
158    pub fn with_network(mut self, network: bool) -> Self {
159        self.network = network;
160        self
161    }
162
163    /// Run command when machine boots.
164    pub fn with_bootcmd(mut self, cmd: &str) -> Self {
165        self.bootcmd.push(cmd.into());
166        self
167    }
168
169    /// Run command when machine has booted.
170    pub fn with_runcmd(mut self, cmd: &str) -> Self {
171        self.runcmd.push(cmd.into());
172        self
173    }
174}
175
176/// Possible errors from contructing a `cloud-init` data source.
177#[derive(Debug, thiserror::Error)]
178pub enum CloudInitError {
179    /// Programming error.
180    #[error("programming error: field LocalDataStoreBuilder::{0} has not been set")]
181    Missing(&'static str),
182
183    /// Programming error.
184    #[error("programming error: must add at least one command to run to LocalDataStore")]
185    NeedRunCmd,
186
187    /// Can't convert data source to YAML.
188    #[error("failed to serialize data store data to YAML")]
189    ToYaml(#[from] serde_norway::Error),
190
191    /// Can't create temporary directory.
192    #[error("failed to create a temporary directory")]
193    TempDir(#[source] std::io::Error),
194
195    /// Can't write file.
196    #[error("failed to write data to {0}")]
197    Write(PathBuf, #[source] std::io::Error),
198
199    /// Can't run command.
200    #[error("failed to execute command {0}")]
201    Command(String, #[source] std::io::Error),
202
203    /// Can't create ISO.
204    #[error("failed to create ISO image with xorrisofs: {0}")]
205    IsoFailed(String),
206}
207
208#[cfg(test)]
209mod test {
210    use super::*;
211
212    fn setup(runcmd: &[&str]) -> Result<LocalDataStore, Box<dyn std::error::Error>> {
213        let mut ds = LocalDataStoreBuilder::default().with_hostname("foo");
214        for cmd in runcmd.iter() {
215            ds = ds.with_runcmd(cmd);
216        }
217
218        Ok(ds.build()?)
219    }
220
221    #[test]
222    fn metadata() -> Result<(), Box<dyn std::error::Error>> {
223        let ds = setup(&["echo hello, world"])?;
224        assert_eq!(ds.meta_data()?, "hostname: foo\n");
225        Ok(())
226    }
227
228    #[test]
229    fn userdata_fails_without_runcmd() -> Result<(), Box<dyn std::error::Error>> {
230        let r = setup(&[]);
231        assert!(r.is_err());
232        Ok(())
233    }
234
235    #[test]
236    fn userdata() -> Result<(), Box<dyn std::error::Error>> {
237        let ds = setup(&["echo xyzzy"])?;
238        assert_eq!(
239            ds.user_data()?,
240            r#"#cloud-config
241runcmd:
242- echo xyzzy
243"#
244        );
245        Ok(())
246    }
247
248    #[test]
249    fn iso() -> Result<(), Box<dyn std::error::Error>> {
250        let ds = setup(&["echo plugh"])?;
251        let tmp = tempdir()?;
252        let filename = tmp.path().join("cloud-init.iso");
253        ds.iso(&filename)?;
254        assert!(filename.exists());
255        Ok(())
256    }
257}