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.

745 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//! - `up --only linux` and `down --only windows` when the person starts
24//! or stops one kind of job in settings: the other is left as it is.
25//! Every `up` names the kinds that are on (`--only`), so the launcher
26//! never starts one the person stopped. A launcher from before `--only`
27//! says no `turned_off` in its status, and is never sent `down --only`,
28//! which it would read as `down`, stopping everything.
29//! - `update`, while nothing runs: the newest signed image, the same
30//! registration.
31//!
32//! What a status's causes and errors mean is read here (`why`); what the
33//! app says about them is diagnosis.rs's.
34
35use std::ffi::OsString;
36use std::path::Path;
37use std::process::{Command, Stdio};
38use std::time::Duration;
39
40use serde::Deserialize;
41
42use super::backend::quiet;
43
44/// The launcher's state folder, under the app's runner folder.
45pub const FOLDER: &str = "sandbox";
46/// The status shape the app reads (`schema`).
47pub const SCHEMA: u32 = 1;
48/// How often the app asks the sandbox how it is: a call takes about a second.
49pub const STATUS_EVERY: Duration = Duration::from_secs(8);
50/// What installs WSL on Windows, for the person to run as administrator.
51pub const WSL_INSTALL: &str = "wsl --install --no-distribution";
52/// What installs vfkit on a Mac.
53pub const VFKIT_INSTALL: &str = "brew install vfkit";
54/// What loads KVM on Linux, Intel's or AMD's.
55pub const KVM_LOAD: &str = "sudo modprobe kvm_intel || sudo modprobe kvm_amd";
56/// What lets this account use /dev/kvm, after logging in again.
57pub const KVM_GROUP: &str = "sudo usermod -aG kvm $USER";
58
59/// What installs QEMU on this Linux, from `/etc/os-release` and the
60/// machine (`x86_64`, `aarch64`): None where it isn't known.
61pub fn qemu_install(os_release: &str, arch: &str) -> Option<String> {
62 let field = |name: &str| {
63 os_release.lines().find_map(|line| line.strip_prefix(name).and_then(|rest| rest.strip_prefix('='))).map(|value| value.trim_matches('"').to_ascii_lowercase()).unwrap_or_default()
64 };
65 let family = format!("{} {}", field("ID"), field("ID_LIKE"));
66 let is = |name: &str| family.split_whitespace().any(|id| id == name);
67 let system = if arch == "aarch64" { "qemu-system-arm" } else { "qemu-system-x86" };
68 if is("debian") || is("ubuntu") {
69 Some(format!("sudo apt install {system}"))
70 } else if is("fedora") || is("rhel") || is("centos") {
71 Some("sudo dnf install qemu-kvm".into())
72 } else if is("arch") {
73 Some("sudo pacman -S qemu-base".into())
74 } else if is("opensuse") || is("suse") {
75 Some("sudo zypper install qemu-kvm".into())
76 } else {
77 None
78 }
79}
80/// How often the app looks again for a release with the sandbox's images,
81/// while the newest is published without them.
82pub const IMAGES_AGAIN: Duration = Duration::from_secs(5 * 60);
83
84/// A sandbox's, or one capability's, state.
85#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Deserialize)]
86#[serde(rename_all = "snake_case")]
87pub enum State {
88 /// Never made, or deleted.
89 #[default]
90 Absent,
91 Stopped,
92 Starting,
93 Running,
94 Updating,
95 Error,
96 /// A state a newer launcher has that this app does not know.
97 #[serde(other)]
98 Unknown,
99}
100
101/// What a runner is running now.
102#[derive(Clone, Debug, Default, PartialEq, Eq, Deserialize)]
103#[serde(default)]
104pub struct Job {
105 /// `workflow` or `agent`.
106 pub kind: String,
107 pub id: String,
108 pub name: String,
109 pub repo: String,
110}
111
112impl Job {
113 /// How the tray says it: `build (acme/web)`.
114 pub fn said(&self) -> String {
115 if self.repo.is_empty() { self.name.clone() } else { format!("{} ({})", self.name, self.repo) }
116 }
117}
118
119/// One kind of job the computer can run sandboxed: `linux`, `windows` or `macos`.
120#[derive(Clone, Debug, Default, PartialEq, Eq, Deserialize)]
121#[serde(default)]
122pub struct Capability {
123 pub id: String,
124 pub available: bool,
125 pub state: State,
126 pub isolation: Option<String>,
127 pub runner_version: Option<String>,
128 pub job: Option<Job>,
129 /// Why it is not available, in a sentence.
130 pub cause: Option<String>,
131 /// What to do about it, in a sentence.
132 pub fix: Option<String>,
133 /// The person stopped it (`down --only`): stopped, not failing. None
134 /// from a launcher before it could stop one kind of job on its own.
135 pub turned_off: Option<bool>,
136 /// What this computer still lacks for it, by name, first what must
137 /// come first: `wsl`, `vm_platform`, `virtualization`, `restart`, …
138 /// (the launcher's needs.rs). diagnosis.rs says each. Empty from a
139 /// launcher before them, whose causes are read by their words (`why`).
140 pub needs: Vec<String>,
141}
142
143impl Capability {
144 pub fn off(&self) -> bool {
145 self.turned_off == Some(true)
146 }
147}
148
149/// `g1t-runner sandbox status --json`. The top-level fields are the Linux
150/// VM's. Fields the app does not use (`resources`, `state_dir`) are left
151/// out, and any a newer launcher adds are ignored.
152#[derive(Clone, Debug, Default, PartialEq, Eq, Deserialize)]
153#[serde(default)]
154pub struct Status {
155 pub schema: u32,
156 pub state: State,
157 /// `wsl2`, `vfkit`, `qemu` or `sysbox`.
158 pub backend: Option<String>,
159 pub runner_version: Option<String>,
160 pub launcher_version: Option<String>,
161 pub image_version: Option<String>,
162 pub isolation: Option<String>,
163 pub job: Option<Job>,
164 /// A sentence for a person, whenever something is wrong.
165 pub error: Option<String>,
166 pub capabilities: Vec<Capability>,
167}
168
169impl Status {
170 pub fn capability(&self, id: &str) -> Option<&Capability> {
171 self.capabilities.iter().find(|c| c.id == id)
172 }
173
174 /// The Linux capability, as the top-level fields say it when a launcher lists none.
175 pub fn linux(&self) -> Capability {
176 self.capability("linux").cloned().unwrap_or_else(|| Capability {
177 id: "linux".into(),
178 available: self.state != State::Error || self.error.is_none(),
179 state: self.state,
180 runner_version: self.runner_version.clone(),
181 job: self.job.clone(),
182 ..Capability::default()
183 })
184 }
185
186 /// Whether any of its runners is running.
187 pub fn running(&self) -> bool {
188 self.state == State::Running || self.capabilities.iter().any(|c| c.state == State::Running)
189 }
190
191 /// Whether anything here can be sandboxed at all.
192 pub fn anything_available(&self) -> bool {
193 if self.capabilities.is_empty() { self.linux().available } else { self.capabilities.iter().any(|c| c.available) }
194 }
195
196 /// A capability that can be had here, and is on, but was never set
197 /// up: registering it needs a token.
198 pub fn unregistered(&self) -> bool {
199 let wanting = |c: &Capability| c.available && !c.off() && c.state == State::Absent;
200 if self.capabilities.is_empty() { wanting(&self.linux()) } else { self.capabilities.iter().any(wanting) }
201 }
202
203 /// Whether this launcher starts and stops each kind of job on its own
204 /// (`--only`), which it says by saying which are turned off.
205 pub fn per_kind(&self) -> bool {
206 !self.capabilities.is_empty() && self.capabilities.iter().all(|c| c.turned_off.is_some())
207 }
208
209 /// Whether everything that could run here was turned off by the person.
210 pub fn all_off(&self) -> bool {
211 let available: Vec<&Capability> = self.capabilities.iter().filter(|c| c.available || c.off()).collect();
212 !available.is_empty() && available.iter().all(|c| c.off())
213 }
214
215 /// What a runner is running, the Linux one's first.
216 pub fn job(&self) -> Option<&Job> {
217 self.job.as_ref().or_else(|| self.capabilities.iter().find_map(|c| c.job.as_ref()))
218 }
219
220 /// The runner's version, as the tray and the site say it.
221 pub fn version(&self) -> Option<String> {
222 self.runner_version
223 .clone()
224 .or_else(|| self.capabilities.iter().find_map(|c| c.runner_version.clone()))
225 .or_else(|| self.launcher_version.clone())
226 }
227
228 /// What this computer lacks for Linux jobs, by name: none when the
229 /// person stopped them.
230 pub fn linux_needs(&self) -> Vec<String> {
231 let linux = self.linux();
232 if linux.off() { vec![] } else { linux.needs }
233 }
234
235 /// What is wrong with Linux jobs, in the launcher's words: its error,
236 /// else why the capability is unavailable.
237 pub fn trouble(&self) -> Option<(String, Option<String>)> {
238 let linux = self.linux();
239 // Stopped by the person: nothing is wrong with it.
240 if linux.off() {
241 return None;
242 }
243 if !linux.available {
244 let cause = linux.cause.or_else(|| self.error.clone())?;
245 return Some((cause, linux.fix));
246 }
247 if linux.state == State::Error || self.state == State::Error {
248 return Some((self.error.clone().unwrap_or_else(|| "The sandbox stopped, and didn't say why.".into()), None));
249 }
250 None
251 }
252}
253
254/// Reads what a launcher command printed: log lines, then the status as
255/// one JSON object, which starts on a line of its own. A launcher whose
256/// schema is not this app's is not read, nor is a `g1t-runner` from before
257/// the launcher, which prints no status at all.
258pub fn parse(output: &str) -> Result<Status, String> {
259 let start = output
260 .match_indices('\n')
261 .map(|(at, _)| at + 1)
262 .chain(std::iter::once(0))
263 .filter(|at| output[*at..].starts_with('{'))
264 .max()
265 .ok_or_else(|| "this g1t-runner printed no sandbox status: it is older than its sandbox launcher".to_owned())?;
266 let status: Status = serde_json::from_str(output[start..].trim()).map_err(|e| format!("the sandbox's status doesn't read: {e}"))?;
267 if status.schema != SCHEMA {
268 return Err(format!("the sandbox's status is in schema {}, and this g1t reads schema {SCHEMA}: update g1t", status.schema));
269 }
270 Ok(status)
271}
272
273/// Whether a line of a launcher's output starts its status, rather than being a log line.
274pub fn starts_status(line: &str) -> bool {
275 line.starts_with('{')
276}
277
278/// How far the sandbox's image has downloaded, from the launcher's log.
279#[derive(Clone, Debug, PartialEq, Eq)]
280pub struct Download {
281 /// `wsl-x64`.
282 pub key: String,
283 pub percent: u8,
284}
285
286/// Reads `Fetching the sandbox's wsl-x64 image (612 MB)`, the start of a
287/// download, and `Fetching the sandbox's wsl-x64 image: 40% of 612 MB`,
288/// each tenth of it (the launcher's image.rs), after the log's time.
289pub fn download(line: &str) -> Option<Download> {
290 let (_, rest) = line.split_once("Fetching the sandbox's ")?;
291 let (key, rest) = rest.split_once(" image")?;
292 let percent = match rest.strip_prefix(": ") {
293 Some(said) => said.split_once('%')?.0.trim().parse::<u8>().ok()?.min(100),
294 None => 0,
295 };
296 Some(Download { key: key.to_owned(), percent })
297}
298
299/// What `up` is told the first time, and again on every start (the
300/// launcher keeps what it was told, and `--token` only matters once).
301#[derive(Clone, PartialEq, Eq)]
302pub struct Up<'a> {
303 pub url: &'a str,
304 pub name: &'a str,
305 pub labels: &'a str,
306 /// The registration token: only for a sandbox that has no registration yet.
307 pub token: Option<&'a str>,
308 /// The kinds of job to start (`--only`): those the person has on.
309 pub only: Vec<&'a str>,
310}
311
312// Never print a token, in a log or a panic.
313impl std::fmt::Debug for Up<'_> {
314 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
315 f.debug_struct("Up")
316 .field("url", &self.url)
317 .field("name", &self.name)
318 .field("labels", &self.labels)
319 .field("token", &self.token.map(|_| "…"))
320 .field("only", &self.only)
321 .finish()
322 }
323}
324
325fn args(action: &str, dir: &Path) -> Vec<OsString> {
326 let mut args: Vec<OsString> = vec!["sandbox".into(), action.into(), "--json".into(), "--state-dir".into()];
327 args.push(dir.as_os_str().to_owned());
328 args
329}
330
331/// `sandbox up`'s arguments. The token is never one of them: anyone on the
332/// computer can read a process's command line while it runs, so it goes in
333/// `up`'s environment instead ([`with_token`]).
334pub fn up_args(dir: &Path, up: &Up) -> Vec<OsString> {
335 let mut args = args("up", dir);
336 for (name, value) in [("--url", up.url), ("--name", up.name), ("--labels", up.labels)] {
337 args.push(name.into());
338 args.push(value.into());
339 }
340 if !up.only.is_empty() {
341 args.push("--only".into());
342 args.push(up.only.join(",").into());
343 }
344 args
345}
346
347/// Gives a launcher command the registration token, as `G1T_RUNNER_TOKEN`
348/// in its environment and nowhere else.
349pub fn with_token(command: &mut Command, token: Option<&str>) {
350 if let Some(token) = token {
351 command.env("G1T_RUNNER_TOKEN", token);
352 }
353}
354
355/// How `down` stops the sandbox.
356#[derive(Clone, Copy, Debug, PartialEq, Eq)]
357pub enum Down {
358 /// Now: a running job is cut short (as g1t quits, or Stop).
359 Now,
360 /// After the running job (another way of running jobs was chosen).
361 Drain,
362 /// Now, and the VM and the registration with it (another workspace or server).
363 Delete,
364}
365
366pub fn down_args(dir: &Path, how: Down) -> Vec<OsString> {
367 let mut args = args("down", dir);
368 match how {
369 Down::Now => {}
370 Down::Drain => args.push("--drain".into()),
371 Down::Delete => args.push("--delete".into()),
372 }
373 args
374}
375
376/// `down --only <kind>`: that kind of job stops, and stays off; the others
377/// are left as they are. Only for a launcher whose status is [`Status::per_kind`].
378pub fn down_only_args(dir: &Path, kind: &str) -> Vec<OsString> {
379 let mut args = args("down", dir);
380 args.push("--only".into());
381 args.push(kind.into());
382 args
383}
384
385/// `down --only <kind> --drain`: as [`down_only_args`], after the job
386/// that kind is running, if any. An update stops a kind this way while
387/// its program is replaced, and starts it again after.
388pub fn drain_only_args(dir: &Path, kind: &str) -> Vec<OsString> {
389 let mut args = down_only_args(dir, kind);
390 args.push("--drain".into());
391 args
392}
393
394pub fn status_args(dir: &Path) -> Vec<OsString> {
395 args("status", dir)
396}
397
398pub fn update_args(dir: &Path) -> Vec<OsString> {
399 args("update", dir)
400}
401
402/// A launcher command whose output the app reads a line at a time
403/// (`up` and `update`, which take minutes the first time).
404pub fn command(binary: &Path, args: Vec<OsString>) -> Command {
405 let mut command = quiet(binary);
406 // What the app's own environment may have set for another runner is not this one's.
407 for (name, _) in std::env::vars_os() {
408 if name.to_string_lossy().starts_with("G1T_RUNNER_") {
409 command.env_remove(&name);
410 }
411 }
412 command.args(args).stdout(Stdio::piped()).stderr(Stdio::piped());
413 command
414}
415
416/// Runs a short launcher command to its end and reads its status, which
417/// it prints whether it worked or not.
418fn run(binary: &Path, args: Vec<OsString>) -> Result<Status, String> {
419 run_done(binary, args).map(|(status, _)| status)
420}
421
422/// [`run`], and whether the command did what it was asked (its exit code).
423fn run_done(binary: &Path, args: Vec<OsString>) -> Result<(Status, bool), String> {
424 let out = command(binary, args).output().map_err(|e| format!("g1t-runner could not start: {e}"))?;
425 let printed = String::from_utf8_lossy(&out.stdout);
426 match parse(&printed) {
427 Ok(status) => Ok((status, out.status.success())),
428 Err(_) if !out.status.success() && !out.stderr.is_empty() => Err(String::from_utf8_lossy(&out.stderr).trim().trim_start_matches("g1t-runner: ").to_owned()),
429 Err(why) => Err(why),
430 }
431}
432
433/// How the sandbox is now.
434pub fn status(binary: &Path, dir: &Path) -> Result<Status, String> {
435 run(binary, status_args(dir))
436}
437
438/// Stops the sandbox, waiting for it: how it is now.
439pub fn down(binary: &Path, dir: &Path, how: Down) -> Result<Status, String> {
440 down_with(binary, down_args(dir, how))
441}
442
443/// Stops one kind of job, and keeps it off, waiting for it.
444pub fn down_only(binary: &Path, dir: &Path, kind: &str) -> Result<Status, String> {
445 down_with(binary, down_only_args(dir, kind))
446}
447
448fn down_with(binary: &Path, args: Vec<OsString>) -> Result<Status, String> {
449 if !binary.exists() {
450 return Err("g1t-runner isn't installed".into());
451 }
452 match run_done(binary, args)? {
453 (status, true) => Ok(status),
454 (status, false) => Err(status.error.unwrap_or_else(|| "g1t-runner sandbox down failed.".into())),
455 }
456}
457
458/// What a launcher's sentence is about: the words the app shows for it
459/// are diagnosis.rs's. Read from the causes and errors the launcher
460/// writes (crates/runner/src/selfhosted/launcher and native.rs).
461#[derive(Clone, Copy, Debug, PartialEq, Eq)]
462pub enum Why {
463 NoWsl,
464 WslMirrored,
465 NoVirtualization,
466 NoVfkit,
467 /// Linux, with neither Sysbox in Docker nor KVM.
468 NoSysboxOrKvm,
469 /// The newest release is published without the sandbox's images (for
470 /// this computer), so there is nothing to download yet.
471 NoImages,
472 /// The sandbox's image couldn't be downloaded, or isn't the release's.
473 Image,
474 /// g1t refused the registration token.
475 TokenRefused,
476 /// g1t no longer knows the runner.
477 Forgotten,
478 /// The sandbox has no registration, and no token was given.
479 Unregistered,
480 /// Windows Sandbox is off, or this edition of Windows has none.
481 WindowsSandboxOff,
482 /// Windows Sandbox without `wsb`, from before Windows 11 24H2.
483 NoWsb,
484 /// Sandboxed macOS jobs are not released.
485 MacNotReleased,
486 /// Anything else: shown in the launcher's own words.
487 Other,
488}
489
490/// The release a "has no sandbox images" sentence names, and the image
491/// it lacks when it is only this computer's: `release 0.3.0 has no
492/// sandbox image vm-arm64-disk for this machine`.
493pub fn missing_images(text: &str) -> Option<(String, Option<String>)> {
494 let (before, after) = text.split_once(" has no sandbox image")?;
495 let version = before.rsplit("release ").next()?.trim().to_owned();
496 let image = after.strip_prefix(' ').and_then(|rest| rest.split_whitespace().next()).map(str::to_owned);
497 (!version.is_empty() && !version.contains(' ')).then_some((version, image))
498}
499
500pub fn why(text: &str) -> Why {
501 let lower = text.to_ascii_lowercase();
502 let has = |words: &str| lower.contains(words);
503 if has("wsl is not installed") {
504 Why::NoWsl
505 } else if has("mirrored") {
506 Why::WslMirrored
507 } else if has("virtualization is not enabled")
508 || has("enable virtualization")
509 || has("virtual machine platform")
510 || has("hcs_e_hyperv_not_installed")
511 || has("vt-x")
512 || has("hypervisor is not present")
513 {
514 Why::NoVirtualization
515 } else if has("vfkit") && has("not installed") {
516 Why::NoVfkit
517 } else if has("neither sysbox nor kvm") || has("docker has no sysbox") {
518 Why::NoSysboxOrKvm
519 } else if has("token") && (has("not valid") || has("expired") || has("invalid")) {
520 Why::TokenRefused
521 } else if has("no longer knows this runner") || has("was removed from g1t") {
522 Why::Forgotten
523 } else if has("is not registered") {
524 Why::Unregistered
525 } else if has("has no sandbox image") {
526 Why::NoImages
527 } 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") {
528 Why::Image
529 } else if has("windows sandbox is not turned on") {
530 Why::WindowsSandboxOff
531 } else if has("24h2") {
532 Why::NoWsb
533 } else if has("macos jobs are not released") {
534 Why::MacNotReleased
535 } else {
536 Why::Other
537 }
538}
539
540#[cfg(test)]
541mod tests {
542 use super::*;
543 use std::path::PathBuf;
544
545 /// What `status --json` printed on a Windows 11 Pro machine with WSL
546 /// and Windows Sandbox, before `up` (g1t-runner 0.2.1).
547 const ABSENT: &str = r#"{
548 "schema": 1,
549 "state": "absent",
550 "backend": "wsl2",
551 "runner_version": null,
552 "launcher_version": "0.2.1",
553 "image_version": null,
554 "isolation": "nested-container",
555 "job": null,
556 "error": null,
557 "resources": {
558 "cpus": 0,
559 "memory_mib": 0,
560 "disk_gib": 0
561 },
562 "capabilities": [
563 {
564 "id": "linux",
565 "available": true,
566 "state": "absent",
567 "isolation": "nested-container",
568 "runner_version": null,
569 "job": null,
570 "cause": null,
571 "fix": null
572 },
573 {
574 "id": "windows",
575 "available": true,
576 "state": "absent",
577 "isolation": "vm",
578 "runner_version": null,
579 "job": null,
580 "cause": null,
581 "fix": null
582 }
583 ],
584 "state_dir": "C:\\Users\\syntaqx\\.g1t-runner\\sandbox"
585}"#;
586
587 /// The launcher's documented example: running a job, Windows Sandbox off.
588 const RUNNING: &str = r#"{
589 "schema": 1, "state": "running", "backend": "wsl2",
590 "runner_version": "0.3.0", "launcher_version": "0.3.0", "image_version": "0.3.0",
591 "isolation": "nested-container",
592 "job": { "kind": "workflow", "id": "job_1", "name": "build", "repo": "acme/web" },
593 "error": null,
594 "resources": { "cpus": 4, "memory_mib": 8192, "disk_gib": 64 },
595 "capabilities": [
596 { "id": "linux", "available": true, "state": "running", "isolation": "nested-container",
597 "runner_version": "0.3.0", "job": { "kind": "workflow", "id": "job_1", "name": "build", "repo": "acme/web" }, "cause": null, "fix": null },
598 { "id": "windows", "available": false, "state": "absent", "isolation": "vm",
599 "runner_version": null, "job": null,
600 "cause": "Windows Sandbox is not turned on, or this edition of Windows has none (Home does not).",
601 "fix": "On Windows 11 Pro, Enterprise or Education, turn on Windows Sandbox under Settings, System, Optional features, More Windows features, then restart." }
602 ],
603 "state_dir": "C:\\Users\\a\\.g1t-runner\\sandbox",
604 "something_new": { "a": 1 }
605}"#;
606
607 #[test]
608 fn a_status_is_read_from_after_the_log() {
609 let printed = format!("2026-10-12T08:00:00Z Downloading the sandbox image wsl-x64.\n2026-10-12T08:03:00Z {{not json}}\n{RUNNING}\n");
610 let status = parse(&printed).unwrap();
611 assert_eq!(status.state, State::Running);
612 assert_eq!(status.backend.as_deref(), Some("wsl2"));
613 assert_eq!(status.job().map(Job::said).as_deref(), Some("build (acme/web)"));
614 assert_eq!(status.version().as_deref(), Some("0.3.0"));
615 assert!(status.running());
616 assert!(!status.unregistered());
617 let windows = status.capability("windows").unwrap();
618 assert!(!windows.available);
619 assert_eq!(why(windows.cause.as_deref().unwrap()), Why::WindowsSandboxOff);
620 assert_eq!(status.trouble(), None);
621 }
622
623 #[test]
624 fn a_sandbox_never_made_needs_registering() {
625 let status = parse(ABSENT).unwrap();
626 assert_eq!(status.state, State::Absent);
627 assert!(status.anything_available());
628 assert!(status.unregistered());
629 assert!(!status.running());
630 assert_eq!(status.version().as_deref(), Some("0.2.1"));
631 }
632
633 #[test]
634 fn what_is_not_a_status_is_said_so() {
635 assert!(parse("g1t-runner: unknown command sandbox\n").unwrap_err().contains("older than its sandbox launcher"));
636 assert!(parse(&ABSENT.replace("\"schema\": 1", "\"schema\": 2")).unwrap_err().contains("schema 2"));
637 let unknown = parse(&ABSENT.replace("\"state\": \"absent\",\n \"backend\"", "\"state\": \"hibernating\",\n \"backend\"")).unwrap();
638 assert_eq!(unknown.state, State::Unknown);
639 assert!(starts_status("{") && !starts_status(" \"schema\": 1,"));
640 }
641
642 #[test]
643 fn without_wsl_linux_says_why_and_nothing_else_can_run_on_home() {
644 let status = parse(
645 r#"{"schema":1,"state":"error","backend":"wsl2","launcher_version":"0.2.1",
646 "error":"WSL is not installed, and the sandbox for Linux jobs is a WSL distribution of its own.",
647 "capabilities":[
648 {"id":"linux","available":false,"state":"error",
649 "cause":"WSL is not installed, and the sandbox for Linux jobs is a WSL distribution of its own.",
650 "fix":"Open a terminal as administrator and run `wsl --install --no-distribution`, then restart Windows."},
651 {"id":"windows","available":false,"state":"absent","cause":"Windows Sandbox is not turned on, or this edition of Windows has none (Home does not).","fix":"…"}]}"#,
652 )
653 .unwrap();
654 assert!(!status.anything_available());
655 let (cause, fix) = status.trouble().unwrap();
656 assert_eq!(why(&cause), Why::NoWsl);
657 assert!(fix.unwrap().contains(WSL_INSTALL));
658 }
659
660 #[test]
661 fn the_launchers_sentences_are_told_apart() {
662 for (text, expected) in [
663 ("WSL is set to mirrored networking, in which Linux reaches this computer's own localhost.", Why::WslMirrored),
664 ("WSL could not import the sandbox: Please enable the Virtual Machine Platform Windows feature and ensure virtualization is enabled in the BIOS.", Why::NoVirtualization),
665 ("vfkit, which runs the sandbox's VM with Apple's Virtualization framework, is not installed.", Why::NoVfkit),
666 ("This machine has neither Sysbox nor KVM, so there is nothing to sandbox the runner with.", Why::NoSysboxOrKvm),
667 ("the sandbox image wsl-x64.tar.gz's SHA-256 is not the release's; not using it", Why::Image),
668 ("could not fetch https://g1t.sh/downloads/runner/0.3.0/wsl-x64.tar.gz: dns error", Why::Image),
669 ("release 0.3.0 has no sandbox images", Why::NoImages),
670 ("Windows jobs are sandboxed and running, but Linux jobs are not: release 0.3.0 has no sandbox images", Why::NoImages),
671 ("release 0.3.0 has no sandbox image vm-arm64-disk for this machine", Why::NoImages),
672 ("The registration token is not valid, or has expired.", Why::TokenRefused),
673 ("g1t no longer knows this runner (401 unauthorized). Register it again.", Why::Forgotten),
674 ("the sandbox's runner is not registered, and no --url and --token were given", Why::Unregistered),
675 ("This Windows has Windows Sandbox but not its command line, wsb, which came in Windows 11 24H2.", Why::NoWsb),
676 ("Sandboxed macOS jobs are not released yet.", Why::MacNotReleased),
677 ("This user may not use /dev/kvm.", Why::Other),
678 ] {
679 assert_eq!(why(text), expected, "{text}");
680 }
681 }
682
683 #[test]
684 fn a_kind_turned_off_is_stopped_by_the_person() {
685 let status = parse(
686 r#"{"schema":1,"state":"stopped","capabilities":[
687 {"id":"linux","available":true,"state":"stopped","turned_off":true},
688 {"id":"windows","available":true,"state":"absent","turned_off":false}]}"#,
689 )
690 .unwrap();
691 assert!(status.per_kind());
692 assert!(status.capability("linux").unwrap().off());
693 assert!(!status.all_off());
694 assert_eq!(status.trouble(), None);
695 // Only Windows, never set up, wants registering; Linux is off.
696 assert!(status.unregistered());
697 let all = parse(r#"{"schema":1,"state":"stopped","capabilities":[{"id":"linux","available":true,"state":"stopped","turned_off":true},{"id":"windows","available":false,"state":"absent","turned_off":false}]}"#).unwrap();
698 assert!(all.all_off());
699 assert!(!all.unregistered());
700 // A launcher from before `--only` says nothing of it.
701 assert!(!parse(RUNNING).unwrap().per_kind());
702 }
703
704 #[test]
705 fn a_download_says_how_far_it_is() {
706 assert_eq!(download("2026-10-12T08:00:00Z Fetching the sandbox's wsl-x64 image (612 MB)"), Some(Download { key: "wsl-x64".into(), percent: 0 }));
707 assert_eq!(download("2026-10-12T08:01:00Z Fetching the sandbox's wsl-x64 image: 40% of 612 MB"), Some(Download { key: "wsl-x64".into(), percent: 40 }));
708 assert_eq!(download("2026-10-12T08:01:00Z Took build (acme/web)"), None);
709 assert_eq!(missing_images("release 0.3.0 has no sandbox images"), Some(("0.3.0".into(), None)));
710 assert_eq!(missing_images("Windows jobs are sandboxed and running, but Linux jobs are not: release 0.3.0 has no sandbox images"), Some(("0.3.0".into(), None)));
711 assert_eq!(missing_images("release 0.4.0 has no sandbox image vm-arm64-disk for this machine"), Some(("0.4.0".into(), Some("vm-arm64-disk".into()))));
712 assert_eq!(missing_images("could not fetch"), None);
713 }
714
715 #[test]
716 fn the_commands_are_the_launchers() {
717 let dir = PathBuf::from("data").join("runner").join(FOLDER);
718 let text = |args: Vec<OsString>| args.iter().map(|a| a.to_string_lossy().into_owned()).collect::<Vec<_>>();
719 let first = Up { url: "https://g1t.sh", name: "build-box", labels: "desktop", token: Some("g1trt_secret"), only: vec!["linux", "windows"] };
720 let up = text(up_args(&dir, &first));
721 assert_eq!(&up[..4], ["sandbox", "up", "--json", "--state-dir"]);
722 assert_eq!(up[4], dir.to_string_lossy());
723 // Never the token: it goes in the environment, out of the process list.
724 assert_eq!(&up[5..], ["--url", "https://g1t.sh", "--name", "build-box", "--labels", "desktop", "--only", "linux,windows"]);
725 assert_eq!(text(down_only_args(&dir, "windows"))[5..], ["--only", "windows"]);
726 assert_eq!(text(drain_only_args(&dir, "windows"))[5..], ["--only", "windows", "--drain"]);
727 assert!(!up.iter().any(|a| a == "--token" || a.starts_with("g1trt_")));
728 let mut command = command(Path::new("g1t-runner"), up_args(&dir, &first));
729 with_token(&mut command, first.token);
730 assert!(command.get_envs().any(|(name, value)| name == "G1T_RUNNER_TOKEN" && value == Some(std::ffi::OsStr::new("g1trt_secret"))));
731 assert!(!command.get_args().any(|a| a.to_string_lossy().contains("g1trt_")));
732 // Its name and labels as the app has them: the launcher adds `-linux` and the OS labels.
733 assert!(!up.iter().any(|a| a.ends_with("-linux")));
734 let again = text(up_args(&dir, &Up { token: None, only: vec![], ..first.clone() }));
735 assert!(!again.iter().any(|a| a == "--only"));
736 assert!(!again.iter().any(|a| a == "--token" || a.starts_with("g1trt_")));
737 assert!(!format!("{first:?}").contains("g1trt_secret"));
738 assert_eq!(text(down_args(&dir, Down::Now))[..3], ["sandbox", "down", "--json"]);
739 assert_eq!(text(down_args(&dir, Down::Now)).len(), 5);
740 assert_eq!(text(down_args(&dir, Down::Drain)).last().unwrap(), "--drain");
741 assert_eq!(text(down_args(&dir, Down::Delete)).last().unwrap(), "--delete");
742 assert_eq!(text(status_args(&dir))[..3], ["sandbox", "status", "--json"]);
743 assert_eq!(text(update_args(&dir))[..3], ["sandbox", "update", "--json"]);
744 }
745}