Skip to main content

ambient_ci/
config.rs

1//! Configuration file for Ambient CI application.
2
3use std::{
4    collections::HashMap,
5    path::{Path, PathBuf},
6};
7
8use byte_unit::Byte;
9use clingwrap::{
10    config::{ConfigFile, ConfigValidator},
11    tildepathbuf::TildePathBuf,
12};
13use directories::ProjectDirs;
14use serde::{Deserialize, Serialize};
15
16use crate::{
17    linter::{Linter, LinterError},
18    project::Projects,
19};
20
21const QUAL: &str = "liw.fi";
22const ORG: &str = "Ambient CI";
23const APP: &str = env!("CARGO_PKG_NAME");
24
25const DEFAULT_CPUS: usize = 1;
26const DEFAULT_MEMORY: Byte = Byte::GIBIBYTE;
27
28/// The run time configuration for `ambient`, loaded from files and
29/// built in defaults and validated to be as correct as it can be at
30/// the time of creation.
31#[derive(Debug, Serialize, Deserialize, Default, Clone)]
32#[serde(deny_unknown_fields)]
33pub struct Config {
34    tmpdir: PathBuf,
35    image_store: PathBuf,
36    projects: PathBuf,
37    state: PathBuf,
38    rsync_target: Option<String>,
39    rsync_target_base: Option<String>,
40    rsync_target_map: Option<HashMap<String, String>>,
41    dput_target: Option<String>,
42    executor: Option<PathBuf>,
43    artifacts_max_size: Byte,
44    cache_max_size: Byte,
45    qemu: QemuConfig,
46    uefi: bool,
47    lint: bool,
48}
49
50impl Config {
51    /// Directory where temporary files are to be created.
52    pub fn tmpdir(&self) -> &Path {
53        &self.tmpdir
54    }
55
56    /// Location of image store.
57    pub fn image_store(&self) -> &Path {
58        &self.image_store
59    }
60
61    /// Projects file.
62    pub fn projects(&self) -> &Path {
63        &self.projects
64    }
65
66    /// Location of pre-project state directories
67    pub fn state(&self) -> &Path {
68        &self.state
69    }
70
71    /// Set `rsync_target`.
72    pub fn set_rsync_target(&mut self, rsync_target: &str) {
73        self.rsync_target = Some(rsync_target.into());
74    }
75
76    /// Target for the `rsync` action.
77    pub fn rsync_target(&self) -> Option<&str> {
78        self.rsync_target.as_deref()
79    }
80
81    /// Base target for `rsync` action, to be combined with per-project
82    /// directory from `rsync_target_map`.
83    pub fn rsync_target_base(&self) -> Option<&str> {
84        self.rsync_target_base.as_deref()
85    }
86
87    /// Per-project directories to be combined with `rsync_target_base`
88    /// for the `rsync` action.
89    pub fn rsync_target_map(&self) -> Option<&HashMap<String, String>> {
90        self.rsync_target_map.as_ref()
91    }
92
93    /// Get `rsync` target for a named project.
94    pub fn rsync_target_for_project(&self, name: &str) -> Option<String> {
95        fn join(base: &str, x: &str) -> Option<String> {
96            Some(format!("{base}/{x}"))
97        }
98
99        match (
100            &self.rsync_target,
101            &self.rsync_target_base,
102            &self.rsync_target_map,
103        ) {
104            (Some(target), _, _) => Some(target.to_string()),
105            (None, None, _) => None,
106            (None, Some(base), None) => join(base, name),
107            (None, Some(base), Some(map)) => {
108                if let Some(x) = map.get(name) {
109                    join(base, x)
110                } else {
111                    join(base, name)
112                }
113            }
114        }
115    }
116
117    /// Set `dput_target`.
118    pub fn set_dput_target(&mut self, dput_target: &str) {
119        self.dput_target = Some(dput_target.into());
120    }
121
122    /// Target for the `dput` action.
123    pub fn dput_target(&self) -> Option<&str> {
124        self.dput_target.as_deref()
125    }
126
127    /// Set `executor`.
128    pub fn set_executor(&mut self, executor: &Path) {
129        self.executor = Some(executor.into());
130    }
131
132    /// Program to use to execute runnable plan inside VM.
133    pub fn executor(&self) -> Option<&Path> {
134        self.executor.as_deref()
135    }
136
137    /// Should VM be booted with UEFI support?
138    pub fn uefi(&self) -> bool {
139        self.uefi
140    }
141
142    /// Lint projects, if requested.
143    pub fn lint(&self, projects: &Projects) -> Result<(), LinterError> {
144        if self.lint {
145            Linter::new(self, projects).lint()
146        } else {
147            Ok(())
148        }
149    }
150
151    /// Number of CPUs to emulate in VM.
152    pub fn cpus(&self) -> usize {
153        self.qemu.cpus
154    }
155
156    /// Amount of RAM to allocation for VM.
157    pub fn memory(&self) -> Byte {
158        self.qemu.memory
159    }
160
161    /// QEMU/KVM binary for executing VM.
162    pub fn kvm_binary(&self) -> PathBuf {
163        self.qemu.kvm_binary.clone()
164    }
165
166    /// UEFI OVMF variables file to use.
167    pub fn ovmf_vars_file(&self) -> PathBuf {
168        self.qemu.ovmf_vars_file.clone()
169    }
170
171    /// UEFI OVMF code file to use.
172    pub fn ovmf_code_file(&self) -> PathBuf {
173        self.qemu.ovmf_code_file.clone()
174    }
175
176    /// Maximum size of per-project artifacts directory.
177    pub fn artifacts_max_size(&self) -> u64 {
178        self.artifacts_max_size.as_u64()
179    }
180
181    /// Maximum size of per-project cache directory.
182    pub fn cache_max_size(&self) -> u64 {
183        self.cache_max_size.as_u64()
184    }
185}
186
187/// The `Config::qemu` field.
188#[derive(Debug, Default, Clone, Serialize, Deserialize)]
189#[serde(deny_unknown_fields)]
190pub struct QemuConfig {
191    cpus: usize,
192    memory: Byte,
193    kvm_binary: PathBuf,
194    ovmf_vars_file: PathBuf,
195    ovmf_code_file: PathBuf,
196}
197
198/// This is a representation of an individual configuration file.
199///
200/// You probably want [`Config`], which is the result of merging some
201/// number of individual files. it is also validated.
202#[derive(Debug, Clone, Deserialize)]
203#[serde(deny_unknown_fields)]
204pub struct StoredConfig {
205    /// Temporary directory to use. Default is either the value of the
206    /// `TMPDIR` environment variable, if set, or `/tmp`.
207    pub tmpdir: Option<TildePathBuf>,
208
209    /// Location of the image store.
210    pub image_store: Option<TildePathBuf>,
211
212    /// The projects file to use.
213    pub projects: Option<TildePathBuf>,
214
215    /// The project state directory.
216    pub state: Option<TildePathBuf>,
217
218    /// Where to publish with the `rsync` action. This will be given
219    /// to `rsync` as the "destination" argument.
220    #[serde(alias = "target")]
221    pub rsync_target: Option<String>,
222
223    /// Like `rsync_target`, but will be combined with the per-project
224    /// value from `rsync_target_map`.
225    pub rsync_target_base: Option<String>,
226
227    /// A per-project directory names to be combined with `rsync_target_base`.
228    pub rsync_target_map: Option<HashMap<String, String>>,
229
230    /// The `dput` target for uploading a Debian package in the `dput` action.
231    pub dput_target: Option<String>,
232
233    /// The program to upload to the VM to execute a runnable plan. Defaults to
234    /// `ambient-execute-plan`.
235    pub executor: Option<TildePathBuf>,
236
237    /// Maximum size of the build artifacts directory for any one project.
238    pub artifacts_max_size: Option<Byte>,
239
240    /// Maximum size of the cache directory for any one project.
241    pub cache_max_size: Option<Byte>,
242
243    /// Should VM be booted with UEFI support?
244    pub uefi: Option<bool>,
245
246    /// Run linter on projects? Defaults to true.
247    pub lint: Option<bool>,
248
249    /// Virtual machine QEMU configuration.
250    #[serde(default)]
251    pub qemu: StoredQemuConfig,
252
253    /// Obsolete: use `qemu.cpus` instead.
254    pub cpus: Option<usize>,
255
256    /// Obsolete: use `qemu.memory` instead.
257    pub memory: Option<Byte>,
258}
259
260impl<'a> ConfigFile<'a> for StoredConfig {
261    type Error = ConfigError;
262
263    fn merge(&mut self, other: Self) -> Result<(), Self::Error> {
264        fn tildepathbuf(us: &mut Option<TildePathBuf>, them: &Option<TildePathBuf>) {
265            if let Some(x) = them {
266                *us = Some(x.clone());
267            }
268        }
269
270        fn string(us: &mut Option<String>, them: &Option<String>) {
271            if let Some(x) = them {
272                *us = Some(x.into());
273            }
274        }
275
276        fn byte(us: &mut Option<Byte>, them: &Option<Byte>) {
277            if let Some(x) = them {
278                *us = Some(*x);
279            }
280        }
281
282        fn bool(us: &mut Option<bool>, them: &Option<bool>) {
283            if let Some(x) = them {
284                *us = Some(*x);
285            }
286        }
287
288        fn yousize(us: &mut Option<usize>, them: &Option<usize>) {
289            if let Some(x) = them {
290                *us = Some(*x);
291            }
292        }
293
294        if other.cpus.is_some() {
295            eprintln!("deprecated: the `cpus` field is replaced by `qemu.cpus`");
296        }
297        if other.memory.is_some() {
298            eprintln!("deprecated: the `memory` field is replaced by `qemu.memory`");
299        }
300        tildepathbuf(&mut self.tmpdir, &other.tmpdir);
301        tildepathbuf(&mut self.image_store, &other.image_store);
302        tildepathbuf(&mut self.projects, &other.projects);
303        tildepathbuf(&mut self.state, &other.state);
304        tildepathbuf(&mut self.executor, &other.executor);
305
306        string(&mut self.rsync_target, &other.rsync_target);
307        string(&mut self.rsync_target_base, &other.rsync_target_base);
308        string(&mut self.dput_target, &other.dput_target);
309
310        if let Some(map) = &other.rsync_target_map {
311            self.rsync_target_map = Some(map.clone());
312        }
313
314        byte(&mut self.artifacts_max_size, &other.artifacts_max_size);
315        byte(&mut self.cache_max_size, &other.cache_max_size);
316
317        yousize(&mut self.qemu.cpus, &other.cpus);
318        yousize(&mut self.qemu.cpus, &other.qemu.cpus);
319
320        byte(&mut self.qemu.memory, &other.memory);
321        byte(&mut self.qemu.memory, &other.qemu.memory);
322
323        byte(&mut self.qemu.memory, &other.qemu.memory);
324        tildepathbuf(&mut self.qemu.kvm_binary, &other.qemu.kvm_binary);
325        tildepathbuf(&mut self.qemu.ovmf_code_file, &other.qemu.ovmf_code_file);
326        tildepathbuf(&mut self.qemu.ovmf_vars_file, &other.qemu.ovmf_vars_file);
327
328        bool(&mut self.uefi, &other.uefi);
329        bool(&mut self.lint, &other.lint);
330
331        Ok(())
332    }
333}
334
335impl Default for StoredConfig {
336    fn default() -> Self {
337        let dirs = ProjectDirs::from(QUAL, ORG, APP).expect("have home directory");
338        #[allow(clippy::unwrap_used)]
339        let state = dirs.state_dir().unwrap();
340
341        let tmp = std::env::var("TMPDIR")
342            .map(PathBuf::from)
343            .unwrap_or(PathBuf::from("/tmp"));
344
345        Self {
346            tmpdir: Some(TildePathBuf::new(tmp)),
347            image_store: Some(TildePathBuf::new(state.join("images"))),
348            projects: Some(dirs.config_dir().join("projects.yaml").into()),
349            state: Some(TildePathBuf::new(state.join("projects"))),
350            rsync_target: None,
351            rsync_target_base: None,
352            rsync_target_map: None,
353            dput_target: None,
354            executor: None,
355            qemu: Default::default(),
356            artifacts_max_size: Byte::MEBIBYTE.multiply(10),
357            cache_max_size: Byte::GIBIBYTE.multiply(10),
358            cpus: None,
359            memory: None,
360            uefi: None,
361            lint: None,
362        }
363    }
364}
365
366impl ConfigValidator for StoredConfig {
367    type File = StoredConfig;
368    type Valid = Config;
369    type Error = ConfigError;
370
371    fn validate(&self, merged: &Self::File) -> Result<Self::Valid, Self::Error> {
372        fn mkabs(name: &'static str, path: &Option<TildePathBuf>) -> Result<PathBuf, ConfigError> {
373            if let Some(path) = path {
374                let path = path.path();
375                let path = std::path::absolute(path)
376                    .map_err(|err| ConfigError::Absolute(path.to_path_buf(), err))?;
377                Ok(path)
378            } else {
379                Err(ConfigError::Missing(name))
380            }
381        }
382
383        if merged.cpus.is_some() {
384            eprintln!("deprecated: the `cpus` field is replaced by `qemu.cpus`");
385        }
386        if merged.memory.is_some() {
387            eprintln!("deprecated: the `memory` field is replaced by `qemu.memory`");
388        }
389
390        let qemu = QemuConfig {
391            cpus: if let Some(cpus) = merged.qemu.cpus {
392                cpus
393            } else if let Some(cpus) = merged.cpus {
394                cpus
395            } else {
396                DEFAULT_CPUS
397            },
398            memory: if let Some(memory) = merged.qemu.memory {
399                memory
400            } else if let Some(memory) = merged.memory {
401                memory
402            } else {
403                DEFAULT_MEMORY
404            },
405            kvm_binary: mkabs("kvm_binary", &merged.qemu.kvm_binary)?,
406            ovmf_vars_file: mkabs("ovmf_vars_file", &merged.qemu.ovmf_vars_file)?,
407            ovmf_code_file: mkabs("ovmf_code_file", &merged.qemu.ovmf_code_file)?,
408        };
409
410        Ok(Config {
411            tmpdir: mkabs("tmpdir", &merged.tmpdir)?,
412            image_store: mkabs("image_store", &merged.image_store)?,
413            projects: mkabs("projects", &merged.projects)?,
414            state: mkabs("state", &merged.state)?,
415            rsync_target: merged.rsync_target.clone(),
416            rsync_target_base: merged.rsync_target_base.clone(),
417            rsync_target_map: merged.rsync_target_map.clone(),
418            dput_target: merged.dput_target.clone(),
419            executor: merged.executor.as_ref().map(|path| path.path().into()),
420            uefi: merged.uefi.unwrap_or_default(),
421            lint: merged.lint.unwrap_or(true),
422            artifacts_max_size: merged
423                .artifacts_max_size
424                .ok_or(ConfigError::Missing("artifacts_max_size"))?,
425            cache_max_size: merged
426                .cache_max_size
427                .ok_or(ConfigError::Missing("cache_max_size"))?,
428            qemu,
429        })
430    }
431}
432
433/// Per-VM configuration.
434#[derive(Debug, Clone, Deserialize)]
435#[serde(deny_unknown_fields, default)]
436pub struct StoredQemuConfig {
437    /// The QEMU/KVM binary to use.
438    pub kvm_binary: Option<TildePathBuf>,
439
440    /// The UEFI OVMF variables file to use. Default is `/usr/share/ovmf/OVMF.fd`.
441    pub ovmf_vars_file: Option<TildePathBuf>,
442
443    /// The UEFI OVMF code file to use. Default is `/usr/share/ovmf/OVMF.fd`.
444    pub ovmf_code_file: Option<TildePathBuf>,
445
446    /// Number of CPUs in the VM.
447    pub cpus: Option<usize>,
448
449    /// Amount of RAM to allocate for the VM.
450    pub memory: Option<Byte>,
451}
452
453impl Default for StoredQemuConfig {
454    fn default() -> Self {
455        Self {
456            cpus: None,
457            memory: None,
458            kvm_binary: Some(TildePathBuf::new("/usr/bin/kvm".into())),
459            ovmf_vars_file: Some(TildePathBuf::new("/usr/share/ovmf/OVMF.fd".into())),
460            ovmf_code_file: Some(TildePathBuf::new("/usr/share/ovmf/OVMF.fd".into())),
461        }
462    }
463}
464
465/// Errors from configuration file handling.
466#[derive(Debug, thiserror::Error)]
467pub enum ConfigError {
468    /// Can't find home directory.
469    #[error("failed to find home directory, while looking for configuration file")]
470    ProjectDirs,
471
472    /// Can't read configuration file.
473    #[error("failed to read configuration file {0}")]
474    Read(PathBuf, #[source] std::io::Error),
475
476    /// Can't parse configuration file as YAML.
477    #[error("failed to parse configuration file as YAML: {0}")]
478    Yaml(PathBuf, #[source] serde_norway::Error),
479
480    /// Programming error.
481    #[error("programming error: stored config field {0} is missing")]
482    Missing(&'static str),
483
484    /// Can't load configuration files.
485    #[error("failed to load configuration from files")]
486    Load(#[source] clingwrap::config::ConfigError),
487
488    /// Can't convert filename to absolute.
489    #[error("failed to make filename absolute: {0}")]
490    Absolute(PathBuf, #[source] std::io::Error),
491}
492
493#[cfg(test)]
494#[allow(clippy::unwrap_used)]
495mod test {
496    use super::*;
497
498    #[test]
499    fn does_not_merge_unset() {
500        let stored = StoredConfig::default();
501        let mut config = StoredConfig::default();
502
503        assert!(stored.rsync_target.is_none());
504        assert!(config.rsync_target.is_none());
505
506        config.merge(stored).unwrap();
507        assert!(config.rsync_target.is_none());
508    }
509
510    #[test]
511    fn merges_set_value() {
512        let stored = StoredConfig {
513            tmpdir: Some(TildePathBuf::new(PathBuf::from("/yo"))),
514            image_store: Some(TildePathBuf::new(PathBuf::from("/images"))),
515            projects: Some(TildePathBuf::new(PathBuf::from("/projects.yaml"))),
516            state: Some(TildePathBuf::new(PathBuf::from("/state"))),
517            rsync_target: Some("xyzzy".into()),
518            rsync_target_base: Some("plugh".into()),
519            rsync_target_map: Some(HashMap::from([("yo".into(), "yo.liw.fi".into())])),
520            dput_target: Some("colossal-cave".into()),
521            executor: Some(TildePathBuf::new(PathBuf::from("/run-ci"))),
522            artifacts_max_size: Some(Byte::MEBIBYTE),
523            cache_max_size: Some(Byte::GIBIBYTE),
524            qemu: StoredQemuConfig {
525                cpus: Some(42),
526                memory: Some(Byte::TEBIBYTE),
527                kvm_binary: Some(TildePathBuf::from(PathBuf::from("/run-ci"))),
528                ovmf_code_file: Some(TildePathBuf::from(PathBuf::from("/ovmf-code"))),
529                ovmf_vars_file: Some(TildePathBuf::from(PathBuf::from("/ovmf-vars"))),
530            },
531            uefi: Some(true),
532            lint: Some(true),
533            cpus: Some(4),
534            memory: Some(Byte::PEBIBYTE),
535        };
536        let mut config = StoredConfig::default();
537
538        assert!(config.rsync_target.is_none());
539
540        config.merge(stored.clone()).unwrap();
541        assert_eq!(config.tmpdir.unwrap().path(), stored.tmpdir.unwrap().path());
542        assert_eq!(
543            config.image_store.unwrap().path(),
544            stored.image_store.unwrap().path()
545        );
546        assert_eq!(
547            config.projects.unwrap().path(),
548            stored.projects.unwrap().path()
549        );
550        assert_eq!(config.state.unwrap().path(), stored.state.unwrap().path());
551        assert_eq!(config.rsync_target, stored.rsync_target);
552        assert_eq!(config.rsync_target_base, stored.rsync_target_base);
553        assert_eq!(config.rsync_target_map, stored.rsync_target_map);
554        assert_eq!(config.dput_target, stored.dput_target);
555        assert_eq!(
556            config.executor.unwrap().path(),
557            stored.executor.unwrap().path(),
558        );
559        assert_eq!(config.uefi, Some(true));
560        assert_eq!(config.lint, Some(true));
561        assert_eq!(config.artifacts_max_size, stored.artifacts_max_size);
562        assert_eq!(config.cache_max_size, stored.cache_max_size);
563        assert_eq!(config.qemu.cpus, stored.qemu.cpus);
564        assert_eq!(config.qemu.memory, stored.qemu.memory);
565        assert_eq!(
566            config.qemu.kvm_binary.unwrap().path(),
567            stored.qemu.kvm_binary.unwrap().path()
568        );
569        assert_eq!(
570            config.qemu.ovmf_code_file.unwrap().path(),
571            stored.qemu.ovmf_code_file.unwrap().path()
572        );
573        assert_eq!(
574            config.qemu.ovmf_vars_file.unwrap().path(),
575            stored.qemu.ovmf_vars_file.unwrap().path()
576        );
577    }
578
579    #[test]
580    fn merges_legacy_value_into_qemu() {
581        let stored = StoredConfig {
582            qemu: StoredQemuConfig {
583                cpus: None,
584                memory: None,
585                ..Default::default()
586            },
587            cpus: Some(4),
588            memory: Some(Byte::PEBIBYTE),
589            ..Default::default()
590        };
591        let mut config = StoredConfig::default();
592
593        config.merge(stored.clone()).unwrap();
594        assert_eq!(config.qemu.cpus, stored.cpus);
595        assert_eq!(config.qemu.memory, stored.memory);
596    }
597
598    #[test]
599    fn rsync_target_for_project_with_rsync_target_set() {
600        let config = Config {
601            rsync_target: Some("root@server:/".to_string()),
602            rsync_target_base: Some("root@server:/srv/http".to_string()),
603            rsync_target_map: Some(HashMap::from([("foo".to_string(), "foo".to_string())])),
604            ..Default::default()
605        };
606        assert_eq!(
607            config.rsync_target_for_project("bar"),
608            Some("root@server:/".into())
609        );
610        assert_eq!(
611            config.rsync_target_for_project("foo"),
612            Some("root@server:/".into())
613        );
614    }
615
616    #[test]
617    fn rsync_target_for_project_with_base_and_map_only() {
618        let config = Config {
619            rsync_target_base: Some("root@server:/srv/http".to_string()),
620            rsync_target_map: Some(HashMap::from([(
621                "foo".to_string(),
622                "foo-website".to_string(),
623            )])),
624            ..Default::default()
625        };
626        assert_eq!(
627            config.rsync_target_for_project("bar"),
628            Some("root@server:/srv/http/bar".into())
629        );
630        assert_eq!(
631            config.rsync_target_for_project("foo"),
632            Some("root@server:/srv/http/foo-website".into())
633        );
634    }
635}