Skip to main content

isoloom_spec/model/
machine_file.rs

1//! A file placed at a path on a machine (`machines.*.files`): a configuration the image doesn't
2//! carry, written the same way on every target.
3
4use schemars::JsonSchema;
5use serde::{Deserialize, Serialize};
6
7/// The mode a file gets when the spec gives none: readable by everyone, written by its owner.
8pub const DEFAULT_FILE_MODE: &str = "0644";
9
10/// A file placed on the machine before its workload runs: a container gets it from its runtime
11/// (a Compose config, a Kubernetes ConfigMap), a VM from its set-up. On Compose, a project file
12/// with an owner or a mode is read into Compose's environment by `isoloom run` and copied in.
13#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
14#[serde(deny_unknown_fields)]
15pub struct MachineFile {
16    /// Where the file goes in the machine: an absolute path (`/etc/app/app.ini`). Its folders
17    /// are created when missing.
18    pub path: String,
19    /// The file to copy, from the project folder (`config/app.ini`). Give this or `content`.
20    #[serde(default, skip_serializing_if = "Option::is_none")]
21    pub source: Option<String>,
22    /// The file's text, written in the spec. Give this or `source`.
23    #[serde(default, skip_serializing_if = "Option::is_none")]
24    pub content: Option<String>,
25    /// The user id that owns the file (default 0, root). Linux machines only.
26    #[serde(default, skip_serializing_if = "Option::is_none")]
27    pub uid: Option<u32>,
28    /// The group id that owns the file (default 0, root). Linux machines only.
29    #[serde(default, skip_serializing_if = "Option::is_none")]
30    pub gid: Option<u32>,
31    /// The file's permissions, in octal (`0600`, `0755`; default `0644`), quoted or not: the
32    /// digits are kept as written. Linux machines only.
33    #[serde(default, skip_serializing_if = "Option::is_none", deserialize_with = "octal")]
34    #[schemars(with = "Option<super::ScalarSchema>")]
35    pub mode: Option<String>,
36}
37
38impl MachineFile {
39    /// Its mode, the default when the spec gives none.
40    #[must_use]
41    pub fn mode_or_default(&self) -> &str {
42        self.mode.as_deref().unwrap_or(DEFAULT_FILE_MODE)
43    }
44
45    /// Whether the spec sets its owner or its mode.
46    #[must_use]
47    pub fn sets_owner_or_mode(&self) -> bool {
48        self.uid.is_some() || self.gid.is_some() || self.mode.is_some()
49    }
50}
51
52/// A mode written as text or as a number, kept as its digits.
53fn octal<'de, D: serde::Deserializer<'de>>(d: D) -> Result<Option<String>, D::Error> {
54    let raw: Option<serde_json::Value> = Deserialize::deserialize(d)?;
55    match raw {
56        None => Ok(None),
57        Some(serde_json::Value::String(s)) => Ok(Some(s)),
58        Some(serde_json::Value::Number(n)) => Ok(Some(n.to_string())),
59        Some(_) => Err(serde::de::Error::custom("`mode`: octal digits, like \"0644\"")),
60    }
61}
62
63#[cfg(test)]
64mod tests {
65    use super::*;
66
67    #[test]
68    fn a_mode_reads_as_text_or_number() {
69        let from_text: MachineFile = serde_yaml_ng::from_str("{ path: /a, content: x, mode: 0600 }").unwrap();
70        assert_eq!(from_text.mode.as_deref(), Some("0600"));
71        let value: serde_yaml_ng::Value = serde_yaml_ng::from_str("{ path: /a, content: x, mode: 0600 }").unwrap();
72        let from_value: MachineFile = serde_yaml_ng::from_value(value).unwrap();
73        assert_eq!(from_value.mode.as_deref(), Some("0600"));
74        for (yaml, mode) in [
75            ("{ path: /a, content: x, mode: 600 }", "600"),
76            ("{ path: /a, content: x, mode: \"0600\" }", "0600"),
77        ] {
78            let f: MachineFile = serde_yaml_ng::from_str(yaml).unwrap();
79            assert_eq!(f.mode_or_default(), mode, "{yaml}");
80        }
81        let unset: MachineFile = serde_yaml_ng::from_str("{ path: /a, content: x }").unwrap();
82        assert_eq!((unset.mode_or_default(), unset.sets_owner_or_mode()), ("0644", false));
83        assert!(serde_yaml_ng::from_str::<MachineFile>("{ path: /a, mode: [1] }").is_err());
84    }
85
86    #[test]
87    fn an_owner_or_a_mode_is_set_by_any_one_of_them() -> Result<(), serde_yaml_ng::Error> {
88        for field in ["uid: 1000", "gid: 1000", "mode: \"0600\""] {
89            let f: MachineFile = serde_yaml_ng::from_str(&format!("{{ path: /a, content: x, {field} }}"))?;
90            assert!(f.sets_owner_or_mode(), "{field}");
91        }
92        Ok(())
93    }
94}