Skip to main content

isoloom_spec/
instance.rs

1//! Instances: the same environment several times on one host. An instance number (1 to 99)
2//! gives a copy its own names and its own room on the host, so two instances of a spec never
3//! collide: the name gets a suffix (so Compose project, container, network, VM and namespace
4//! names differ), the Docker networks move to other blocks (Docker refuses two networks on one
5//! subnet), published host ports shift, and the files go to `.isoloom-<n>/` next to the
6//! committed `.isoloom/`. Local VM networks are isolated by name already (VirtualBox internal
7//! networks), so machines keep the addresses the spec writes.
8
9use crate::model::{NetworkDocker, Spec};
10use crate::validate::docker_cidrs;
11use crate::validate::{Cidr, Cidr6};
12use crate::{Error, Result};
13
14/// The highest instance number.
15pub const MAX: u8 = 99;
16
17/// Where an instance's generated files go.
18#[must_use]
19pub fn output_dir(instance: Option<u8>) -> String {
20    match instance {
21        Some(n) => format!(".isoloom-{n}"),
22        None => ".isoloom".to_owned(),
23    }
24}
25
26/// The spec as instance `n` of itself (validated specs only).
27///
28/// # Errors
29///
30/// [`Error::InstanceNumber`] outside 1 to [`MAX`], [`Error::InstanceName`] when the name with its
31/// suffix is too long, and [`Error::InstancePort`] or [`Error::InstanceIpv6`] when a published
32/// port or an IPv6 block leaves no room to shift.
33pub fn apply(spec: &Spec, n: u8) -> Result<Spec> {
34    if n == 0 || n > MAX {
35        return Err(Error::InstanceNumber(n));
36    }
37    let mut copy = spec.clone();
38    copy.name = format!("{}-{n}", spec.name);
39    if copy.name.len() > 55 {
40        return Err(Error::InstanceName(copy.name));
41    }
42    // Docker blocks: the second octet moves by n (10.61.10.0/24 -> 10.63.10.0/24 for instance 2),
43    // staying inside 10.0.0.0/8; networks keep their distance from each other.
44    for (name, block) in docker_cidrs(spec) {
45        let [_, second, third, fourth] = std::net::Ipv4Addr::from(block.base).octets();
46        let shifted = Cidr {
47            base: u32::from_be_bytes([10, second.wrapping_add(n), third, fourth]),
48            len: block.len,
49        };
50        if let Some(net) = copy.networks.get_mut(&name) {
51            net.docker = Some(NetworkDocker { cidr: shifted.to_string() });
52        }
53    }
54    shift_ipv6(&mut copy, n)?;
55    // Published host ports: 100 apart per instance (8080 -> 8180 for instance 1).
56    for (machine, m) in &mut copy.machines {
57        for port in &mut m.ports {
58            if let Some(published) = port.publish {
59                let shifted = u32::from(published) + 100 * u32::from(n);
60                let moved = u16::try_from(shifted).map_err(|_| Error::InstancePort {
61                    machine: machine.clone(),
62                    port: published,
63                    instance: n,
64                })?;
65                port.publish = Some(moved);
66            }
67        }
68    }
69    Ok(copy)
70}
71
72/// IPv6 blocks: the third group moves by n (`fd00:20::/64` -> `fd00:20:2::/64` for instance 2), on
73/// every target, since Docker refuses two networks on one IPv6 subnet too.
74fn shift_ipv6(copy: &mut Spec, n: u8) -> Result<()> {
75    for (name, net) in &mut copy.networks {
76        let Some(v6) = net.ipv6.as_mut() else { continue };
77        let Some(block) = Cidr6::parse(&v6.cidr) else { continue };
78        let moved = Cidr6 {
79            base: block.base.wrapping_add(u128::from(n) << 80),
80        };
81        if Cidr6::parse(&moved.to_string()).is_none() {
82            return Err(Error::InstanceIpv6 {
83                network: name.clone(),
84                cidr: v6.cidr.clone(),
85                instance: n,
86            });
87        }
88        v6.cidr = moved.to_string();
89    }
90    Ok(())
91}
92
93/// A generated file's path and contents, moved from `.isoloom/` to the instance's folder.
94#[must_use]
95pub fn relocate(path: &str, contents: &str, n: u8) -> (String, String) {
96    let dir = output_dir(Some(n));
97    let path = match path.strip_prefix(".isoloom/") {
98        Some(rest) => format!("{dir}/{rest}"),
99        None => path.to_owned(),
100    };
101    (path, contents.replace(".isoloom/", &format!("{dir}/")))
102}
103
104#[cfg(test)]
105mod tests {
106    use super::*;
107    use crate::testing::{lab, model};
108
109    /// A web machine publishing port 80 on the host's `publish`, on the lab's network.
110    fn web(publish: u16) -> Result<Spec, String> {
111        model(&lab(&format!(
112            "machines:\n  web:\n    docker: {{ image: nginx }}\n    networks: {{ lan: 10 }}\n    ports: [{{ port: 80, publish: {publish} }}]\n"
113        )))
114    }
115
116    #[test]
117    fn an_instance_writes_to_its_own_folder() {
118        assert_eq!(output_dir(None), ".isoloom");
119        assert_eq!(output_dir(Some(3)), ".isoloom-3");
120    }
121
122    #[test]
123    fn instances_are_numbered_1_to_99() -> Result<(), String> {
124        let spec = web(8080)?;
125        for n in [0, 100] {
126            assert!(matches!(apply(&spec, n), Err(Error::InstanceNumber(m)) if m == n), "instance {n}");
127        }
128        for n in [1, 99] {
129            assert_eq!(apply(&spec, n).map(|s| s.name).ok(), Some(format!("t-{n}")), "instance {n}");
130        }
131        Ok(())
132    }
133
134    #[test]
135    fn a_name_with_its_suffix_holds_55_characters() -> Result<(), String> {
136        let mut spec = web(8080)?;
137        spec.name = "a".repeat(53);
138        assert_eq!(apply(&spec, 1).map(|s| s.name.len()).ok(), Some(55));
139        spec.name = "a".repeat(54);
140        assert!(matches!(apply(&spec, 1), Err(Error::InstanceName(name)) if name.len() == 56));
141        Ok(())
142    }
143
144    #[test]
145    fn docker_blocks_move_by_the_instance_in_the_second_octet() -> Result<(), String> {
146        let copy = apply(&web(8080)?, 2).map_err(|e| e.to_string())?;
147        assert_eq!(copy.networks["lan"].docker, Some(NetworkDocker { cidr: "10.2.0.0/24".into() }));
148        assert_eq!(copy.networks["lan"].cidr, "10.0.0.0/24", "the machines keep the addresses the spec writes");
149        Ok(())
150    }
151
152    #[test]
153    fn published_ports_move_100_per_instance() -> Result<(), String> {
154        let copy = apply(&web(8080)?, 3).map_err(|e| e.to_string())?;
155        assert_eq!(copy.machines["web"].ports[0].publish, Some(8380));
156        assert_eq!(copy.machines["web"].ports[0].port, 80, "the machine's own port stays");
157        assert!(matches!(
158            apply(&web(65_500)?, 1),
159            Err(Error::InstancePort { machine, port: 65_500, instance: 1 }) if machine == "web"
160        ));
161        Ok(())
162    }
163
164    #[test]
165    fn ipv6_blocks_move_by_the_instance_in_the_third_group() -> Result<(), String> {
166        let spec = |cidr: &str| {
167            model(&format!(
168                "version: 1\nname: t\nnetworks:\n  lan: {{ cidr: 10.0.0.0/24, ipv6: {{ cidr: \"{cidr}\" }} }}\nmachines: {{}}\n"
169            ))
170        };
171        let copy = apply(&spec("fd00:20::/64")?, 2).map_err(|e| e.to_string())?;
172        assert_eq!(copy.networks["lan"].ipv6.as_ref().map(|v6| v6.cidr.as_str()), Some("fd00:20:2::/64"));
173        assert!(matches!(
174            apply(&spec("fdff:ffff:ffff::/64")?, 1),
175            Err(Error::InstanceIpv6 { network, instance: 1, .. }) if network == "lan"
176        ));
177        Ok(())
178    }
179
180    #[test]
181    fn generated_files_and_the_paths_they_hold_move_to_the_instances_folder() {
182        assert_eq!(
183            relocate(".isoloom/compose.yml", "env_file: .isoloom/web.env", 2),
184            (".isoloom-2/compose.yml".to_owned(), "env_file: .isoloom-2/web.env".to_owned())
185        );
186        assert_eq!(relocate("README.md", "plain", 2), ("README.md".to_owned(), "plain".to_owned()));
187    }
188}