| 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 | |
| 29 | use std::ffi::OsString; |
| 30 | use std::path::Path; |
| 31 | use std::process::{Command, Stdio}; |
| 32 | use std::time::Duration; |
| 33 | |
| 34 | use serde::Deserialize; |
| 35 | |
| 36 | use super::backend::quiet; |
| 37 | |
| 38 | /// The launcher's state folder, under the app's runner folder. |
| 39 | pub const FOLDER: &str = "sandbox"; |
| 40 | /// The status shape the app reads (`schema`). |
| 41 | pub const SCHEMA: u32 = 1; |
| 42 | /// How often the app asks the sandbox how it is: a call takes about a second. |
| 43 | pub const STATUS_EVERY: Duration = Duration::from_secs(8); |
| 44 | /// What installs WSL on Windows, for the person to run as administrator. |
| 45 | pub const WSL_INSTALL: &str = "wsl --install --no-distribution"; |
| 46 | /// What installs vfkit on a Mac. |
| 47 | pub 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")] |
| 52 | pub 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)] |
| 69 | pub struct Job { |
| 70 | /// `workflow` or `agent`. |
| 71 | pub kind: String, |
| 72 | pub id: String, |
| 73 | pub name: String, |
| 74 | pub repo: String, |
| 75 | } |
| 76 | |
| 77 | impl 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)] |
| 87 | pub 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)] |
| 105 | pub 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 | |
| 120 | impl 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. |
| 186 | pub 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. |
| 202 | pub 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)] |
| 209 | pub 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. |
| 218 | impl 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 | |
| 224 | fn 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. |
| 231 | pub 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)] |
| 246 | pub 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 | |
| 255 | pub 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 | |
| 265 | pub fn status_args(dir: &Path) -> Vec<OsString> { |
| 266 | args("status", dir) |
| 267 | } |
| 268 | |
| 269 | pub 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). |
| 275 | pub 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. |
| 289 | fn 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. |
| 300 | pub fn status(binary: &Path, dir: &Path) -> Result<Status, String> { |
| 301 | run(binary, status_args(dir)) |
| 302 | } |
| 303 | |
| 304 | /// Stops the sandbox, waiting for it. |
| 305 | pub 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)] |
| 320 | pub 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 | |
| 345 | pub 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)] |
| 384 | mod 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 | } |