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