1use isoloom_plan::Environment;
7use isoloom_plan::lifecycle::{RunKind, Step};
8use isoloom_spec::shell::quote;
9
10use crate::configure::{self, Configured, shell};
11use crate::network::{ipv6, more_addresses};
12use crate::{constraints, powershell, secrets};
13
14mod disks;
15mod files;
16mod network;
17mod process;
18mod settings;
19mod shared_volume;
20mod wait;
21
22pub const PROJECT_DIR: &str = "/opt/isoloom";
24pub const PROJECT_ARCHIVE: &str = "/tmp/isoloom-project.tgz";
26pub const INPUTS_FILE: &str = "/tmp/isoloom-inputs.env";
28pub const KEPT_INPUTS_FILE: &str = "/var/lib/isoloom/inputs.env";
30pub const CLOUD_INIT_WAIT: &str = "cloud-init status --wait >/dev/null 2>&1 || true";
33
34pub const PUBLISHED_FROM_OUTSIDE: &str = r"cat > /etc/systemd/system/isoloom-published.service <<'UNIT'
41[Unit]
42Description=Isoloom: published ports reach containers of networks without a default route
43After=docker.service
44Wants=docker.service
45
46[Service]
47Type=oneshot
48RemainAfterExit=yes
49ExecStart=/bin/sh -c 'iptables -t nat -C POSTROUTING -m conntrack --ctstate DNAT -o br-+ -j MASQUERADE 2>/dev/null || iptables -t nat -I POSTROUTING -m conntrack --ctstate DNAT -o br-+ -j MASQUERADE'
50
51[Install]
52WantedBy=multi-user.target
53UNIT
54systemctl daemon-reload
55systemctl enable --now isoloom-published.service
56";
57
58pub const DOCKER_VERSION: &str = "29.9.0";
61
62pub const COMPOSE_VERSION: &str = "5.6.0";
64
65pub const DOCKER_APT_KEY_SHA256: &str = "1500c1f56fa9e26b9b8f42452a553675796ade0807cdce11975eb98170b3a570";
68
69pub const INSTALL_DOCKER: &str = r#"command -v docker >/dev/null || if command -v apt-get >/dev/null; then (
78 set -e
79 export DEBIAN_FRONTEND=noninteractive
80 apt-get update -qq
81 apt-get install -y -qq ca-certificates curl
82 install -m 0755 -d /etc/apt/keyrings
83 k=$(mktemp)
84 # shellcheck disable=SC1091,SC2154 # /etc/os-release is on the guest, not here.
85 _id=$(. /etc/os-release && printf %s "$ID")
86 # shellcheck disable=SC1091,SC2154
87 _cn=$(. /etc/os-release && printf %s "$VERSION_CODENAME")
88 curl -fsSL "https://download.docker.com/linux/$_id/gpg" -o "$k"
89 echo "1500c1f56fa9e26b9b8f42452a553675796ade0807cdce11975eb98170b3a570 $k" | sha256sum -c -
90 install -m 0644 "$k" /etc/apt/keyrings/docker.asc
91 rm -f "$k"
92 echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/$_id $_cn stable" > /etc/apt/sources.list.d/docker.list
93 apt-get update -qq
94 v=$(apt-cache madison docker-ce | awk '$3 ~ /(^|:)29[.]9[.]0/ { print $3; exit }')
95 [ -n "$v" ] || { echo "docker-ce 29.9.0 is not in Docker's apt repository" >&2; exit 1; }
96 apt-get install -y -qq --allow-downgrades "docker-ce=$v" "docker-ce-cli=$v" containerd.io docker-buildx-plugin "docker-compose-plugin=5.6.0-*"
97)
98else
99 curl -fsSL https://get.docker.com | VERSION=29.9.0 sh
100fi"#;
101
102const WINDOWS_PROJECT_DIR: &str = "C:\\isoloom";
104
105const PKG: &str = "pkg() { if command -v apt-get >/dev/null; then apt-get update -qq && DEBIAN_FRONTEND=noninteractive apt-get install -y -qq \"$@\"; elif command -v dnf >/dev/null; then dnf install -y -q \"$@\"; else yum install -y -q \"$@\"; fi; }";
108
109const BOOT_ID: &str = "/proc/sys/kernel/random/boot_id";
111
112#[must_use]
115pub fn linux(env: &Environment, machine: &str, step: &Step) -> Option<String> {
116 match step {
117 Step::Prelude => constraints::linux_prelude(env.spec()),
118 Step::DiskGrow => Some(disks::GROW_ROOT_SH.to_owned()),
119 Step::Disks { disks } => disks::linux(disks),
120 Step::Settings => env.spec().machines.get(machine).and_then(settings::linux),
121 Step::Hosts { entries } => network::hosts(entries),
122 Step::Routes { routes, default } => network::routes(routes, *default),
123 Step::Ipv6 { addresses, routes } => Some(ipv6::linux(addresses, routes)),
124 Step::MoreAddresses { addresses } => Some(more_addresses::linux(addresses)),
125 Step::Trunks => network::trunks(env, machine),
126 Step::Volumes { paths } => (!paths.is_empty()).then(|| format!("mkdir -p {}\n", paths.iter().map(|p| quote(p)).collect::<Vec<_>>().join(" "))),
127 Step::SharedVolume { volume, path, role } => Some(shared_volume::linux(volume, path, role)),
128 Step::Project => Some(format!(
129 "mkdir -p {PROJECT_DIR} && tar -xzf {PROJECT_ARCHIVE} -C {PROJECT_DIR} --strip-components=1 && rm -f {PROJECT_ARCHIVE}\n"
130 )),
131 Step::Files { files } => Some(files::linux(files)),
132 Step::Secrets => env.machines.get(machine).map(|mp| secrets::step(machine, &mp.secrets)),
133 Step::Inputs { persist } => persist.then(|| format!("mkdir -p /var/lib/isoloom && install -m 600 {INPUTS_FILE} {KEPT_INPUTS_FILE}\n")),
134 Step::WaitFor { machine: dep, ports } => Some(wait::linux(dep, ports)),
135 Step::Run { path, kind, creates } => run(env, machine, path, *kind).map(|line| guarded(line, creates.as_deref())),
136 Step::Configure { resources, fresh } => Configured::of(env, machine).map(|c| shell::script(&c, resources, *fresh)),
137 Step::Reboot => Some(format!("{}(sleep 2 && reboot) >/dev/null 2>&1 &\n", record_boot())),
138 Step::Egress => Some(network::egress(env)),
139 Step::Tc => network::tc(env, machine),
140 Step::Command => process::command(env, machine),
141 Step::Setup { paths } => process::setup(env, machine, paths),
142 Step::Ready => Some(format!(
143 "{}mkdir -p /var/lib/isoloom && echo ready > /var/lib/isoloom/ready\n",
144 process::healthy(env, machine).unwrap_or_default()
145 )),
146 }
147}
148
149#[must_use]
152pub fn windows(env: &Environment, machine: &str, step: &Step) -> Option<String> {
153 match step {
154 Step::DiskGrow => Some(disks::GROW_ROOT_PS1.to_owned()),
155 Step::Disks { disks } => disks::windows(disks),
156 Step::Settings => env.spec().machines.get(machine).and_then(settings::windows),
157 Step::Hosts { entries } => network::windows_hosts(entries),
158 Step::Routes { routes, .. } => network::windows_routes(routes),
159 Step::Ipv6 { addresses, routes } => Some(ipv6::windows(addresses, routes)),
160 Step::MoreAddresses { addresses } => Some(more_addresses::windows(addresses)),
161 Step::Project => Some(format!(
162 "$archive = 'C:\\Windows\\Temp\\isoloom-project.tgz'; $dest = '{WINDOWS_PROJECT_DIR}'; $strip = 1\n{}",
163 powershell::UNTAR
164 )),
165 Step::Files { files } => Some(files::windows(files)),
166 Step::Secrets => env.machines.get(machine).map(|mp| secrets::windows::step(machine, &mp.secrets)),
167 Step::WaitFor { machine: dep, ports } => wait::windows(dep, ports),
168 Step::Run {
169 path,
170 kind: RunKind::PowerShell,
171 creates,
172 } => {
173 let run = format!(
174 "Set-Location {WINDOWS_PROJECT_DIR}; & powershell -NoProfile -ExecutionPolicy Bypass -File {}; if ($LASTEXITCODE) {{ exit $LASTEXITCODE }}",
175 powershell::quote(path)
176 );
177 Some(match creates {
178 Some(done) => format!("if (-not (Test-Path -LiteralPath {})) {{ {run} }}", powershell::quote(done)),
179 None => run,
180 })
181 }
182 Step::Configure { resources, fresh } => Configured::of(env, machine).map(|c| configure::powershell::script(&c, resources, *fresh)),
183 Step::Command => process::windows_command(env, machine),
184 Step::Setup { paths } => Some(process::windows_setup(paths)),
185 Step::Ready => Some(format!(
186 "{}New-Item -ItemType Directory -Force {WINDOWS_PROJECT_DIR} | Out-Null; Set-Content {WINDOWS_PROJECT_DIR}\\ready ready",
187 process::windows_healthy(env, machine).unwrap_or_default()
188 )),
189 Step::Prelude
192 | Step::Trunks
193 | Step::Volumes { .. }
194 | Step::SharedVolume { .. }
195 | Step::Inputs { .. }
196 | Step::Run { .. }
197 | Step::Reboot
198 | Step::Egress
199 | Step::Tc => None,
200 }
201}
202
203fn run(env: &Environment, machine: &str, path: &str, kind: RunKind) -> Option<String> {
206 let inputs = inputs_file(env, machine).map(|f| format!("set -a; . {f}; set +a; ")).unwrap_or_default();
207 match kind {
208 RunKind::PowerShell => None,
209 RunKind::Shell => Some(format!("cd {PROJECT_DIR} && sh -c {}\n", quote(&format!("{inputs}sh {path}")))),
210 RunKind::Playbook => Some(format!(
213 "{PKG}\ncommand -v ansible-playbook >/dev/null || pkg ansible-core\ncd {PROJECT_DIR} && sh -c {}\n",
214 quote(&format!("{inputs}ansible-playbook -c local -i {machine}, {path}"))
215 )),
216 }
217}
218
219fn guarded(line: String, creates: Option<&str>) -> String {
221 match creates {
222 Some(path) => format!("[ -e {} ] || {{ {} }}\n", quote(path), line.trim_end()),
223 None => line,
224 }
225}
226
227fn inputs_file(env: &Environment, machine: &str) -> Option<&'static str> {
230 let m = env.machines.get(machine).filter(|m| !m.inputs.is_empty())?;
231 let kept = m.lifecycle.iter().any(|s| matches!(s, Step::Inputs { persist: true }));
232 Some(if kept { KEPT_INPUTS_FILE } else { INPUTS_FILE })
233}
234
235#[must_use]
238pub fn record_boot() -> String {
239 format!("mkdir -p /var/lib/isoloom && cp {BOOT_ID} /var/lib/isoloom/boot-id\n")
240}
241
242#[must_use]
244pub fn reboot_check() -> String {
245 format!("if [ \"$(cat {BOOT_ID})\" = \"$(cat /var/lib/isoloom/boot-id)\" ]; then echo 'the reboot step: the machine did not restart' >&2; exit 1; fi\n")
246}
247
248#[cfg(test)]
249mod tests;