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 }