Skip to main content

isoloom_spec/model/
storage.rs

1//! Where a machine keeps data: how it mounts a volume (all of it, or one folder of a shared one),
2//! its memory-backed mounts, and the data disks a VM is given beside its own.
3
4use schemars::JsonSchema;
5use serde::{Deserialize, Serialize};
6
7/// Where a machine mounts one of its volumes: a path, or a path and the folder of a shared
8/// volume it takes (`{ path: /srv/www, subpath: www }`).
9#[derive(Debug, Clone, PartialEq, Eq, Serialize, JsonSchema)]
10#[serde(untagged)]
11pub enum Mount {
12    /// The whole volume at this absolute path.
13    Path(String),
14    /// A mount with its options.
15    Part(MountPart),
16}
17
18/// A mount with its options.
19#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
20#[serde(deny_unknown_fields)]
21pub struct MountPart {
22    /// The absolute path in the machine.
23    pub path: String,
24    /// One folder of a shared volume (`www`, `logs/app`), relative to the volume's root, instead
25    /// of all of it. Created when missing. The volume's owner holds all of it.
26    #[serde(default, skip_serializing_if = "Option::is_none")]
27    pub subpath: Option<String>,
28}
29
30impl Mount {
31    /// The absolute path in the machine.
32    #[must_use]
33    pub fn path(&self) -> &str {
34        match self {
35            Mount::Path(p) => p,
36            Mount::Part(m) => &m.path,
37        }
38    }
39
40    /// The folder of a shared volume it takes, if not all of it.
41    #[must_use]
42    pub fn subpath(&self) -> Option<&str> {
43        match self {
44            Mount::Path(_) => None,
45            Mount::Part(m) => m.subpath.as_deref(),
46        }
47    }
48}
49
50impl From<&str> for Mount {
51    fn from(path: &str) -> Mount {
52        Mount::Path(path.to_owned())
53    }
54}
55
56// A string is a path; a map is a mount with options. Written by hand (not `untagged`) so a typo
57// in the map is reported with its field instead of "no variant matched".
58impl<'de> Deserialize<'de> for Mount {
59    fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
60        struct V;
61        impl<'de> serde::de::Visitor<'de> for V {
62            type Value = Mount;
63            fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
64                f.write_str("an absolute path, or `{ path, subpath }`")
65            }
66            fn visit_str<E: serde::de::Error>(self, v: &str) -> Result<Mount, E> {
67                Ok(Mount::Path(v.to_owned()))
68            }
69            fn visit_map<A: serde::de::MapAccess<'de>>(self, map: A) -> Result<Mount, A::Error> {
70                MountPart::deserialize(serde::de::value::MapAccessDeserializer::new(map)).map(Mount::Part)
71            }
72        }
73        d.deserialize_any(V)
74    }
75}
76
77/// A memory-backed mount: a path, or a path with a size and a mode
78/// (`{ path: /run, size: 64m, mode: "1777" }`).
79#[derive(Debug, Clone, PartialEq, Eq, Serialize, JsonSchema)]
80#[serde(untagged)]
81pub enum Tmpfs {
82    /// A mount at this absolute path, of the runtime's default size and mode.
83    Path(String),
84    /// A mount with its options.
85    Sized(TmpfsMount),
86}
87
88/// A memory-backed mount with its options.
89#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
90#[serde(deny_unknown_fields)]
91pub struct TmpfsMount {
92    /// The absolute path in the machine.
93    pub path: String,
94    /// Its most memory: bytes, or a number with `k`, `m` or `g` (`64m`).
95    #[serde(default, skip_serializing_if = "Option::is_none")]
96    pub size: Option<String>,
97    /// Its permissions, in octal (`"1777"`, `"0700"`). Quote it: YAML reads `0700` unquoted as
98    /// a number.
99    #[serde(default, skip_serializing_if = "Option::is_none")]
100    pub mode: Option<String>,
101}
102
103impl Tmpfs {
104    /// The absolute path in the machine.
105    #[must_use]
106    pub fn path(&self) -> &str {
107        match self {
108            Tmpfs::Path(p) => p,
109            Tmpfs::Sized(t) => &t.path,
110        }
111    }
112
113    /// Its size, when given.
114    #[must_use]
115    pub fn size(&self) -> Option<&str> {
116        match self {
117            Tmpfs::Path(_) => None,
118            Tmpfs::Sized(t) => t.size.as_deref(),
119        }
120    }
121
122    /// Its mode, when given (octal digits).
123    #[must_use]
124    pub fn mode(&self) -> Option<&str> {
125        match self {
126            Tmpfs::Path(_) => None,
127            Tmpfs::Sized(t) => t.mode.as_deref(),
128        }
129    }
130}
131
132impl From<&str> for Tmpfs {
133    fn from(path: &str) -> Tmpfs {
134        Tmpfs::Path(path.to_owned())
135    }
136}
137
138impl<'de> Deserialize<'de> for Tmpfs {
139    fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
140        struct V;
141        impl<'de> serde::de::Visitor<'de> for V {
142            type Value = Tmpfs;
143            fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
144                f.write_str("an absolute path, or `{ path, size, mode }`")
145            }
146            fn visit_str<E: serde::de::Error>(self, v: &str) -> Result<Tmpfs, E> {
147                Ok(Tmpfs::Path(v.to_owned()))
148            }
149            fn visit_map<A: serde::de::MapAccess<'de>>(self, map: A) -> Result<Tmpfs, A::Error> {
150                TmpfsMount::deserialize(serde::de::value::MapAccessDeserializer::new(map)).map(Tmpfs::Sized)
151            }
152        }
153        d.deserialize_any(V)
154    }
155}
156
157/// A data disk a machine is given beside its own: on a VM, a disk of its own, formatted and
158/// mounted at boot (a raw one is left as it is); on the container targets, a volume at the
159/// mount (a claim of that size on Kubernetes). It goes with the machine.
160#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
161#[serde(deny_unknown_fields)]
162pub struct Disk {
163    /// Its size in GB (1 to 16384).
164    pub size_gb: u32,
165    /// Its filesystem: `ext4` (the default on Linux), `xfs`, `ntfs` (the default on Windows), or
166    /// `raw` for a block device left unformatted.
167    #[serde(default, skip_serializing_if = "Option::is_none")]
168    pub fs: Option<Filesystem>,
169    /// Where it is mounted: an absolute path on Linux and in containers, a drive letter (`E:`)
170    /// on Windows. Required unless the disk is `raw`.
171    #[serde(default, skip_serializing_if = "Option::is_none")]
172    pub mount: Option<String>,
173}
174
175/// A data disk's filesystem.
176#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
177#[serde(rename_all = "lowercase")]
178pub enum Filesystem {
179    /// Linux's usual filesystem.
180    Ext4,
181    /// XFS, for large files.
182    Xfs,
183    /// Windows' filesystem.
184    Ntfs,
185    /// No filesystem: a block device the machine formats or uses itself.
186    Raw,
187}
188
189impl Filesystem {
190    /// The name `mkfs` and fstab know it by.
191    #[must_use]
192    pub fn id(self) -> &'static str {
193        match self {
194            Filesystem::Ext4 => "ext4",
195            Filesystem::Xfs => "xfs",
196            Filesystem::Ntfs => "ntfs",
197            Filesystem::Raw => "raw",
198        }
199    }
200}
201
202#[cfg(test)]
203mod tests {
204    use super::*;
205
206    /// Why `yaml` doesn't read as a `T`.
207    fn refusal<T: serde::de::DeserializeOwned>(yaml: &str) -> String {
208        serde_yaml_ng::from_str::<T>(yaml).map(|_| ()).err().map(|e| e.to_string()).unwrap_or_default()
209    }
210
211    #[test]
212    fn a_mount_is_a_path_or_a_folder_of_a_shared_volume() -> Result<(), serde_yaml_ng::Error> {
213        let whole: Mount = serde_yaml_ng::from_str("/srv")?;
214        assert_eq!((whole.path(), whole.subpath()), ("/srv", None));
215        let part: Mount = serde_yaml_ng::from_str("{ path: /srv/www, subpath: www }")?;
216        assert_eq!((part.path(), part.subpath()), ("/srv/www", Some("www")));
217        assert!(refusal::<Mount>("3").ends_with("expected an absolute path, or `{ path, subpath }`"));
218        Ok(())
219    }
220
221    #[test]
222    fn a_tmpfs_is_a_path_or_one_with_its_size_and_mode() -> Result<(), serde_yaml_ng::Error> {
223        let bare: Tmpfs = serde_yaml_ng::from_str("/run")?;
224        assert_eq!((bare.path(), bare.size(), bare.mode()), ("/run", None, None));
225        let sized: Tmpfs = serde_yaml_ng::from_str("{ path: /tmp, size: 64m, mode: \"1777\" }")?;
226        assert_eq!((sized.path(), sized.size(), sized.mode()), ("/tmp", Some("64m"), Some("1777")));
227        assert!(refusal::<Tmpfs>("3").ends_with("expected an absolute path, or `{ path, size, mode }`"));
228        Ok(())
229    }
230
231    #[test]
232    fn a_filesystem_goes_by_the_name_a_spec_writes() -> Result<(), serde_yaml_ng::Error> {
233        for fs in [Filesystem::Ext4, Filesystem::Xfs, Filesystem::Ntfs, Filesystem::Raw] {
234            assert_eq!(serde_yaml_ng::from_str::<Filesystem>(fs.id())?, fs);
235        }
236        Ok(())
237    }
238}