1use 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#[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 pub fn tmpdir(&self) -> &Path {
53 &self.tmpdir
54 }
55
56 pub fn image_store(&self) -> &Path {
58 &self.image_store
59 }
60
61 pub fn projects(&self) -> &Path {
63 &self.projects
64 }
65
66 pub fn state(&self) -> &Path {
68 &self.state
69 }
70
71 pub fn set_rsync_target(&mut self, rsync_target: &str) {
73 self.rsync_target = Some(rsync_target.into());
74 }
75
76 pub fn rsync_target(&self) -> Option<&str> {
78 self.rsync_target.as_deref()
79 }
80
81 pub fn rsync_target_base(&self) -> Option<&str> {
84 self.rsync_target_base.as_deref()
85 }
86
87 pub fn rsync_target_map(&self) -> Option<&HashMap<String, String>> {
90 self.rsync_target_map.as_ref()
91 }
92
93 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 pub fn set_dput_target(&mut self, dput_target: &str) {
119 self.dput_target = Some(dput_target.into());
120 }
121
122 pub fn dput_target(&self) -> Option<&str> {
124 self.dput_target.as_deref()
125 }
126
127 pub fn set_executor(&mut self, executor: &Path) {
129 self.executor = Some(executor.into());
130 }
131
132 pub fn executor(&self) -> Option<&Path> {
134 self.executor.as_deref()
135 }
136
137 pub fn uefi(&self) -> bool {
139 self.uefi
140 }
141
142 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 pub fn cpus(&self) -> usize {
153 self.qemu.cpus
154 }
155
156 pub fn memory(&self) -> Byte {
158 self.qemu.memory
159 }
160
161 pub fn kvm_binary(&self) -> PathBuf {
163 self.qemu.kvm_binary.clone()
164 }
165
166 pub fn ovmf_vars_file(&self) -> PathBuf {
168 self.qemu.ovmf_vars_file.clone()
169 }
170
171 pub fn ovmf_code_file(&self) -> PathBuf {
173 self.qemu.ovmf_code_file.clone()
174 }
175
176 pub fn artifacts_max_size(&self) -> u64 {
178 self.artifacts_max_size.as_u64()
179 }
180
181 pub fn cache_max_size(&self) -> u64 {
183 self.cache_max_size.as_u64()
184 }
185}
186
187#[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#[derive(Debug, Clone, Deserialize)]
203#[serde(deny_unknown_fields)]
204pub struct StoredConfig {
205 pub tmpdir: Option<TildePathBuf>,
208
209 pub image_store: Option<TildePathBuf>,
211
212 pub projects: Option<TildePathBuf>,
214
215 pub state: Option<TildePathBuf>,
217
218 #[serde(alias = "target")]
221 pub rsync_target: Option<String>,
222
223 pub rsync_target_base: Option<String>,
226
227 pub rsync_target_map: Option<HashMap<String, String>>,
229
230 pub dput_target: Option<String>,
232
233 pub executor: Option<TildePathBuf>,
236
237 pub artifacts_max_size: Option<Byte>,
239
240 pub cache_max_size: Option<Byte>,
242
243 pub uefi: Option<bool>,
245
246 pub lint: Option<bool>,
248
249 #[serde(default)]
251 pub qemu: StoredQemuConfig,
252
253 pub cpus: Option<usize>,
255
256 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#[derive(Debug, Clone, Deserialize)]
435#[serde(deny_unknown_fields, default)]
436pub struct StoredQemuConfig {
437 pub kvm_binary: Option<TildePathBuf>,
439
440 pub ovmf_vars_file: Option<TildePathBuf>,
442
443 pub ovmf_code_file: Option<TildePathBuf>,
445
446 pub cpus: Option<usize>,
448
449 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#[derive(Debug, thiserror::Error)]
467pub enum ConfigError {
468 #[error("failed to find home directory, while looking for configuration file")]
470 ProjectDirs,
471
472 #[error("failed to read configuration file {0}")]
474 Read(PathBuf, #[source] std::io::Error),
475
476 #[error("failed to parse configuration file as YAML: {0}")]
478 Yaml(PathBuf, #[source] serde_norway::Error),
479
480 #[error("programming error: stored config field {0} is missing")]
482 Missing(&'static str),
483
484 #[error("failed to load configuration from files")]
486 Load(#[source] clingwrap::config::ConfigError),
487
488 #[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}