1#![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#[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 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 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#[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 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 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 pub fn with_network(mut self, network: bool) -> Self {
159 self.network = network;
160 self
161 }
162
163 pub fn with_bootcmd(mut self, cmd: &str) -> Self {
165 self.bootcmd.push(cmd.into());
166 self
167 }
168
169 pub fn with_runcmd(mut self, cmd: &str) -> Self {
171 self.runcmd.push(cmd.into());
172 self
173 }
174}
175
176#[derive(Debug, thiserror::Error)]
178pub enum CloudInitError {
179 #[error("programming error: field LocalDataStoreBuilder::{0} has not been set")]
181 Missing(&'static str),
182
183 #[error("programming error: must add at least one command to run to LocalDataStore")]
185 NeedRunCmd,
186
187 #[error("failed to serialize data store data to YAML")]
189 ToYaml(#[from] serde_norway::Error),
190
191 #[error("failed to create a temporary directory")]
193 TempDir(#[source] std::io::Error),
194
195 #[error("failed to write data to {0}")]
197 Write(PathBuf, #[source] std::io::Error),
198
199 #[error("failed to execute command {0}")]
201 Command(String, #[source] std::io::Error),
202
203 #[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}