/ src / project.rs
project.rs
  1  //! Project specification.
  2  
  3  use std::{
  4      collections::HashMap,
  5      fs::{read, write},
  6      io::Write,
  7      path::{Path, PathBuf},
  8  };
  9  
 10  use clingwrap::tildepathbuf::TildePathBuf;
 11  use serde::{Deserialize, Serialize};
 12  
 13  use 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)]
 21  pub struct Projects {
 22      projects: HashMap<String, Project>,
 23  }
 24  
 25  impl 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)]
 65  pub 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  
 94  impl 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)]
180  pub 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  
193  impl 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)]
392  pub 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  }