use std::path::{Path, PathBuf};
use indexmap::IndexMap;
use serde::Deserialize;
use serde_yaml_ng::{Mapping, Value};
use crate::files::GeneratedFile;
use crate::host::Host;
use crate::images::Table;
use crate::{Error, Result};
pub const PROJECT_FILE: &str = "isoloom.defaults.yml";
#[derive(Debug, Clone, Default, PartialEq, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Defaults {
#[serde(default)]
pub images: Table,
#[serde(default)]
pub vagrant: Vagrant,
#[serde(default)]
pub cloud: IndexMap<String, Cloud>,
}
#[derive(Debug, Clone, Default, PartialEq, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Vagrant {
#[serde(default)]
pub provider: Option<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Cloud {
#[serde(default)]
pub region: Option<String>,
}
pub const BUILTIN_REGIONS: &[(&str, &str)] = &[
("aws", "eu-west-3"),
("azure", "swedencentral"),
("gcp", "europe-west9"),
("digitalocean", "fra1"),
("linode", "fr-par"),
("oci", "eu-paris-1"),
];
#[derive(Debug, Clone, PartialEq)]
pub struct Layer {
pub source: String,
pub value: Value,
}
#[derive(Debug, Clone, PartialEq)]
pub struct Resolved {
pub defaults: Defaults,
pub merged: Value,
pub sources: Vec<(String, String)>,
}
#[must_use]
pub fn user_file(host: &Host) -> PathBuf {
host.home().join("defaults.yml")
}
#[derive(Debug, Clone)]
pub struct Sources<'a> {
pub project: &'a Path,
pub images_file: Option<&'a Path>,
pub profile: Option<&'a crate::profile::Profile>,
pub sets: &'a [String],
pub host: Host,
}
pub fn load(from: &Sources) -> Result<Resolved> {
let mut layers = Vec::new();
for (label, path) in [("user file", user_file(&from.host)), ("project file", from.project.join(PROJECT_FILE))] {
if let Some(value) = read_layer(&path)? {
layers.push(Layer {
source: format!("{label} {}", path.display()),
value,
});
}
}
if let Some(p) = from.profile {
layers.push(p.layer());
}
if let Some(f) = from.images_file {
let text = std::fs::read_to_string(f).map_err(|source| Error::Read { path: f.to_path_buf(), source })?;
let table: Value = serde_yaml_ng::from_str(&text).map_err(|source| Error::Layer { path: f.to_path_buf(), source })?;
let mut m = Mapping::new();
m.insert(Value::String("images".into()), table);
layers.push(Layer {
source: format!("--images {}", f.display()),
value: Value::Mapping(m),
});
}
for (k, v) in from_env(from.host.text_vars().into_iter()) {
layers.push(Layer {
source: format!("environment ISOLOOM_{}", k.replace('.', "__").to_uppercase()),
value: at_path(&k, scalar(&v)),
});
}
for s in from.sets {
let (k, v) = parse_set(s)?;
let Some(k) = k.strip_prefix("defaults.") else { continue };
layers.push(Layer {
source: format!("-s {s}"),
value: at_path(k, scalar(v)),
});
}
resolve(&layers)
}
fn read_layer(path: &Path) -> Result<Option<Value>> {
match std::fs::read_to_string(path) {
Ok(text) => serde_yaml_ng::from_str(&text).map(Some).map_err(|source| Error::Layer {
path: path.to_path_buf(),
source,
}),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(source) => Err(Error::Read {
path: path.to_path_buf(),
source,
}),
}
}
pub fn resolve(layers: &[Layer]) -> Result<Resolved> {
let mut merged = Value::Mapping(Mapping::new());
let mut sources: Vec<(String, String)> = Vec::new();
for layer in layers {
merge(&mut merged, &layer.value);
for leaf in leaves(&layer.value, "") {
sources.retain(|(k, _)| *k != leaf);
sources.push((leaf, layer.source.clone()));
}
}
let defaults: Defaults = serde_yaml_ng::from_value(merged.clone()).map_err(|source| Error::Defaults {
sources: layers.iter().map(|l| l.source.as_str()).collect::<Vec<_>>().join(", "),
source,
})?;
Ok(Resolved { defaults, merged, sources })
}
pub fn from_env(vars: impl Iterator<Item = (String, String)>) -> Vec<(String, String)> {
let mut out: Vec<(String, String)> = vars
.filter_map(|(k, v)| {
let rest = k.strip_prefix("ISOLOOM_")?;
let key = rest.to_lowercase().replace("__", ".");
let section = key.split('.').next().unwrap_or("");
["images", "vagrant", "cloud"].contains(§ion).then_some((key, v))
})
.collect();
out.sort();
out
}
pub fn parse_set(s: &str) -> Result<(&str, &str)> {
let (k, v) = s.split_once('=').ok_or_else(|| Error::SetShape { set: s.to_owned() })?;
if k.is_empty() || k.split('.').any(str::is_empty) {
return Err(Error::SetKey { set: s.to_owned() });
}
Ok((k, v))
}
#[must_use]
pub fn scalar(text: &str) -> Value {
serde_yaml_ng::from_str(text).unwrap_or_else(|_| Value::String(text.to_owned()))
}
pub(crate) fn at_path(path: &str, value: Value) -> Value {
path.rsplit('.').fold(value, |inner, key| {
let mut m = Mapping::new();
m.insert(Value::String(key.to_owned()), inner);
Value::Mapping(m)
})
}
pub fn merge(into: &mut Value, from: &Value) {
match (into, from) {
(Value::Mapping(a), Value::Mapping(b)) => {
for (k, v) in b {
match a.get_mut(k) {
Some(existing) => merge(existing, v),
None => {
a.insert(k.clone(), v.clone());
}
}
}
}
(into, from) => *into = from.clone(),
}
}
#[must_use]
pub fn leaves(v: &Value, prefix: &str) -> Vec<String> {
match v {
Value::Mapping(m) if !m.is_empty() => m
.iter()
.flat_map(|(k, c)| {
let key = k.as_str().map_or_else(|| format!("{k:?}"), str::to_owned);
leaves(c, &if prefix.is_empty() { key } else { format!("{prefix}.{key}") })
})
.collect(),
_ => vec![prefix.to_owned()],
}
}
pub fn override_spec(yaml: &str, sets: &[String]) -> Result<String> {
override_spec_with(yaml, None, sets)
}
pub fn override_spec_with(yaml: &str, base: Option<&Value>, sets: &[String]) -> Result<String> {
let mut doc: Value = serde_yaml_ng::from_str(yaml)?;
let mut any = false;
if let Some(b) = base.filter(|b| b.as_mapping().is_some_and(|m| !m.is_empty())) {
merge(&mut doc, b);
any = true;
}
for s in sets {
let (k, v) = parse_set(s)?;
if k.starts_with("defaults.") {
continue;
}
merge(&mut doc, &at_path(k, scalar(v)));
any = true;
}
if !any {
return Ok(yaml.to_owned());
}
Ok(serde_yaml_ng::to_string(&doc)?)
}
#[must_use]
pub fn apply_to_files(files: Vec<GeneratedFile>, d: &Defaults) -> Vec<GeneratedFile> {
files
.into_iter()
.map(|mut f| {
let parts: Vec<&str> = f.path.split('/').collect();
if let [_, target, cloud, "main.tf"] = parts.as_slice()
&& target.starts_with("cloud-")
&& let Some(region) = d.cloud.get(*cloud).and_then(|c| c.region.as_deref())
{
f.contents = set_region(&f.contents, region);
}
f
})
.collect()
}
#[must_use]
pub fn set_region(tf: &str, region: &str) -> String {
const VARIABLE: &str = "variable \"region\"";
let replaced = tf.split_once(VARIABLE).and_then(|(before, rest)| {
let (block, after) = rest.split_once("\n}")?;
let (head, from_default) = block.split_once("default")?;
let (to_quote, quoted) = from_default.split_once('"')?;
let (_, past_quote) = quoted.split_once('"')?;
Some(format!("{before}{VARIABLE}{head}default{to_quote}\"{region}\"{past_quote}\n}}{after}"))
});
replaced.unwrap_or_else(|| tf.to_owned())
}
#[must_use]
pub fn builtin() -> Value {
let mut cloud = Mapping::new();
for (c, r) in BUILTIN_REGIONS {
let mut m = Mapping::new();
m.insert(Value::String("region".into()), Value::String((*r).into()));
cloud.insert(Value::String((*c).into()), Value::Mapping(m));
}
let mut top = Mapping::new();
top.insert(Value::String("cloud".into()), Value::Mapping(cloud));
Value::Mapping(top)
}
#[cfg(test)]
mod tests {
use std::ffi::OsString;
use super::*;
use crate::testing::folder;
type Outcome = std::result::Result<(), Box<dyn std::error::Error>>;
fn host(vars: &[(&str, &str)]) -> Host {
Host::new(vars.iter().map(|(k, v)| (OsString::from(k), OsString::from(v))))
}
fn region<'d>(d: &'d Defaults, cloud: &str) -> Option<&'d str> {
d.cloud.get(cloud)?.region.as_deref()
}
#[test]
fn the_users_file_is_in_isoloom_home() {
assert_eq!(user_file(&host(&[("ISOLOOM_HOME", "/srv/iso")])), PathBuf::from("/srv/iso/defaults.yml"));
}
#[test]
fn layers_merge_in_order_and_each_leaf_names_the_last_that_set_it() -> Outcome {
let home = folder("defaults-layers-home")?;
let project = folder("defaults-layers-project")?;
std::fs::write(home.join("defaults.yml"), "cloud: { aws: { region: from-user }, gcp: { region: from-user } }\n")?;
std::fs::write(project.join(PROJECT_FILE), "cloud: { aws: { region: from-project } }\n")?;
let home_text = home.to_string_lossy().into_owned();
let sets = ["defaults.vagrant.provider=libvirt".to_owned(), "machines.web.os=alpine".to_owned()];
let resolved = load(&Sources {
project: &project,
images_file: None,
profile: None,
sets: &sets,
host: host(&[("ISOLOOM_HOME", &home_text), ("ISOLOOM_CLOUD__AZURE__REGION", "from-env")]),
})?;
let d = &resolved.defaults;
assert_eq!(region(d, "aws"), Some("from-project"));
assert_eq!(region(d, "gcp"), Some("from-user"), "a file that sets one region leaves the others alone");
assert_eq!(region(d, "azure"), Some("from-env"));
assert_eq!(d.vagrant.provider.as_deref(), Some("libvirt"));
let user = format!("user file {}", home.join("defaults.yml").display());
let sources: Vec<(&str, &str)> = resolved.sources.iter().map(|(k, s)| (k.as_str(), s.as_str())).collect();
assert_eq!(
sources,
[
("cloud.gcp.region", user.as_str()),
("cloud.aws.region", &format!("project file {}", project.join(PROJECT_FILE).display())),
("cloud.azure.region", "environment ISOLOOM_CLOUD__AZURE__REGION"),
("vagrant.provider", "-s defaults.vagrant.provider=libvirt"),
],
"a `-s` without the `defaults.` prefix is the spec's, not a layer"
);
Ok(())
}
#[test]
fn a_layer_file_that_cant_be_read_is_an_error_unlike_a_missing_one() -> Outcome {
let project = folder("defaults-unreadable")?;
let sources = |folder| Sources {
project: folder,
images_file: None,
profile: None,
sets: &[],
host: host(&[("ISOLOOM_HOME", "/nonexistent/isoloom")]),
};
assert_eq!(load(&sources(&project))?.defaults, Defaults::default(), "no file, no layer");
std::fs::create_dir(project.join(PROJECT_FILE))?;
assert!(matches!(load(&sources(&project)), Err(Error::Read { path, .. }) if path == project.join(PROJECT_FILE)));
Ok(())
}
#[test]
fn only_the_defaults_sections_are_read_from_the_environment() {
let vars = [
("ISOLOOM_VAGRANT__PROVIDER", "qemu"),
("ISOLOOM_HOME", "/x"),
("PATH", "/bin"),
("ISOLOOM_IMAGES__ACCESS__VM", "kali"),
];
assert_eq!(
from_env(vars.iter().map(|(k, v)| ((*k).to_owned(), (*v).to_owned()))),
[
("images.access.vm".to_owned(), "kali".to_owned()),
("vagrant.provider".to_owned(), "qemu".to_owned())
]
);
}
#[test]
fn a_set_is_a_dotted_key_then_its_value() {
assert!(matches!(parse_set("a.b=c=d"), Ok(("a.b", "c=d"))));
assert!(matches!(parse_set("a.b"), Err(Error::SetShape { .. })));
for s in ["=1", "a..b=1", ".a=1"] {
assert!(matches!(parse_set(s), Err(Error::SetKey { .. })), "{s}");
}
}
#[test]
fn an_empty_mapping_is_a_leaf_of_its_own() -> Outcome {
let v: Value = serde_yaml_ng::from_str("cloud: { aws: {} }\nvagrant: { provider: qemu }\n")?;
assert_eq!(leaves(&v, ""), ["cloud.aws", "vagrant.provider"]);
Ok(())
}
fn module(region: &str) -> String {
format!(
"variable \"zone\" {{\n default = \"a\"\n}}\nvariable \"region\" {{\n type = string\n default = \"{region}\"\n}}\nvariable \"size\" {{\n default = \"s\"\n}}\n"
)
}
#[test]
fn a_region_replaces_only_the_region_variables_default() {
assert_eq!(set_region(&module("eu-west-3"), "eu-north-1"), module("eu-north-1"));
assert_eq!(
set_region("variable \"zone\" {}\n", "eu-north-1"),
"variable \"zone\" {}\n",
"no region, no change"
);
}
#[test]
fn regions_reach_each_clouds_module_and_nothing_else() {
let file = |path: &str| GeneratedFile {
path: path.to_owned(),
contents: module("old"),
};
let d = Defaults {
cloud: [("aws".to_owned(), Cloud { region: Some("new".into()) })].into_iter().collect(),
..Defaults::default()
};
let paths = [
".isoloom/cloud-vm/aws/main.tf",
".isoloom-2/cloud-docker/aws/main.tf",
".isoloom/cloud-vm/gcp/main.tf",
".isoloom/cloud-vm/aws/variables.tf",
".isoloom/vagrant/aws/main.tf",
".isoloom/cloud-vm/aws/extra/main.tf",
];
let regions: Vec<bool> = apply_to_files(paths.iter().map(|p| file(p)).collect(), &d)
.iter()
.map(|f| f.contents == module("new"))
.collect();
assert_eq!(regions, [true, true, false, false, false, false]);
}
#[test]
fn the_built_in_layer_holds_every_clouds_region() -> Outcome {
let d: Defaults = serde_yaml_ng::from_value(builtin())?;
let regions: Vec<(&str, &str)> = d.cloud.iter().filter_map(|(c, r)| Some((c.as_str(), r.region.as_deref()?))).collect();
assert_eq!(regions, BUILTIN_REGIONS);
Ok(())
}
}