use std::{
collections::HashMap,
path::{Path, PathBuf},
};
use byte_unit::Byte;
use clingwrap::{
config::{ConfigFile, ConfigValidator},
tildepathbuf::TildePathBuf,
};
use directories::ProjectDirs;
use serde::{Deserialize, Serialize};
use crate::{
linter::{Linter, LinterError},
project::Projects,
};
const QUAL: &str = "liw.fi";
const ORG: &str = "Ambient CI";
const APP: &str = env!("CARGO_PKG_NAME");
const DEFAULT_CPUS: usize = 1;
const DEFAULT_MEMORY: Byte = Byte::GIBIBYTE;
#[derive(Debug, Serialize, Deserialize, Default, Clone)]
#[serde(deny_unknown_fields)]
pub struct Config {
tmpdir: PathBuf,
image_store: PathBuf,
projects: PathBuf,
state: PathBuf,
rsync_target: Option<String>,
rsync_target_base: Option<String>,
rsync_target_map: Option<HashMap<String, String>>,
dput_target: Option<String>,
executor: Option<PathBuf>,
artifacts_max_size: Byte,
cache_max_size: Byte,
qemu: QemuConfig,
uefi: bool,
lint: bool,
}
impl Config {
pub fn tmpdir(&self) -> &Path {
&self.tmpdir
}
pub fn image_store(&self) -> &Path {
&self.image_store
}
pub fn projects(&self) -> &Path {
&self.projects
}
pub fn state(&self) -> &Path {
&self.state
}
pub fn set_rsync_target(&mut self, rsync_target: &str) {
self.rsync_target = Some(rsync_target.into());
}
pub fn rsync_target(&self) -> Option<&str> {
self.rsync_target.as_deref()
}
pub fn rsync_target_base(&self) -> Option<&str> {
self.rsync_target_base.as_deref()
}
pub fn rsync_target_map(&self) -> Option<&HashMap<String, String>> {
self.rsync_target_map.as_ref()
}
pub fn rsync_target_for_project(&self, name: &str) -> Option<String> {
fn join(base: &str, x: &str) -> Option<String> {
Some(format!("{base}/{x}"))
}
match (
&self.rsync_target,
&self.rsync_target_base,
&self.rsync_target_map,
) {
(Some(target), _, _) => Some(target.to_string()),
(None, None, _) => None,
(None, Some(base), None) => join(base, name),
(None, Some(base), Some(map)) => {
if let Some(x) = map.get(name) {
join(base, x)
} else {
join(base, name)
}
}
}
}
pub fn set_dput_target(&mut self, dput_target: &str) {
self.dput_target = Some(dput_target.into());
}
pub fn dput_target(&self) -> Option<&str> {
self.dput_target.as_deref()
}
pub fn set_executor(&mut self, executor: &Path) {
self.executor = Some(executor.into());
}
pub fn executor(&self) -> Option<&Path> {
self.executor.as_deref()
}
pub fn uefi(&self) -> bool {
self.uefi
}
pub fn lint(&self, projects: &Projects) -> Result<(), LinterError> {
if self.lint {
Linter::new(self, projects).lint()
} else {
Ok(())
}
}
pub fn cpus(&self) -> usize {
self.qemu.cpus
}
pub fn memory(&self) -> Byte {
self.qemu.memory
}
pub fn kvm_binary(&self) -> PathBuf {
self.qemu.kvm_binary.clone()
}
pub fn ovmf_vars_file(&self) -> PathBuf {
self.qemu.ovmf_vars_file.clone()
}
pub fn ovmf_code_file(&self) -> PathBuf {
self.qemu.ovmf_code_file.clone()
}
pub fn artifacts_max_size(&self) -> u64 {
self.artifacts_max_size.as_u64()
}
pub fn cache_max_size(&self) -> u64 {
self.cache_max_size.as_u64()
}
}
#[derive(Debug, Default, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct QemuConfig {
cpus: usize,
memory: Byte,
kvm_binary: PathBuf,
ovmf_vars_file: PathBuf,
ovmf_code_file: PathBuf,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct StoredConfig {
pub tmpdir: Option<TildePathBuf>,
pub image_store: Option<TildePathBuf>,
pub projects: Option<TildePathBuf>,
pub state: Option<TildePathBuf>,
#[serde(alias = "target")]
pub rsync_target: Option<String>,
pub rsync_target_base: Option<String>,
pub rsync_target_map: Option<HashMap<String, String>>,
pub dput_target: Option<String>,
pub executor: Option<TildePathBuf>,
pub artifacts_max_size: Option<Byte>,
pub cache_max_size: Option<Byte>,
pub uefi: Option<bool>,
pub lint: Option<bool>,
#[serde(default)]
pub qemu: StoredQemuConfig,
pub cpus: Option<usize>,
pub memory: Option<Byte>,
}
impl<'a> ConfigFile<'a> for StoredConfig {
type Error = ConfigError;
fn merge(&mut self, other: Self) -> Result<(), Self::Error> {
fn tildepathbuf(us: &mut Option<TildePathBuf>, them: &Option<TildePathBuf>) {
if let Some(x) = them {
*us = Some(x.clone());
}
}
fn string(us: &mut Option<String>, them: &Option<String>) {
if let Some(x) = them {
*us = Some(x.into());
}
}
fn byte(us: &mut Option<Byte>, them: &Option<Byte>) {
if let Some(x) = them {
*us = Some(*x);
}
}
fn bool(us: &mut Option<bool>, them: &Option<bool>) {
if let Some(x) = them {
*us = Some(*x);
}
}
fn yousize(us: &mut Option<usize>, them: &Option<usize>) {
if let Some(x) = them {
*us = Some(*x);
}
}
if other.cpus.is_some() {
eprintln!("deprecated: the `cpus` field is replaced by `qemu.cpus`");
}
if other.memory.is_some() {
eprintln!("deprecated: the `memory` field is replaced by `qemu.memory`");
}
tildepathbuf(&mut self.tmpdir, &other.tmpdir);
tildepathbuf(&mut self.image_store, &other.image_store);
tildepathbuf(&mut self.projects, &other.projects);
tildepathbuf(&mut self.state, &other.state);
tildepathbuf(&mut self.executor, &other.executor);
string(&mut self.rsync_target, &other.rsync_target);
string(&mut self.rsync_target_base, &other.rsync_target_base);
string(&mut self.dput_target, &other.dput_target);
if let Some(map) = &other.rsync_target_map {
self.rsync_target_map = Some(map.clone());
}
byte(&mut self.artifacts_max_size, &other.artifacts_max_size);
byte(&mut self.cache_max_size, &other.cache_max_size);
yousize(&mut self.qemu.cpus, &other.cpus);
yousize(&mut self.qemu.cpus, &other.qemu.cpus);
byte(&mut self.qemu.memory, &other.memory);
byte(&mut self.qemu.memory, &other.qemu.memory);
byte(&mut self.qemu.memory, &other.qemu.memory);
tildepathbuf(&mut self.qemu.kvm_binary, &other.qemu.kvm_binary);
tildepathbuf(&mut self.qemu.ovmf_code_file, &other.qemu.ovmf_code_file);
tildepathbuf(&mut self.qemu.ovmf_vars_file, &other.qemu.ovmf_vars_file);
bool(&mut self.uefi, &other.uefi);
bool(&mut self.lint, &other.lint);
Ok(())
}
}
impl Default for StoredConfig {
fn default() -> Self {
let dirs = ProjectDirs::from(QUAL, ORG, APP).expect("have home directory");
#[allow(clippy::unwrap_used)]
let state = dirs.state_dir().unwrap();
let tmp = std::env::var("TMPDIR")
.map(PathBuf::from)
.unwrap_or(PathBuf::from("/tmp"));
Self {
tmpdir: Some(TildePathBuf::new(tmp)),
image_store: Some(TildePathBuf::new(state.join("images"))),
projects: Some(dirs.config_dir().join("projects.yaml").into()),
state: Some(TildePathBuf::new(state.join("projects"))),
rsync_target: None,
rsync_target_base: None,
rsync_target_map: None,
dput_target: None,
executor: None,
qemu: Default::default(),
artifacts_max_size: Byte::MEBIBYTE.multiply(10),
cache_max_size: Byte::GIBIBYTE.multiply(10),
cpus: None,
memory: None,
uefi: None,
lint: None,
}
}
}
impl ConfigValidator for StoredConfig {
type File = StoredConfig;
type Valid = Config;
type Error = ConfigError;
fn validate(&self, merged: &Self::File) -> Result<Self::Valid, Self::Error> {
fn mkabs(name: &'static str, path: &Option<TildePathBuf>) -> Result<PathBuf, ConfigError> {
if let Some(path) = path {
let path = path.path();
let path = std::path::absolute(path)
.map_err(|err| ConfigError::Absolute(path.to_path_buf(), err))?;
Ok(path)
} else {
Err(ConfigError::Missing(name))
}
}
if merged.cpus.is_some() {
eprintln!("deprecated: the `cpus` field is replaced by `qemu.cpus`");
}
if merged.memory.is_some() {
eprintln!("deprecated: the `memory` field is replaced by `qemu.memory`");
}
let qemu = QemuConfig {
cpus: if let Some(cpus) = merged.qemu.cpus {
cpus
} else if let Some(cpus) = merged.cpus {
cpus
} else {
DEFAULT_CPUS
},
memory: if let Some(memory) = merged.qemu.memory {
memory
} else if let Some(memory) = merged.memory {
memory
} else {
DEFAULT_MEMORY
},
kvm_binary: mkabs("kvm_binary", &merged.qemu.kvm_binary)?,
ovmf_vars_file: mkabs("ovmf_vars_file", &merged.qemu.ovmf_vars_file)?,
ovmf_code_file: mkabs("ovmf_code_file", &merged.qemu.ovmf_code_file)?,
};
Ok(Config {
tmpdir: mkabs("tmpdir", &merged.tmpdir)?,
image_store: mkabs("image_store", &merged.image_store)?,
projects: mkabs("projects", &merged.projects)?,
state: mkabs("state", &merged.state)?,
rsync_target: merged.rsync_target.clone(),
rsync_target_base: merged.rsync_target_base.clone(),
rsync_target_map: merged.rsync_target_map.clone(),
dput_target: merged.dput_target.clone(),
executor: merged.executor.as_ref().map(|path| path.path().into()),
uefi: merged.uefi.unwrap_or_default(),
lint: merged.lint.unwrap_or(true),
artifacts_max_size: merged
.artifacts_max_size
.ok_or(ConfigError::Missing("artifacts_max_size"))?,
cache_max_size: merged
.cache_max_size
.ok_or(ConfigError::Missing("cache_max_size"))?,
qemu,
})
}
}
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields, default)]
pub struct StoredQemuConfig {
pub kvm_binary: Option<TildePathBuf>,
pub ovmf_vars_file: Option<TildePathBuf>,
pub ovmf_code_file: Option<TildePathBuf>,
pub cpus: Option<usize>,
pub memory: Option<Byte>,
}
impl Default for StoredQemuConfig {
fn default() -> Self {
Self {
cpus: None,
memory: None,
kvm_binary: Some(TildePathBuf::new("/usr/bin/kvm".into())),
ovmf_vars_file: Some(TildePathBuf::new("/usr/share/ovmf/OVMF.fd".into())),
ovmf_code_file: Some(TildePathBuf::new("/usr/share/ovmf/OVMF.fd".into())),
}
}
}
#[derive(Debug, thiserror::Error)]
pub enum ConfigError {
#[error("failed to find home directory, while looking for configuration file")]
ProjectDirs,
#[error("failed to read configuration file {0}")]
Read(PathBuf, #[source] std::io::Error),
#[error("failed to parse configuration file as YAML: {0}")]
Yaml(PathBuf, #[source] serde_norway::Error),
#[error("programming error: stored config field {0} is missing")]
Missing(&'static str),
#[error("failed to load configuration from files")]
Load(#[source] clingwrap::config::ConfigError),
#[error("failed to make filename absolute: {0}")]
Absolute(PathBuf, #[source] std::io::Error),
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod test {
use super::*;
#[test]
fn does_not_merge_unset() {
let stored = StoredConfig::default();
let mut config = StoredConfig::default();
assert!(stored.rsync_target.is_none());
assert!(config.rsync_target.is_none());
config.merge(stored).unwrap();
assert!(config.rsync_target.is_none());
}
#[test]
fn merges_set_value() {
let stored = StoredConfig {
tmpdir: Some(TildePathBuf::new(PathBuf::from("/yo"))),
image_store: Some(TildePathBuf::new(PathBuf::from("/images"))),
projects: Some(TildePathBuf::new(PathBuf::from("/projects.yaml"))),
state: Some(TildePathBuf::new(PathBuf::from("/state"))),
rsync_target: Some("xyzzy".into()),
rsync_target_base: Some("plugh".into()),
rsync_target_map: Some(HashMap::from([("yo".into(), "yo.liw.fi".into())])),
dput_target: Some("colossal-cave".into()),
executor: Some(TildePathBuf::new(PathBuf::from("/run-ci"))),
artifacts_max_size: Some(Byte::MEBIBYTE),
cache_max_size: Some(Byte::GIBIBYTE),
qemu: StoredQemuConfig {
cpus: Some(42),
memory: Some(Byte::TEBIBYTE),
kvm_binary: Some(TildePathBuf::from(PathBuf::from("/run-ci"))),
ovmf_code_file: Some(TildePathBuf::from(PathBuf::from("/ovmf-code"))),
ovmf_vars_file: Some(TildePathBuf::from(PathBuf::from("/ovmf-vars"))),
},
uefi: Some(true),
lint: Some(true),
cpus: Some(4),
memory: Some(Byte::PEBIBYTE),
};
let mut config = StoredConfig::default();
assert!(config.rsync_target.is_none());
config.merge(stored.clone()).unwrap();
assert_eq!(config.tmpdir.unwrap().path(), stored.tmpdir.unwrap().path());
assert_eq!(
config.image_store.unwrap().path(),
stored.image_store.unwrap().path()
);
assert_eq!(
config.projects.unwrap().path(),
stored.projects.unwrap().path()
);
assert_eq!(config.state.unwrap().path(), stored.state.unwrap().path());
assert_eq!(config.rsync_target, stored.rsync_target);
assert_eq!(config.rsync_target_base, stored.rsync_target_base);
assert_eq!(config.rsync_target_map, stored.rsync_target_map);
assert_eq!(config.dput_target, stored.dput_target);
assert_eq!(
config.executor.unwrap().path(),
stored.executor.unwrap().path(),
);
assert_eq!(config.uefi, Some(true));
assert_eq!(config.lint, Some(true));
assert_eq!(config.artifacts_max_size, stored.artifacts_max_size);
assert_eq!(config.cache_max_size, stored.cache_max_size);
assert_eq!(config.qemu.cpus, stored.qemu.cpus);
assert_eq!(config.qemu.memory, stored.qemu.memory);
assert_eq!(
config.qemu.kvm_binary.unwrap().path(),
stored.qemu.kvm_binary.unwrap().path()
);
assert_eq!(
config.qemu.ovmf_code_file.unwrap().path(),
stored.qemu.ovmf_code_file.unwrap().path()
);
assert_eq!(
config.qemu.ovmf_vars_file.unwrap().path(),
stored.qemu.ovmf_vars_file.unwrap().path()
);
}
#[test]
fn merges_legacy_value_into_qemu() {
let stored = StoredConfig {
qemu: StoredQemuConfig {
cpus: None,
memory: None,
..Default::default()
},
cpus: Some(4),
memory: Some(Byte::PEBIBYTE),
..Default::default()
};
let mut config = StoredConfig::default();
config.merge(stored.clone()).unwrap();
assert_eq!(config.qemu.cpus, stored.cpus);
assert_eq!(config.qemu.memory, stored.memory);
}
#[test]
fn rsync_target_for_project_with_rsync_target_set() {
let config = Config {
rsync_target: Some("root@server:/".to_string()),
rsync_target_base: Some("root@server:/srv/http".to_string()),
rsync_target_map: Some(HashMap::from([("foo".to_string(), "foo".to_string())])),
..Default::default()
};
assert_eq!(
config.rsync_target_for_project("bar"),
Some("root@server:/".into())
);
assert_eq!(
config.rsync_target_for_project("foo"),
Some("root@server:/".into())
);
}
#[test]
fn rsync_target_for_project_with_base_and_map_only() {
let config = Config {
rsync_target_base: Some("root@server:/srv/http".to_string()),
rsync_target_map: Some(HashMap::from([(
"foo".to_string(),
"foo-website".to_string(),
)])),
..Default::default()
};
assert_eq!(
config.rsync_target_for_project("bar"),
Some("root@server:/srv/http/bar".into())
);
assert_eq!(
config.rsync_target_for_project("foo"),
Some("root@server:/srv/http/foo-website".into())
);
}
}