/ src / cloud_init.rs
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  
  6  use std::{
  7      fs::write,
  8      path::{Path, PathBuf},
  9      process::Command,
 10  };
 11  
 12  use serde::Serialize;
 13  use tempfile::tempdir;
 14  
 15  /// Local data source for "NoCloud".
 16  #[derive(Debug, Clone)]
 17  pub struct LocalDataStore {
 18      hostname: String,
 19      network: bool,
 20      bootcmd: Vec<String>,
 21      runcmd: Vec<String>,
 22  }
 23  
 24  impl 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#"---
 91  network:
 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)]
128  pub struct LocalDataStoreBuilder {
129      hostname: Option<String>,
130      network: bool,
131      bootcmd: Vec<String>,
132      runcmd: Vec<String>,
133  }
134  
135  impl 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)]
178  pub 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)]
209  mod 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
241  runcmd:
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  }