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
102pub(crate) const WINDOWS_PROJECT_DIR: &str = "C:\\isoloom";
105
106const 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; }";
109
110const BOOT_ID: &str = "/proc/sys/kernel/random/boot_id";
112
113#[must_use]
116pub fn linux(env: &Environment, machine: &str, step: &Step) -> Option<String> {
117 match step {
118 Step::Prelude => constraints::linux_prelude(env.spec()),
119 Step::DiskGrow => Some(disks::GROW_ROOT_SH.to_owned()),
120 Step::Disks { disks } => disks::linux(disks),
121 Step::Settings => env.spec().machines.get(machine).and_then(settings::linux),
122 Step::Hosts { entries } => network::hosts(entries),
123 Step::Routes { routes, default } => network::routes(routes, *default),
124 Step::Ipv6 { addresses, routes } => Some(ipv6::linux(addresses, routes)),
125 Step::MoreAddresses { addresses } => Some(more_addresses::linux(addresses)),
126 Step::Trunks => network::trunks(env, machine),
127 Step::Volumes { paths } => (!paths.is_empty()).then(|| format!("mkdir -p {}\n", paths.iter().map(|p| quote(p)).collect::<Vec<_>>().join(" "))),
128 Step::SharedVolume { volume, path, role } => Some(shared_volume::linux(volume, path, role)),
129 Step::Project => Some(format!(
130 "mkdir -p {PROJECT_DIR} && tar -xzf {PROJECT_ARCHIVE} -C {PROJECT_DIR} --strip-components=1 && rm -f {PROJECT_ARCHIVE}\n"
131 )),
132 Step::Files { files } => Some(files::linux(files)),
133 Step::Secrets => env.machines.get(machine).map(|mp| secrets::step(machine, &mp.secrets)),
134 Step::Inputs { persist } => persist.then(|| format!("mkdir -p /var/lib/isoloom && install -m 600 {INPUTS_FILE} {KEPT_INPUTS_FILE}\n")),
135 Step::WaitFor { machine: dep, ports } => Some(wait::linux(dep, ports)),
136 Step::Run { path, kind, creates } => run(env, machine, path, *kind).map(|line| guarded(line, creates.as_deref())),
137 Step::Configure { resources, fresh } => Configured::of(env, machine).map(|c| shell::script(&c, resources, *fresh)),
138 Step::Reboot => Some(format!("{}(sleep 2 && reboot) >/dev/null 2>&1 &\n", record_boot())),
139 Step::Egress => Some(network::egress(env)),
140 Step::Tc => network::tc(env, machine),
141 Step::Command => process::command(env, machine),
142 Step::Setup { paths } => process::setup(env, machine, paths),
143 Step::Ready => Some(format!(
144 "{}mkdir -p /var/lib/isoloom && echo ready > /var/lib/isoloom/ready\n",
145 process::healthy(env, machine).unwrap_or_default()
146 )),
147 }
148}
149
150#[must_use]
153pub fn windows(env: &Environment, machine: &str, step: &Step) -> Option<String> {
154 match step {
155 Step::DiskGrow => Some(disks::GROW_ROOT_PS1.to_owned()),
156 Step::Disks { disks } => disks::windows(disks),
157 Step::Settings => env.spec().machines.get(machine).and_then(settings::windows),
158 Step::Hosts { entries } => network::windows_hosts(entries),
159 Step::Routes { routes, .. } => network::windows_routes(routes),
160 Step::Ipv6 { addresses, routes } => Some(ipv6::windows(addresses, routes)),
161 Step::MoreAddresses { addresses } => Some(more_addresses::windows(addresses)),
162 Step::Project => Some(format!(
163 "$archive = 'C:\\Windows\\Temp\\isoloom-project.tgz'; $dest = '{WINDOWS_PROJECT_DIR}'; $strip = 1\n{}",
164 powershell::UNTAR
165 )),
166 Step::Files { files } => Some(files::windows(files)),
167 Step::Secrets => env.machines.get(machine).map(|mp| secrets::windows::step(machine, &mp.secrets)),
168 Step::WaitFor { machine: dep, ports } => wait::windows(dep, ports),
169 Step::Run {
170 path,
171 kind: RunKind::PowerShell,
172 creates,
173 } => {
174 let run = format!(
175 "Set-Location {WINDOWS_PROJECT_DIR}; & powershell -NoProfile -ExecutionPolicy Bypass -File {}; if ($LASTEXITCODE) {{ exit $LASTEXITCODE }}",
176 powershell::quote(path)
177 );
178 Some(match creates {
179 Some(done) => format!("if (-not (Test-Path -LiteralPath {})) {{ {run} }}", powershell::quote(done)),
180 None => run,
181 })
182 }
183 Step::Configure { resources, fresh } => Configured::of(env, machine).map(|c| configure::powershell::script(&c, resources, *fresh)),
184 Step::Command => process::windows_command(env, machine),
185 Step::Setup { paths } => Some(process::windows_setup(paths)),
186 Step::Ready => Some(format!(
187 "{}New-Item -ItemType Directory -Force {WINDOWS_PROJECT_DIR} | Out-Null; Set-Content {WINDOWS_PROJECT_DIR}\\ready ready",
188 process::windows_healthy(env, machine).unwrap_or_default()
189 )),
190 Step::Prelude
193 | Step::Trunks
194 | Step::Volumes { .. }
195 | Step::SharedVolume { .. }
196 | Step::Inputs { .. }
197 | Step::Run { .. }
198 | Step::Reboot
199 | Step::Egress
200 | Step::Tc => None,
201 }
202}
203
204fn run(env: &Environment, machine: &str, path: &str, kind: RunKind) -> Option<String> {
207 let inputs = inputs_file(env, machine).map(|f| format!("set -a; . {f}; set +a; ")).unwrap_or_default();
208 match kind {
209 RunKind::PowerShell => None,
210 RunKind::Shell => Some(format!("cd {PROJECT_DIR} && sh -c {}\n", quote(&format!("{inputs}sh {path}")))),
211 RunKind::Playbook => Some(format!(
214 "{PKG}\ncommand -v ansible-playbook >/dev/null || pkg ansible-core\ncd {PROJECT_DIR} && sh -c {}\n",
215 quote(&format!("{inputs}ansible-playbook -c local -i {machine}, {path}"))
216 )),
217 }
218}
219
220fn guarded(line: String, creates: Option<&str>) -> String {
222 match creates {
223 Some(path) => format!("[ -e {} ] || {{ {} }}\n", quote(path), line.trim_end()),
224 None => line,
225 }
226}
227
228fn inputs_file(env: &Environment, machine: &str) -> Option<&'static str> {
231 let m = env.machines.get(machine).filter(|m| !m.inputs.is_empty())?;
232 let kept = m.lifecycle.iter().any(|s| matches!(s, Step::Inputs { persist: true }));
233 Some(if kept { KEPT_INPUTS_FILE } else { INPUTS_FILE })
234}
235
236#[must_use]
239pub fn record_boot() -> String {
240 format!("mkdir -p /var/lib/isoloom && cp {BOOT_ID} /var/lib/isoloom/boot-id\n")
241}
242
243#[must_use]
245pub fn reboot_check() -> String {
246 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")
247}
248
249#[cfg(test)]
250mod tests;