1use crate::model::{NetworkDocker, Spec};
10use crate::validate::docker_cidrs;
11use crate::validate::{Cidr, Cidr6};
12use crate::{Error, Result};
13
14pub const MAX: u8 = 99;
16
17#[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
26pub 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 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 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
72fn 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#[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 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}