Skip to main content

ambient_ci/
project.rs

1//! Project specification.
2
3use std::{
4    collections::HashMap,
5    fs::{read, write},
6    io::Write,
7    path::{Path, PathBuf},
8};
9
10use clingwrap::tildepathbuf::TildePathBuf;
11use serde::{Deserialize, Serialize};
12
13use crate::{
14    action::{PostPlanAction, PrePlanAction, UnsafeAction},
15    util::mkdir,
16};
17
18/// A list of projects.
19#[derive(Debug, Deserialize, Clone)]
20#[serde(deny_unknown_fields)]
21pub struct Projects {
22    projects: HashMap<String, Project>,
23}
24
25impl Projects {
26    /// Load from a file.
27    pub fn from_file(filename: &Path) -> Result<Self, ProjectError> {
28        let dirname = if let Some(parent) = filename.parent() {
29            parent.to_path_buf()
30        } else {
31            return Err(ProjectError::Parent(filename.into()));
32        };
33
34        let yaml = read(filename).map_err(|e| ProjectError::Read(filename.into(), e))?;
35        let mut projects: Self =
36            serde_norway::from_slice(&yaml).map_err(|e| ProjectError::Yaml(filename.into(), e))?;
37
38        for (name, p) in projects.projects.iter_mut() {
39            p.expand_tilde(&dirname)?;
40            if !p.expanded_source.is_dir() {
41                return Err(ProjectError::NotADirectory(
42                    name.into(),
43                    p.expanded_source.clone(),
44                ));
45            }
46        }
47
48        Ok(projects)
49    }
50
51    /// Look up project by name.
52    pub fn get(&self, name: &str) -> Option<&Project> {
53        self.projects.get(name)
54    }
55
56    /// Iterator over projects.
57    pub fn iter(&self) -> impl Iterator<Item = (&str, &Project)> {
58        self.projects.iter().map(|(k, v)| (k.as_str(), v))
59    }
60}
61
62/// Specification of one CI project.
63#[derive(Debug, Deserialize, Clone)]
64#[serde(deny_unknown_fields)]
65pub struct Project {
66    /// Source directory.
67    pub source: TildePathBuf,
68
69    #[serde(skip)]
70    expanded_source: PathBuf,
71
72    /// Virtual machine image to use.
73    pub image: TildePathBuf,
74
75    #[serde(skip)]
76    expanded_image: PathBuf,
77
78    /// Pre-plan actions.
79    pub pre_plan: Option<Vec<PrePlanAction>>,
80
81    /// Plan actions.
82    pub plan: Option<Vec<UnsafeAction>>,
83
84    /// Post-plan actions.
85    pub post_plan: Option<Vec<PostPlanAction>>,
86
87    /// Maximum size of artifacts directory for this project, in bytes.
88    pub artifact_max_size: Option<u64>,
89
90    /// Maximum size of cache directory for this project, in bytes.
91    pub cache_max_size: Option<u64>,
92}
93
94impl Project {
95    fn expand_tilde(&mut self, basedir: &Path) -> Result<(), ProjectError> {
96        self.expanded_source = Self::abspath(basedir.join(self.source.path()))?;
97        self.expanded_image = Self::abspath(basedir.join(self.image.path()))?;
98        Ok(())
99    }
100
101    /// Load from file.
102    pub fn from_file(filename: &Path) -> Result<Self, ProjectError> {
103        let dirname = if let Some(parent) = filename.parent() {
104            parent.to_path_buf()
105        } else {
106            return Err(ProjectError::Parent(filename.into()));
107        };
108
109        let yaml = read(filename).map_err(|e| ProjectError::Read(filename.into(), e))?;
110        let mut project: Project =
111            serde_norway::from_slice(&yaml).map_err(|e| ProjectError::Yaml(filename.into(), e))?;
112
113        project.expand_tilde(&dirname)?;
114        if !project.expanded_source.is_dir() {
115            return Err(ProjectError::NotADirectory(
116                filename.to_string_lossy().to_string(),
117                project.expanded_source,
118            ));
119        }
120
121        Ok(project)
122    }
123
124    fn abspath(path: PathBuf) -> Result<PathBuf, ProjectError> {
125        path.canonicalize()
126            .map_err(|e| ProjectError::Canonicalize(path, e))
127    }
128
129    /// Source directory.
130    pub fn source(&self) -> &Path {
131        &self.expanded_source
132    }
133
134    /// Virtual machine image file.
135    pub fn image(&self) -> &Path {
136        &self.expanded_image
137    }
138
139    /// Maximum size of artifacts directory, in bytes.
140    pub fn artifact_max_size(&self) -> Option<u64> {
141        self.artifact_max_size
142    }
143
144    /// Maximum size of cache directory, in bytes.
145    pub fn cache_max_size(&self) -> Option<u64> {
146        self.cache_max_size
147    }
148
149    /// List of pre-plan actions.
150    pub fn pre_plan(&self) -> &[PrePlanAction] {
151        if let Some(plan) = &self.pre_plan {
152            plan.as_slice()
153        } else {
154            &[]
155        }
156    }
157
158    /// List of plan actions.
159    pub fn plan(&self) -> &[UnsafeAction] {
160        if let Some(plan) = &self.plan {
161            plan.as_slice()
162        } else {
163            &[]
164        }
165    }
166
167    /// List of post-plan actions.
168    pub fn post_plan(&self) -> &[PostPlanAction] {
169        if let Some(plan) = &self.post_plan {
170            plan.as_slice()
171        } else {
172            &[]
173        }
174    }
175}
176
177/// Persistent project state.
178#[derive(Debug, Clone, Deserialize, Serialize)]
179#[allow(dead_code)]
180pub struct State {
181    // File where this state is stored, if it's stored.
182    #[serde(skip)]
183    filename: PathBuf,
184
185    // Where persistent state is stored for this project.
186    #[serde(skip)]
187    statedir: PathBuf,
188
189    /// Latest commit that CI has run on, if any.
190    pub latest_commit: Option<String>,
191}
192
193impl State {
194    /// Load state for a project from a file, if it's present. If it's
195    /// not present, return an empty state.
196    pub fn from_file(statedir: &Path, project: &str) -> Result<Self, ProjectError> {
197        let statedir = statedir.join(project);
198        let filename = statedir.join("meta.yaml");
199        let state = if filename.exists() {
200            let yaml = read(&filename).map_err(|e| ProjectError::ReadState(filename.clone(), e))?;
201            let mut state: Self = serde_norway::from_slice(&yaml)
202                .map_err(|e| ProjectError::ParseState(filename.clone(), e))?;
203            state.filename = filename;
204            state.statedir = statedir;
205            state
206        } else {
207            Self {
208                filename,
209                statedir,
210                latest_commit: None,
211            }
212        };
213
214        mkdir(&state.artifactsdir())?;
215        mkdir(&state.cachedir())?;
216        mkdir(&state.dependenciesdir())?;
217
218        Ok(state)
219    }
220
221    /// Write project state.
222    pub fn write_to_file(&self) -> Result<(), ProjectError> {
223        let yaml = serde_norway::to_string(&self)
224            .map_err(|e| ProjectError::SerializeState(self.clone(), e))?;
225        if !self.statedir.exists() {
226            std::fs::create_dir(&self.statedir)
227                .map_err(|e| ProjectError::CreateState(self.statedir.clone(), e))?;
228        }
229        write(&self.filename, yaml)
230            .map_err(|e| ProjectError::WriteState(self.filename.clone(), e))?;
231        Ok(())
232    }
233
234    /// Return state directory.
235    pub fn statedir(&self) -> &Path {
236        &self.statedir
237    }
238
239    /// Return artifacts directory for project.
240    pub fn artifactsdir(&self) -> PathBuf {
241        self.statedir.join("artifacts")
242    }
243
244    /// Return cache directory for project.
245    pub fn cachedir(&self) -> PathBuf {
246        self.statedir.join("cache")
247    }
248
249    /// Return dependencies directory for a project.
250    pub fn dependenciesdir(&self) -> PathBuf {
251        self.statedir.join("dependencies")
252    }
253
254    /// Return latest commit that CI has run on.
255    pub fn latest_commit(&self) -> Option<&str> {
256        self.latest_commit.as_deref()
257    }
258
259    /// Set latest commit.
260    pub fn set_latest_commot(&mut self, commit: Option<&str>) {
261        self.latest_commit = commit.map(|s| s.into());
262    }
263
264    /// Path to console log.
265    pub fn console_log_filename(&self) -> PathBuf {
266        self.statedir.join("console.log")
267    }
268
269    /// Remove any existing console log.
270    pub fn remove_console_log(&self) -> Result<(), ProjectError> {
271        let filename = self.console_log_filename();
272        if filename.exists() {
273            std::fs::remove_file(&filename)
274                .map_err(|err| ProjectError::RemoveConsoleLog(filename, err))?;
275        }
276        Ok(())
277    }
278
279    /// Create empty console log file. Return its filename.
280    pub fn create_console_log(&self) -> Result<PathBuf, ProjectError> {
281        let filename = self.console_log_filename();
282        std::fs::OpenOptions::new()
283            .create(true)
284            .write(true)
285            .truncate(true)
286            .open(&filename)
287            .map_err(|err| ProjectError::CreateConsoleLog(filename.clone(), err))?;
288        Ok(filename)
289    }
290
291    /// Append data to console log. The file must already exist.
292    pub fn append_to_console_log(&self, data: &[u8]) -> Result<(), ProjectError> {
293        let filename = self.console_log_filename();
294        let mut file = std::fs::OpenOptions::new()
295            .append(true)
296            .open(&filename)
297            .map_err(|err| ProjectError::CreateConsoleLog(filename.clone(), err))?;
298
299        file.write_all(data)
300            .map_err(|err| ProjectError::AppendToConsoleLog(filename, err))?;
301
302        Ok(())
303    }
304
305    /// Return contents of console log.
306    pub fn read_console_log(&self) -> Result<Vec<u8>, ProjectError> {
307        let filename = self.run_log_filename();
308        let data =
309            std::fs::read(&filename).map_err(|err| ProjectError::ReadConsoleLog(filename, err))?;
310        Ok(data)
311    }
312
313    /// Path to run log.
314    pub fn run_log_filename(&self) -> PathBuf {
315        self.statedir.join("run.log")
316    }
317
318    /// Remove any existing run log.
319    pub fn remove_run_log(&self) -> Result<(), ProjectError> {
320        let filename = self.run_log_filename();
321        if filename.exists() {
322            std::fs::remove_file(&filename)
323                .map_err(|err| ProjectError::RemoveRunLog(filename, err))?;
324        }
325        Ok(())
326    }
327
328    /// Create empty run log file. Return its filename.
329    pub fn create_run_log(&self) -> Result<PathBuf, ProjectError> {
330        let filename = self.run_log_filename();
331        std::fs::OpenOptions::new()
332            .create(true)
333            .write(true)
334            .truncate(true)
335            .open(&filename)
336            .map_err(|err| ProjectError::CreateRunLog(filename.clone(), err))?;
337        Ok(filename)
338    }
339
340    /// Create empty raw log file. Return its filename.
341    pub fn create_raw_log(&self) -> Result<PathBuf, ProjectError> {
342        let filename = self.raw_log_filename();
343        std::fs::OpenOptions::new()
344            .create(true)
345            .write(true)
346            .truncate(true)
347            .open(&filename)
348            .map_err(|err| ProjectError::CreateRunLog(filename.clone(), err))?;
349        Ok(filename)
350    }
351
352    /// Remove any existing raw log.
353    pub fn remove_raw_log(&self) -> Result<(), ProjectError> {
354        let filename = self.raw_log_filename();
355        if filename.exists() {
356            std::fs::remove_file(&filename)
357                .map_err(|err| ProjectError::RemoveRawLog(filename, err))?;
358        }
359        Ok(())
360    }
361
362    /// Path to raw log. This is where the output from virtual machine goes.
363    pub fn raw_log_filename(&self) -> PathBuf {
364        self.statedir.join("raw.log")
365    }
366
367    /// Append data to run log. The file must already exist.
368    pub fn append_to_run_log(&self, data: &[u8]) -> Result<(), ProjectError> {
369        let filename = self.run_log_filename();
370        let mut file = std::fs::OpenOptions::new()
371            .append(true)
372            .open(&filename)
373            .map_err(|err| ProjectError::CreateRunLog(filename.clone(), err))?;
374
375        file.write_all(data)
376            .map_err(|err| ProjectError::AppendToRunLog(filename, err))?;
377
378        Ok(())
379    }
380
381    /// Return contents of run log.
382    pub fn read_run_log(&self) -> Result<Vec<u8>, ProjectError> {
383        let filename = self.run_log_filename();
384        let data =
385            std::fs::read(&filename).map_err(|err| ProjectError::ReadRunLog(filename, err))?;
386        Ok(data)
387    }
388}
389
390/// Errors from handling project specifications.
391#[derive(Debug, thiserror::Error)]
392pub enum ProjectError {
393    /// Can't find parent directory.
394    #[error("failed to determine directory containing project file {0}")]
395    Parent(PathBuf),
396
397    /// Can't make filename absolute.
398    #[error("failed to make filename absolute: {0}")]
399    Canonicalize(PathBuf, #[source] std::io::Error),
400
401    /// Can't read projects file.
402    #[error("failed top read project file {0}")]
403    Read(PathBuf, #[source] std::io::Error),
404
405    /// Can't parse projects file as YAML.
406    #[error("failed to parse project file as YAML: {0}")]
407    Yaml(PathBuf, #[source] serde_norway::Error),
408
409    /// Can't serialize project state as YAML.
410    #[error("failed to serialize project state as YAML: {0:#?}")]
411    SerializeState(State, #[source] serde_norway::Error),
412
413    /// Can't write project state to file.
414    #[error("failed to write project state to file {0}")]
415    WriteState(PathBuf, #[source] std::io::Error),
416
417    /// Can't read project state from file.
418    #[error("failed to read project state from file {0}")]
419    ReadState(PathBuf, #[source] std::io::Error),
420
421    /// Can't parse project state as YAML.
422    #[error("failed to parse project state file as YAML: {0}")]
423    ParseState(PathBuf, #[source] serde_norway::Error),
424
425    /// Can't create project state directory.
426    #[error("failed to create project state directory {0}")]
427    CreateState(PathBuf, #[source] std::io::Error),
428
429    /// Can't remove run log file.
430    #[error("failed to remove run log file {0}")]
431    RemoveRunLog(PathBuf, #[source] std::io::Error),
432
433    /// Can't remove raw log file.
434    #[error("failed to remove raw log file {0}")]
435    RemoveRawLog(PathBuf, #[source] std::io::Error),
436
437    /// Can't create run log file.
438    #[error("failed to create run log file {0}")]
439    CreateRunLog(PathBuf, #[source] std::io::Error),
440
441    /// Can't append to run log file.
442    #[error("failed to append to run log file {0}")]
443    AppendToRunLog(PathBuf, #[source] std::io::Error),
444
445    /// Can't read run log file.
446    #[error("failed to read run log file {0}")]
447    ReadRunLog(PathBuf, #[source] std::io::Error),
448
449    /// Can't remove console log file.
450    #[error("failed to remove console log file {0}")]
451    RemoveConsoleLog(PathBuf, #[source] std::io::Error),
452
453    /// Can't create consolelog file.
454    #[error("failed to create consolelog file {0}")]
455    CreateConsoleLog(PathBuf, #[source] std::io::Error),
456
457    /// Can't append to console log file.
458    #[error("failed to append to console log file {0}")]
459    AppendToConsoleLog(PathBuf, #[source] std::io::Error),
460
461    /// Can't read console log file.
462    #[error("failed to read console log file {0}")]
463    ReadConsoleLog(PathBuf, #[source] std::io::Error),
464
465    /// Can't create directory.
466    #[error(transparent)]
467    MKdir(#[from] crate::util::UtilError),
468
469    /// Source directory isn't a directory.
470    #[error("project {0} source directory is not a directory: {1}")]
471    NotADirectory(String, PathBuf),
472}