Skip to main content

isoloom_spec/
profile.rs

1//! Profiles: one spec, one file per customer or site.
2//!
3//! A product has one spec and many places to run it: this customer on Proxmox without
4//! internet, that one on AWS in their own region, another on local VMs with a bigger web
5//! server. A profile says what differs for one of them, kept with the project in
6//! `profiles/<name>.yml` and chosen with `--profile <name>` (or `ISOLOOM_PROFILE`):
7//!
8//! ```yaml
9//! # profiles/bank-fr.yml
10//! description: Bank, Paris site: Proxmox, no internet
11//! target: proxmox                     # the target this profile runs on
12//! spec:                               # merged into the spec, as -s would
13//!   networks: { lan: { internet: false } }
14//!   machines: { web: { resources: { cpus: 4, memory_mb: 8192 } } }
15//! defaults:                           # a layer of the defaults (see `defaults`)
16//!   cloud: { aws: { region: eu-west-3 } }
17//! inputs:                             # launch-time values, unless the environment sets them
18//!   REGISTRY: registry.bank.local
19//! ```
20//!
21//! Where it sits: the spec's own values, then the profile's `spec`, then `-s`. For the
22//! defaults: built in, the user's file, the project's file, the profile, `--images`, the
23//! environment, `-s defaults.<key>`. An input set in the environment wins over the profile's.
24//!
25//! A profile names only what the spec has: an unknown machine, network or input is an
26//! error, so a renamed machine can't leave a profile silently doing nothing.
27
28use std::fmt;
29use std::path::{Path, PathBuf};
30
31use indexmap::IndexMap;
32use serde::Deserialize;
33use serde_yaml_ng::{Mapping, Value};
34
35use crate::defaults::Layer;
36use crate::model::Target;
37use crate::{Error, Result};
38
39/// The folder of profiles, next to the spec.
40pub const DIR: &str = "profiles";
41
42/// A profile, read and checked against nothing yet (see [`Profile::check`]).
43#[derive(Debug, Clone, PartialEq)]
44pub struct Profile {
45    /// The profile's name: its file name without `.yml` (`isoloom generate --profile <name>`).
46    pub name: String,
47    /// The profile's file.
48    pub path: PathBuf,
49    /// What the profile is for, as `isoloom profiles` lists it.
50    pub description: Option<String>,
51    /// The target the profile runs on: the default when none is given, and the only one allowed.
52    pub target: Option<Target>,
53    /// Merged into the spec before `-s`.
54    pub spec: Value,
55    /// A layer of the defaults.
56    pub defaults: Value,
57    /// Launch-time inputs, by name.
58    pub inputs: IndexMap<String, String>,
59}
60
61#[derive(Deserialize)]
62#[serde(deny_unknown_fields)]
63struct File {
64    #[serde(default)]
65    description: Option<String>,
66    #[serde(default)]
67    target: Option<Target>,
68    #[serde(default)]
69    spec: Option<Value>,
70    #[serde(default)]
71    defaults: Option<Value>,
72    #[serde(default)]
73    inputs: IndexMap<String, Value>,
74}
75
76/// A profile name: kebab-case, as a spec's name.
77#[must_use]
78pub fn valid_name(name: &str) -> bool {
79    !name.is_empty() && !name.starts_with('-') && !name.ends_with('-') && name.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
80}
81
82/// The profile file for `name` in `project`: `profiles/<name>.yml` or `.yaml`, never both.
83///
84/// # Errors
85///
86/// [`Error::ProfileName`] for a name that isn't kebab-case, [`Error::NoProfile`] (with the
87/// project's profiles) when there is no such file, [`Error::TwoProfiles`] when both exist.
88pub fn path(project: &Path, name: &str) -> Result<PathBuf> {
89    if !valid_name(name) {
90        return Err(Error::ProfileName(name.to_owned()));
91    }
92    let found: Vec<PathBuf> = ["yml", "yaml"]
93        .iter()
94        .map(|e| project.join(DIR).join(format!("{name}.{e}")))
95        .filter(|p| p.is_file())
96        .collect();
97    match found.as_slice() {
98        [one] => Ok(one.clone()),
99        [] => Err(Error::NoProfile {
100            name: name.to_owned(),
101            folder: project.join(DIR),
102            known: list(project),
103        }),
104        _ => Err(Error::TwoProfiles { name: name.to_owned() }),
105    }
106}
107
108/// The profiles of a project, by name, sorted.
109#[must_use]
110pub fn list(project: &Path) -> Vec<String> {
111    let Ok(entries) = std::fs::read_dir(project.join(DIR)) else { return vec![] };
112    let mut names: Vec<String> = entries
113        .filter_map(|e| {
114            let p = e.ok()?.path();
115            let ext = p.extension()?.to_str()?;
116            if !p.is_file() || !["yml", "yaml"].contains(&ext) {
117                return None;
118            }
119            let stem = p.file_stem()?.to_str()?.to_owned();
120            valid_name(&stem).then_some(stem)
121        })
122        .collect();
123    names.sort();
124    names.dedup();
125    names
126}
127
128/// Reads `profiles/<name>.yml` in `project`.
129///
130/// # Errors
131///
132/// As [`path`], then [`Error::Read`] for a file that can't be read, then as [`parse`].
133pub fn load(project: &Path, name: &str) -> Result<Profile> {
134    let path = path(project, name)?;
135    let text = std::fs::read_to_string(&path).map_err(|source| Error::Read { path: path.clone(), source })?;
136    parse(name, path, &text)
137}
138
139/// A profile from its text.
140///
141/// # Errors
142///
143/// [`Error::ProfileYaml`] for text that isn't YAML or a field a profile doesn't have,
144/// [`Error::ProfileMapping`] for a `spec` or `defaults` that isn't a mapping, and
145/// [`Error::ProfileInput`] for an input that isn't one value.
146pub fn parse(name: &str, path: PathBuf, text: &str) -> Result<Profile> {
147    let file: File = match serde_yaml_ng::from_str(text) {
148        Ok(file) => file,
149        Err(source) => {
150            return Err(Error::ProfileYaml {
151                name: name.to_owned(),
152                path,
153                source,
154            });
155        }
156    };
157    let mapping = |v: Option<Value>, key: &'static str| -> Result<Value> {
158        match v {
159            None | Some(Value::Null) => Ok(Value::Mapping(Mapping::new())),
160            Some(m @ Value::Mapping(_)) => Ok(m),
161            Some(_) => Err(Error::ProfileMapping {
162                name: name.to_owned(),
163                path: path.clone(),
164                key,
165            }),
166        }
167    };
168    let spec = mapping(file.spec, "spec")?;
169    let defaults = mapping(file.defaults, "defaults")?;
170    let mut inputs = IndexMap::new();
171    for (k, v) in file.inputs {
172        let value = match v {
173            Value::String(s) => s,
174            Value::Number(n) => n.to_string(),
175            Value::Bool(b) => b.to_string(),
176            _ => {
177                return Err(Error::ProfileInput {
178                    name: name.to_owned(),
179                    path,
180                    input: k,
181                });
182            }
183        };
184        inputs.insert(k, value);
185    }
186    Ok(Profile {
187        name: name.to_owned(),
188        path,
189        description: file.description,
190        target: file.target,
191        spec,
192        defaults,
193        inputs,
194    })
195}
196
197impl Profile {
198    /// Where it shows in `isoloom defaults` and in errors.
199    #[must_use]
200    pub fn source(&self) -> String {
201        format!("profile {} ({})", self.name, self.path.display())
202    }
203
204    /// Its layer of the defaults.
205    #[must_use]
206    pub fn layer(&self) -> Layer {
207        Layer {
208            source: self.source(),
209            value: self.defaults.clone(),
210        }
211    }
212
213    /// What's wrong with the profile for this spec (the spec's YAML as written, before any
214    /// override): machines, networks and inputs it names that the spec doesn't have.
215    #[must_use]
216    pub fn check(&self, spec: &Value) -> Vec<ProfileProblem> {
217        let mut problems = Vec::new();
218        let names = |section: &str| -> Vec<String> {
219            spec.get(section)
220                .and_then(Value::as_mapping)
221                .map(|m| m.keys().filter_map(|k| k.as_str().map(str::to_owned)).collect())
222                .unwrap_or_default()
223        };
224        for section in ["machines", "networks"] {
225            let known = names(section);
226            if let Some(m) = self.spec.get(section).and_then(Value::as_mapping) {
227                for k in m.keys() {
228                    let k = k.as_str().unwrap_or_default();
229                    if !known.iter().any(|n| n == k) {
230                        problems.push(ProfileProblem::Unknown {
231                            profile: self.name.clone(),
232                            section,
233                            name: k.to_owned(),
234                        });
235                    }
236                }
237            }
238        }
239        let declared: Vec<String> = spec
240            .get("inputs")
241            .and_then(Value::as_sequence)
242            .map(|s| s.iter().filter_map(|v| v.as_str().map(str::to_owned)).collect())
243            .unwrap_or_default();
244        for k in self.inputs.keys() {
245            if !declared.iter().any(|d| d == k) {
246                problems.push(ProfileProblem::Input {
247                    profile: self.name.clone(),
248                    input: k.clone(),
249                });
250            }
251        }
252        problems
253    }
254
255    /// The target to use: the profile's when none is asked for; an error when another is.
256    ///
257    /// # Errors
258    ///
259    /// [`Error::ProfileTarget`] when another target than the profile's is asked for.
260    pub fn pin(&self, asked: Option<&str>) -> Result<Option<String>> {
261        match (self.target, asked) {
262            (Some(t), None) => Ok(Some(t.id().to_owned())),
263            (Some(t), Some(a)) if a != t.id() => Err(Error::ProfileTarget {
264                profile: self.name.clone(),
265                target: t,
266                asked: a.to_owned(),
267            }),
268            (_, a) => Ok(a.map(str::to_owned)),
269        }
270    }
271}
272
273/// What a profile names that its spec doesn't have.
274#[derive(Debug, Clone, PartialEq, Eq)]
275#[non_exhaustive]
276pub enum ProfileProblem {
277    /// A machine or a network of its `spec`.
278    Unknown {
279        /// The profile's name.
280        profile: String,
281        /// The section, `machines` or `networks`.
282        section: &'static str,
283        /// The name the spec doesn't have.
284        name: String,
285    },
286    /// One of its `inputs`.
287    Input {
288        /// The profile's name.
289        profile: String,
290        /// The input the spec doesn't declare.
291        input: String,
292    },
293}
294
295impl fmt::Display for ProfileProblem {
296    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
297        match self {
298            ProfileProblem::Unknown { profile, section, name } => {
299                let one = section.strip_suffix('s').unwrap_or(section);
300                write!(f, "profile `{profile}`: `spec.{section}.{name}`: the spec has no {one} `{name}`")
301            }
302            ProfileProblem::Input { profile, input } => write!(f, "profile `{profile}`: input `{input}` isn't one of the spec's `inputs`"),
303        }
304    }
305}