Skip to content
557 linesCodeBlameRaw
1//! The one place the app meets `g1t-runner sandbox`, the launcher that
2//! makes a sandbox of the runner's own on this computer and runs the
3//! runner inside it (crates/runner/src/selfhosted/launcher): Linux jobs in
4//! a Linux VM (a WSL 2 distribution on Windows, vfkit on macOS, Sysbox or
5//! QEMU with KVM on Linux), each a fresh container inside it
6//! (`nested-container`), and, on Windows 11 Pro and up, Windows jobs each
7//! in a Windows Sandbox of its own (`vm`).
8//!
9//! It is the `g1t-runner` the app already keeps up to date (native.rs),
10//! with its state in the app's runner folder (`--state-dir`). The app runs
11//! four commands, each with `--json`, and reads the one object each prints
12//! last (the launcher's documented status, schema 1):
13//!
14//! - `up --url … --name … --labels desktop [--token …]`, after the window
15//! opens: makes the sandbox if there is none and starts it, waiting until
16//! the runner asks for work. The token goes only to the first `up`; the
17//! launcher keeps the registration from then on, across updates too. It
18//! adds `-linux` and `-windows` to the name, and the OS labels, itself.
19//! - `status`, every few seconds, for the tray, settings and the site.
20//! - `down` as g1t quits or the person stops it; `down --drain` when they
21//! choose another way of running jobs; `down --delete` when the
22//! registration is no longer the one wanted.
23//! - `update`, while nothing runs: the newest signed image, the same
24//! registration.
25//!
26//! What a status's causes and errors mean is read here (`why`); what the
27//! app says about them is diagnosis.rs's.
28
29use std::ffi::OsString;
30use std::path::Path;
31use std::process::{Command, Stdio};
32use std::time::Duration;
33
34use serde::Deserialize;
35
36use super::backend::quiet;
37
38/// The launcher's state folder, under the app's runner folder.
39pub const FOLDER: &str = "sandbox";
40/// The status shape the app reads (`schema`).
41pub const SCHEMA: u32 = 1;
42/// How often the app asks the sandbox how it is: a call takes about a second.
43pub const STATUS_EVERY: Duration = Duration::from_secs(8);
44/// What installs WSL on Windows, for the person to run as administrator.
45pub const WSL_INSTALL: &str = "wsl --install --no-distribution";
46/// What installs vfkit on a Mac.
47pub const VFKIT_INSTALL: &str = "brew install vfkit";
48
49/// A sandbox's, or one capability's, state.
50#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Deserialize)]
51#[serde(rename_all = "snake_case")]
52pub enum State {
53 /// Never made, or deleted.
54 #[default]
55 Absent,
56 Stopped,
57 Starting,
58 Running,
59 Updating,
60 Error,
61 /// A state a newer launcher has that this app does not know.
62 #[serde(other)]
63 Unknown,
64}
65
66/// What a runner is running now.
67#[derive(Clone, Debug, Default, PartialEq, Eq, Deserialize)]
68#[serde(default)]
69pub struct Job {
70 /// `workflow` or `agent`.
71 pub kind: String,
72 pub id: String,
73 pub name: String,
74 pub repo: String,
75}
76
77impl Job {
78 /// How the tray says it: `build (acme/web)`.
79 pub fn said(&self) -> String {
80 if self.repo.is_empty() { self.name.clone() } else { format!("{} ({})", self.name, self.repo) }
81 }
82}
83
84/// One kind of job the computer can run sandboxed: `linux`, `windows` or `macos`.
85#[derive(Clone, Debug, Default, PartialEq, Eq, Deserialize)]
86#[serde(default)]
87pub struct Capability {
88 pub id: String,
89 pub available: bool,
90 pub state: State,
91 pub isolation: Option<String>,
92 pub runner_version: Option<String>,
93 pub job: Option<Job>,
94 /// Why it is not available, in a sentence.
95 pub cause: Option<String>,
96 /// What to do about it, in a sentence.
97 pub fix: Option<String>,
98}
99
100/// `g1t-runner sandbox status --json`. The top-level fields are the Linux
101/// VM's. Fields the app does not use (`resources`, `state_dir`) are left
102/// out, and any a newer launcher adds are ignored.
103#[derive(Clone, Debug, Default, PartialEq, Eq, Deserialize)]
104#[serde(default)]
105pub struct Status {
106 pub schema: u32,
107 pub state: State,
108 /// `wsl2`, `vfkit`, `qemu` or `sysbox`.
109 pub backend: Option<String>,
110 pub runner_version: Option<String>,
111 pub launcher_version: Option<String>,
112 pub image_version: Option<String>,
113 pub isolation: Option<String>,
114 pub job: Option<Job>,
115 /// A sentence for a person, whenever something is wrong.
116 pub error: Option<String>,
117 pub capabilities: Vec<Capability>,
118}
119
120impl Status {
121 pub fn capability(&self, id: &str) -> Option<&Capability> {
122 self.capabilities.iter().find(|c| c.id == id)
123 }
124
125 /// The Linux capability, as the top-level fields say it when a launcher lists none.
126 pub fn linux(&self) -> Capability {
127 self.capability("linux").cloned().unwrap_or_else(|| Capability {
128 id: "linux".into(),
129 available: self.state != State::Error || self.error.is_none(),
130 state: self.state,
131 runner_version: self.runner_version.clone(),
132 job: self.job.clone(),
133 ..Capability::default()
134 })
135 }
136
137 /// Whether any of its runners is running.
138 pub fn running(&self) -> bool {
139 self.state == State::Running || self.capabilities.iter().any(|c| c.state == State::Running)
140 }
141
142 /// Whether anything here can be sandboxed at all.
143 pub fn anything_available(&self) -> bool {
144 if self.capabilities.is_empty() { self.linux().available } else { self.capabilities.iter().any(|c| c.available) }
145 }
146
147 /// A capability that can be had here but was never set up: registering
148 /// it needs a token.
149 pub fn unregistered(&self) -> bool {
150 let wanting = |c: &Capability| c.available && c.state == State::Absent;
151 if self.capabilities.is_empty() { wanting(&self.linux()) } else { self.capabilities.iter().any(wanting) }
152 }
153
154 /// What a runner is running, the Linux one's first.
155 pub fn job(&self) -> Option<&Job> {
156 self.job.as_ref().or_else(|| self.capabilities.iter().find_map(|c| c.job.as_ref()))
157 }
158
159 /// The runner's version, as the tray and the site say it.
160 pub fn version(&self) -> Option<String> {
161 self.runner_version
162 .clone()
163 .or_else(|| self.capabilities.iter().find_map(|c| c.runner_version.clone()))
164 .or_else(|| self.launcher_version.clone())
165 }
166
167 /// What is wrong with Linux jobs, in the launcher's words: its error,
168 /// else why the capability is unavailable.
169 pub fn trouble(&self) -> Option<(String, Option<String>)> {
170 let linux = self.linux();
171 if !linux.available {
172 let cause = linux.cause.or_else(|| self.error.clone())?;
173 return Some((cause, linux.fix));
174 }
175 if linux.state == State::Error || self.state == State::Error {
176 return Some((self.error.clone().unwrap_or_else(|| "The sandbox stopped, and didn't say why.".into()), None));
177 }
178 None
179 }
180}
181
182/// Reads what a launcher command printed: log lines, then the status as
183/// one JSON object, which starts on a line of its own. A launcher whose
184/// schema is not this app's is not read, nor is a `g1t-runner` from before
185/// the launcher, which prints no status at all.
186pub fn parse(output: &str) -> Result<Status, String> {
187 let start = output
188 .match_indices('\n')
189 .map(|(at, _)| at + 1)
190 .chain(std::iter::once(0))
191 .filter(|at| output[*at..].starts_with('{'))
192 .max()
193 .ok_or_else(|| "this g1t-runner printed no sandbox status: it is older than its sandbox launcher".to_owned())?;
194 let status: Status = serde_json::from_str(output[start..].trim()).map_err(|e| format!("the sandbox's status doesn't read: {e}"))?;
195 if status.schema != SCHEMA {
196 return Err(format!("the sandbox's status is in schema {}, and this g1t reads schema {SCHEMA}: update g1t", status.schema));
197 }
198 Ok(status)
199}
200
201/// Whether a line of a launcher's output starts its status, rather than being a log line.
202pub fn starts_status(line: &str) -> bool {
203 line.starts_with('{')
204}
205
206/// What `up` is told the first time, and again on every start (the
207/// launcher keeps what it was told, and `--token` only matters once).
208#[derive(Clone, PartialEq, Eq)]
209pub struct Up<'a> {
210 pub url: &'a str,
211 pub name: &'a str,
212 pub labels: &'a str,
213 /// The registration token: only for a sandbox that has no registration yet.
214 pub token: Option<&'a str>,
215}
216
217// Never print a token, in a log or a panic.
218impl std::fmt::Debug for Up<'_> {
219 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
220 f.debug_struct("Up").field("url", &self.url).field("name", &self.name).field("labels", &self.labels).field("token", &self.token.map(|_| "…")).finish()
221 }
222}
223
224fn args(action: &str, dir: &Path) -> Vec<OsString> {
225 let mut args: Vec<OsString> = vec!["sandbox".into(), action.into(), "--json".into(), "--state-dir".into()];
226 args.push(dir.as_os_str().to_owned());
227 args
228}
229
230/// `sandbox up`'s arguments. The token is never one of them: anyone on the
231/// computer can read a process's command line while it runs, so it goes in
232/// `up`'s environment instead ([`with_token`]).
233pub fn up_args(dir: &Path, up: &Up) -> Vec<OsString> {
234 let mut args = args("up", dir);
235 for (name, value) in [("--url", up.url), ("--name", up.name), ("--labels", up.labels)] {
236 args.push(name.into());
237 args.push(value.into());
238 }
239 args
240}
241
242/// Gives a launcher command the registration token, as `G1T_RUNNER_TOKEN`
243/// in its environment and nowhere else.
244pub fn with_token(command: &mut Command, token: Option<&str>) {
245 if let Some(token) = token {
246 command.env("G1T_RUNNER_TOKEN", token);
247 }
248}
249
250/// How `down` stops the sandbox.
251#[derive(Clone, Copy, Debug, PartialEq, Eq)]
252pub enum Down {
253 /// Now: a running job is cut short (as g1t quits, or Stop).
254 Now,
255 /// After the running job (another way of running jobs was chosen).
256 Drain,
257 /// Now, and the VM and the registration with it (another workspace or server).
258 Delete,
259}
260
261pub fn down_args(dir: &Path, how: Down) -> Vec<OsString> {
262 let mut args = args("down", dir);
263 match how {
264 Down::Now => {}
265 Down::Drain => args.push("--drain".into()),
266 Down::Delete => args.push("--delete".into()),
267 }
268 args
269}
270
271pub fn status_args(dir: &Path) -> Vec<OsString> {
272 args("status", dir)
273}
274
275pub fn update_args(dir: &Path) -> Vec<OsString> {
276 args("update", dir)
277}
278
279/// A launcher command whose output the app reads a line at a time
280/// (`up` and `update`, which take minutes the first time).
281pub fn command(binary: &Path, args: Vec<OsString>) -> Command {
282 let mut command = quiet(binary);
283 // What the app's own environment may have set for another runner is not this one's.
284 for (name, _) in std::env::vars_os() {
285 if name.to_string_lossy().starts_with("G1T_RUNNER_") {
286 command.env_remove(&name);
287 }
288 }
289 command.args(args).stdout(Stdio::piped()).stderr(Stdio::piped());
290 command
291}
292
293/// Runs a short launcher command to its end and reads its status, which
294/// it prints whether it worked or not.
295fn run(binary: &Path, args: Vec<OsString>) -> Result<Status, String> {
296 let out = command(binary, args).output().map_err(|e| format!("g1t-runner could not start: {e}"))?;
297 let printed = String::from_utf8_lossy(&out.stdout);
298 match parse(&printed) {
299 Ok(status) => Ok(status),
300 Err(_) if !out.status.success() && !out.stderr.is_empty() => Err(String::from_utf8_lossy(&out.stderr).trim().trim_start_matches("g1t-runner: ").to_owned()),
301 Err(why) => Err(why),
302 }
303}
304
305/// How the sandbox is now.
306pub fn status(binary: &Path, dir: &Path) -> Result<Status, String> {
307 run(binary, status_args(dir))
308}
309
310/// Stops the sandbox, waiting for it.
311pub fn down(binary: &Path, dir: &Path, how: Down) -> Result<(), String> {
312 if !binary.exists() {
313 return Err("g1t-runner isn't installed".into());
314 }
315 let status = run(binary, down_args(dir, how))?;
316 match status.error {
317 Some(error) => Err(error),
318 None => Ok(()),
319 }
320}
321
322/// What a launcher's sentence is about: the words the app shows for it
323/// are diagnosis.rs's. Read from the causes and errors the launcher
324/// writes (crates/runner/src/selfhosted/launcher and native.rs).
325#[derive(Clone, Copy, Debug, PartialEq, Eq)]
326pub enum Why {
327 NoWsl,
328 WslMirrored,
329 NoVirtualization,
330 NoVfkit,
331 /// Linux, with neither Sysbox in Docker nor KVM.
332 NoSysboxOrKvm,
333 /// The sandbox's image couldn't be downloaded, or isn't the release's.
334 Image,
335 /// g1t refused the registration token.
336 TokenRefused,
337 /// g1t no longer knows the runner.
338 Forgotten,
339 /// The sandbox has no registration, and no token was given.
340 Unregistered,
341 /// Windows Sandbox is off, or this edition of Windows has none.
342 WindowsSandboxOff,
343 /// Windows Sandbox without `wsb`, from before Windows 11 24H2.
344 NoWsb,
345 /// Sandboxed macOS jobs are not released.
346 MacNotReleased,
347 /// Anything else: shown in the launcher's own words.
348 Other,
349}
350
351pub fn why(text: &str) -> Why {
352 let lower = text.to_ascii_lowercase();
353 let has = |words: &str| lower.contains(words);
354 if has("wsl is not installed") {
355 Why::NoWsl
356 } else if has("mirrored") {
357 Why::WslMirrored
358 } else if has("virtualization is not enabled")
359 || has("enable virtualization")
360 || has("virtual machine platform")
361 || has("hcs_e_hyperv_not_installed")
362 || has("vt-x")
363 || has("hypervisor is not present")
364 {
365 Why::NoVirtualization
366 } else if has("vfkit") && has("not installed") {
367 Why::NoVfkit
368 } else if has("neither sysbox nor kvm") || has("docker has no sysbox") {
369 Why::NoSysboxOrKvm
370 } else if has("token") && (has("not valid") || has("expired") || has("invalid")) {
371 Why::TokenRefused
372 } else if has("no longer knows this runner") || has("was removed from g1t") {
373 Why::Forgotten
374 } else if has("is not registered") {
375 Why::Unregistered
376 } else if has("sandbox image") || has("sandbox's images") || has("no sandbox images") || has("sha-256") || has("signature") || has("release key") || has("could not fetch") || has("broke off") {
377 Why::Image
378 } else if has("windows sandbox is not turned on") {
379 Why::WindowsSandboxOff
380 } else if has("24h2") {
381 Why::NoWsb
382 } else if has("macos jobs are not released") {
383 Why::MacNotReleased
384 } else {
385 Why::Other
386 }
387}
388
389#[cfg(test)]
390mod tests {
391 use super::*;
392 use std::path::PathBuf;
393
394 /// What `status --json` printed on a Windows 11 Pro machine with WSL
395 /// and Windows Sandbox, before `up` (g1t-runner 0.2.1).
396 const ABSENT: &str = r#"{
397 "schema": 1,
398 "state": "absent",
399 "backend": "wsl2",
400 "runner_version": null,
401 "launcher_version": "0.2.1",
402 "image_version": null,
403 "isolation": "nested-container",
404 "job": null,
405 "error": null,
406 "resources": {
407 "cpus": 0,
408 "memory_mib": 0,
409 "disk_gib": 0
410 },
411 "capabilities": [
412 {
413 "id": "linux",
414 "available": true,
415 "state": "absent",
416 "isolation": "nested-container",
417 "runner_version": null,
418 "job": null,
419 "cause": null,
420 "fix": null
421 },
422 {
423 "id": "windows",
424 "available": true,
425 "state": "absent",
426 "isolation": "vm",
427 "runner_version": null,
428 "job": null,
429 "cause": null,
430 "fix": null
431 }
432 ],
433 "state_dir": "C:\\Users\\syntaqx\\.g1t-runner\\sandbox"
434}"#;
435
436 /// The launcher's documented example: running a job, Windows Sandbox off.
437 const RUNNING: &str = r#"{
438 "schema": 1, "state": "running", "backend": "wsl2",
439 "runner_version": "0.3.0", "launcher_version": "0.3.0", "image_version": "0.3.0",
440 "isolation": "nested-container",
441 "job": { "kind": "workflow", "id": "job_1", "name": "build", "repo": "acme/web" },
442 "error": null,
443 "resources": { "cpus": 4, "memory_mib": 8192, "disk_gib": 64 },
444 "capabilities": [
445 { "id": "linux", "available": true, "state": "running", "isolation": "nested-container",
446 "runner_version": "0.3.0", "job": { "kind": "workflow", "id": "job_1", "name": "build", "repo": "acme/web" }, "cause": null, "fix": null },
447 { "id": "windows", "available": false, "state": "absent", "isolation": "vm",
448 "runner_version": null, "job": null,
449 "cause": "Windows Sandbox is not turned on, or this edition of Windows has none (Home does not).",
450 "fix": "On Windows 11 Pro, Enterprise or Education, turn on Windows Sandbox under Settings, System, Optional features, More Windows features, then restart." }
451 ],
452 "state_dir": "C:\\Users\\a\\.g1t-runner\\sandbox",
453 "something_new": { "a": 1 }
454}"#;
455
456 #[test]
457 fn a_status_is_read_from_after_the_log() {
458 let printed = format!("2026-10-12T08:00:00Z Downloading the sandbox image wsl-x64.\n2026-10-12T08:03:00Z {{not json}}\n{RUNNING}\n");
459 let status = parse(&printed).unwrap();
460 assert_eq!(status.state, State::Running);
461 assert_eq!(status.backend.as_deref(), Some("wsl2"));
462 assert_eq!(status.job().map(Job::said).as_deref(), Some("build (acme/web)"));
463 assert_eq!(status.version().as_deref(), Some("0.3.0"));
464 assert!(status.running());
465 assert!(!status.unregistered());
466 let windows = status.capability("windows").unwrap();
467 assert!(!windows.available);
468 assert_eq!(why(windows.cause.as_deref().unwrap()), Why::WindowsSandboxOff);
469 assert_eq!(status.trouble(), None);
470 }
471
472 #[test]
473 fn a_sandbox_never_made_needs_registering() {
474 let status = parse(ABSENT).unwrap();
475 assert_eq!(status.state, State::Absent);
476 assert!(status.anything_available());
477 assert!(status.unregistered());
478 assert!(!status.running());
479 assert_eq!(status.version().as_deref(), Some("0.2.1"));
480 }
481
482 #[test]
483 fn what_is_not_a_status_is_said_so() {
484 assert!(parse("g1t-runner: unknown command sandbox\n").unwrap_err().contains("older than its sandbox launcher"));
485 assert!(parse(&ABSENT.replace("\"schema\": 1", "\"schema\": 2")).unwrap_err().contains("schema 2"));
486 let unknown = parse(&ABSENT.replace("\"state\": \"absent\",\n \"backend\"", "\"state\": \"hibernating\",\n \"backend\"")).unwrap();
487 assert_eq!(unknown.state, State::Unknown);
488 assert!(starts_status("{") && !starts_status(" \"schema\": 1,"));
489 }
490
491 #[test]
492 fn without_wsl_linux_says_why_and_nothing_else_can_run_on_home() {
493 let status = parse(
494 r#"{"schema":1,"state":"error","backend":"wsl2","launcher_version":"0.2.1",
495 "error":"WSL is not installed, and the sandbox for Linux jobs is a WSL distribution of its own.",
496 "capabilities":[
497 {"id":"linux","available":false,"state":"error",
498 "cause":"WSL is not installed, and the sandbox for Linux jobs is a WSL distribution of its own.",
499 "fix":"Open a terminal as administrator and run `wsl --install --no-distribution`, then restart Windows."},
500 {"id":"windows","available":false,"state":"absent","cause":"Windows Sandbox is not turned on, or this edition of Windows has none (Home does not).","fix":"…"}]}"#,
501 )
502 .unwrap();
503 assert!(!status.anything_available());
504 let (cause, fix) = status.trouble().unwrap();
505 assert_eq!(why(&cause), Why::NoWsl);
506 assert!(fix.unwrap().contains(WSL_INSTALL));
507 }
508
509 #[test]
510 fn the_launchers_sentences_are_told_apart() {
511 for (text, expected) in [
512 ("WSL is set to mirrored networking, in which Linux reaches this computer's own localhost.", Why::WslMirrored),
513 ("WSL could not import the sandbox: Please enable the Virtual Machine Platform Windows feature and ensure virtualization is enabled in the BIOS.", Why::NoVirtualization),
514 ("vfkit, which runs the sandbox's VM with Apple's Virtualization framework, is not installed.", Why::NoVfkit),
515 ("This machine has neither Sysbox nor KVM, so there is nothing to sandbox the runner with.", Why::NoSysboxOrKvm),
516 ("the sandbox image wsl-x64.tar.gz's SHA-256 is not the release's; not using it", Why::Image),
517 ("could not fetch https://g1t.sh/downloads/runner/0.3.0/wsl-x64.tar.gz: dns error", Why::Image),
518 ("release 0.3.0 has no sandbox images", Why::Image),
519 ("The registration token is not valid, or has expired.", Why::TokenRefused),
520 ("g1t no longer knows this runner (401 unauthorized). Register it again.", Why::Forgotten),
521 ("the sandbox's runner is not registered, and no --url and --token were given", Why::Unregistered),
522 ("This Windows has Windows Sandbox but not its command line, wsb, which came in Windows 11 24H2.", Why::NoWsb),
523 ("Sandboxed macOS jobs are not released yet.", Why::MacNotReleased),
524 ("This user may not use /dev/kvm.", Why::Other),
525 ] {
526 assert_eq!(why(text), expected, "{text}");
527 }
528 }
529
530 #[test]
531 fn the_commands_are_the_launchers() {
532 let dir = PathBuf::from("data").join("runner").join(FOLDER);
533 let text = |args: Vec<OsString>| args.iter().map(|a| a.to_string_lossy().into_owned()).collect::<Vec<_>>();
534 let first = Up { url: "https://g1t.sh", name: "build-box", labels: "desktop", token: Some("g1trt_secret") };
535 let up = text(up_args(&dir, &first));
536 assert_eq!(&up[..4], ["sandbox", "up", "--json", "--state-dir"]);
537 assert_eq!(up[4], dir.to_string_lossy());
538 // Never the token: it goes in the environment, out of the process list.
539 assert_eq!(&up[5..], ["--url", "https://g1t.sh", "--name", "build-box", "--labels", "desktop"]);
540 assert!(!up.iter().any(|a| a == "--token" || a.starts_with("g1trt_")));
541 let mut command = command(Path::new("g1t-runner"), up_args(&dir, &first));
542 with_token(&mut command, first.token);
543 assert!(command.get_envs().any(|(name, value)| name == "G1T_RUNNER_TOKEN" && value == Some(std::ffi::OsStr::new("g1trt_secret"))));
544 assert!(!command.get_args().any(|a| a.to_string_lossy().contains("g1trt_")));
545 // Its name and labels as the app has them: the launcher adds `-linux` and the OS labels.
546 assert!(!up.iter().any(|a| a.ends_with("-linux") || a.contains("windows")));
547 let again = text(up_args(&dir, &Up { token: None, ..first.clone() }));
548 assert!(!again.iter().any(|a| a == "--token" || a.starts_with("g1trt_")));
549 assert!(!format!("{first:?}").contains("g1trt_secret"));
550 assert_eq!(text(down_args(&dir, Down::Now))[..3], ["sandbox", "down", "--json"]);
551 assert_eq!(text(down_args(&dir, Down::Now)).len(), 5);
552 assert_eq!(text(down_args(&dir, Down::Drain)).last().unwrap(), "--drain");
553 assert_eq!(text(down_args(&dir, Down::Delete)).last().unwrap(), "--delete");
554 assert_eq!(text(status_args(&dir))[..3], ["sandbox", "status", "--json"]);
555 assert_eq!(text(update_args(&dir))[..3], ["sandbox", "update", "--json"]);
556 }
557}