Skip to main content

isoloom_plan/
storage.rs

1//! A machine's storage beyond its root disk, as every target reads it: how long a volume lives
2//! (with the environment, past it, or already there), its data disks with their filesystem
3//! resolved for its OS, and what that storage asks of a backend.
4
5use isoloom_spec::images;
6use isoloom_spec::model::{Filesystem, Machine, Spec};
7
8use crate::requirements::Requirement;
9
10/// How long a volume lives.
11#[derive(Debug, Clone, PartialEq, Eq)]
12pub enum Lifetime {
13    /// Created with the environment, removed by `down`.
14    Environment,
15    /// Created with the environment, kept by `down` (removed by `down --volumes`).
16    Persistent,
17    /// Already there: mounted by this name, never created or removed.
18    External {
19        /// Its name on the platform.
20        name: String,
21    },
22}
23
24/// The lifetime of the storage behind machine volume `volume`: a top-level volume's own, else
25/// the environment's.
26#[must_use]
27pub fn lifetime(spec: &Spec, volume: &str) -> Lifetime {
28    let Some(v) = spec.volumes.get(volume) else {
29        return Lifetime::Environment;
30    };
31    match &v.external {
32        Some(name) => Lifetime::External { name: name.clone() },
33        None if v.persistent => Lifetime::Persistent,
34        None => Lifetime::Environment,
35    }
36}
37
38/// A data disk of a machine.
39#[derive(Debug, Clone, PartialEq, Eq)]
40pub struct DataDisk {
41    /// Its name in the spec (the filesystem's label).
42    pub name: String,
43    /// The name the platform knows it by: `<machine>-<disk>`.
44    pub key: String,
45    /// Its size, in GB.
46    pub size_gb: u32,
47    /// Its filesystem, the OS's default when the spec leaves it out.
48    pub fs: Filesystem,
49    /// Where it is mounted (a path, or a drive letter on Windows); `None` for a raw disk.
50    pub mount: Option<String>,
51}
52
53impl DataDisk {
54    /// Whether it is a block device left unformatted.
55    #[must_use]
56    pub fn is_raw(&self) -> bool {
57        self.fs == Filesystem::Raw
58    }
59
60    /// Its size in bytes, as the OS sees it (GiB: what every platform's GB is).
61    #[must_use]
62    pub fn bytes(&self) -> u64 {
63        u64::from(self.size_gb) << 30
64    }
65}
66
67/// Machine `name`'s data disks, in spec order.
68#[must_use]
69pub fn disks(name: &str, m: &Machine) -> Vec<DataDisk> {
70    let windows = m.vm.as_ref().is_some_and(|vm| images::is_windows(&vm.os));
71    let default = if windows { Filesystem::Ntfs } else { Filesystem::Ext4 };
72    m.disks
73        .iter()
74        .map(|(d, disk)| DataDisk {
75            name: d.clone(),
76            key: format!("{name}-{d}"),
77            size_gb: disk.size_gb,
78            fs: disk.fs.unwrap_or(default),
79            mount: disk.mount.clone(),
80        })
81        .collect()
82}
83
84/// What the spec's storage asks of a backend: volumes kept past `down` or existing already,
85/// data disks (raw ones apart), tmpfs modes. At most one of each kind: the first that asks.
86#[must_use]
87pub fn requirements(spec: &Spec) -> Vec<Requirement> {
88    let mut out = Vec::new();
89    if let Some((v, _)) = spec.volumes.iter().find(|(_, v)| v.persistent) {
90        out.push(Requirement::PersistentVolume { volume: v.clone() });
91    }
92    if let Some((v, _)) = spec.volumes.iter().find(|(_, v)| v.external.is_some()) {
93        out.push(Requirement::ExternalVolume { volume: v.clone() });
94    }
95    if let Some((m, _)) = spec.machines.iter().find(|(_, m)| !m.disks.is_empty()) {
96        out.push(Requirement::DataDisks { machine: m.clone() });
97    }
98    let raw = |m: &Machine| m.disks.values().any(|d| d.fs == Some(Filesystem::Raw));
99    if let Some((m, _)) = spec.machines.iter().find(|(_, m)| raw(m)) {
100        out.push(Requirement::RawDisks { machine: m.clone() });
101    }
102    if let Some((m, _)) = spec.machines.iter().find(|(_, m)| m.tmpfs.iter().any(|t| t.mode().is_some())) {
103        out.push(Requirement::TmpfsModes { machine: m.clone() });
104    }
105    out
106}
107
108/// Why a target whose storage goes with its machines refuses persistent volume `volume`.
109pub(crate) fn no_persistent_volume(f: &mut std::fmt::Formatter<'_>, volume: &str) -> std::fmt::Result {
110    write!(
111        f,
112        "volume `{volume}` is persistent: it outlives `down` on Docker and Kubernetes, while this target's storage goes with its machines"
113    )
114}
115
116/// Why a target with nothing to mount by name refuses existing volume `volume`.
117pub(crate) fn no_existing_volume(f: &mut std::fmt::Formatter<'_>, volume: &str) -> std::fmt::Result {
118    write!(
119        f,
120        "volume `{volume}` is an existing one (`external`): Docker and Kubernetes mount one by name, this target has none to mount"
121    )
122}
123
124/// Why a target that attaches no disk refuses machine `machine`'s.
125pub(crate) fn no_data_disks(f: &mut std::fmt::Formatter<'_>, machine: &str) -> std::fmt::Result {
126    write!(f, "machine `{machine}` has data disks (`disks`), which this target can't attach")
127}
128
129/// Why a target whose containers get no block device refuses machine `machine`'s raw disk.
130pub(crate) fn no_raw_disks(f: &mut std::fmt::Formatter<'_>, machine: &str) -> std::fmt::Result {
131    write!(
132        f,
133        "machine `{machine}` has a raw disk (`fs: raw`): a block device, which this target's containers don't get"
134    )
135}
136
137/// Why Kubernetes refuses machine `machine`'s tmpfs mode.
138pub(crate) fn no_tmpfs_modes(f: &mut std::fmt::Formatter<'_>, machine: &str) -> std::fmt::Result {
139    write!(f, "machine `{machine}` sets a tmpfs `mode`: Kubernetes' memory volumes have none")
140}
141
142#[cfg(test)]
143mod tests {
144    use super::*;
145
146    #[test]
147    fn a_volume_lives_with_the_environment_past_it_or_already_exists() {
148        let spec = crate::testing::lab(
149            "version: 1\nname: t\nvolumes: { keep: { persistent: true }, old: {}, tmp: {} }\nmachines:\n  m: { app: { image: x }, volumes: { keep: /k, old: /o, tmp: /t, own: /w }, tmpfs: [{ path: /run/x, mode: '1777' }] }\noverrides: { docker: { volumes: { old: { external: data-1 } } } }\n",
150        )
151        .for_target(isoloom_spec::model::Target::Docker);
152        assert_eq!(lifetime(&spec, "keep"), Lifetime::Persistent);
153        assert_eq!(lifetime(&spec, "old"), Lifetime::External { name: "data-1".into() });
154        assert_eq!(lifetime(&spec, "tmp"), Lifetime::Environment);
155        assert_eq!(lifetime(&spec, "own"), Lifetime::Environment);
156        assert_eq!(
157            requirements(&spec),
158            [
159                Requirement::PersistentVolume { volume: "keep".into() },
160                Requirement::ExternalVolume { volume: "old".into() },
161                Requirement::TmpfsModes { machine: "m".into() }
162            ]
163        );
164    }
165
166    #[test]
167    fn a_disk_takes_its_os_default_filesystem_and_a_key_of_its_machine() {
168        let spec = crate::testing::spec(
169            "version: 1\nname: t\nnetworks: { lan: { cidr: 10.9.0.0/24 } }\nmachines:\n  db: { networks: { lan: 10 }, os: debian@12, disks: { data: { size_gb: 5, mount: /data }, blk: { size_gb: 1, fs: raw } } }\n  win: { networks: { lan: 11 }, os: windows-server@2022, disks: { logs: { size_gb: 2, mount: 'E:' } } }\n",
170        );
171        let db = disks("db", &spec.machines["db"]);
172        assert_eq!((db[0].key.as_str(), db[0].fs, db[0].bytes()), ("db-data", Filesystem::Ext4, 5 << 30));
173        assert!(db[1].is_raw() && !db[0].is_raw());
174        assert_eq!(disks("win", &spec.machines["win"])[0].fs, Filesystem::Ntfs);
175        let kinds: Vec<_> = requirements(&spec).iter().map(Requirement::feature).collect();
176        assert_eq!(kinds, [crate::Feature::DataDisks, crate::Feature::RawDisks]);
177        let formatted =
178            crate::testing::spec("version: 1\nname: t\nmachines:\n  db: { os: debian@12, disks: { data: { size_gb: 5, mount: /data, fs: xfs } } }\n");
179        assert_eq!(requirements(&formatted), [Requirement::DataDisks { machine: "db".into() }]);
180    }
181}