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. | |
| Merge the desktop runner's settings: a card for each kind of job, Linux and Windows, each with its state, job, cause and fix, and its own Start and Stop; an honest wait for a release's sandbox images, checked again by itself; and the same on the Runners page. | 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. | |
| 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 | //! - `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. | |
| 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. | 34 | |
| 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. | 35 | 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. | 36 | use std::path::Path; |
| 37 | 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. | 38 | use std::time::Duration; |
| 39 | ||
| 40 | 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. | 41 | |
| 42 | use super::backend::quiet; | |
| 43 | ||
| 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. | 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"; | |
| Merge the desktop runner's settings: a card for each kind of job, Linux and Windows, each with its state, job, cause and fix, and its own Start and Stop; an honest wait for a release's sandbox images, checked again by itself; and the same on the Runners page. | 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); | |
| 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. | 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>, | |
| Merge the desktop runner's settings: a card for each kind of job, Linux and Windows, each with its state, job, cause and fix, and its own Start and Stop; an honest wait for a release's sandbox images, checked again by itself; and the same on the Runners page. | 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>, | |
| 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. | 110 | } |
| 111 | ||
| Merge the desktop runner's settings: a card for each kind of job, Linux and Windows, each with its state, job, cause and fix, and its own Start and Stop; an honest wait for a release's sandbox images, checked again by itself; and the same on the Runners page. | 112 | impl Capability { |
| 113 | pub fn off(&self) -> bool { | |
| 114 | self.turned_off == Some(true) | |
| 115 | } | |
| 116 | } | |
| 117 | ||
| 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. | 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 | ||
| Merge the desktop runner's settings: a card for each kind of job, Linux and Windows, each with its state, job, cause and fix, and its own Start and Stop; an honest wait for a release's sandbox images, checked again by itself; and the same on the Runners page. | 165 | /// A capability that can be had here, and is on, but was never set |
| 166 | /// up: registering it needs a 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. | 167 | pub fn unregistered(&self) -> bool { |
| Merge the desktop runner's settings: a card for each kind of job, Linux and Windows, each with its state, job, cause and fix, and its own Start and Stop; an honest wait for a release's sandbox images, checked again by itself; and the same on the Runners page. | 168 | let wanting = |c: &Capability| c.available && !c.off() && c.state == State::Absent; |
| 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. | 169 | if self.capabilities.is_empty() { wanting(&self.linux()) } else { self.capabilities.iter().any(wanting) } |
| 170 | } | |
| 171 | ||
| Merge the desktop runner's settings: a card for each kind of job, Linux and Windows, each with its state, job, cause and fix, and its own Start and Stop; an honest wait for a release's sandbox images, checked again by itself; and the same on the Runners page. | 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 | ||
| 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. | 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(); | |
| Merge the desktop runner's settings: a card for each kind of job, Linux and Windows, each with its state, job, cause and fix, and its own Start and Stop; an honest wait for a release's sandbox images, checked again by itself; and the same on the Runners page. | 201 | // Stopped by the person: nothing is wrong with it. |
| 202 | if linux.off() { | |
| 203 | return None; | |
| 204 | } | |
| 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. | 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 | ||
| Merge the desktop runner's settings: a card for each kind of job, Linux and Windows, each with its state, job, cause and fix, and its own Start and Stop; an honest wait for a release's sandbox images, checked again by itself; and the same on the Runners page. | 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 | ||
| 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 | /// 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>, | |
| Merge the desktop runner's settings: a card for each kind of job, Linux and Windows, each with its state, job, cause and fix, and its own Start and Stop; an honest wait for a release's sandbox images, checked again by itself; and the same on the Runners page. | 270 | /// The kinds of job to start (`--only`): those the person has on. |
| 271 | pub only: Vec<&'a str>, | |
| 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. | 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 { | |
| Merge the desktop runner's settings: a card for each kind of job, Linux and Windows, each with its state, job, cause and fix, and its own Start and Stop; an honest wait for a release's sandbox images, checked again by itself; and the same on the Runners page. | 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() | |
| 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. | 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 | ||
| 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. | 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`]). | |
| 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. | 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 | } | |
| Merge the desktop runner's settings: a card for each kind of job, Linux and Windows, each with its state, job, cause and fix, and its own Start and Stop; an honest wait for a release's sandbox images, checked again by itself; and the same on the Runners page. | 302 | if !up.only.is_empty() { |
| 303 | args.push("--only".into()); | |
| 304 | args.push(up.only.join(",").into()); | |
| 305 | } | |
| 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. | 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); | |
| 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. | 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 | } | |
| 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. | 327 | |
| 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. | 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 | ||
| Merge the desktop runner's settings: a card for each kind of job, Linux and Windows, each with its state, job, cause and fix, and its own Start and Stop; an honest wait for a release's sandbox images, checked again by itself; and the same on the Runners page. | 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 | ||
| The desktop app is 0.3.2, and its runner updates are fast, seen and only ever g1t's. The app now trusts a g1t-runner release only when its manifest's Ed25519 signature, latest.json.sig, checks out against g1t's runner release key, which is built into the app (the public half of RUNNER_RELEASE_PUBLIC_KEY, kept in runner/native.rs, where development builds have it too; G1T_RUNNER_RELEASE_KEY overrides it for a g1t with its own key), as the runner already did with its own updates. A manifest with no signature, or one that doesn't check out, is refused and nothing in it is used: the runner keeps the version it has and says "Update refused: not signed by g1t", or, with no runner installed yet, "Not running: the runner's release isn't signed by g1t", each with why and what to do. It looks every hour rather than every six, on a thread of its own so the runner is looked after meanwhile, and what it finds waits, downloaded and checked, until each kind of job is idle, then goes in kind by kind, never cutting a job short: Linux jobs take the release's image (sandbox update) as soon as they have no job, while Windows jobs, whose runner is the program itself, are stopped after their job (sandbox down --only windows --drain), the program is replaced and they start again, the other kind running on throughout; a runner in Docker or on the computer stops between jobs and starts on the new one. The program there is moved aside rather than overwritten, so a status the app is asking at that moment can't stop it. Each kind of job's card in Settings shows its runner version, when the app last looked ("Checked 12 min ago") and a newer one waiting ("0.3.2 ready, installs after this job", "Installing 0.3.2"), and "Check for runner updates" beside the computer's name looks at once and installs what it finds, even with "Download updates by themselves" off, which otherwise stops the hourly look. The desktop report carries the same (snake_case: the diagnosis's update, with checked_at, ready and note, and each kind's update), so the Runners page's Desktops cards show it, "Checked" with how long ago, and this computer's row has the button too. All the words are diagnosis.rs's. The desktop guide says how updates work, what each card shows, the new troubleshooting rows and where the release key lives. Tests cover the signature with a key pair of their own and with release 0.3.0's manifest and signature as g1t.sh published them, kept byte for byte (.gitattributes), the wording, the cards and the report. | 347 | /// `down --only <kind> --drain`: as [`down_only_args`], after the job |
| 348 | /// that kind is running, if any. An update stops a kind this way while | |
| 349 | /// its program is replaced, and starts it again after. | |
| 350 | pub fn drain_only_args(dir: &Path, kind: &str) -> Vec<OsString> { | |
| 351 | let mut args = down_only_args(dir, kind); | |
| 352 | args.push("--drain".into()); | |
| 353 | args | |
| 354 | } | |
| 355 | ||
| 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. | 356 | pub fn status_args(dir: &Path) -> Vec<OsString> { |
| 357 | args("status", dir) | |
| 358 | } | |
| 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. | 359 | |
| 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. | 360 | pub fn update_args(dir: &Path) -> Vec<OsString> { |
| 361 | args("update", dir) | |
| 362 | } | |
| 363 | ||
| 364 | /// A launcher command whose output the app reads a line at a time | |
| 365 | /// (`up` and `update`, which take minutes the first time). | |
| 366 | 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. | 367 | 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. | 368 | // What the app's own environment may have set for another runner is not this one's. |
| 369 | for (name, _) in std::env::vars_os() { | |
| 370 | if name.to_string_lossy().starts_with("G1T_RUNNER_") { | |
| 371 | command.env_remove(&name); | |
| 372 | } | |
| 373 | } | |
| 374 | 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. | 375 | command |
| 376 | } | |
| 377 | ||
| 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. | 378 | /// Runs a short launcher command to its end and reads its status, which |
| 379 | /// it prints whether it worked or not. | |
| 380 | fn run(binary: &Path, args: Vec<OsString>) -> Result<Status, String> { | |
| Merge the desktop runner's settings: a card for each kind of job, Linux and Windows, each with its state, job, cause and fix, and its own Start and Stop; an honest wait for a release's sandbox images, checked again by itself; and the same on the Runners page. | 381 | run_done(binary, args).map(|(status, _)| status) |
| 382 | } | |
| 383 | ||
| 384 | /// [`run`], and whether the command did what it was asked (its exit code). | |
| 385 | fn run_done(binary: &Path, args: Vec<OsString>) -> Result<(Status, bool), String> { | |
| 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. | 386 | let out = command(binary, args).output().map_err(|e| format!("g1t-runner could not start: {e}"))?; |
| 387 | let printed = String::from_utf8_lossy(&out.stdout); | |
| 388 | match parse(&printed) { | |
| Merge the desktop runner's settings: a card for each kind of job, Linux and Windows, each with its state, job, cause and fix, and its own Start and Stop; an honest wait for a release's sandbox images, checked again by itself; and the same on the Runners page. | 389 | Ok(status) => Ok((status, out.status.success())), |
| 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. | 390 | Err(_) if !out.status.success() && !out.stderr.is_empty() => Err(String::from_utf8_lossy(&out.stderr).trim().trim_start_matches("g1t-runner: ").to_owned()), |
| 391 | Err(why) => Err(why), | |
| 392 | } | |
| 393 | } | |
| 394 | ||
| 395 | /// How the sandbox is now. | |
| 396 | pub fn status(binary: &Path, dir: &Path) -> Result<Status, String> { | |
| 397 | run(binary, status_args(dir)) | |
| 398 | } | |
| 399 | ||
| Merge the desktop runner's settings: a card for each kind of job, Linux and Windows, each with its state, job, cause and fix, and its own Start and Stop; an honest wait for a release's sandbox images, checked again by itself; and the same on the Runners page. | 400 | /// Stops the sandbox, waiting for it: how it is now. |
| 401 | pub fn down(binary: &Path, dir: &Path, how: Down) -> Result<Status, String> { | |
| 402 | down_with(binary, down_args(dir, how)) | |
| 403 | } | |
| 404 | ||
| 405 | /// Stops one kind of job, and keeps it off, waiting for it. | |
| 406 | pub fn down_only(binary: &Path, dir: &Path, kind: &str) -> Result<Status, String> { | |
| 407 | down_with(binary, down_only_args(dir, kind)) | |
| 408 | } | |
| 409 | ||
| 410 | fn down_with(binary: &Path, args: Vec<OsString>) -> Result<Status, String> { | |
| 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. | 411 | if !binary.exists() { |
| 412 | return Err("g1t-runner isn't installed".into()); | |
| 413 | } | |
| Merge the desktop runner's settings: a card for each kind of job, Linux and Windows, each with its state, job, cause and fix, and its own Start and Stop; an honest wait for a release's sandbox images, checked again by itself; and the same on the Runners page. | 414 | match run_done(binary, args)? { |
| 415 | (status, true) => Ok(status), | |
| 416 | (status, false) => Err(status.error.unwrap_or_else(|| "g1t-runner sandbox down failed.".into())), | |
| 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. | 417 | } |
| 418 | } | |
| 419 | ||
| 420 | /// What a launcher's sentence is about: the words the app shows for it | |
| 421 | /// are diagnosis.rs's. Read from the causes and errors the launcher | |
| 422 | /// writes (crates/runner/src/selfhosted/launcher and native.rs). | |
| 423 | #[derive(Clone, Copy, Debug, PartialEq, Eq)] | |
| 424 | pub enum Why { | |
| 425 | NoWsl, | |
| 426 | WslMirrored, | |
| 427 | NoVirtualization, | |
| 428 | NoVfkit, | |
| 429 | /// Linux, with neither Sysbox in Docker nor KVM. | |
| 430 | NoSysboxOrKvm, | |
| Merge the desktop runner's settings: a card for each kind of job, Linux and Windows, each with its state, job, cause and fix, and its own Start and Stop; an honest wait for a release's sandbox images, checked again by itself; and the same on the Runners page. | 431 | /// The newest release is published without the sandbox's images (for |
| 432 | /// this computer), so there is nothing to download yet. | |
| 433 | NoImages, | |
| 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. | 434 | /// The sandbox's image couldn't be downloaded, or isn't the release's. |
| 435 | Image, | |
| 436 | /// g1t refused the registration token. | |
| 437 | TokenRefused, | |
| 438 | /// g1t no longer knows the runner. | |
| 439 | Forgotten, | |
| 440 | /// The sandbox has no registration, and no token was given. | |
| 441 | Unregistered, | |
| 442 | /// Windows Sandbox is off, or this edition of Windows has none. | |
| 443 | WindowsSandboxOff, | |
| 444 | /// Windows Sandbox without `wsb`, from before Windows 11 24H2. | |
| 445 | NoWsb, | |
| 446 | /// Sandboxed macOS jobs are not released. | |
| 447 | MacNotReleased, | |
| 448 | /// Anything else: shown in the launcher's own words. | |
| 449 | 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. | 450 | } |
| 451 | ||
| Merge the desktop runner's settings: a card for each kind of job, Linux and Windows, each with its state, job, cause and fix, and its own Start and Stop; an honest wait for a release's sandbox images, checked again by itself; and the same on the Runners page. | 452 | /// The release a "has no sandbox images" sentence names, and the image |
| 453 | /// it lacks when it is only this computer's: `release 0.3.0 has no | |
| 454 | /// sandbox image vm-arm64-disk for this machine`. | |
| 455 | pub fn missing_images(text: &str) -> Option<(String, Option<String>)> { | |
| 456 | let (before, after) = text.split_once(" has no sandbox image")?; | |
| 457 | let version = before.rsplit("release ").next()?.trim().to_owned(); | |
| 458 | let image = after.strip_prefix(' ').and_then(|rest| rest.split_whitespace().next()).map(str::to_owned); | |
| 459 | (!version.is_empty() && !version.contains(' ')).then_some((version, image)) | |
| 460 | } | |
| 461 | ||
| 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. | 462 | pub fn why(text: &str) -> Why { |
| 463 | let lower = text.to_ascii_lowercase(); | |
| 464 | let has = |words: &str| lower.contains(words); | |
| 465 | if has("wsl is not installed") { | |
| 466 | Why::NoWsl | |
| 467 | } else if has("mirrored") { | |
| 468 | Why::WslMirrored | |
| 469 | } else if has("virtualization is not enabled") | |
| 470 | || has("enable virtualization") | |
| 471 | || has("virtual machine platform") | |
| 472 | || has("hcs_e_hyperv_not_installed") | |
| 473 | || has("vt-x") | |
| 474 | || has("hypervisor is not present") | |
| 475 | { | |
| 476 | Why::NoVirtualization | |
| 477 | } else if has("vfkit") && has("not installed") { | |
| 478 | Why::NoVfkit | |
| 479 | } else if has("neither sysbox nor kvm") || has("docker has no sysbox") { | |
| 480 | Why::NoSysboxOrKvm | |
| 481 | } else if has("token") && (has("not valid") || has("expired") || has("invalid")) { | |
| 482 | Why::TokenRefused | |
| 483 | } else if has("no longer knows this runner") || has("was removed from g1t") { | |
| 484 | Why::Forgotten | |
| 485 | } else if has("is not registered") { | |
| 486 | Why::Unregistered | |
| Merge the desktop runner's settings: a card for each kind of job, Linux and Windows, each with its state, job, cause and fix, and its own Start and Stop; an honest wait for a release's sandbox images, checked again by itself; and the same on the Runners page. | 487 | } else if has("has no sandbox image") { |
| 488 | Why::NoImages | |
| 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. | 489 | } 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") { |
| 490 | Why::Image | |
| 491 | } else if has("windows sandbox is not turned on") { | |
| 492 | Why::WindowsSandboxOff | |
| 493 | } else if has("24h2") { | |
| 494 | Why::NoWsb | |
| 495 | } else if has("macos jobs are not released") { | |
| 496 | Why::MacNotReleased | |
| 497 | } else { | |
| 498 | Why::Other | |
| 499 | } | |
| 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. | 500 | } |
| 501 | ||
| 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. | 502 | #[cfg(test)] |
| 503 | mod tests { | |
| 504 | use super::*; | |
| 505 | use std::path::PathBuf; | |
| 506 | ||
| 507 | /// What `status --json` printed on a Windows 11 Pro machine with WSL | |
| 508 | /// and Windows Sandbox, before `up` (g1t-runner 0.2.1). | |
| 509 | const ABSENT: &str = r#"{ | |
| 510 | "schema": 1, | |
| 511 | "state": "absent", | |
| 512 | "backend": "wsl2", | |
| 513 | "runner_version": null, | |
| 514 | "launcher_version": "0.2.1", | |
| 515 | "image_version": null, | |
| 516 | "isolation": "nested-container", | |
| 517 | "job": null, | |
| 518 | "error": null, | |
| 519 | "resources": { | |
| 520 | "cpus": 0, | |
| 521 | "memory_mib": 0, | |
| 522 | "disk_gib": 0 | |
| 523 | }, | |
| 524 | "capabilities": [ | |
| 525 | { | |
| 526 | "id": "linux", | |
| 527 | "available": true, | |
| 528 | "state": "absent", | |
| 529 | "isolation": "nested-container", | |
| 530 | "runner_version": null, | |
| 531 | "job": null, | |
| 532 | "cause": null, | |
| 533 | "fix": null | |
| 534 | }, | |
| 535 | { | |
| 536 | "id": "windows", | |
| 537 | "available": true, | |
| 538 | "state": "absent", | |
| 539 | "isolation": "vm", | |
| 540 | "runner_version": null, | |
| 541 | "job": null, | |
| 542 | "cause": null, | |
| 543 | "fix": null | |
| 544 | } | |
| 545 | ], | |
| 546 | "state_dir": "C:\\Users\\syntaqx\\.g1t-runner\\sandbox" | |
| 547 | }"#; | |
| 548 | ||
| 549 | /// The launcher's documented example: running a job, Windows Sandbox off. | |
| 550 | const RUNNING: &str = r#"{ | |
| 551 | "schema": 1, "state": "running", "backend": "wsl2", | |
| 552 | "runner_version": "0.3.0", "launcher_version": "0.3.0", "image_version": "0.3.0", | |
| 553 | "isolation": "nested-container", | |
| 554 | "job": { "kind": "workflow", "id": "job_1", "name": "build", "repo": "acme/web" }, | |
| 555 | "error": null, | |
| 556 | "resources": { "cpus": 4, "memory_mib": 8192, "disk_gib": 64 }, | |
| 557 | "capabilities": [ | |
| 558 | { "id": "linux", "available": true, "state": "running", "isolation": "nested-container", | |
| 559 | "runner_version": "0.3.0", "job": { "kind": "workflow", "id": "job_1", "name": "build", "repo": "acme/web" }, "cause": null, "fix": null }, | |
| 560 | { "id": "windows", "available": false, "state": "absent", "isolation": "vm", | |
| 561 | "runner_version": null, "job": null, | |
| 562 | "cause": "Windows Sandbox is not turned on, or this edition of Windows has none (Home does not).", | |
| 563 | "fix": "On Windows 11 Pro, Enterprise or Education, turn on Windows Sandbox under Settings, System, Optional features, More Windows features, then restart." } | |
| 564 | ], | |
| 565 | "state_dir": "C:\\Users\\a\\.g1t-runner\\sandbox", | |
| 566 | "something_new": { "a": 1 } | |
| 567 | }"#; | |
| 568 | ||
| 569 | #[test] | |
| 570 | fn a_status_is_read_from_after_the_log() { | |
| 571 | let printed = format!("2026-10-12T08:00:00Z Downloading the sandbox image wsl-x64.\n2026-10-12T08:03:00Z {{not json}}\n{RUNNING}\n"); | |
| 572 | let status = parse(&printed).unwrap(); | |
| 573 | assert_eq!(status.state, State::Running); | |
| 574 | assert_eq!(status.backend.as_deref(), Some("wsl2")); | |
| 575 | assert_eq!(status.job().map(Job::said).as_deref(), Some("build (acme/web)")); | |
| 576 | assert_eq!(status.version().as_deref(), Some("0.3.0")); | |
| 577 | assert!(status.running()); | |
| 578 | assert!(!status.unregistered()); | |
| 579 | let windows = status.capability("windows").unwrap(); | |
| 580 | assert!(!windows.available); | |
| 581 | assert_eq!(why(windows.cause.as_deref().unwrap()), Why::WindowsSandboxOff); | |
| 582 | assert_eq!(status.trouble(), None); | |
| 583 | } | |
| 584 | ||
| 585 | #[test] | |
| 586 | fn a_sandbox_never_made_needs_registering() { | |
| 587 | let status = parse(ABSENT).unwrap(); | |
| 588 | assert_eq!(status.state, State::Absent); | |
| 589 | assert!(status.anything_available()); | |
| 590 | assert!(status.unregistered()); | |
| 591 | assert!(!status.running()); | |
| 592 | assert_eq!(status.version().as_deref(), Some("0.2.1")); | |
| 593 | } | |
| 594 | ||
| 595 | #[test] | |
| 596 | fn what_is_not_a_status_is_said_so() { | |
| 597 | assert!(parse("g1t-runner: unknown command sandbox\n").unwrap_err().contains("older than its sandbox launcher")); | |
| 598 | assert!(parse(&ABSENT.replace("\"schema\": 1", "\"schema\": 2")).unwrap_err().contains("schema 2")); | |
| 599 | let unknown = parse(&ABSENT.replace("\"state\": \"absent\",\n \"backend\"", "\"state\": \"hibernating\",\n \"backend\"")).unwrap(); | |
| 600 | assert_eq!(unknown.state, State::Unknown); | |
| 601 | assert!(starts_status("{") && !starts_status(" \"schema\": 1,")); | |
| 602 | } | |
| 603 | ||
| 604 | #[test] | |
| 605 | fn without_wsl_linux_says_why_and_nothing_else_can_run_on_home() { | |
| 606 | let status = parse( | |
| 607 | r#"{"schema":1,"state":"error","backend":"wsl2","launcher_version":"0.2.1", | |
| 608 | "error":"WSL is not installed, and the sandbox for Linux jobs is a WSL distribution of its own.", | |
| 609 | "capabilities":[ | |
| 610 | {"id":"linux","available":false,"state":"error", | |
| 611 | "cause":"WSL is not installed, and the sandbox for Linux jobs is a WSL distribution of its own.", | |
| 612 | "fix":"Open a terminal as administrator and run `wsl --install --no-distribution`, then restart Windows."}, | |
| 613 | {"id":"windows","available":false,"state":"absent","cause":"Windows Sandbox is not turned on, or this edition of Windows has none (Home does not).","fix":"…"}]}"#, | |
| 614 | ) | |
| 615 | .unwrap(); | |
| 616 | assert!(!status.anything_available()); | |
| 617 | let (cause, fix) = status.trouble().unwrap(); | |
| 618 | assert_eq!(why(&cause), Why::NoWsl); | |
| 619 | assert!(fix.unwrap().contains(WSL_INSTALL)); | |
| 620 | } | |
| 621 | ||
| 622 | #[test] | |
| 623 | fn the_launchers_sentences_are_told_apart() { | |
| 624 | for (text, expected) in [ | |
| 625 | ("WSL is set to mirrored networking, in which Linux reaches this computer's own localhost.", Why::WslMirrored), | |
| 626 | ("WSL could not import the sandbox: Please enable the Virtual Machine Platform Windows feature and ensure virtualization is enabled in the BIOS.", Why::NoVirtualization), | |
| 627 | ("vfkit, which runs the sandbox's VM with Apple's Virtualization framework, is not installed.", Why::NoVfkit), | |
| 628 | ("This machine has neither Sysbox nor KVM, so there is nothing to sandbox the runner with.", Why::NoSysboxOrKvm), | |
| 629 | ("the sandbox image wsl-x64.tar.gz's SHA-256 is not the release's; not using it", Why::Image), | |
| 630 | ("could not fetch https://g1t.sh/downloads/runner/0.3.0/wsl-x64.tar.gz: dns error", Why::Image), | |
| Merge the desktop runner's settings: a card for each kind of job, Linux and Windows, each with its state, job, cause and fix, and its own Start and Stop; an honest wait for a release's sandbox images, checked again by itself; and the same on the Runners page. | 631 | ("release 0.3.0 has no sandbox images", Why::NoImages), |
| 632 | ("Windows jobs are sandboxed and running, but Linux jobs are not: release 0.3.0 has no sandbox images", Why::NoImages), | |
| 633 | ("release 0.3.0 has no sandbox image vm-arm64-disk for this machine", Why::NoImages), | |
| 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. | 634 | ("The registration token is not valid, or has expired.", Why::TokenRefused), |
| 635 | ("g1t no longer knows this runner (401 unauthorized). Register it again.", Why::Forgotten), | |
| 636 | ("the sandbox's runner is not registered, and no --url and --token were given", Why::Unregistered), | |
| 637 | ("This Windows has Windows Sandbox but not its command line, wsb, which came in Windows 11 24H2.", Why::NoWsb), | |
| 638 | ("Sandboxed macOS jobs are not released yet.", Why::MacNotReleased), | |
| 639 | ("This user may not use /dev/kvm.", Why::Other), | |
| 640 | ] { | |
| 641 | assert_eq!(why(text), expected, "{text}"); | |
| 642 | } | |
| 643 | } | |
| 644 | ||
| 645 | #[test] | |
| Merge the desktop runner's settings: a card for each kind of job, Linux and Windows, each with its state, job, cause and fix, and its own Start and Stop; an honest wait for a release's sandbox images, checked again by itself; and the same on the Runners page. | 646 | fn a_kind_turned_off_is_stopped_by_the_person() { |
| 647 | let status = parse( | |
| 648 | r#"{"schema":1,"state":"stopped","capabilities":[ | |
| 649 | {"id":"linux","available":true,"state":"stopped","turned_off":true}, | |
| 650 | {"id":"windows","available":true,"state":"absent","turned_off":false}]}"#, | |
| 651 | ) | |
| 652 | .unwrap(); | |
| 653 | assert!(status.per_kind()); | |
| 654 | assert!(status.capability("linux").unwrap().off()); | |
| 655 | assert!(!status.all_off()); | |
| 656 | assert_eq!(status.trouble(), None); | |
| 657 | // Only Windows, never set up, wants registering; Linux is off. | |
| 658 | assert!(status.unregistered()); | |
| 659 | 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(); | |
| 660 | assert!(all.all_off()); | |
| 661 | assert!(!all.unregistered()); | |
| 662 | // A launcher from before `--only` says nothing of it. | |
| 663 | assert!(!parse(RUNNING).unwrap().per_kind()); | |
| 664 | } | |
| 665 | ||
| 666 | #[test] | |
| 667 | fn a_download_says_how_far_it_is() { | |
| 668 | 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 })); | |
| 669 | 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 })); | |
| 670 | assert_eq!(download("2026-10-12T08:01:00Z Took build (acme/web)"), None); | |
| 671 | assert_eq!(missing_images("release 0.3.0 has no sandbox images"), Some(("0.3.0".into(), None))); | |
| 672 | 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))); | |
| 673 | 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())))); | |
| 674 | assert_eq!(missing_images("could not fetch"), None); | |
| 675 | } | |
| 676 | ||
| 677 | #[test] | |
| 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. | 678 | fn the_commands_are_the_launchers() { |
| 679 | let dir = PathBuf::from("data").join("runner").join(FOLDER); | |
| 680 | let text = |args: Vec<OsString>| args.iter().map(|a| a.to_string_lossy().into_owned()).collect::<Vec<_>>(); | |
| Merge the desktop runner's settings: a card for each kind of job, Linux and Windows, each with its state, job, cause and fix, and its own Start and Stop; an honest wait for a release's sandbox images, checked again by itself; and the same on the Runners page. | 681 | let first = Up { url: "https://g1t.sh", name: "build-box", labels: "desktop", token: Some("g1trt_secret"), only: vec!["linux", "windows"] }; |
| 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. | 682 | let up = text(up_args(&dir, &first)); |
| 683 | assert_eq!(&up[..4], ["sandbox", "up", "--json", "--state-dir"]); | |
| 684 | 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. | 685 | // Never the token: it goes in the environment, out of the process list. |
| Merge the desktop runner's settings: a card for each kind of job, Linux and Windows, each with its state, job, cause and fix, and its own Start and Stop; an honest wait for a release's sandbox images, checked again by itself; and the same on the Runners page. | 686 | assert_eq!(&up[5..], ["--url", "https://g1t.sh", "--name", "build-box", "--labels", "desktop", "--only", "linux,windows"]); |
| 687 | assert_eq!(text(down_only_args(&dir, "windows"))[5..], ["--only", "windows"]); | |
| The desktop app is 0.3.2, and its runner updates are fast, seen and only ever g1t's. The app now trusts a g1t-runner release only when its manifest's Ed25519 signature, latest.json.sig, checks out against g1t's runner release key, which is built into the app (the public half of RUNNER_RELEASE_PUBLIC_KEY, kept in runner/native.rs, where development builds have it too; G1T_RUNNER_RELEASE_KEY overrides it for a g1t with its own key), as the runner already did with its own updates. A manifest with no signature, or one that doesn't check out, is refused and nothing in it is used: the runner keeps the version it has and says "Update refused: not signed by g1t", or, with no runner installed yet, "Not running: the runner's release isn't signed by g1t", each with why and what to do. It looks every hour rather than every six, on a thread of its own so the runner is looked after meanwhile, and what it finds waits, downloaded and checked, until each kind of job is idle, then goes in kind by kind, never cutting a job short: Linux jobs take the release's image (sandbox update) as soon as they have no job, while Windows jobs, whose runner is the program itself, are stopped after their job (sandbox down --only windows --drain), the program is replaced and they start again, the other kind running on throughout; a runner in Docker or on the computer stops between jobs and starts on the new one. The program there is moved aside rather than overwritten, so a status the app is asking at that moment can't stop it. Each kind of job's card in Settings shows its runner version, when the app last looked ("Checked 12 min ago") and a newer one waiting ("0.3.2 ready, installs after this job", "Installing 0.3.2"), and "Check for runner updates" beside the computer's name looks at once and installs what it finds, even with "Download updates by themselves" off, which otherwise stops the hourly look. The desktop report carries the same (snake_case: the diagnosis's update, with checked_at, ready and note, and each kind's update), so the Runners page's Desktops cards show it, "Checked" with how long ago, and this computer's row has the button too. All the words are diagnosis.rs's. The desktop guide says how updates work, what each card shows, the new troubleshooting rows and where the release key lives. Tests cover the signature with a key pair of their own and with release 0.3.0's manifest and signature as g1t.sh published them, kept byte for byte (.gitattributes), the wording, the cards and the report. | 688 | assert_eq!(text(drain_only_args(&dir, "windows"))[5..], ["--only", "windows", "--drain"]); |
| 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. | 689 | assert!(!up.iter().any(|a| a == "--token" || a.starts_with("g1trt_"))); |
| 690 | let mut command = command(Path::new("g1t-runner"), up_args(&dir, &first)); | |
| 691 | with_token(&mut command, first.token); | |
| 692 | assert!(command.get_envs().any(|(name, value)| name == "G1T_RUNNER_TOKEN" && value == Some(std::ffi::OsStr::new("g1trt_secret")))); | |
| 693 | 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. | 694 | // Its name and labels as the app has them: the launcher adds `-linux` and the OS labels. |
| Merge the desktop runner's settings: a card for each kind of job, Linux and Windows, each with its state, job, cause and fix, and its own Start and Stop; an honest wait for a release's sandbox images, checked again by itself; and the same on the Runners page. | 695 | assert!(!up.iter().any(|a| a.ends_with("-linux"))); |
| 696 | let again = text(up_args(&dir, &Up { token: None, only: vec![], ..first.clone() })); | |
| 697 | assert!(!again.iter().any(|a| a == "--only")); | |
| 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. | 698 | assert!(!again.iter().any(|a| a == "--token" || a.starts_with("g1trt_"))); |
| 699 | assert!(!format!("{first:?}").contains("g1trt_secret")); | |
| 700 | assert_eq!(text(down_args(&dir, Down::Now))[..3], ["sandbox", "down", "--json"]); | |
| 701 | assert_eq!(text(down_args(&dir, Down::Now)).len(), 5); | |
| 702 | assert_eq!(text(down_args(&dir, Down::Drain)).last().unwrap(), "--drain"); | |
| 703 | assert_eq!(text(down_args(&dir, Down::Delete)).last().unwrap(), "--delete"); | |
| 704 | assert_eq!(text(status_args(&dir))[..3], ["sandbox", "status", "--json"]); | |
| 705 | assert_eq!(text(update_args(&dir))[..3], ["sandbox", "update", "--json"]); | |
| 706 | } | |
| 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. | 707 | } |
This file's history is long; its oldest lines are credited to the oldest commit read.