Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

302 linesCodeBlameRaw
1//! Windows: the sandbox is a WSL 2 distribution of its own, `g1t-sandbox`,
2//! imported from the release's signed root filesystem.
3//!
4//! Why WSL 2 rather than a Hyper-V VM: WSL 2 is on every edition of
5//! Windows 11, Home included, and once it is installed (one administrator
6//! step, `wsl --install --no-distribution`, which many people have done
7//! already), importing, starting and removing a distribution needs no
8//! administrator. A Hyper-V VM needs the Hyper-V feature, which Home does
9//! not have, and membership of Hyper-V Administrators to make or start a
10//! VM. A WSL 2 distribution is a real Linux kernel in a VM, not the
11//! Windows kernel.
12//!
13//! What WSL would otherwise share with Windows is turned off: the image's
14//! `/etc/wsl.conf` mounts no drives and starts no Windows programs, and
15//! the supervisor inside unmounts what WSL mounts into every distribution
16//! anyway and refuses to run while anything of the computer's is reachable
17//! (guest.rs). The distribution is started by a hidden `wsl.exe` that the
18//! launcher keeps running (WSL stops a distribution once nothing of
19//! Windows' is attached to it); nothing listens on the computer.
20//!
21//! What it cannot do: every WSL 2 distribution, the person's own
22//! included, runs in one utility VM with one kernel, so root in this
23//! distribution could reach theirs. That is why nothing a job runs is root
24//! here: the runner is a Sysbox container and each job a container inside
25//! it. Global WSL settings (`%USERPROFILE%\.wslconfig`) apply too: with
26//! `networkingMode=mirrored` Linux reaches Windows' own `localhost`, so the
27//! launcher refuses to start with it.
28
29use std::path::{Path, PathBuf};
30use std::process::{Command, Output, Stdio};
31
32use anyhow::{Context, Result, bail};
33
34use super::guest::{GuestStatus, Settings};
35use super::image::{self, Local};
36use super::{Backend, Resources, StopMode, process_alive};
37use crate::selfhosted::native::{Availability, hide_window};
38use crate::selfhosted::needs::{self, WindowsFacts, WslFacts};
39
40/// What the distribution is called.
41pub const DISTRO: &str = "g1t-sandbox";
42const GUEST: &str = "/usr/local/bin/g1t-runner";
43
44pub struct Wsl {
45 pub distro: String,
46 /// The launcher's state folder: the distribution's disk is in `vm`.
47 pub dir: PathBuf,
48}
49
50fn wsl() -> Command {
51 let mut command = Command::new("wsl.exe");
52 command.env("WSL_UTF8", "1");
53 hide_window(&mut command);
54 command
55}
56
57/// `wsl.exe --list --verbose`: each distribution's name and state.
58pub fn parse_list(text: &str) -> Vec<(String, String)> {
59 text.replace('\0', "")
60 .lines()
61 .skip(1)
62 .filter_map(|line| {
63 let fields: Vec<&str> = line.trim().trim_start_matches('*').split_whitespace().collect();
64 (fields.len() >= 2).then(|| (fields[0].to_owned(), fields[1].to_owned()))
65 })
66 .collect()
67}
68
69/// Whether `wsl.exe --list --verbose` lists a WSL 2 distribution: WSL 2
70/// works here.
71pub fn has_wsl2(text: &str) -> bool {
72 text.replace('\0', "").lines().skip(1).any(|line| line.trim().trim_start_matches('*').split_whitespace().nth(2) == Some("2"))
73}
74
75/// Whether `.wslconfig` puts WSL on mirrored networking, where Linux
76/// reaches Windows' own `localhost`.
77pub fn mirrored(wslconfig: &str) -> bool {
78 let mut section = String::new();
79 for line in wslconfig.lines() {
80 let line = line.trim();
81 if line.starts_with('[') {
82 section = line.trim_matches(['[', ']']).trim().to_ascii_lowercase();
83 } else if section == "wsl2"
84 && let Some((key, value)) = line.split_once('=')
85 && key.trim().eq_ignore_ascii_case("networkingMode")
86 {
87 return value.split('#').next().unwrap_or("").trim().eq_ignore_ascii_case("mirrored");
88 }
89 }
90 false
91}
92
93impl Wsl {
94 fn list(&self) -> Vec<(String, String)> {
95 wsl().args(["--list", "--verbose"]).output().map(|o| parse_list(&String::from_utf8_lossy(&o.stdout))).unwrap_or_default()
96 }
97
98 fn state(&self) -> Option<String> {
99 self.list().into_iter().find(|(name, _)| name.eq_ignore_ascii_case(&self.distro)).map(|(_, state)| state)
100 }
101
102 fn keepalive_pid(&self) -> PathBuf {
103 self.dir.join("keepalive.pid")
104 }
105
106 /// `g1t-runner sandbox guest …` inside the distribution.
107 fn guest(&self, args: &[&str], input: Option<&[u8]>) -> Result<Output> {
108 let mut command = wsl();
109 command.args(["-d", &self.distro, "-u", "root", "--exec", GUEST, "sandbox", "guest"]).args(args);
110 command.stdout(Stdio::piped()).stderr(Stdio::piped());
111 command.stdin(if input.is_some() { Stdio::piped() } else { Stdio::null() });
112 let mut child = command.spawn().context("could not run wsl.exe")?;
113 if let Some(bytes) = input {
114 use std::io::Write;
115 let mut stdin = child.stdin.take().expect("piped");
116 stdin.write_all(bytes)?;
117 }
118 let output = child.wait_with_output()?;
119 if !output.status.success() {
120 bail!("the sandbox said: {}", String::from_utf8_lossy(&output.stderr).trim().trim_start_matches("g1t-runner: "));
121 }
122 Ok(output)
123 }
124}
125
126impl Backend for Wsl {
127 fn name(&self) -> &'static str {
128 "wsl2"
129 }
130
131 fn image_keys(&self) -> Vec<String> {
132 vec![format!("wsl-{}", image::arch())]
133 }
134
135 fn availability(&self) -> Availability {
136 let installed = wsl().arg("--status").stdout(Stdio::null()).stderr(Stdio::null()).status().is_ok_and(|s| s.success());
137 let config = std::env::var_os("USERPROFILE").map(|home| PathBuf::from(home).join(".wslconfig"));
138 if installed && config.and_then(|c| std::fs::read_to_string(c).ok()).is_some_and(|text| mirrored(&text)) {
139 return Availability::no(
140 "WSL is set to mirrored networking, in which Linux reaches this computer's own localhost.",
141 "Remove networkingMode=mirrored from %USERPROFILE%\\.wslconfig, run `wsl --shutdown`, and start the sandbox again.",
142 );
143 }
144 // Made already: WSL works, and what goes wrong from here starting it says.
145 if installed && self.exists() {
146 return Availability::yes();
147 }
148 let version = || wsl().arg("--version").stderr(Stdio::null()).output().ok().filter(|o| o.status.success()).and_then(|o| needs::wsl_version(&String::from_utf8_lossy(&o.stdout)));
149 let listed = || wsl().args(["--list", "--verbose"]).output().map(|o| has_wsl2(&String::from_utf8_lossy(&o.stdout))).unwrap_or(false);
150 let facts = WslFacts { installed, version: if installed { version() } else { None }, has_wsl2: installed && listed() };
151 let windows = WindowsFacts::look_in(&self.dir);
152 Availability::needing(needs::wsl_needs(&windows, &facts, needs::restart_pending(&self.dir, &windows)), "linux")
153 }
154
155 fn exists(&self) -> bool {
156 self.state().is_some()
157 }
158
159 fn running(&self) -> bool {
160 self.state().is_some_and(|s| s.eq_ignore_ascii_case("Running"))
161 }
162
163 fn create(&self, local: &Local, _resources: &Resources) -> Result<()> {
164 let key = &self.image_keys()[0];
165 let Some(rootfs) = local.files.get(key) else { bail!("no {key} image") };
166 let vm = self.dir.join("vm");
167 std::fs::create_dir_all(&vm)?;
168 let output = wsl().args(import_args(&self.distro, &vm, rootfs)).output().context("could not run wsl.exe")?;
169 if !output.status.success() {
170 bail!("WSL could not import the sandbox: {}{}", String::from_utf8_lossy(&output.stdout).trim(), String::from_utf8_lossy(&output.stderr).trim());
171 }
172 // The disk is not limited to `disk_gib`: `wsl --manage --resize`
173 // only works with all of WSL shut down, which would stop the
174 // person's own distributions and Docker Desktop. It grows as
175 // needed, up to WSL's default of 1 TB.
176 Ok(())
177 }
178
179 fn configure(&self, settings: &Settings) -> Result<()> {
180 self.guest(&["configure"], Some(&serde_json::to_vec(settings)?))?;
181 Ok(())
182 }
183
184 fn load_image(&self, tar: &Path) -> Result<()> {
185 let bytes = std::fs::read(tar).with_context(|| format!("could not read {}", tar.display()))?;
186 self.guest(&["load-image"], Some(&bytes))?;
187 Ok(())
188 }
189
190 fn start(&self) -> Result<()> {
191 let pid_file = self.keepalive_pid();
192 if std::fs::read_to_string(&pid_file).ok().and_then(|p| p.trim().parse().ok()).is_some_and(process_alive) && self.running() {
193 return Ok(());
194 }
195 let log = std::fs::File::create(self.dir.join("sandbox.log"))?;
196 let mut command = wsl();
197 command
198 .args(["-d", &self.distro, "-u", "root", "--exec", GUEST, "sandbox", "guest", "run"])
199 .stdin(Stdio::null())
200 .stdout(log.try_clone()?)
201 .stderr(log);
202 let child = super::spawn_detached(command)?;
203 std::fs::write(&pid_file, child.to_string())?;
204 Ok(())
205 }
206
207 fn guest_status(&self) -> Option<GuestStatus> {
208 if !self.running() {
209 return None;
210 }
211 let output = self.guest(&["status"], None).ok()?;
212 serde_json::from_slice(&output.stdout).ok()
213 }
214
215 fn stop(&self, mode: StopMode) -> Result<()> {
216 if self.running() {
217 let args: Vec<&str> = match mode {
218 StopMode::Drain => vec!["stop"],
219 StopMode::Now => vec!["stop", "--now", "--wait", "120"],
220 };
221 // Stopping now goes on to end the distribution whatever the
222 // guest said; a drain that failed must not.
223 if let Err(error) = self.guest(&args, None)
224 && mode == StopMode::Drain
225 {
226 return Err(error);
227 }
228 }
229 let _ = wsl().args(["--terminate", &self.distro]).stdout(Stdio::null()).stderr(Stdio::null()).status();
230 if let Some(pid) = std::fs::read_to_string(self.keepalive_pid()).ok().and_then(|p| p.trim().parse::<u32>().ok()) {
231 if process_alive(pid) {
232 let _ = Command::new("taskkill").args(["/F", "/PID", &pid.to_string()]).stdout(Stdio::null()).stderr(Stdio::null()).status();
233 }
234 let _ = std::fs::remove_file(self.keepalive_pid());
235 }
236 Ok(())
237 }
238
239 fn export_state(&self) -> Result<Option<Vec<u8>>> {
240 if !self.exists() {
241 return Ok(None);
242 }
243 // Not registered yet: nothing to carry.
244 Ok(self.guest(&["export-state"], None).ok().map(|o| o.stdout).filter(|b| !b.is_empty()))
245 }
246
247 fn import_state(&self, bytes: &[u8]) -> Result<()> {
248 self.guest(&["import-state"], Some(bytes))?;
249 Ok(())
250 }
251
252 fn remove(&self) -> Result<()> {
253 self.stop(StopMode::Now)?;
254 if self.exists() {
255 let output = wsl().args(["--unregister", &self.distro]).output().context("could not run wsl.exe")?;
256 if !output.status.success() {
257 bail!("WSL could not remove the sandbox: {}", String::from_utf8_lossy(&output.stdout).trim());
258 }
259 }
260 let _ = std::fs::remove_dir_all(self.dir.join("vm"));
261 Ok(())
262 }
263}
264
265/// `wsl.exe --import`: a WSL 2 distribution with its disk in `vm`.
266pub fn import_args(distro: &str, vm: &Path, rootfs: &Path) -> Vec<String> {
267 vec!["--import".into(), distro.into(), vm.display().to_string(), rootfs.display().to_string(), "--version".into(), "2".into()]
268}
269
270#[cfg(test)]
271mod tests {
272 use super::*;
273
274 #[test]
275 fn the_list_reads_with_its_default_marker() {
276 let text = " NAME STATE VERSION\n* Ubuntu Stopped 2\n g1t-sandbox Running 2\n docker-desktop Running 2\n";
277 let list = parse_list(text);
278 assert_eq!(list[0], ("Ubuntu".to_owned(), "Stopped".to_owned()));
279 assert_eq!(list[1], ("g1t-sandbox".to_owned(), "Running".to_owned()));
280 assert_eq!(list.len(), 3);
281 assert!(has_wsl2(text));
282 assert!(!has_wsl2(" NAME STATE VERSION\n* Ubuntu Stopped 1\n"));
283 assert!(!has_wsl2(""));
284 // wsl.exe without WSL_UTF8 writes UTF-16: its NULs are dropped.
285 assert_eq!(parse_list("N\0A\0M\0E\0\n\0g\x001\0t\0 \0R\0u\0n\0\n")[0].0, "g1t");
286 }
287
288 #[test]
289 fn mirrored_networking_is_found() {
290 assert!(mirrored("[wsl2]\nmemory=16GB\nnetworkingMode = Mirrored # yes\n"));
291 assert!(!mirrored("[wsl2]\nnetworkingMode=nat\n"));
292 assert!(!mirrored("[experimental]\nnetworkingMode=mirrored\n"));
293 assert!(!mirrored(""));
294 }
295
296 #[test]
297 fn the_import_is_wsl2_with_a_size() {
298 let args = import_args("g1t-sandbox", Path::new(r"C:\s\vm"), Path::new(r"C:\s\images\0.3.0\g1t-sandbox-wsl-x64.tar.gz"));
299 assert_eq!(args[..3], ["--import", "g1t-sandbox", r"C:\s\vm"]);
300 assert!(args.windows(2).any(|w| w == ["--version", "2"]));
301 }
302}