Skip to content
545 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.
231pub fn up_args(dir: &Path, up: &Up) -> Vec<OsString> {
232 let mut args = args("up", dir);
233 for (name, value) in [("--url", up.url), ("--name", up.name), ("--labels", up.labels)] {
234 args.push(name.into());
235 args.push(value.into());
236 }
237 if let Some(token) = up.token {
238 args.push("--token".into());
239 args.push(token.into());
240 }
241 args
242}
243
244/// How `down` stops the sandbox.
245#[derive(Clone, Copy, Debug, PartialEq, Eq)]
246pub enum Down {
247 /// Now: a running job is cut short (as g1t quits, or Stop).
248 Now,
249 /// After the running job (another way of running jobs was chosen).
250 Drain,
251 /// Now, and the VM and the registration with it (another workspace or server).
252 Delete,
253}
254
255pub fn down_args(dir: &Path, how: Down) -> Vec<OsString> {
256 let mut args = args("down", dir);
257 match how {
258 Down::Now => {}
259 Down::Drain => args.push("--drain".into()),
260 Down::Delete => args.push("--delete".into()),
261 }
262 args
263}
264
265pub fn status_args(dir: &Path) -> Vec<OsString> {
266 args("status", dir)
267}
268
269pub fn update_args(dir: &Path) -> Vec<OsString> {
270 args("update", dir)
271}
272
273/// A launcher command whose output the app reads a line at a time
274/// (`up` and `update`, which take minutes the first time).
275pub fn command(binary: &Path, args: Vec<OsString>) -> Command {
276 let mut command = quiet(binary);
277 // What the app's own environment may have set for another runner is not this one's.
278 for (name, _) in std::env::vars_os() {
279 if name.to_string_lossy().starts_with("G1T_RUNNER_") {
280 command.env_remove(&name);
281 }
282 }
283 command.args(args).stdout(Stdio::piped()).stderr(Stdio::piped());
284 command
285}
286
287/// Runs a short launcher command to its end and reads its status, which
288/// it prints whether it worked or not.
289fn run(binary: &Path, args: Vec<OsString>) -> Result<Status, String> {
290 let out = command(binary, args).output().map_err(|e| format!("g1t-runner could not start: {e}"))?;
291 let printed = String::from_utf8_lossy(&out.stdout);
292 match parse(&printed) {
293 Ok(status) => Ok(status),
294 Err(_) if !out.status.success() && !out.stderr.is_empty() => Err(String::from_utf8_lossy(&out.stderr).trim().trim_start_matches("g1t-runner: ").to_owned()),
295 Err(why) => Err(why),
296 }
297}
298
299/// How the sandbox is now.
300pub fn status(binary: &Path, dir: &Path) -> Result<Status, String> {
301 run(binary, status_args(dir))
302}
303
304/// Stops the sandbox, waiting for it.
305pub fn down(binary: &Path, dir: &Path, how: Down) -> Result<(), String> {
306 if !binary.exists() {
307 return Err("g1t-runner isn't installed".into());
308 }
309 let status = run(binary, down_args(dir, how))?;
310 match status.error {
311 Some(error) => Err(error),
312 None => Ok(()),
313 }
314}
315
316/// What a launcher's sentence is about: the words the app shows for it
317/// are diagnosis.rs's. Read from the causes and errors the launcher
318/// writes (crates/runner/src/selfhosted/launcher and native.rs).
319#[derive(Clone, Copy, Debug, PartialEq, Eq)]
320pub enum Why {
321 NoWsl,
322 WslMirrored,
323 NoVirtualization,
324 NoVfkit,
325 /// Linux, with neither Sysbox in Docker nor KVM.
326 NoSysboxOrKvm,
327 /// The sandbox's image couldn't be downloaded, or isn't the release's.
328 Image,
329 /// g1t refused the registration token.
330 TokenRefused,
331 /// g1t no longer knows the runner.
332 Forgotten,
333 /// The sandbox has no registration, and no token was given.
334 Unregistered,
335 /// Windows Sandbox is off, or this edition of Windows has none.
336 WindowsSandboxOff,
337 /// Windows Sandbox without `wsb`, from before Windows 11 24H2.
338 NoWsb,
339 /// Sandboxed macOS jobs are not released.
340 MacNotReleased,
341 /// Anything else: shown in the launcher's own words.
342 Other,
343}
344
345pub fn why(text: &str) -> Why {
346 let lower = text.to_ascii_lowercase();
347 let has = |words: &str| lower.contains(words);
348 if has("wsl is not installed") {
349 Why::NoWsl
350 } else if has("mirrored") {
351 Why::WslMirrored
352 } else if has("virtualization is not enabled")
353 || has("enable virtualization")
354 || has("virtual machine platform")
355 || has("hcs_e_hyperv_not_installed")
356 || has("vt-x")
357 || has("hypervisor is not present")
358 {
359 Why::NoVirtualization
360 } else if has("vfkit") && has("not installed") {
361 Why::NoVfkit
362 } else if has("neither sysbox nor kvm") || has("docker has no sysbox") {
363 Why::NoSysboxOrKvm
364 } else if has("token") && (has("not valid") || has("expired") || has("invalid")) {
365 Why::TokenRefused
366 } else if has("no longer knows this runner") || has("was removed from g1t") {
367 Why::Forgotten
368 } else if has("is not registered") {
369 Why::Unregistered
370 } 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") {
371 Why::Image
372 } else if has("windows sandbox is not turned on") {
373 Why::WindowsSandboxOff
374 } else if has("24h2") {
375 Why::NoWsb
376 } else if has("macos jobs are not released") {
377 Why::MacNotReleased
378 } else {
379 Why::Other
380 }
381}
382
383#[cfg(test)]
384mod tests {
385 use super::*;
386 use std::path::PathBuf;
387
388 /// What `status --json` printed on a Windows 11 Pro machine with WSL
389 /// and Windows Sandbox, before `up` (g1t-runner 0.2.1).
390 const ABSENT: &str = r#"{
391 "schema": 1,
392 "state": "absent",
393 "backend": "wsl2",
394 "runner_version": null,
395 "launcher_version": "0.2.1",
396 "image_version": null,
397 "isolation": "nested-container",
398 "job": null,
399 "error": null,
400 "resources": {
401 "cpus": 0,
402 "memory_mib": 0,
403 "disk_gib": 0
404 },
405 "capabilities": [
406 {
407 "id": "linux",
408 "available": true,
409 "state": "absent",
410 "isolation": "nested-container",
411 "runner_version": null,
412 "job": null,
413 "cause": null,
414 "fix": null
415 },
416 {
417 "id": "windows",
418 "available": true,
419 "state": "absent",
420 "isolation": "vm",
421 "runner_version": null,
422 "job": null,
423 "cause": null,
424 "fix": null
425 }
426 ],
427 "state_dir": "C:\\Users\\syntaqx\\.g1t-runner\\sandbox"
428}"#;
429
430 /// The launcher's documented example: running a job, Windows Sandbox off.
431 const RUNNING: &str = r#"{
432 "schema": 1, "state": "running", "backend": "wsl2",
433 "runner_version": "0.3.0", "launcher_version": "0.3.0", "image_version": "0.3.0",
434 "isolation": "nested-container",
435 "job": { "kind": "workflow", "id": "job_1", "name": "build", "repo": "acme/web" },
436 "error": null,
437 "resources": { "cpus": 4, "memory_mib": 8192, "disk_gib": 64 },
438 "capabilities": [
439 { "id": "linux", "available": true, "state": "running", "isolation": "nested-container",
440 "runner_version": "0.3.0", "job": { "kind": "workflow", "id": "job_1", "name": "build", "repo": "acme/web" }, "cause": null, "fix": null },
441 { "id": "windows", "available": false, "state": "absent", "isolation": "vm",
442 "runner_version": null, "job": null,
443 "cause": "Windows Sandbox is not turned on, or this edition of Windows has none (Home does not).",
444 "fix": "On Windows 11 Pro, Enterprise or Education, turn on Windows Sandbox under Settings, System, Optional features, More Windows features, then restart." }
445 ],
446 "state_dir": "C:\\Users\\a\\.g1t-runner\\sandbox",
447 "something_new": { "a": 1 }
448}"#;
449
450 #[test]
451 fn a_status_is_read_from_after_the_log() {
452 let printed = format!("2026-10-12T08:00:00Z Downloading the sandbox image wsl-x64.\n2026-10-12T08:03:00Z {{not json}}\n{RUNNING}\n");
453 let status = parse(&printed).unwrap();
454 assert_eq!(status.state, State::Running);
455 assert_eq!(status.backend.as_deref(), Some("wsl2"));
456 assert_eq!(status.job().map(Job::said).as_deref(), Some("build (acme/web)"));
457 assert_eq!(status.version().as_deref(), Some("0.3.0"));
458 assert!(status.running());
459 assert!(!status.unregistered());
460 let windows = status.capability("windows").unwrap();
461 assert!(!windows.available);
462 assert_eq!(why(windows.cause.as_deref().unwrap()), Why::WindowsSandboxOff);
463 assert_eq!(status.trouble(), None);
464 }
465
466 #[test]
467 fn a_sandbox_never_made_needs_registering() {
468 let status = parse(ABSENT).unwrap();
469 assert_eq!(status.state, State::Absent);
470 assert!(status.anything_available());
471 assert!(status.unregistered());
472 assert!(!status.running());
473 assert_eq!(status.version().as_deref(), Some("0.2.1"));
474 }
475
476 #[test]
477 fn what_is_not_a_status_is_said_so() {
478 assert!(parse("g1t-runner: unknown command sandbox\n").unwrap_err().contains("older than its sandbox launcher"));
479 assert!(parse(&ABSENT.replace("\"schema\": 1", "\"schema\": 2")).unwrap_err().contains("schema 2"));
480 let unknown = parse(&ABSENT.replace("\"state\": \"absent\",\n \"backend\"", "\"state\": \"hibernating\",\n \"backend\"")).unwrap();
481 assert_eq!(unknown.state, State::Unknown);
482 assert!(starts_status("{") && !starts_status(" \"schema\": 1,"));
483 }
484
485 #[test]
486 fn without_wsl_linux_says_why_and_nothing_else_can_run_on_home() {
487 let status = parse(
488 r#"{"schema":1,"state":"error","backend":"wsl2","launcher_version":"0.2.1",
489 "error":"WSL is not installed, and the sandbox for Linux jobs is a WSL distribution of its own.",
490 "capabilities":[
491 {"id":"linux","available":false,"state":"error",
492 "cause":"WSL is not installed, and the sandbox for Linux jobs is a WSL distribution of its own.",
493 "fix":"Open a terminal as administrator and run `wsl --install --no-distribution`, then restart Windows."},
494 {"id":"windows","available":false,"state":"absent","cause":"Windows Sandbox is not turned on, or this edition of Windows has none (Home does not).","fix":"…"}]}"#,
495 )
496 .unwrap();
497 assert!(!status.anything_available());
498 let (cause, fix) = status.trouble().unwrap();
499 assert_eq!(why(&cause), Why::NoWsl);
500 assert!(fix.unwrap().contains(WSL_INSTALL));
501 }
502
503 #[test]
504 fn the_launchers_sentences_are_told_apart() {
505 for (text, expected) in [
506 ("WSL is set to mirrored networking, in which Linux reaches this computer's own localhost.", Why::WslMirrored),
507 ("WSL could not import the sandbox: Please enable the Virtual Machine Platform Windows feature and ensure virtualization is enabled in the BIOS.", Why::NoVirtualization),
508 ("vfkit, which runs the sandbox's VM with Apple's Virtualization framework, is not installed.", Why::NoVfkit),
509 ("This machine has neither Sysbox nor KVM, so there is nothing to sandbox the runner with.", Why::NoSysboxOrKvm),
510 ("the sandbox image wsl-x64.tar.gz's SHA-256 is not the release's; not using it", Why::Image),
511 ("could not fetch https://g1t.sh/downloads/runner/0.3.0/wsl-x64.tar.gz: dns error", Why::Image),
512 ("release 0.3.0 has no sandbox images", Why::Image),
513 ("The registration token is not valid, or has expired.", Why::TokenRefused),
514 ("g1t no longer knows this runner (401 unauthorized). Register it again.", Why::Forgotten),
515 ("the sandbox's runner is not registered, and no --url and --token were given", Why::Unregistered),
516 ("This Windows has Windows Sandbox but not its command line, wsb, which came in Windows 11 24H2.", Why::NoWsb),
517 ("Sandboxed macOS jobs are not released yet.", Why::MacNotReleased),
518 ("This user may not use /dev/kvm.", Why::Other),
519 ] {
520 assert_eq!(why(text), expected, "{text}");
521 }
522 }
523
524 #[test]
525 fn the_commands_are_the_launchers() {
526 let dir = PathBuf::from("data").join("runner").join(FOLDER);
527 let text = |args: Vec<OsString>| args.iter().map(|a| a.to_string_lossy().into_owned()).collect::<Vec<_>>();
528 let first = Up { url: "https://g1t.sh", name: "build-box", labels: "desktop", token: Some("g1trt_secret") };
529 let up = text(up_args(&dir, &first));
530 assert_eq!(&up[..4], ["sandbox", "up", "--json", "--state-dir"]);
531 assert_eq!(up[4], dir.to_string_lossy());
532 assert_eq!(&up[5..], ["--url", "https://g1t.sh", "--name", "build-box", "--labels", "desktop", "--token", "g1trt_secret"]);
533 // Its name and labels as the app has them: the launcher adds `-linux` and the OS labels.
534 assert!(!up.iter().any(|a| a.ends_with("-linux") || a.contains("windows")));
535 let again = text(up_args(&dir, &Up { token: None, ..first.clone() }));
536 assert!(!again.iter().any(|a| a == "--token" || a.starts_with("g1trt_")));
537 assert!(!format!("{first:?}").contains("g1trt_secret"));
538 assert_eq!(text(down_args(&dir, Down::Now))[..3], ["sandbox", "down", "--json"]);
539 assert_eq!(text(down_args(&dir, Down::Now)).len(), 5);
540 assert_eq!(text(down_args(&dir, Down::Drain)).last().unwrap(), "--drain");
541 assert_eq!(text(down_args(&dir, Down::Delete)).last().unwrap(), "--delete");
542 assert_eq!(text(status_args(&dir))[..3], ["sandbox", "status", "--json"]);
543 assert_eq!(text(update_args(&dir))[..3], ["sandbox", "update", "--json"]);
544 }
545}