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